diff --git a/docs/features/mcp-gateway.mdx b/docs/features/mcp-gateway.mdx index 393a4c85b..0d6cca447 100644 --- a/docs/features/mcp-gateway.mdx +++ b/docs/features/mcp-gateway.mdx @@ -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 diff --git a/internal/server/auth.go b/internal/server/auth.go index a6f57580c..7d73db6b9 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -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)) @@ -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) { diff --git a/internal/server/master_key_user_path_test.go b/internal/server/master_key_user_path_test.go index ff17be0cf..d016d449e 100644 --- a/internal/server/master_key_user_path_test.go +++ b/internal/server/master_key_user_path_test.go @@ -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) + }) + } +}