feat(19.7-01): in-process dispatch marker and compiled handler lookup

- bouncer.Dispatch, WithDispatch, Dispatched: context-only pre-resolved
  principal marker honoured by Registry.Middleware for listed guard names
- cabana.ResolveBackend honours a backend-kind marker; additive
  ResolveBackendToken, ScopeLimitFor and BackendPrincipal resolvers
- surf.Router.Handler and compileHandlers: one compiled chain per route,
  shared by BuildRouter validation and compile
- smoke tests TestDispatchSmoke, TestDispatchedSmoke, TestDispatchMarkerSmoke
- READMEs and docs/services/{authentication,routing}.md updated
This commit is contained in:
Jakub Zych
2026-10-22 16:00:19 +02:00
parent a74a218cd9
commit 1bd91a4522
14 changed files with 522 additions and 11 deletions

View File

@@ -29,6 +29,7 @@ Use cases:
- Named guard registry: `bouncer.Registry.Register` accepts any `bouncer.Guard` or `bouncer.CredentialGuard`; `bouncer.Registry.Middleware` derives middleware that stores the principal (and credential, if any) on the context. Guards that do not implement `bouncer.UnauthorizedWriter` let unauthenticated requests through so later middleware can decide.
- Standalone bearer middleware: `bouncer.Middleware`.
- Context helpers: `bouncer.WithUser` and `bouncer.User` for the principal, `bouncer.WithCredential` and `bouncer.Credential` for the credential behind it (for example an API token record).
- In-process dispatch marker: `bouncer.Dispatch` carries a pre-resolved principal (and credential) for a request a framework dispatcher builds itself, such as the MCP module calling a plugin route. `bouncer.WithDispatch` stores it under an unexported context key; nothing reads a header, cookie or query string for it, so an HTTP request can never carry one. `bouncer.Registry.Middleware` honours a marker only for the exact guard names it lists (`bouncer.Dispatched`) and then skips the guard; every other request authenticates as before.
- Scope limits for personal API tokens: `bouncer.ScopeLimit` on `bouncer.Principal.Scope` (nil means unrestricted, as for a JWT session). A token guard sets it after filtering the token's scopes by the request method; `Full` means the token acts as the owner on every method (`*`) or on GET/HEAD (`admin:read`).
- Blacklist stores behind `bouncer.BlacklistStore`: `bouncer.MemoryBlacklist` for tests and `bouncer.PostgresBlacklist` for production, which works on a caller-supplied table with `jti`, `expires_at` and `valid_until` columns and rejects unsafe table names.
- Cookie transport: `bouncer.LoadCookieConfig` reads the `auth.cookie.*` block into a `bouncer.CookieConfig` and refuses an invalid domain, `same_site: none` without Secure and, in the production environment, a Secure that resolves to false (set to `false`, or inferred from an http or unset `app.url`); `bouncer.CookieConfig.TokenCookie` builds the httpOnly token cookie whose Max-Age is the refresh window, and `bouncer.CookieConfig.ForgetTokenCookie` the matching cookie that deletes it. A hand-built config with `SameSite` unset writes `SameSite=Lax`, and the zero `bouncer.CookieConfig` writes `token` on `/` with `HttpOnly`, `SameSite=Lax` and `Secure`. `bouncer.CookieRefreshHandler` and `bouncer.CookieLogoutHandler` are ready-made handlers for a cookie-based refresh and logout; the application supplies the refresh or revoke call and a writer for its own response envelope.
@@ -153,6 +154,9 @@ func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, se
| `bouncer.AudienceUser`, `bouncer.AudienceBackend` | The frontend and admin audience values. |
| `bouncer.WithUser`, `bouncer.User` | Store and read the principal on a context. |
| `bouncer.WithCredential`, `bouncer.Credential` | Store and read the resolved credential on a context. |
| `bouncer.Dispatch` | The in-process dispatch marker: the guard names that honour it, the pre-resolved principal and an optional credential. Context-only; never read from a request. |
| `bouncer.WithDispatch` | Stores a `bouncer.Dispatch` on a context. Only an in-process dispatcher inside the framework calls it. |
| `bouncer.Dispatched` | Reads the marker when it carries a principal and lists the given guard name exactly; false otherwise. |
| `bouncer.BlacklistStore` | Revoked-jti store with a grace window and sweeping. |
| `bouncer.NewMemoryBlacklist` | In-process blacklist for tests. |
| `bouncer.NewPostgresBlacklist` | Blacklist over a `*sql.DB` and a table name. |

View File

@@ -0,0 +1,54 @@
package bouncer
import "context"
type dispatchKey struct{}
// Dispatch is the pre-resolved principal marker an in-process dispatcher
// attaches to a request it builds itself (the MCP module calling one of a
// plugin's routes on behalf of an already authenticated caller). The marker
// is context-only: it lives under an unexported key, nothing reads a header,
// cookie or query string for it, and an HTTP request can therefore never
// carry one. Only an in-process dispatcher inside the framework may call
// WithDispatch; plugins never set it.
//
// Guards lists the exact registry guard names (for example "api_token" or
// "backend") whose middleware honours the marker; Registry.Middleware for a
// name the marker does not list ignores it and authenticates the request as
// usual. Principal is the identity the dispatcher resolved and Credential the
// optional credential record stored next to it (WithCredential), nil for a
// principal without one.
type Dispatch struct {
Guards []string
Principal *Principal
Credential any
}
// WithDispatch stores the marker on ctx. A nil ctx becomes a background
// context, as WithUser does. Only an in-process dispatcher may call it.
func WithDispatch(ctx context.Context, d *Dispatch) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, dispatchKey{}, d)
}
// Dispatched returns the marker on ctx when it carries a principal and lists
// guard among its Guards (exact name comparison, no wildcard). Any other
// context, a marker without a principal or a marker for other guards answers
// false, so the caller falls back to authenticating the request.
func Dispatched(ctx context.Context, guard string) (*Dispatch, bool) {
if ctx == nil || guard == "" {
return nil, false
}
d, ok := ctx.Value(dispatchKey{}).(*Dispatch)
if !ok || d == nil || d.Principal == nil {
return nil, false
}
for _, name := range d.Guards {
if name == guard {
return d, true
}
}
return nil, false
}

View File

@@ -0,0 +1,40 @@
package bouncer
import (
"context"
"testing"
)
// TestDispatchedSmoke covers the reader of the in-process dispatch marker
// (phase 19.7 D-17): only a marker with a principal that lists the exact guard
// name is honoured.
func TestDispatchedSmoke(t *testing.T) {
if _, ok := Dispatched(context.Background(), "api_token"); ok {
t.Fatal("plain context reports a dispatch")
}
if _, ok := Dispatched(nil, "api_token"); ok {
t.Fatal("nil context reports a dispatch")
}
noPrincipal := WithDispatch(context.Background(), &Dispatch{Guards: []string{"api_token"}})
if _, ok := Dispatched(noPrincipal, "api_token"); ok {
t.Fatal("marker without a principal reports a dispatch")
}
marker := &Dispatch{Guards: []string{"api_token"}, Principal: &Principal{ID: 42}, Credential: "cred"}
ctx := WithDispatch(context.Background(), marker)
if _, ok := Dispatched(ctx, "backend"); ok {
t.Fatal("unlisted guard reports a dispatch")
}
if _, ok := Dispatched(ctx, ""); ok {
t.Fatal("empty guard name reports a dispatch")
}
if _, ok := Dispatched(ctx, "api_token*"); ok {
t.Fatal("wildcard guard name reports a dispatch")
}
got, ok := Dispatched(ctx, "api_token")
if !ok || got != marker || got.Principal.ID != 42 || got.Credential != "cred" {
t.Fatalf("Dispatched = %+v, %v", got, ok)
}
if ctx := WithDispatch(nil, marker); ctx == nil {
t.Fatal("WithDispatch(nil) returned nil")
}
}

View File

@@ -70,6 +70,9 @@ func (reg *Registry) Owner(name string) (string, bool) {
// if a credential was returned) then next.ServeHTTP.
// On failure: if the guard implements UnauthorizedWriter, it writes the
// response and the chain stops; otherwise next.ServeHTTP runs unauthenticated.
// A request whose context carries a Dispatch marker listing name (an
// in-process dispatch, never an HTTP request) skips the guard: its principal
// and credential are stored as if the guard had resolved them.
func (reg *Registry) Middleware(name string) (func(http.Handler) http.Handler, error) {
if reg == nil {
return nil, fmt.Errorf("bouncer: registry is nil")
@@ -80,6 +83,14 @@ func (reg *Registry) Middleware(name string) (func(http.Handler) http.Handler, e
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if d, ok := Dispatched(r.Context(), name); ok {
ctx := WithUser(r.Context(), d.Principal)
if d.Credential != nil {
ctx = WithCredential(ctx, d.Credential)
}
next.ServeHTTP(w, r.WithContext(ctx))
return
}
principal, cred, err := authenticate(ng.g, r)
if err != nil || principal == nil {
if wtr, ok := ng.g.(UnauthorizedWriter); ok {