fix(cabana): the backend middleware refuses a dispatch marker carrying a frontend principal
- cabana wraps the bouncer backend guard middleware: a marker that lists the backend guard with Backend false answers 401 before the guard honours it, as ResolveBackend already does, so a frontend user id is never read as an administrator's by the admin API or a backend-guarded plugin route - found by plan 19.7-05's TestDispatchMarker (admin API in-process case)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user