335 lines
32 KiB
Markdown
335 lines
32 KiB
Markdown
---
|
|
phase: 06-http-routing-auth-groups-and-rate-limiting
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- summercms.go/pact/capabilities.go
|
|
- summercms.go/surf/router.go
|
|
- summercms.go/surf/router_test.go
|
|
- summercms.go/bouncer/guard.go
|
|
- summercms.go/bouncer/registry.go
|
|
- summercms.go/bouncer/registry_test.go
|
|
- summercms.go/bouncer/context.go
|
|
- summercms.go/bouncer/context_test.go
|
|
- summercms.go/bouncer/jwt.go
|
|
- fonoteka.go/plugins/golem15/user/plugin.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/models/api_token.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard_test.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_test.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/routes.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go
|
|
- fonoteka.go/parity/genres_seed_test.go
|
|
- fonoteka.go/parity/manifest.yaml
|
|
autonomous: true
|
|
requirements: [HTTP-03, HTTP-05]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "A JWT-authenticated GET /_fonoteka/api/v1/genres and a personal-token GET /api/v1/fonoteka/genres are served by the literal same controllers.ListGenres(p.app) handler value (D-15 shared-handler proof)"
|
|
- "bouncer.User(ctx) resolves identically for a JWT caller and a personal-token caller; only one accessor exists (D-06)"
|
|
- "A missing/unknown/malformed inv_ token on the personal-token group returns 401 {\"error\":\"Invalid token\"}; a token missing the required scope returns 403 {\"error\":\"Missing required scope: <scope>\"} -- never bouncer's {\"error\":true,\"message\":...} shape (D-08)"
|
|
- "The jwt guard's existing 401 bodies and Middleware(secret, users) behavior are byte-identical to Phase 3 after being re-expressed through the registry (D-10)"
|
|
- "Registering two guards under the same name, or referencing an unregistered guard name, fails boot with a message naming both plugins (D-06)"
|
|
- "The oauth guard name is not registered by any code in this plan -- grep for Register( calls finds only \"jwt\" and \"inv_token\" (D-09)"
|
|
- "GET /api/v1/fonoteka/genres personal_token is status: ported in manifest.yaml and passes TestParityCorpus against real Postgres via a seed-hook-inserted token, not a production mint path (D-07)"
|
|
- "Parameterized (\"name:param\") middleware names are resolved by a surf factory mechanism, not a fixed string table, so \"inv.scope:write\" and a future \"throttle:10,1\" are written at call sites exactly as PHP's ->middleware() calls (D-05)"
|
|
artifacts:
|
|
- path: summercms.go/bouncer/registry.go
|
|
provides: "Named Guard registry: Register(pluginID, name string, g any) error, Middleware(name string) (func(http.Handler) http.Handler, error)"
|
|
- path: summercms.go/bouncer/guard.go
|
|
provides: "Guard, CredentialGuard, UnauthorizedWriter interfaces"
|
|
- path: fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go
|
|
provides: "TokenGuard: real inv_token verification against models.ApiToken (hash lookup, expiry, revocation, last-used stamp)"
|
|
- path: fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go
|
|
provides: "InvScope(scope string) pact.Middleware with exact PHP 401/403 bodies"
|
|
key_links:
|
|
- from: fonoteka.go/plugins/golem15/fonoteka/routes.go
|
|
to: fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go
|
|
via: "both r.Group calls register controllers.ListGenres(p.app) as the GET handler"
|
|
pattern: "controllers\\.ListGenres\\(p\\.app\\)"
|
|
- from: fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go
|
|
to: summercms.go/bouncer/context.go
|
|
via: "InvScope reads bouncer.User(ctx) and bouncer.Credential(ctx)"
|
|
pattern: "bouncer\\.(User|Credential)\\(r\\.Context\\(\\)\\)"
|
|
- from: fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go
|
|
to: fonoteka.go/plugins/golem15/fonoteka/models/api_token.go
|
|
via: "hash lookup against ApiToken.TokenHash, then IsUsable()/HasScope()"
|
|
pattern: "token_hash = \\?|IsUsable\\(\\)|HasScope\\("
|
|
---
|
|
|
|
<objective>
|
|
Turn the Phase 3 JWT-only routing skeleton into a real two-guard auth surface: extend the router to support non-GET verbs and parameterized ("name:param") middleware, build a named Guard registry in `bouncer` that both the existing `jwt` guard and a new real `inv_token` guard resolve through to one `bouncer.User(ctx)` accessor, and prove HTTP-03's core claim -- the same handler serves both the JWT group and the personal-token group -- by mounting `GET genres` under `/_fonoteka/api/v1` (JWT) and `/api/v1/fonoteka` (`inv_token` + `inv.scope:read`).
|
|
|
|
Purpose: this is the foundational wave every later Phase 6 plan (rate limiting, raw groups, response conventions) builds on -- the router's verb/factory growth and the guard registry are load-bearing seams, not local-only code.
|
|
Output: `pact.Router`/`surf.Router` support Post/Put/Patch/Delete and colon-parameterized middleware names; `bouncer.Registry` with two real guards; `golem15.fonoteka`'s `inv_token` guard and `inv.scope` middleware; genres reachable and parity-green on both auth groups.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md
|
|
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md
|
|
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-PATTERNS.md
|
|
</context>
|
|
|
|
<interfaces>
|
|
New file summercms.go/bouncer/guard.go:
|
|
|
|
package bouncer
|
|
|
|
import "net/http"
|
|
|
|
// Guard resolves the caller's Principal for r, or an error describing why not.
|
|
type Guard interface {
|
|
Authenticate(r *http.Request) (*Principal, error)
|
|
}
|
|
|
|
// CredentialGuard resolves the Principal AND its underlying credential (e.g.
|
|
// *models.ApiToken) in one pass -- a DB-backed guard must never verify twice
|
|
// per request (RESEARCH.md Pitfall 5: last_used_at must stamp once).
|
|
type CredentialGuard interface {
|
|
AuthenticateCredential(r *http.Request) (*Principal, any, error)
|
|
}
|
|
|
|
// UnauthorizedWriter lets a guard write its own failure response. jwtGuard
|
|
// implements this (reusing write401's {"error":true,"message":...} shape).
|
|
// TokenGuard does NOT implement it: PHP's TokenScope, not ApiTokenGuard, owns
|
|
// the {"error":"Invalid token"} 401 body (D-08) -- Registry.Middleware must
|
|
// pass an unauthenticated request through untouched when a guard has no
|
|
// UnauthorizedWriter, leaving denial to downstream middleware.
|
|
type UnauthorizedWriter interface {
|
|
WriteUnauthorized(w http.ResponseWriter, err error)
|
|
}
|
|
|
|
New file summercms.go/bouncer/registry.go:
|
|
|
|
package bouncer
|
|
|
|
import "net/http"
|
|
|
|
type Registry struct{ /* unexported: map[string]namedGuard */ }
|
|
|
|
func NewRegistry() *Registry
|
|
|
|
// Register stores g under name. g must implement Guard or CredentialGuard.
|
|
// Empty name, nil g, a type implementing neither, or a duplicate name all
|
|
// fail with a "bouncer: ..." error naming pluginID and name.
|
|
func (reg *Registry) Register(pluginID, name string, g any) error
|
|
|
|
// Middleware derives an http middleware from a registered guard. Unknown
|
|
// names fail (fail boot, mirrors surf.RegisterMiddleware's contract).
|
|
// On Authenticate/AuthenticateCredential success: WithUser (+WithCredential
|
|
// 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.
|
|
func (reg *Registry) Middleware(name string) (func(http.Handler) http.Handler, error)
|
|
|
|
Extended summercms.go/bouncer/context.go (add alongside the existing userKey/WithUser/User):
|
|
|
|
type credentialKey struct{}
|
|
|
|
func WithCredential(ctx context.Context, cred any) context.Context
|
|
func Credential(ctx context.Context) (any, bool)
|
|
|
|
New guard constructor in summercms.go/bouncer/jwt.go (adapter over the UNCHANGED existing helpers -- do not edit bearerToken/Verify/write401/msgUserNotFound bodies):
|
|
|
|
// NewJWTGuard adapts the existing bearerToken -> Verify -> users.FindByID
|
|
// chain (identical to Middleware's body) into a Guard + UnauthorizedWriter,
|
|
// so Registry.Middleware("jwt") is byte-identical to bouncer.Middleware.
|
|
func NewJWTGuard(secret string, users UserProvider) Guard
|
|
|
|
Extended summercms.go/pact/capabilities.go (Router interface):
|
|
|
|
type Router interface {
|
|
Group(prefix string, middleware []string, fn func(Router))
|
|
Get(path string, handler http.HandlerFunc, middleware ...string)
|
|
Post(path string, handler http.HandlerFunc, middleware ...string)
|
|
Put(path string, handler http.HandlerFunc, middleware ...string)
|
|
Patch(path string, handler http.HandlerFunc, middleware ...string)
|
|
Delete(path string, handler http.HandlerFunc, middleware ...string)
|
|
Where(param, pattern string)
|
|
WhereIn(param string, values ...string)
|
|
}
|
|
|
|
Extended summercms.go/surf/router.go:
|
|
|
|
// RegisterMiddlewareFactory stores a parameterized middleware. At wrap time,
|
|
// a route middleware name not found in r.named is split on the first ':'
|
|
// (strings.Cut); if the base name matches a registered factory, fn(param)
|
|
// builds the pact.Middleware for that one route. Duplicate factory names
|
|
// fail exactly like RegisterMiddleware.
|
|
func (r *Router) RegisterMiddlewareFactory(pluginID, name string, fn func(param string) pact.Middleware) error
|
|
|
|
Extended summercms.go/pact/capabilities.go (new capability, collected in surf.Assemble in the same loop as HasMiddleware):
|
|
|
|
type HasMiddlewareFactories interface {
|
|
MiddlewareFactories() map[string]func(param string) Middleware
|
|
}
|
|
</interfaces>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1 (summercms.go): Router verb growth, parameterized-middleware factories, and the bouncer Guard registry</name>
|
|
<files>summercms.go/pact/capabilities.go, summercms.go/surf/router.go, summercms.go/surf/router_test.go, summercms.go/bouncer/guard.go, summercms.go/bouncer/registry.go, summercms.go/bouncer/registry_test.go, summercms.go/bouncer/context.go, summercms.go/bouncer/context_test.go, summercms.go/bouncer/jwt.go</files>
|
|
<read_first>
|
|
summercms.go/pact/capabilities.go (full -- current Router interface, HasMiddleware)
|
|
summercms.go/surf/router.go (full -- route struct, add/compile/wrap, RegisterMiddleware, Assemble)
|
|
summercms.go/surf/router_test.go (full -- existing test shapes to extend, esp. TestMissingMiddlewareNamesPluginAndName)
|
|
summercms.go/bouncer/jwt.go (full -- bearerToken, Verify, write401, mapJWTError, msgUserNotFound: reuse verbatim, do not rewrite)
|
|
summercms.go/bouncer/context.go (full -- WithUser/User shape to mirror for WithCredential/Credential)
|
|
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md (Pitfall 9: router is GET-only, exact lines to generalize; Pattern 1)
|
|
</read_first>
|
|
<action>
|
|
In pact/capabilities.go: add Post, Put, Patch, Delete to the Router interface with the exact same signature shape as Get (path string, handler http.HandlerFunc, middleware ...string). Add HasMiddlewareFactories interface { MiddlewareFactories() map[string]func(param string) Middleware } alongside the existing HasMiddleware (bare Middleware, not pact.Middleware -- this interface is declared inside package pact itself, where Middleware is already the unqualified local type).
|
|
|
|
In surf/router.go: thread a method string parameter through add(pluginID, prefix string, groupMW []string, method, path string, handler http.HandlerFunc, extra []string); change the duplicate-detection key from the hardcoded "GET " + full to method + " " + full; change route{method: "GET", ...} to use the passed method. Add Post/Put/Patch/Delete on both *Router and *Group, each calling add with its own HTTP method string, mirroring Get's and Group.Get's existing nil-guard shape exactly. In compile(), change mux.Handle("GET "+rt.path, h) to mux.Handle(rt.method+" "+rt.path, h).
|
|
|
|
Add a factories map[string]namedMiddlewareFactory field to Router (initialize in New), a namedMiddlewareFactory{pluginID string; fn func(param string) pact.Middleware} type, and RegisterMiddlewareFactory(pluginID, name string, fn func(param string) pact.Middleware) error mirroring RegisterMiddleware's nil/empty/duplicate checks and "surf: middleware factory %q already registered by %s" error shape. In wrap()'s middleware-resolution loop, when r.named[name] misses, call strings.Cut(name, ":"); if hasParam and r.factories[base] exists, build h = r.factories[base].fn(param)(h) and continue the loop instead of falling through to the existing "unknown middleware" error (only fall through to that error when neither a named middleware nor a factory match). In Assemble(), add a second loop (after the existing HasMiddleware loop, before the Routes loop) that type-asserts each plugin against pact.HasMiddlewareFactories and calls r.RegisterMiddlewareFactory(p.ID(), name, fn) for each entry.
|
|
|
|
Create bouncer/guard.go with the Guard, CredentialGuard, UnauthorizedWriter interfaces exactly as specified in the interfaces block above.
|
|
|
|
Create bouncer/registry.go with Registry, NewRegistry(), Register(pluginID, name string, g any) error (validate g is non-nil and implements Guard or CredentialGuard via a type switch; fail with "bouncer: plugin %q registered guard %q that implements neither Guard nor CredentialGuard" otherwise; duplicate name fails with "bouncer: guard %q already registered by %s", mirroring RegisterMiddleware's message shape), and Middleware(name string) (func(http.Handler) http.Handler, error) exactly as specified above: prefer CredentialGuard.AuthenticateCredential when the guard implements it, else Guard.Authenticate; on error, call UnauthorizedWriter.WriteUnauthorized if implemented, else pass the request through unauthenticated (no principal attached); on success, WithUser then WithCredential (only if a non-nil credential was returned) before calling next.
|
|
|
|
Extend bouncer/context.go with credentialKey{} and WithCredential/Credential, an exact structural mirror of userKey{}/WithUser/User (nil-context guard, context.WithValue, type-assert-with-ok).
|
|
|
|
In bouncer/jwt.go, add NewJWTGuard(secret string, users UserProvider) Guard returning an unexported jwtGuard{secret, users} struct whose Authenticate method runs the IDENTICAL sequence Middleware's closure already runs (bearerToken(r) -> Verify(raw, secret) -> strconv.ParseUint the subject -> users.FindByID -> nil checks) by calling those same existing package-level helpers, returning (*Principal, error) instead of writing a response; its WriteUnauthorized(w, err) method calls the existing write401(w, err.Error()) verbatim. Do NOT change the body of the existing exported Middleware function -- it keeps working unmodified (D-10).
|
|
</action>
|
|
<verify>
|
|
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./surf/... ./bouncer/... ./pact/... -short</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- surf.Router and surf.Group both expose working Post/Put/Patch/Delete; a new test registers POST /items and GET /items on the same path and asserts both are dispatched independently (no collision, no duplicate-route error).
|
|
- A new test registers a middleware factory under "inv.scope" and a route using "inv.scope:write"; asserts the factory receives exactly "write" as param and its returned middleware runs.
|
|
- Registry duplicate/unknown-name tests in bouncer/registry_test.go assert the error string contains both plugin id and guard name (mirroring TestMissingMiddlewareNamesPluginAndName's assertion shape).
|
|
- A test registers a fake guard implementing only Guard + UnauthorizedWriter, asserts Registry.Middleware writes the guard's own response and never calls next on failure.
|
|
- A test registers a fake guard implementing only CredentialGuard (no UnauthorizedWriter), asserts next still runs on failure with NO principal attached to the context (soft-fail path), and that a successful AuthenticateCredential attaches both bouncer.User(ctx) and bouncer.Credential(ctx).
|
|
- bouncer.NewJWTGuard(secret, users) wrapped through Registry.Middleware("jwt") produces byte-identical response bodies/status codes to bouncer.Middleware(secret, users) for the same four failure cases already covered by bouncer/jwt_test.go (missing token, expired, bad signature, unknown user).
|
|
</acceptance_criteria>
|
|
<done>pact.Router supports all four non-GET verbs; surf resolves "name:param" middleware via registered factories; bouncer.Registry exists with Guard/CredentialGuard/UnauthorizedWriter and is unit-tested in isolation from any real guard.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2 (fonoteka.go): Real inv_token guard, inv.scope middleware, and guard registration in both plugins</name>
|
|
<files>fonoteka.go/plugins/golem15/user/plugin.go, fonoteka.go/plugins/golem15/fonoteka/plugin.go, fonoteka.go/plugins/golem15/fonoteka/models/api_token.go, fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go, fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard_test.go, fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go, fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_test.go</files>
|
|
<behavior>
|
|
- ApiToken.HasScope("read") is true only when "read" is present in Scopes.Get().
|
|
- ApiToken.IsUsable() is false when RevokedAt != nil, false when ExpiresAt != nil and in the past, true otherwise.
|
|
- TokenGuard.AuthenticateCredential returns an error for: no Authorization header, a bearer not prefixed inv_, an unknown hash, a revoked token, an expired token.
|
|
- TokenGuard.AuthenticateCredential on a valid token returns (*bouncer.Principal{ID: token.UserID, ...}, *models.ApiToken, nil) and stamps LastUsedAt/LastUsedIP exactly once (assert via a row re-read, not a mock).
|
|
- InvScope("write") on a request with no bouncer.User(ctx) writes 401 {"error":"Invalid token"}.
|
|
- InvScope("write") on a request with a resolved user but a credential lacking the write scope writes 403 {"error":"Missing required scope: write"}.
|
|
- InvScope("read") on a request with a read-scoped credential calls next.
|
|
</behavior>
|
|
<read_first>
|
|
summercms.go/bouncer/guard.go, summercms.go/bouncer/registry.go, summercms.go/bouncer/context.go (Task 1 output)
|
|
fonoteka.go/plugins/golem15/fonoteka/models/api_token.go (full -- Fillable/Hidden/fields, Scopes is lagoon.Jsonable[[]string])
|
|
fonoteka.go/plugins/golem15/fonoteka/middleware/must_change_password.go (full -- exact same-package middleware shape to mirror)
|
|
fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go (writeJSON/writeOpaque500 -- do NOT import cross-package; token_scope.go needs its own tiny writer, see action)
|
|
fonoteka.go/plugins/golem15/user/plugin.go (full -- Boot/Middlewares to restructure)
|
|
fonoteka.go/plugins/golem15/fonoteka/plugin.go (full -- Boot/Middlewares to extend)
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/TokenScope.php (exact 401/403 bodies, lines 39-60)
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/ApiTokenGuard.php (verify/stamp sequence, lines 42-63)
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/ApiTokenManager.php (PREFIX = 'inv_', sha256 hash, verify() gate, lines 20-83)
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/ApiToken.php (isExpired/isRevoked/isUsable/hasScope, lines 63-77)
|
|
</read_first>
|
|
<action>
|
|
In models/api_token.go, add two methods: HasScope(scope string) bool returning slices.Contains(t.Scopes.Get(), scope) (import "slices"), and IsUsable() bool returning t.RevokedAt == nil && (t.ExpiresAt == nil || t.ExpiresAt.After(time.Now())). Leave Fillable/Hidden/TableName untouched.
|
|
|
|
Create classes/auth/token_guard.go (package auth): TokenGuard struct { db *gorm.DB }, NewTokenGuard(db *gorm.DB) *TokenGuard. Implement AuthenticateCredential(r *http.Request) (*bouncer.Principal, any, error): read Authorization header, trim a leading "Bearer " (same convention as PHP's $request->bearerToken()); if empty or not strings.HasPrefix(bearer, "inv_"), return an error (mutual-exclusion gate, mirrors ApiTokenManager::verify's prefix check); compute sha256.Sum256([]byte(bearer)) hex-encoded; SELECT one models.ApiToken WHERE token_hash = ?; if not found or !token.IsUsable(), return an error; stamp last_used_at/last_used_ip with a single UpdateColumns call against models.ApiToken{} filtered by id = ? (derive the IP from r.RemoteAddr via net.SplitHostPort, falling back to the raw value on split error -- the trusted-proxy-aware client IP function lands in 06-02; do not build it here) so no GORM hooks or query logging capture the raw bearer; load the owning usermodels.User by token.UserID; return &bouncer.Principal{ID: user.ID, MustChangePassword: user.MustChangePassword}, &token, nil. TokenGuard implements bouncer.CredentialGuard only -- it must NOT implement UnauthorizedWriter (D-08: TokenScope, not the guard, owns the 401/403 bodies).
|
|
|
|
Create middleware/token_scope.go (package middleware, same package as must_change_password.go): InvScope(scope string) pact.Middleware. Body: read bouncer.User(r.Context()); if not ok, write status 401 with body {"error":"Invalid token"} and return (do not call next). Otherwise read bouncer.Credential(r.Context()), type-assert to *models.ApiToken; if the assertion fails or !token.HasScope(scope), write status 403 with body {"error":"Missing required scope: " + scope} and return. Otherwise call next.ServeHTTP(w, r). Write the two JSON bodies with a small unexported writeJSON(w http.ResponseWriter, status int, v any) local to this file (Content-Type: application/json, json.NewEncoder(w).Encode(v)) -- do not reuse bouncer's write401 (different body shape: bool error there, string error here) and do not cross-import controllers.
|
|
|
|
In fonoteka.go/plugins/golem15/user/plugin.go: in Boot, after the existing classes.JWTSecret(app) and hook-registration code, look up *bouncer.Registry via app.Lookup[*bouncer.Registry](); if absent, construct with bouncer.NewRegistry() and app.Publish(reg); call reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app})) and return its error. Rewrite Middlewares() to look up the registry (p.app.Lookup[*bouncer.Registry]()), call reg.Middleware("jwt"), and return map[string]pact.Middleware{"jwt.auth": mw} (nil map if either lookup fails, matching the existing nil-on-error convention already used for the JWT-secret failure path).
|
|
|
|
In fonoteka.go/plugins/golem15/fonoteka/plugin.go: inside the existing if gdb, ok := app.Lookup[*gorm.DB](); ok { ... } block in Boot, after classes.RegisterHooks(gdb) succeeds, look up-or-create the SAME *bouncer.Registry (identical lookup-or-create snippet as the user plugin) and call reg.Register(p.ID(), "inv_token", auth.NewTokenGuard(gdb)), returning its error. Extend Middlewares() to also look up bouncer.Registry and, if reg.Middleware("inv_token") succeeds, add "inv_token": mw to the returned map alongside the existing "inv.must-change-password" entry. Add a new method MiddlewareFactories() map[string]func(param string) pact.Middleware { return map[string]func(string) pact.Middleware{"inv.scope": func(scope string) pact.Middleware { return middleware.InvScope(scope) }} } and declare _ pact.HasMiddlewareFactories = (*Plugin)(nil) alongside the existing interface assertions.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... -short</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- go test ./plugins/golem15/fonoteka/classes/auth/... -run TestTokenGuard covers every behavior case listed above, using a real (testcontainers) Postgres ApiToken row, not a mock.
|
|
- go test ./plugins/golem15/fonoteka/middleware/... -run TestInvScope asserts the exact byte bodies {"error":"Invalid token"} and {"error":"Missing required scope: write"} (not the {"error":true,"message":...} shape).
|
|
- A boot-time test (activating both plugins against a real *backpack.App) asserts reg.Middleware("jwt") and reg.Middleware("inv_token") both resolve without error after party.Activate.
|
|
- LastUsedAt/LastUsedIP are asserted to change exactly once per AuthenticateCredential call in the test (no double-stamp).
|
|
</acceptance_criteria>
|
|
<done>Both guards are registered through the same bouncer.Registry; jwt.auth is behaviorally unchanged; inv_token + inv.scope reproduce TokenScope.php's exact 401/403 contract.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3 (fonoteka.go, parity): Mount genres under both auth groups, parity seed hook for the token surface, shared-handler + isolation tests</name>
|
|
<files>fonoteka.go/plugins/golem15/fonoteka/routes.go, fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go, fonoteka.go/parity/genres_seed_test.go, fonoteka.go/parity/manifest.yaml</files>
|
|
<read_first>
|
|
fonoteka.go/plugins/golem15/fonoteka/routes.go (current, full -- 14 lines)
|
|
fonoteka.go/parity/genres_seed_test.go (full -- seedGenres, upsertParityAlice, upsertParityCollection, mintTestJWT patterns to mirror for a test-only token mint)
|
|
fonoteka.go/parity/manifest.yaml lines 2803-2841 (the two personal_token genres entries; the sibling GET /_fonoteka/api/v1/genres jwt entry at lines 1514-1532 for the exact status: ported / seed_hook: genres shape to copy)
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php lines 449-454, 513-514 (the real /api/v1/fonoteka group prefix, middleware, and the genres route's inv.scope:read)
|
|
</read_first>
|
|
<action>
|
|
In routes.go, add a second r.Group("/api/v1/fonoteka", surf.Use("inv_token", "inv.scope:read"), func(g pact.Router) { g.Get("/genres", controllers.ListGenres(p.app)) }) call after the existing JWT group, passing the SAME controllers.ListGenres(p.app) handler value used in the JWT group (proves D-15's shared-handler requirement by construction, not convention). PHP's real group middleware also carries a group-level throttle:fonoteka-api-token (routes.php line 450) -- that bucket does not exist until 06-02, so leave a "// TODO(06-02): throttle:fonoteka-api-token" comment directly above this r.Group call as the exact landing spot; do not add a "throttle:..." string to surf.Use(...) yet (it would fail boot with "unknown middleware" until 06-02 registers the factory).
|
|
|
|
In parity/genres_seed_test.go, extend seedGenres (reuse the existing Alice + collection setup it already performs) to also mint a test-only personal token: generate 32 random bytes, base64url-encode (no padding) with an "inv_" prefix -- mirroring ApiTokenManager::mint's secret shape but minted directly via GORM (minting stays test-only per D-07, never through a production endpoint); compute sha256 hex of the secret; upsert (idempotent, same Where(...).Take then Create pattern as upsertParityAlice) a fonotekamodels.ApiToken{UserID: alice.ID, Name: <a *string pointing at "Parity MCP">, TokenHash: hash, Scopes: lagoon.Jsonable[[]string]{Data: []string{"read"}, Valid: true}} row; call store.Set("token:mcp-read", secret) so the manifest's existing Authorization: "Bearer {{token:mcp-read}}" fixture headers resolve during replay. Keep this inside the existing seedGenres function and the existing "genres" map entry in seedHooks -- do not introduce a second hook name, since the personal-token genres route needs the identical Alice/collection context seedGenres already builds.
|
|
|
|
In parity/manifest.yaml, on the GET /api/v1/fonoteka/genres personal_token entry (currently status: pending, no seed_hook key), change status: pending to status: ported and add a seed_hook: genres line directly below status:, matching the exact key placement already used on the GET /_fonoteka/api/v1/genres jwt entry. Do not modify any other manifest entry (in particular leave POST /api/v1/fonoteka/genres personal_token and every other pending entry untouched).
|
|
|
|
Create routes_group_test.go (matching the existing test package convention in this directory) with httptest-based assertions: (a) a request to /_fonoteka/api/v1/genres with a valid JWT and a request to /api/v1/fonoteka/genres with a valid inv_ token both return 200 with equal GenreList bodies for the same seeded user/collection (shared-handler proof); (b) a request to /api/v1/fonoteka/genres with no Authorization header returns 401 {"error":"Invalid token"}; (c) a request with a well-formed but unknown inv_ bearer returns 401 {"error":"Invalid token"}; (d) a request with a token minted with only ["write"] scopes returns 403 {"error":"Missing required scope: read"} on the same route.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./plugins/golem15/fonoteka/... -run TestGenresSharedHandler -short && go test ./parity/... -run TestParityCorpus</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- The new httptest suite passes all four sub-assertions listed above.
|
|
- go test ./parity/... -run TestParityCorpus reports GET /api/v1/fonoteka/genres personal_token as passing (not pending) and does not regress the existing GET /_fonoteka/api/v1/genres jwt passing count.
|
|
- grep -n "status: ported" fonoteka.go/parity/manifest.yaml shows exactly two entries (the pre-existing jwt genres route and the newly-flipped personal_token genres route).
|
|
</acceptance_criteria>
|
|
<done>Genres is reachable and parity-green under both auth groups through the identical handler; the personal-token guard, scope gate, and 401/403 bodies are proven end to end, not just unit-tested in isolation.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|--------------|
|
|
| client -> Authorization header | untrusted bearer credential (JWT or inv_ token) parsed on every request |
|
|
| guard registry -> plugin Boot | plugin-declared guard names become live auth middleware; a misregistration is a silent-bypass risk if not fail-loud |
|
|
| inv_token guard -> golem15_fonoteka_api_tokens | hash-indexed lookup of an untrusted bearer against stored token_hash |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-06-01 | Spoofing | bouncer.Registry.Register | mitigate | Duplicate or unregistered guard names fail Boot loudly (Task 1 test), mirroring surf.RegisterMiddleware's existing fail-boot contract -- no silent no-op auth |
|
|
| T-06-02 | Elevation of Privilege | inv_token credential vs jwt credential | mitigate | Each guard is registered and resolved independently; Task 3's shared-handler test proves both groups reach the SAME handler through DIFFERENT guards, and Task 3 asserts the personal-token group rejects a missing/invalid inv_ credential -- full route-table mutual-exclusivity (zero jwt.auth on the token group, zero inv_token/inv.scope on the JWT group) is completed in 06-03 once the route table exists; this plan's partial coverage is the two groups never sharing a middleware list literal |
|
|
| T-06-03 | Information Disclosure | ApiToken.TokenHash | accept | Already hidden via json:"-" and Hidden() (Phase 5, verified by 05-06's hidden-marshal test); this plan adds no new serialization path for the hash |
|
|
| T-06-04 | Repudiation | last_used_at/last_used_ip stamping | mitigate | TokenGuard stamps via a single UpdateColumns call with no query/error logging of the raw bearer; Task 2 test asserts exactly one stamp per AuthenticateCredential call |
|
|
| T-06-05 | Tampering | inv_token guard's SHA-256 hash lookup | accept | Indexed equality lookup (not a byte-for-byte secret compare) is not a timing side-channel per RESEARCH.md's V6 Cryptography note; crypto/subtle is reserved for a future raw-compare path (e.g. OAuth client secrets, Phase 8), not needed here |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
cd summercms.go && go vet ./... && go test ./surf/... ./bouncer/... ./pact/... -short
|
|
cd ../fonoteka.go && go vet ./... && go test ./plugins/golem15/... -short && go test ./parity/... -run TestParityCorpus
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- pact.Router and surf.Router/Group support Get/Post/Put/Patch/Delete and colon-parameterized middleware factories.
|
|
- bouncer.Registry resolves both "jwt" and "inv_token" to the same bouncer.User(ctx) accessor; jwt's behavior is unchanged from Phase 3.
|
|
- golem15.fonoteka's inv_token guard and inv.scope middleware reproduce TokenScope.php's exact 401/403 bodies.
|
|
- GET genres is reachable and parity-green on both /_fonoteka/api/v1 (JWT) and /api/v1/fonoteka (personal token) through the identical handler value.
|
|
- go vet ./... and go test ./... are green in both repos (testcontainers-gated tests included).
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-01-SUMMARY.md` when done
|
|
</output>
|