diff --git a/docs/services/authentication.md b/docs/services/authentication.md index 7b57b027..baf73909 100644 --- a/docs/services/authentication.md +++ b/docs/services/authentication.md @@ -137,7 +137,7 @@ To protect routes, return that middleware from `pact.HasMiddleware` under a name A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`. -A framework dispatcher that calls a route in-process on behalf of a caller it has already authenticated (the MCP module running a tool through the plugin's own route) does not mint a token for the call. It attaches a `bouncer.Dispatch` marker to the request context with `bouncer.WithDispatch`: the pre-resolved principal, its optional credential and the exact guard names that may honour it. The marker is context-only. Nothing reads a header, cookie or query string for it, so an HTTP request can never carry one, and only a dispatcher inside the framework sets it; plugins never do. `bouncer.Registry.Middleware` honours a marker only for a guard name the marker lists (`bouncer.Dispatched`) and then skips the guard, which covers every guard registered through the registry, such as a plugin's API token guard. `cabana.ResolveBackend` honours a marker for the `backend` guard when its principal is a backend one; a frontend marker is refused as unauthenticated, and a scoped principal is still checked against the cover codes. A marker for one guard has no effect on a route behind another. The one dispatcher in the framework is the [MCP endpoint](mcp.md): pier resolves the caller from the bearer of every MCP request, asks the tool's guard provider for the principal and sets the marker on the in-process call, so the route sees the same principal an HTTP request with that user's token would carry, while the bearer itself never reaches the route. +A framework dispatcher that calls a route in-process on behalf of a caller it has already authenticated (the MCP module running a tool through the plugin's own route) does not mint a token for the call. It attaches a `bouncer.Dispatch` marker to the request context with `bouncer.WithDispatch`: the pre-resolved principal, its optional credential and the exact guard names that may honour it. The marker is context-only. Nothing reads a header, cookie or query string for it, so an HTTP request can never carry one, and only a dispatcher inside the framework sets it; plugins never do. `bouncer.Registry.Middleware` honours a marker only for a guard name the marker lists (`bouncer.Dispatched`) and then skips the guard, which covers every guard registered through the registry, such as a plugin's API token guard. `cabana.ResolveBackend` and cabana's `backend` middleware (the admin API and every route behind `backend`) honour a marker for the `backend` guard only when its principal is a backend one; a frontend marker is refused as unauthenticated, and a scoped principal is still checked against the cover codes. A marker for one guard has no effect on a route behind another. The one dispatcher in the framework is the [MCP endpoint](mcp.md): pier resolves the caller from the bearer of every MCP request, asks the tool's guard provider for the principal and sets the marker on the in-process call, so the route sees the same principal an HTTP request with that user's token would carry, while the bearer itself never reaches the route. ### Accepting admin tokens on plugin routes diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 078c2f7b..4b9905e9 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -196,10 +196,11 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { return nil, fmt.Errorf("cabana: backend resolver: %w", err) } } - mw, err := guards.Middleware("backend") + guardMW, err := guards.Middleware("backend") if err != nil { return nil, err } + mw := refuseFrontendMarker(guardMW) loginMax, loginDecay := adminLoginWindow(app) svc := &service{ app: app, @@ -243,6 +244,24 @@ func (s *service) serveSPA(w http.ResponseWriter, r *http.Request) { s.spa.ServeHTTP(w, r) } +// refuseFrontendMarker wraps the backend guard middleware: an in-process +// dispatch marker that lists the "backend" guard but carries a frontend +// principal (Backend false) is answered 401 before the guard honours it, as +// ResolveBackend refuses it, so no frontend user id is ever read as an +// administrator's by the admin API or a backend-guarded plugin route. +func refuseFrontendMarker(next pact.Middleware) pact.Middleware { + return func(h http.Handler) http.Handler { + inner := next(h) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if d, ok := bouncer.Dispatched(r.Context(), "backend"); ok && !d.Principal.Backend { + writeUnauthenticated(w, ErrUnauthenticated) + return + } + inner.ServeHTTP(w, r) + }) + } +} + func writeUnauthenticated(w http.ResponseWriter, _ error) { WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) }