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:
@@ -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`. |
|
||||
|
||||
72
modules/cabana/dispatch_smoke_test.go
Normal file
72
modules/cabana/dispatch_smoke_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
107
modules/cabana/token_resolver.go
Normal file
107
modules/cabana/token_resolver.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user