From 1bd91a4522a916ee3c8cd3b5dd42904da23513f8 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 16:00:19 +0200 Subject: [PATCH] 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 --- docs/services/authentication.md | 2 + docs/services/routing.md | 2 + modules/bouncer/README.md | 4 + modules/bouncer/dispatch.go | 54 ++++++++++ modules/bouncer/dispatch_smoke_test.go | 40 +++++++ modules/bouncer/registry.go | 11 ++ modules/cabana/README.md | 5 +- modules/cabana/dispatch_smoke_test.go | 72 +++++++++++++ modules/cabana/token_guard.go | 33 +++++- modules/cabana/token_resolver.go | 107 +++++++++++++++++++ modules/surf/README.md | 1 + modules/surf/dispatch_smoke_test.go | 142 +++++++++++++++++++++++++ modules/surf/router.go | 44 ++++++-- modules/surf/routetable.go | 16 +++ 14 files changed, 522 insertions(+), 11 deletions(-) create mode 100644 modules/bouncer/dispatch.go create mode 100644 modules/bouncer/dispatch_smoke_test.go create mode 100644 modules/cabana/dispatch_smoke_test.go create mode 100644 modules/cabana/token_resolver.go create mode 100644 modules/surf/dispatch_smoke_test.go diff --git a/docs/services/authentication.md b/docs/services/authentication.md index e6a224e7..3243aea2 100644 --- a/docs/services/authentication.md +++ b/docs/services/authentication.md @@ -137,6 +137,8 @@ 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. + ### Accepting admin tokens on plugin routes Plugin routes that should accept an admin JWT or a personal API token call `cabana.ResolveBackend`. It reads only the Authorization Bearer header (never the admin cookie), applies the token's method filter, and for a scoped token requires a method-allowed scope that is full or matches every cover code you pass. On success it returns the request with `bouncer.WithUser` (and `bouncer.WithCredential` for a token). Failures are `cabana.ErrUnauthenticated` or `cabana.ErrTokenScope`. Routes registered before `cabana.Activate` must call it per request, because the resolver is published at activation. diff --git a/docs/services/routing.md b/docs/services/routing.md index 38b6ee37..5249f4c6 100644 --- a/docs/services/routing.md +++ b/docs/services/routing.md @@ -281,6 +281,8 @@ for _, rt := range r.Routes() { // POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536] ``` +`surf.Router.Handler` hands out the compiled chain of one route by method and pattern, for example `r.Handler("GET", "/api/blog/posts/{id}")`. The chain is the route exactly as the mux serves it: constraints, the named middleware (guards, throttles), the body limit, locale, recovery and the CORS profile. A framework component that calls a route in-process, such as the MCP module, serves that handler with a request it builds itself, setting the path values and `Pattern` as the mux would; the caller's identity reaches the guard through the [dispatch marker](authentication.md#guards-and-middleware), never through a header. The lookup answers false for an unknown route and before `surf.BuildRouter` compiled the routes. + ## CORS CORS is configured with the keys of Laravel's `config/cors.php`, under `http.cors`, and applies only to paths that match `http.cors.paths`. With no `http.cors` section, no CORS headers are sent: diff --git a/modules/bouncer/README.md b/modules/bouncer/README.md index fd52fc4c..36cde6b1 100644 --- a/modules/bouncer/README.md +++ b/modules/bouncer/README.md @@ -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. | diff --git a/modules/bouncer/dispatch.go b/modules/bouncer/dispatch.go new file mode 100644 index 00000000..be045b70 --- /dev/null +++ b/modules/bouncer/dispatch.go @@ -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 +} diff --git a/modules/bouncer/dispatch_smoke_test.go b/modules/bouncer/dispatch_smoke_test.go new file mode 100644 index 00000000..de30c625 --- /dev/null +++ b/modules/bouncer/dispatch_smoke_test.go @@ -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") + } +} diff --git a/modules/bouncer/registry.go b/modules/bouncer/registry.go index 1304b7e9..4540a37c 100644 --- a/modules/bouncer/registry.go +++ b/modules/bouncer/registry.go @@ -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 { diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 3eb8a8b9..eb6e9fa1 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -239,8 +239,11 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.RuntimeCommands` | Returns the `admin:create`, `admin:reset-password` and `admin:token:*` commands. | | `cabana.BackendAPIToken` | GORM model of `backend_api_tokens` (hash and hint only; plaintext is never stored). | | `cabana.APITokenPrefix` | The `g15_` prefix of a personal API token plaintext. | -| `cabana.ResolveBackend` | Authenticates a request outside the admin middleware from the Authorization Bearer header only (admin JWT or `g15_` token, never the admin cookie), with an optional scope cover check. | +| `cabana.ResolveBackend` | Authenticates a request outside the admin middleware from the Authorization Bearer header only (admin JWT or `g15_` token, never the admin cookie), with an optional scope cover check. A request whose context carries a `bouncer.Dispatch` marker for the `backend` guard (an in-process dispatch; no HTTP request can set one) is answered from the marker: its principal must be a backend one, a frontend marker is `cabana.ErrUnauthenticated`, and a scoped principal must still cover every cover code. | | `cabana.ErrUnauthenticated` / `cabana.ErrTokenScope` | Sentinel errors from `cabana.ResolveBackend`. | +| `cabana.ResolveBackendToken` | Resolves a `g15_` token outside any request (same hash lookup, usability window, admin load and `last_used_at` stamp as the guard) without the request-method scope filter; returns the principal, the token row and the stored scope names. For an in-process dispatcher that opens a session with a personal token. | +| `cabana.ScopeLimitFor` | Filters stored scope names by one HTTP method into the `bouncer.ScopeLimit` the guard would set for a request with that method, with the known and method-allowed counts. | +| `cabana.BackendPrincipal` | Loads an admin principal by id through the backend users provider (grants, superuser flag, backend kind set); nil, nil for a missing or deactivated admin. | | `cabana.AdminAPIToken` / `cabana.AdminAPITokenScope` / `cabana.AdminAPITokenCreated` / `cabana.AdminAPITokenRequest` | JSON shapes of the account token API. | | `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. | | `cabana.CompileFormWithTypes` | `cabana.CompileForm` that also accepts the plugin field types of one plugin (`[]pact.FormFieldType`), with the boot checks of their aliases, option keys and `CheckOptions`. | diff --git a/modules/cabana/dispatch_smoke_test.go b/modules/cabana/dispatch_smoke_test.go new file mode 100644 index 00000000..ba34c36d --- /dev/null +++ b/modules/cabana/dispatch_smoke_test.go @@ -0,0 +1,72 @@ +package cabana + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +// TestDispatchMarkerSmoke covers ResolveBackend with the in-process dispatch +// marker (phase 19.7 D-17): a backend marker is honoured without a bearer or +// a published resolver, a frontend marker is refused, and a scoped marker is +// still checked against the cover codes. +func TestDispatchMarkerSmoke(t *testing.T) { + app := backpack.New(nil) + withMarker := func(p *bouncer.Principal) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/acme/posts", nil) + marker := &bouncer.Dispatch{Guards: []string{"backend"}, Principal: p, Credential: "cred"} + return r.WithContext(bouncer.WithDispatch(r.Context(), marker)) + } + + // A backend marker: the principal is returned and stored on the context. + r, principal, err := ResolveBackend(app, withMarker(&bouncer.Principal{ID: 42, Backend: true}), "acme.*") + if err != nil || principal == nil || principal.ID != 42 { + t.Fatalf("backend marker: %+v, %v", principal, err) + } + if got, ok := bouncer.User(r.Context()); !ok || got.ID != 42 { + t.Fatalf("principal not stored on the returned request: %+v, %v", got, ok) + } + if cred, ok := bouncer.Credential(r.Context()); !ok || cred != "cred" { + t.Fatalf("credential not stored on the returned request: %v, %v", cred, ok) + } + + // A frontend marker (Backend false) is never a backend principal. + if _, _, err := ResolveBackend(app, withMarker(&bouncer.Principal{ID: 7}), "acme.*"); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("frontend marker: err = %v, want ErrUnauthenticated", err) + } + + // A scoped marker whose scope does not cover acme.* is refused as a + // scoped token would be; one that covers it passes. + scoped := &bouncer.Principal{ID: 42, Backend: true, Scope: &bouncer.ScopeLimit{Scopes: [][]string{{"other.*"}}}} + if _, _, err := ResolveBackend(app, withMarker(scoped), "acme.*"); !errors.Is(err, ErrTokenScope) { + t.Fatalf("scoped marker: err = %v, want ErrTokenScope", err) + } + covering := &bouncer.Principal{ID: 42, Backend: true, Scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.*"}}}} + if _, _, err := ResolveBackend(app, withMarker(covering), "acme.*"); err != nil { + t.Fatalf("covering scoped marker: %v", err) + } + + // Without a marker the request still needs a bearer; a forged header is + // not a marker. + plain := httptest.NewRequest(http.MethodGet, "/acme/posts", nil) + plain.Header.Set("X-Summer-Dispatch", "42") + if _, _, err := ResolveBackend(app, plain, "acme.*"); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("no marker: err = %v, want ErrUnauthenticated", err) + } + + // The additive resolvers answer unauthenticated on an app without cabana. + if _, _, _, err := ResolveBackendToken(context.Background(), app, "g15_0000000000000000000000000000000000000000"); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("ResolveBackendToken without cabana: %v", err) + } + if limit, known, methodOK := ScopeLimitFor(app, []string{"*"}, http.MethodGet); limit != nil || known != 0 || methodOK != 0 { + t.Fatalf("ScopeLimitFor without cabana = %v, %d, %d", limit, known, methodOK) + } + if _, err := BackendPrincipal(context.Background(), app, 1); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("BackendPrincipal without cabana: %v", err) + } +} diff --git a/modules/cabana/token_guard.go b/modules/cabana/token_guard.go index e8df7def..4ce44df0 100644 --- a/modules/cabana/token_guard.go +++ b/modules/cabana/token_guard.go @@ -196,8 +196,32 @@ type backendResolver struct { // cover code. Routes registered before cabana.Activate must call this per // request (lookup the published resolver each time). Returns // ErrUnauthenticated or ErrTokenScope. +// +// A request whose context carries a bouncer.Dispatch marker for the "backend" +// guard (an in-process dispatch; no HTTP request can set one) is answered +// from the marker: its principal must be a backend one (a frontend marker is +// ErrUnauthenticated) and a scoped principal must still cover every cover +// code. No header is read in that case. func ResolveBackend(app *backpack.App, r *http.Request, cover ...string) (*http.Request, *bouncer.Principal, error) { - if app == nil || r == nil || authorizationBearer(r) == "" { + if app == nil || r == nil { + return nil, nil, ErrUnauthenticated + } + if d, ok := bouncer.Dispatched(r.Context(), "backend"); ok { + if !d.Principal.Backend { + return nil, nil, ErrUnauthenticated + } + if d.Principal.Scope != nil && !d.Principal.Scope.Full { + if !scopeCoversAll(d.Principal.Scope, cover) { + return nil, nil, ErrTokenScope + } + } + ctx := bouncer.WithUser(r.Context(), d.Principal) + if d.Credential != nil { + ctx = bouncer.WithCredential(ctx, d.Credential) + } + return r.WithContext(ctx), d.Principal, nil + } + if authorizationBearer(r) == "" { return nil, nil, ErrUnauthenticated } res, ok := app.Lookup[*backendResolver]() @@ -240,6 +264,13 @@ func logTokenAuth(r *http.Request, outcome string, adminID, tokenID uint) { remote = host } } + logTokenAuthAt(method, path, remote, outcome, adminID, tokenID) +} + +// logTokenAuthAt writes the admin.auth line for a token check that has no +// request (ResolveBackendToken passes path "mcp"); logTokenAuth delegates +// here so both keep one shape. +func logTokenAuthAt(method, path, remote, outcome string, adminID, tokenID uint) { args := []any{"outcome", outcome, "method", method, "path", path, "remote", remote} if adminID != 0 { args = append(args, "admin_id", adminID) diff --git a/modules/cabana/token_resolver.go b/modules/cabana/token_resolver.go new file mode 100644 index 00000000..cb8a058c --- /dev/null +++ b/modules/cabana/token_resolver.go @@ -0,0 +1,107 @@ +package cabana + +import ( + "context" + "crypto/subtle" + "time" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +// resolverTokenPath is the path logTokenAuthAt records for a token check that +// has no request behind it (an MCP session opened with a personal token). +const resolverTokenPath = "mcp" + +// ResolveBackendToken resolves a g15_ personal API token outside any request: +// the same hash lookup, constant-time compare, usability window, admin load +// and last_used_at stamp as the backend guard, but without the request-method +// scope filter. It returns the admin principal (without Scope), the token row +// and the scope names stored on the token, so an in-process dispatcher can +// compute the limit per dispatched route method with ScopeLimitFor. A +// malformed, unknown, revoked or expired token, a missing or deactivated +// admin and an application without cabana activated all answer +// ErrUnauthenticated; the audit line is logged under path "mcp". +func ResolveBackendToken(ctx context.Context, app *backpack.App, raw string) (*bouncer.Principal, *BackendAPIToken, []string, error) { + if ctx == nil { + ctx = context.Background() + } + guard := publishedResolverGuard(app) + if guard == nil || !wellFormedAPIToken(raw) || guard.store == nil { + logTokenAuthAt("", resolverTokenPath, "", "failed", 0, 0) + return nil, nil, nil, ErrUnauthenticated + } + now := time.Now().UTC() + hash := hashAPIToken(raw) + row, err := guard.store.FindUsableByHash(ctx, hash, now) + if err != nil || row == nil { + logTokenAuthAt("", resolverTokenPath, "", "failed", 0, 0) + return nil, nil, nil, ErrUnauthenticated + } + if subtle.ConstantTimeCompare([]byte(row.TokenHash), []byte(hash)) != 1 { + logTokenAuthAt("", resolverTokenPath, "", "failed", 0, row.ID) + return nil, nil, nil, ErrUnauthenticated + } + if guard.users == nil { + logTokenAuthAt("", resolverTokenPath, "", "failed", 0, row.ID) + return nil, nil, nil, ErrUnauthenticated + } + principal, err := guard.users.FindByID(ctx, row.BackendUserID) + if err != nil || principal == nil { + logTokenAuthAt("", resolverTokenPath, "", "failed", row.BackendUserID, row.ID) + return nil, nil, nil, ErrUnauthenticated + } + stored := row.Scopes.Get() + if !row.Scopes.Valid { + stored = nil + } + // Personal API tokens keep the request locale (see authenticateToken). + principal.PreferredLocale = "" + stampLastUsed(ctx, guard.stamps, guard.store, row.ID, now) + logTokenAuthAt("", resolverTokenPath, "", "success", principal.ID, row.ID) + return principal, row, append([]string(nil), stored...), nil +} + +// ScopeLimitFor filters the stored scope names of a personal API token by one +// HTTP method, exactly as the backend guard does for a request: the limit to +// put on bouncer.Principal.Scope, the number of names the registry knows and +// the number of those that allow the method. A nil limit with known 0 means +// no stored scope exists any more (unauthenticated); known > 0 with methodOK +// 0 means the token has no scope for that method (token_scope). The second +// and third results are 0 when cabana is not activated on app. +func ScopeLimitFor(app *backpack.App, scopes []string, method string) (*bouncer.ScopeLimit, int, int) { + guard := publishedResolverGuard(app) + if guard == nil || guard.reg == nil { + return nil, 0, 0 + } + return methodFilterScopes(guard.reg.tokenScopes, scopes, method) +} + +// BackendPrincipal loads the admin principal with the given id through the +// backend users provider cabana published at activation: Backend, +// IsSuperuser and PermissionGrants are set as for a signed-in admin. It +// answers nil, nil when the admin does not exist or is deactivated, and an +// error when cabana is not activated on app or the database is unavailable. +func BackendPrincipal(ctx context.Context, app *backpack.App, id uint) (*bouncer.Principal, error) { + if ctx == nil { + ctx = context.Background() + } + guard := publishedResolverGuard(app) + if guard == nil || guard.users == nil { + return nil, ErrUnauthenticated + } + return guard.users.FindByID(ctx, id) +} + +// publishedResolverGuard returns the composite guard behind the published +// cookie-less resolver, or nil when cabana has not activated on app. +func publishedResolverGuard(app *backpack.App) *compositeGuard { + if app == nil { + return nil + } + res, ok := app.Lookup[*backendResolver]() + if !ok || res == nil || res.guard == nil { + return nil + } + return res.guard +} diff --git a/modules/surf/README.md b/modules/surf/README.md index 926637b0..7857fc82 100644 --- a/modules/surf/README.md +++ b/modules/surf/README.md @@ -126,6 +126,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur | `surf.Router.RegisterMiddleware` | Stores a named middleware; duplicates fail. | | `surf.Router.RegisterMiddlewareFactory` | Stores a parameterized middleware used as `name:param`. | | `surf.Router.Routes` | Returns a copy of the registered routes as `surf.RouteInfo` values. | +| `surf.Router.Handler` | Returns the compiled middleware chain of one registered route by method and pattern, for an in-process dispatch that must run the same guards, throttles, body limit, locale and recovery as an HTTP request. False for an unknown route or before `surf.BuildRouter` compiled the routes. | | `surf.Router.AdminPrefix` | The admin mount path and true when `surf.BuildRouter` mounted the cabana admin; false when no plugin registers an admin controller. | | `surf.RouteInfo` | Method, pattern, owning plugin, middleware and raw flag of one route. | | `surf.Use` | Builds a middleware name list for a group. | diff --git a/modules/surf/dispatch_smoke_test.go b/modules/surf/dispatch_smoke_test.go new file mode 100644 index 00000000..78b3d777 --- /dev/null +++ b/modules/surf/dispatch_smoke_test.go @@ -0,0 +1,142 @@ +package surf + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" +) + +// Smoke test for the in-process dispatch tracer of phase 19.7 (D-17): a +// request carrying the bouncer dispatch marker runs a guarded route through +// surf's compiled chain as the pre-resolved principal, while nothing an HTTP +// request can carry is treated as one. + +// refusingGuard never authenticates a request and writes its own 401. +type refusingGuard struct{} + +func (refusingGuard) AuthenticateCredential(*http.Request) (*bouncer.Principal, any, error) { + return nil, nil, errors.New("acme: no token") +} + +func (refusingGuard) WriteUnauthorized(w http.ResponseWriter, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":true,"message":"Unauthenticated"}`)) +} + +// dispatchPlugin registers bouncer guard "acme" as middleware "acme.auth" +// and one guarded route. +type dispatchPlugin struct{} + +func (dispatchPlugin) ID() string { return "acme.items" } +func (dispatchPlugin) Requires() []string { return nil } +func (dispatchPlugin) Register(*backpack.App) error { return nil } +func (dispatchPlugin) Boot(*backpack.App) error { return nil } + +func (p dispatchPlugin) Middlewares() map[string]pact.Middleware { + guards := bouncer.NewRegistry() + if err := guards.Register(p.ID(), "acme", refusingGuard{}); err != nil { + panic(err) + } + auth, err := guards.Middleware("acme") + if err != nil { + panic(err) + } + return map[string]pact.Middleware{"acme.auth": auth} +} + +func (dispatchPlugin) Routes(r pact.Router) error { + r.Group("/acme", Use("acme.auth"), func(g pact.Router) { + g.Get("/items/{id}", func(w http.ResponseWriter, req *http.Request) { + user, _ := bouncer.User(req.Context()) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"user":` + itoa(user.ID) + `,"item":"` + req.PathValue("id") + `"}`)) + }) + }) + return nil +} + +func itoa(n uint) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} + +func TestDispatchSmoke(t *testing.T) { + app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits}) + router, err := BuildRouter(app, []party.Plugin{dispatchPlugin{}}) + if err != nil { + t.Fatal(err) + } + if _, ok := router.Handler("GET", "/acme/items/{id}/missing"); ok { + t.Fatal("Handler found an unregistered route") + } + h, ok := router.Handler("GET", "/acme/items/{id}") + if !ok || h == nil { + t.Fatal("Handler did not find GET /acme/items/{id} after BuildRouter") + } + var nilRouter *Router + if _, ok := nilRouter.Handler("GET", "/acme/items/{id}"); ok { + t.Fatal("nil router found a handler") + } + + serve := func(set func(*http.Request) *http.Request) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/acme/items/7", nil) + req.SetPathValue("id", "7") + req.Pattern = "GET /acme/items/{id}" + req = set(req) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + marker := func(guards ...string) func(*http.Request) *http.Request { + return func(req *http.Request) *http.Request { + d := &bouncer.Dispatch{Guards: guards, Principal: &bouncer.Principal{ID: 42}} + return req.WithContext(bouncer.WithDispatch(req.Context(), d)) + } + } + + rec := serve(marker("acme")) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"user":42,"item":"7"}` { + t.Fatalf("dispatch with the marker = %d %s", rec.Code, rec.Body.String()) + } + if rec := serve(func(req *http.Request) *http.Request { return req }); rec.Code != http.StatusUnauthorized { + t.Fatalf("request without the marker = %d %s", rec.Code, rec.Body.String()) + } + forged := serve(func(req *http.Request) *http.Request { + req.Header.Set("X-Summer-Dispatch", "42") + req.Header.Set("Authorization", "Dispatch 42") + req.AddCookie(&http.Cookie{Name: "summer_dispatch", Value: "42"}) + return req + }) + if forged.Code != http.StatusUnauthorized { + t.Fatalf("forged headers = %d %s", forged.Code, forged.Body.String()) + } + if rec := serve(marker("other")); rec.Code != http.StatusUnauthorized { + t.Fatalf("marker for another guard = %d %s", rec.Code, rec.Body.String()) + } + + // The assembled handler serves the same route over HTTP unchanged. + full, err := router.compile() + if err != nil { + t.Fatal(err) + } + rec = httptest.NewRecorder() + full.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/acme/items/7", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("HTTP request without a token = %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/modules/surf/router.go b/modules/surf/router.go index a787ce9d..c5efab00 100644 --- a/modules/surf/router.go +++ b/modules/surf/router.go @@ -65,6 +65,9 @@ type Router struct { defaultBytes int64 uploadBytes int64 built map[string]pact.Middleware + // handlers holds the compiled middleware chain of every route, keyed + // "METHOD /pattern", filled by compileHandlers; Handler reads it. + handlers map[string]http.Handler app *backpack.App adminPrefix string adminMounted bool @@ -351,17 +354,42 @@ func (r *Router) add(pluginID, prefix string, groupMW []string, method, path str }) } +// compileHandlers wraps every registered route that has no compiled chain +// yet (constraints, named middleware, body limit, locale, recovery, CORS +// profile and the access-log pattern) and records it under "METHOD /pattern". +// BuildRouter calls it to validate the routes, compile reuses the chains and +// Handler hands one out for an in-process dispatch. +func (r *Router) compileHandlers() error { + if r == nil { + return fmt.Errorf("surf: router is nil") + } + if r.handlers == nil { + r.handlers = make(map[string]http.Handler) + } + for _, rt := range r.routes { + key := rt.method + " " + rt.path + if _, done := r.handlers[key]; done { + continue + } + h, err := r.wrap(rt) + if err != nil { + return err + } + r.handlers[key] = h + } + return nil +} + func (r *Router) compile() (http.Handler, error) { if r.compileErr != nil { return nil, r.compileErr } + if err := r.compileHandlers(); err != nil { + return nil, err + } handlers := make([]http.Handler, len(r.routes)) for i, rt := range r.routes { - h, err := r.wrap(rt) - if err != nil { - return nil, err - } - handlers[i] = h + handlers[i] = r.handlers[rt.method+" "+rt.path] } // Routes that ServeMux refuses side by side but Where constraints keep // apart register as overlap families (overlap.go); the rest as before. @@ -652,10 +680,8 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) { r.pages.setAdminPrefix(admin.Prefix) r.adminMounted = true } - for _, rt := range r.routes { - if _, err := r.wrap(rt); err != nil { - return nil, err - } + if err := r.compileHandlers(); err != nil { + return nil, err } warnTurnstileWithoutSecret(app, turnstile, r.routes) if err := r.addCORSPreflights(); err != nil { diff --git a/modules/surf/routetable.go b/modules/surf/routetable.go index b0db2230..3b70c983 100644 --- a/modules/surf/routetable.go +++ b/modules/surf/routetable.go @@ -1,5 +1,7 @@ package surf +import "net/http" + // RouteInfo is a read-only snapshot of one registered route after Assemble // or BuildRouter. type RouteInfo struct { @@ -37,3 +39,17 @@ func (r *Router) Routes() []RouteInfo { } return out } + +// Handler returns the compiled middleware chain of the route registered for +// method and pattern (as listed by Routes), for an in-process dispatch that +// must run the same constraints, guards, throttles, body limit, locale and +// recovery as an HTTP request. The caller sets the path values and Pattern +// on its request before serving, as the mux would. False when no such route +// exists or BuildRouter has not compiled the routes yet. +func (r *Router) Handler(method, pattern string) (http.Handler, bool) { + if r == nil || r.handlers == nil { + return nil, false + } + h, ok := r.handlers[method+" "+pattern] + return h, ok && h != nil +}