Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/features/mcp-gateway.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,11 @@ and an exclusion wins over an allowed path.
| Everyone except contractors | `disallowed_user_paths: ["/contractors"]` |
| Engineering except its contractors | both of the above, scoped under `/engineering` |

The caller's user path comes from its API key when the key has one. Otherwise,
including with the master key, it comes from the `X-GoModel-User-Path` header
(or the header named by `USER_PATH_HEADER`), which is a quick way to check what
a given subtree sees.

Hidden servers are left out of `tools/list`, and `/mcp/{slug}` returns 404 for
them. Like tool filters, visibility edits apply without reconnecting to the
server and are checked on every call, so they also reach MCP sessions that are
Expand Down
26 changes: 24 additions & 2 deletions internal/server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,11 @@ func NewAuthMiddleware(cfg AuthMiddlewareConfig) echo.MiddlewareFunc {
// sessions. Hide every identity value installed by outer extension
// middleware before validating the selected credential; clearing only
// the response header would leave downstream context consumers scoped
// to the wrong principal.
// to the wrong principal. The caller's own user-path header is not
// such an identity and is restored (see transportOwnedUserPath).
setAuthenticationUserHeader(c, "")
ctx := ext.WithoutAuthentication(c.Request().Context())
ctx = core.WithEffectiveUserPath(ctx, "")
ctx = core.WithEffectiveUserPath(ctx, transportOwnedUserPath(c.Request(), userPathHeaderName))
ctx = core.WithCredentialAllowedModels(ctx, nil)
ctx = core.WithAccessScope(ctx, core.AccessScope{})
c.SetRequest(c.Request().WithContext(ctx))
Expand Down Expand Up @@ -158,6 +159,27 @@ func NewAuthMiddleware(cfg AuthMiddlewareConfig) echo.MiddlewareFunc {
}
}

// transportOwnedUserPath returns the request's user-path header for model
// endpoints that own their transport (MCP, realtime, audio uploads), and ""
// for every other endpoint. Those endpoints take no request snapshot, so
// RequestSnapshotCapture seeds the header path as the effective user path;
// without restoring it here, master-key and unbound-key callers lose their
// path there while ingress-managed endpoints keep it through the snapshot.
// Only this middleware and RequestSnapshotCapture write the header, so the
// value is the caller's own, never an outer extension session's. A key with
// a bound user path still overrides it in applyAuthKeyResult.
func transportOwnedUserPath(req *http.Request, headerName string) string {
desc := core.DescribeEndpoint(req.Method, req.URL.Path)
if desc.IngressManaged || !desc.ModelInteraction {
return ""
}
userPath, err := core.NormalizeUserPath(req.Header.Get(headerName))
if err != nil {
return ""
}
return userPath
}

func hasRequestAuthenticators(authenticators []ext.RequestAuthenticator) bool {
for _, authenticator := range authenticators {
if !requestAuthenticatorIsNil(authenticator) {
Expand Down
64 changes: 64 additions & 0 deletions internal/server/master_key_user_path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,67 @@ func TestMasterKeyUserPathHeaderScopesRestrictedModelAccess(t *testing.T) {
})
}
}

// TestTransportOwnedEndpointsKeepHeaderUserPath covers model endpoints that
// own their transport (MCP, realtime, audio uploads). They take no request
// snapshot, so the header path lives only in the effective user path, and the
// explicit-credential reset must not erase it: a master-key or unbound-key
// caller keeps its header path, a bound key still wins, and identity from an
// outer extension session never survives an explicit credential.
func TestTransportOwnedEndpointsKeepHeaderUserPath(t *testing.T) {
authenticator := mockAuthenticator{
enabled: true,
tokenToID: map[string]string{"sk_bound": "key-bound", "sk_unbound": "key-unbound"},
tokenPath: map[string]string{"sk_bound": "/team/bound"},
}
tests := []struct {
name string
path string
token string
headerPath string
outerIdentity string
// configuredHeader is the server's USER_PATH_HEADER; empty keeps the default.
configuredHeader string
want string
}{
{name: "master key on /mcp keeps header path", path: "/mcp", token: "master-key", headerPath: "/eng/platform", want: "/eng/platform"},
{name: "master key on pinned /mcp/{server}", path: "/mcp/github", token: "master-key", headerPath: "/eng", want: "/eng"},
{name: "master key on audio transcription", path: "/v1/audio/transcriptions", token: "master-key", headerPath: "/team/x", want: "/team/x"},
{name: "master key on realtime", path: "/v1/realtime", token: "master-key", headerPath: "/team/x", want: "/team/x"},
{name: "master key without header stays global", path: "/mcp", token: "master-key", want: ""},
{name: "unbound managed key keeps header path", path: "/mcp", token: "sk_unbound", headerPath: "/eng/platform", want: "/eng/platform"},
{name: "bound managed key wins over header", path: "/mcp", token: "sk_bound", headerPath: "/eng/platform", want: "/team/bound"},
{name: "outer extension identity is dropped", path: "/mcp", token: "master-key", outerIdentity: "/ext/session", want: ""},
{name: "header replaces outer extension identity", path: "/mcp", token: "master-key", outerIdentity: "/ext/session", headerPath: "/eng", want: "/eng"},
{name: "configured header name on /mcp", path: "/mcp", token: "master-key", configuredHeader: "X-Tenant-Path", headerPath: "/eng", want: "/eng"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
auth := AuthMiddlewareWithAuthenticator("master-key", authenticator, nil, tt.configuredHeader)(func(c *echo.Context) error {
got = core.UserPathFromContext(c.Request().Context())
return c.String(http.StatusOK, "ok")
})
// An outer extension session installs its identity before the
// gateway's auth middleware runs.
outer := func(c *echo.Context) error {
if tt.outerIdentity != "" {
req := c.Request()
c.SetRequest(req.WithContext(core.WithEffectiveUserPath(req.Context(), tt.outerIdentity)))
}
return auth(c)
}
chain := RequestSnapshotCapture(tt.configuredHeader)(outer)

opts := []echotest.Option{echotest.WithHeader("Authorization", "Bearer "+tt.token)}
if tt.headerPath != "" {
opts = append(opts, echotest.WithHeader(core.UserPathHeaderName(tt.configuredHeader), tt.headerPath))
}
c, rec := echotest.Post(t, tt.path, `{}`, opts...)

require.NoError(t, chain(c))
require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, tt.want, got)
})
}
}
Loading