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 {

View File

@@ -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`. |

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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. |

View File

@@ -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())
}
}

View File

@@ -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 {

View File

@@ -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
}