|
|
|
|
@@ -0,0 +1,259 @@
|
|
|
|
|
---
|
|
|
|
|
phase: quick-261022-usi
|
|
|
|
|
plan: 01
|
|
|
|
|
quick_id: 261022-usi
|
|
|
|
|
slug: user-plugin-jwt-blacklist-and-hooks-wire
|
|
|
|
|
type: execute
|
|
|
|
|
mode: quick
|
|
|
|
|
wave: 1
|
|
|
|
|
depends_on: []
|
|
|
|
|
autonomous: true
|
|
|
|
|
requirements:
|
|
|
|
|
- usi-1 # G4: under summer serve (party.Activate before lagoon.Publish) POST /logout revokes the token: the jwt guard sees the Postgres jwt_blacklist store, the store and the plugin's GORM hooks are wired through lagoon.OnDatabase; the jwt guard reads classes.SessionCookieNames() (G3 leftover)
|
|
|
|
|
files_modified:
|
|
|
|
|
# sm-user-plugin (module golem15.user), worked in the sm-demo-app submodule checkout. Commit there only.
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/plugin.go
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/blacklist.go
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/blacklist_test.go
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/serve_order_test.go
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/admin_harness_test.go
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/README.md
|
|
|
|
|
|
|
|
|
|
estimate:
|
|
|
|
|
tokens: 90000
|
|
|
|
|
raw_tokens: 90000
|
|
|
|
|
tasks: 2
|
|
|
|
|
confidence: low # estimate-calibration: factor 1, sample_count 0
|
|
|
|
|
|
|
|
|
|
must_haves:
|
|
|
|
|
truths:
|
|
|
|
|
- "Booted in the serve order (party.Activate with no database published, then lagoon.Publish, then the router), a session token that POST /logout ended is refused with 401 on a jwt.auth route through the bearer header and through each session cookie, at GET /fetch and at POST /refresh, and jwt_blacklist holds one row for its jti."
|
|
|
|
|
- "Before lagoon.Publish no bouncer.BlacklistStore is published and the plugin's GORM hooks have not run; when lagoon.Publish runs, classes.RegisterHooks runs with the published *gorm.DB, a *bouncer.PostgresBlacklist on jwt_blacklist is published as bouncer.BlacklistStore, and the blacklist sweep starts."
|
|
|
|
|
- "Boot registers the jwt guard exactly once, with or without a database, with the cookie names from classes.SessionCookieNames() and a plugin-local store that resolves the published bouncer.BlacklistStore on every call. Without a published store no token counts as blacklisted and Add and Sweep do nothing. A store error passes through unchanged, so the guard still refuses the token. The wrapper itself is never published."
|
|
|
|
|
- "When the database is published before Boot (the plugin's own tests, the sociallogin and oauthserver harnesses), hooks, store and sweep are still wired synchronously inside Boot, so the existing suites stay green unchanged."
|
|
|
|
|
- "go vet, go test -short and the full Docker-backed go test of the plugin pass at one fix commit on sm-user-plugin master. Nothing is pushed or tagged, the sm-demo-app pointer is not bumped and summercms.go is not modified."
|
|
|
|
|
artifacts:
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/plugin.go
|
|
|
|
|
provides: "Boot wires RegisterHooks, the Postgres blacklist store and its sweep through one lagoon.OnDatabase call; the jwt guard gets the lazy store and classes.SessionCookieNames()"
|
|
|
|
|
contains: "lagoon.OnDatabase(app, func("
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/blacklist.go
|
|
|
|
|
provides: "appBlacklist, the unexported bouncer.BlacklistStore that resolves the published store per call"
|
|
|
|
|
contains: "type appBlacklist struct"
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/admin_harness_test.go
|
|
|
|
|
provides: "adminEnvConfig (extracted, unchanged config) and newServeOrderEnv (Activate, hook point, Publish, uploads, Migrate, Assemble)"
|
|
|
|
|
contains: "func newServeOrderEnv"
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/serve_order_test.go
|
|
|
|
|
provides: "TestBootBeforeDatabase, the Activate-then-Publish regression test (Docker, skips under -short)"
|
|
|
|
|
contains: "func TestBootBeforeDatabase"
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/blacklist_test.go
|
|
|
|
|
provides: "TestAppBlacklist: no store, wrapper-published-as-store, delegation, error pass-through (runs under -short)"
|
|
|
|
|
contains: "func TestAppBlacklist"
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/README.md
|
|
|
|
|
provides: "JWT-sessions bullet describes the OnDatabase wiring and the per-request store; Testing names TestBootBeforeDatabase and TestAppBlacklist"
|
|
|
|
|
contains: "TestBootBeforeDatabase"
|
|
|
|
|
key_links:
|
|
|
|
|
- from: ../sm-demo-app/plugins/golem15/user/plugin.go (Boot)
|
|
|
|
|
to: summercms.go modules/lagoon/ondatabase.go (OnDatabase) and modules/lagoon/connection.go (Publish -> runDatabaseHooks)
|
|
|
|
|
via: "the callback is queued in Boot and drained by the serve command's lagoon.Publish, which runs before BuildRouter"
|
|
|
|
|
pattern: "lagoon\\.OnDatabase\\(app, func\\("
|
|
|
|
|
- from: ../sm-demo-app/plugins/golem15/user/plugin.go (jwt guard registration)
|
|
|
|
|
to: ../sm-demo-app/plugins/golem15/user/blacklist.go (appBlacklist.store)
|
|
|
|
|
via: "bouncer.NewJWTGuard captures the wrapper at Boot; the wrapper looks up bouncer.BlacklistStore on the same app per request"
|
|
|
|
|
pattern: "&appBlacklist\\{app: app\\}"
|
|
|
|
|
- from: ../sm-demo-app/plugins/golem15/user/plugin.go (jwt guard registration)
|
|
|
|
|
to: ../sm-demo-app/plugins/golem15/user/classes/session_user.go (SessionCookieNames)
|
|
|
|
|
via: "the guard, SessionToken, EndSession and the controllers read the same cookie list"
|
|
|
|
|
pattern: "classes\\.SessionCookieNames\\(\\)\\.\\.\\.\\)"
|
|
|
|
|
- from: ../sm-demo-app/plugins/golem15/user/classes/session_user.go (EndSession, the POST /logout path)
|
|
|
|
|
to: the store the OnDatabase callback publishes
|
|
|
|
|
via: "EndSession already looks bouncer.BlacklistStore up at request time; once the callback publishes it, logout writes the jwt_blacklist row the guard then reads"
|
|
|
|
|
pattern: "Publish\\[bouncer\\.BlacklistStore\\]\\(store\\)"
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
<objective>
|
|
|
|
|
Close escalation G4. Under `summer serve`, the generated main runs party.Activate (every plugin Boot) before surf's serve command calls lagoon.Publish. The user plugin's Boot finds no database, so it registers the jwt guard with a nil blacklist and never publishes bouncer.BlacklistStore. classes.EndSession (POST /logout) then finds no store and writes nothing, so a logged-out token keeps working. classes.RegisterHooks has the same hole. After this plan the database-dependent wiring runs through lagoon.OnDatabase, and the guard resolves the store per request. While reworking that Register line, switch the jwt guard to classes.SessionCookieNames() (G3 leftover).
|
|
|
|
|
|
|
|
|
|
Purpose: logout revokes the session in production (serve), not only in tests that publish the database first.
|
|
|
|
|
Output: one fix commit on sm-user-plugin master (code, tests and README together), made in /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user with git -C. No push, no tag, no sm-demo-app pointer bump (SummerCMS v0.5.0 release hold). summercms.go is not modified. The orchestrator handles the planning docs (the G4 row and reply in the escalation note, the todo's Outcome and STATE.md); they are not executor tasks.
|
|
|
|
|
|
|
|
|
|
Locked decisions (from 261022-usi-CONTEXT.md, labelled here for traceability):
|
|
|
|
|
- D-01 Guard wiring, lazy store. bouncer.Registry.Register refuses a duplicate name, so the guard is never registered twice. Boot registers the jwt guard once with a plugin-local bouncer.BlacklistStore. That store resolves the app's published bouncer.BlacklistStore on every call. With no store published, IsBlacklisted answers false with a nil error, and Add and Sweep do nothing. The wrapper is never published as bouncer.BlacklistStore, because the lookup would then recurse. That one Register call passes classes.SessionCookieNames()... in place of the two hardcoded cookie-name literals.
|
|
|
|
|
- D-02 Database work goes through lagoon.OnDatabase. One lagoon.OnDatabase(app, ...) call from Boot does three things: classes.RegisterHooks(gdb), publishing bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist") as bouncer.BlacklistStore, and p.startBlacklistSweep(store). The direct *gorm.DB and *sql.DB lookup branches leave Boot.
|
|
|
|
|
- D-03 The test uses the full serve order. A new Docker-backed test skips under -short. It boots with no database published, then calls lagoon.Publish, then builds the routes. Then it logs in, logs out, and shows that the same token gets 401 on a jwt.auth route and that a jwt_blacklist row exists for its jti. It also shows that RegisterHooks ran after Publish. A test that publishes first does not count.
|
|
|
|
|
|
|
|
|
|
Planner's choices (Claude's discretion in CONTEXT; the executor does not revisit them):
|
|
|
|
|
- QD-1 The lazy store is an unexported `appBlacklist` in a new file blacklist.go at the plugin root (package user). Exporting it would add public API to a core plugin for no consumer.
|
|
|
|
|
- QD-2 startBlacklistSweep stays a method and is not changed.
|
|
|
|
|
- QD-3 "RegisterHooks ran after Publish" is observed with a test hook. The test registers it through classes.RegisterHook; it records the *gorm.DB it was called with. classes keeps its registrants in a process-global slice with no unregister, and adding one would be new exported API. So the hook is gated by an atomic.Bool that t.Cleanup clears, which makes it inert for later boots in the same test binary. Production registers no hooks today, so the hook is the only observable effect.
|
|
|
|
|
- QD-4 The serve-order boot is a harness function, newServeOrderEnv(t, beforePublish), in admin_harness_test.go. It shares the config with newAdminEnv through an extracted adminEnvConfig(t), so the existing helpers (sessionLogin, guarded, assertSessionEnded, call, send) work on it. The order mirrors surf's serve command: Activate, then lagoon.Publish, publishUploads and BuildRouter. lagoon.Migrate sits before Assemble only because the shared harness database may not be migrated yet when this test runs first.
|
|
|
|
|
- QD-5 appBlacklist treats a published value that is itself an *appBlacklist as no store, which guards against recursion. A store error goes back unchanged, so bouncer's guard still fails closed with "Authentication error".
|
|
|
|
|
- QD-6 The OnDatabase call sits where RegisterHooks used to run (after JWTSecret, before the registry). The closure uses Boot's `app` parameter, never p.app, because one registered Plugin value is shared by every party.Activate in a test binary. Boot returns OnDatabase's error, because the callback runs immediately when the database is already published.
|
|
|
|
|
|
|
|
|
|
Source coverage audit:
|
|
|
|
|
| Source | Item | Covered by |
|
|
|
|
|
|--------|------|------------|
|
|
|
|
|
| GOAL | logout revokes under serve | Task 1 (GREEN, TestBootBeforeDatabase) |
|
|
|
|
|
| CONTEXT D-01 | single registration, lazy store, not published, SessionCookieNames | Task 1 GREEN; Task 2 TestAppBlacklist |
|
|
|
|
|
| CONTEXT D-02 | OnDatabase: hooks + publish + sweep; direct lookups removed | Task 1 GREEN and its grep gates |
|
|
|
|
|
| CONTEXT D-03 | Activate-then-Publish Docker test, -short skip, login/logout/401/row, hooks after Publish | Task 1 RED/GREEN |
|
|
|
|
|
| CONTEXT specifics | README updated; commit locally in the plugin repo; no push/tag/bump | Task 2 |
|
|
|
|
|
| CONTEXT specifics | G4 row/reply, todo Outcome | orchestrator (excluded by the quick constraints) |
|
|
|
|
|
| REQ / RESEARCH | none (quick task, no research) | n/a |
|
|
|
|
|
</objective>
|
|
|
|
|
|
|
|
|
|
<execution_context>
|
|
|
|
|
@~/.claude/msd-core/workflows/execute-plan.md
|
|
|
|
|
@~/.claude/msd-core/templates/summary.md
|
|
|
|
|
</execution_context>
|
|
|
|
|
|
|
|
|
|
<context>
|
|
|
|
|
@CLAUDE.md
|
|
|
|
|
@.planning/STATE.md
|
|
|
|
|
@.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-CONTEXT.md
|
|
|
|
|
@.planning/todos/pending/2026-10-22-user-logout-blacklist-missing-under-serve.md
|
|
|
|
|
@../sm-demo-app/plugins/golem15/user/plugin.go
|
|
|
|
|
@modules/lagoon/ondatabase.go
|
|
|
|
|
|
|
|
|
|
Working copies (observed at planning time, 2026-10-22):
|
|
|
|
|
- sm-user-plugin is at ../sm-demo-app/plugins/golem15/user (absolute /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user). Branch master at 8e4b863 (G3), 2 ahead of origin/master, working tree clean, latest tag v0.1.8. Do not fetch, merge, rebase or reset. If master has moved past 8e4b863, build on top and record `git log --oneline 8e4b863..master` in the SUMMARY. If the tree is dirty, stop: another session is working there.
|
|
|
|
|
- Go: sm-demo-app's go.work does not list the plugin under `use`, so every go command in the plugin runs with GOWORK=off. go.mod:176 replaces summercms with ../../../../summercms.go (summercms.go's working tree). lagoon.OnDatabase has existed since v0.1.x.
|
|
|
|
|
- Docker: docker info answered (server 29.9.1). If the Docker socket is denied, run go test through sudo -u golem (CLAUDE.md).
|
|
|
|
|
- sm-demo-app (../sm-demo-app) has another session's uncommitted edits (main.go and the plugins/golem15/user pointer). Read-only here: run tests, never stage or commit.
|
|
|
|
|
- The planning docs in summercms.go belong to the orchestrator. Do not touch STATE.md, the escalation note or the todo.
|
|
|
|
|
|
|
|
|
|
Source facts (plugin checkout at 8e4b863):
|
|
|
|
|
- plugin.go: Plugin{app, sweepStop} :54-57. Boot :67-114: JWTSecret :69, RegisterHooks branch :73-77, registry lookup-or-publish :78-84, blacklist block :85-98 (direct database lookups, Publish, startBlacklistSweep), jwt guard Register :99 with two hardcoded cookie names, api_token guard :102-112, RegisterUserAuthorizer :113. startBlacklistSweep :116-147 (reads p.app.Config golem15.user.jwt.blacklist_sweep_interval, default 10m, starts at most once per Plugin value through sweepStop). withDB :214-227 keeps its own *gorm.DB lookup, which is legitimate for console commands. Middlewares :233-249 reads the registry when the router is built. Imports already include database/sql, lagoon, bouncer, gorm.
|
|
|
|
|
- classes/registry.go: RegisterHook appends to the package-level hookRegistrars; RegisterHooks runs them all. There are no production registrants (only classes/user_groups_test.go). classes/session_user.go: SessionCookieNames() :27 returns a copy of token, auth_token. EndSession (:123) and RequestUser (:74) look bouncer.BlacklistStore up at request time. controllers/api_controller.go :209 (Refresh) and :919 (sessionGuard) do too.
|
|
|
|
|
- summercms.go: lagoon.OnDatabase runs fn immediately when a *gorm.DB is published (it uses gdb.DB() when no *sql.DB was published), and otherwise queues fn; lagoon.Publish (connection.go:162) publishes both handles and then drains the queue, wrapping a callback error as "lagoon: database hook: ...". surf serve.go:70-88 order: OpenFromApp, lagoon.Publish, publishUploads, installCooler, BuildRouter. bouncer.NewJWTGuard(secret, users, bl, cookieNames...) (jwt.go:86) consults bl only when it is non-nil; an IsBlacklisted error gives "Authentication error" (401). bouncer.BlacklistStore (blacklist.go:16) has Add(ctx, jti, expiresAt, validUntil), IsBlacklisted(ctx, jti) and Sweep(ctx, now). bouncer.NewMemoryBlacklist and bouncer.NewPostgresBlacklist exist. backpack.App.Publish refuses a second value of the same type. cabana keeps its own backend blacklist (adminBlacklist, own table) and never publishes bouncer.BlacklistStore.
|
|
|
|
|
- Tests (package user, plugin root): admin_harness_test.go has adminHarnessDB :91 (skips under -short; shared database user_admin in the session container), newAdminEnv :131-192 (config block :134-160, then lagoon.Publish :162, mailCapture logger :165, attach.Publish with memblob :169-173, party.Activate :176, configure, lagoon.Migrate :184, surf.Assemble :187) and the adminEnv fields h, app, gdb, bucket, mail. logout_test.go has the constants userTokensPath, userLogoutPath, userRefreshPath and blacklistedBody :16-23, sessionLogin :208, guarded :220, assertSessionEnded :231 (jwt.auth through bearer/token/auth_token, /fetch, /refresh) and assertLoggedOut :264. session_test.go has sessionConfig, parseToken :644 and bootPlugin :710 (reads p.sweepStop right after Boot). TestLogin checks the store is published after a DB-first Boot. TestLoginRouteGroup boots with no database. TestLoginBlacklistSweep uses a 50ms sweep. session_user_test.go has failingBlacklist/errBlacklistDown :24-35 and sessionUserApp(t, environ...) :39 (an app without a database). No root TestMain; Docker tests skip through adminHarnessDB.
|
|
|
|
|
- The names appBlacklist, adminEnvConfig, newServeOrderEnv, TestBootBeforeDatabase and TestAppBlacklist, and the files blacklist.go, blacklist_test.go and serve_order_test.go, are all unused today.
|
|
|
|
|
- README.md: the JWT-sessions bullet :20 ("Boot registers the `jwt` guard ...", "A background sweep prunes the blacklist."), the classes list with SessionCookieNames :192, the Testing section :247 (TestLogoutEndsTheSession :262, TestSessionRoutesAcceptCookies :264).
|
|
|
|
|
- Consumers that boot this checkout through sm-demo-app's go.work replace: plugins/golem15/sociallogin and plugins/golem15/oauthserver harnesses publish the database before party.Activate (the immediate OnDatabase path).
|
|
|
|
|
</context>
|
|
|
|
|
|
|
|
|
|
<tasks>
|
|
|
|
|
|
|
|
|
|
<task type="tracer" tdd="true">
|
|
|
|
|
<name>Task 1 (tracer): booting in the serve order (Activate, then Publish) wires the blacklist and hooks, so logout revokes the token on jwt.auth end to end on Postgres</name>
|
|
|
|
|
<files>../sm-demo-app/plugins/golem15/user/plugin.go, ../sm-demo-app/plugins/golem15/user/blacklist.go, ../sm-demo-app/plugins/golem15/user/admin_harness_test.go, ../sm-demo-app/plugins/golem15/user/serve_order_test.go</files>
|
|
|
|
|
<precondition>git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user status --short is empty, its branch is master and 8e4b863 is an ancestor of HEAD; Docker is reachable for testcontainers (directly or through sudo -u golem).</precondition>
|
|
|
|
|
<behavior>
|
|
|
|
|
- TestBootBeforeDatabase (serve_order_test.go, package user) boots through newServeOrderEnv. First party.Activate(app, golem15.user) runs with no database published; then lagoon.Publish, attach.Publish, lagoon.Migrate and surf.Assemble follow, which is the serve command's order (per D-03).
|
|
|
|
|
- At the hook point between Activate and Publish: app.Lookup for *gorm.DB is not ok (the order is real), app.Lookup[bouncer.BlacklistStore]() is not ok, and the test hook has not run.
|
|
|
|
|
- After Publish, the published bouncer.BlacklistStore is a *bouncer.PostgresBlacklist (the real store, not the wrapper; per D-01 and D-02), and the test hook ran with exactly e.gdb, which shows RegisterHooks ran on Publish.
|
|
|
|
|
- e.sessionLogin(t, "serve-order") returns a token. e.guarded answers 200 with the bearer header, and also with each name from classes.SessionCookieNames() as the only carrier.
|
|
|
|
|
- POST /logout with the bearer satisfies assertLoggedOut. Then e.assertSessionEnded(t, token) passes: jwt.auth answers 401 through the bearer, token and auth_token carriers, /fetch answers 401 and /refresh answers blacklistedBody. jwt_blacklist has exactly 1 row for the token's jti (from parseToken).
|
|
|
|
|
- RED at 8e4b863: the test fails at the store-after-Publish assertion, because Boot saw no database and never published a store.
|
|
|
|
|
</behavior>
|
|
|
|
|
<action>
|
|
|
|
|
RED. In admin_harness_test.go, move the config block of newAdminEnv into adminEnvConfig(t *testing.T) *compass.Config without changing it: the temp dir with app.yaml, compass.Open with the three environ entries, and the cfg.Set loop. newAdminEnv then calls adminEnvConfig and keeps its own order (adminHarnessDB first, Publish before Activate) unchanged. Add newServeOrderEnv(t *testing.T, beforePublish func(*backpack.App)) *adminEnv after newAdminEnv (per QD-4), in this order:
|
|
|
|
|
- adminHarnessDB(t) first, so the test skips under -short.
|
|
|
|
|
- backpack.New(adminEnvConfig(t)), then publish the mailCapture logger.
|
|
|
|
|
- party.Activate(app, []string{"golem15.user"}).
|
|
|
|
|
- beforePublish(app) when it is non-nil.
|
|
|
|
|
- lagoon.Publish(app, sqlDB, gdb).
|
|
|
|
|
- attach.Publish with a memblob bucket closed in t.Cleanup.
|
|
|
|
|
- lagoon.Migrate(gdb, plugins), then surf.Assemble.
|
|
|
|
|
It returns an adminEnv with the same fields newAdminEnv fills. Its doc comment says it boots like surf's serve command after the generated main's party.Activate, and that Migrate is there only for a harness database that is not migrated yet.
|
|
|
|
|
|
|
|
|
|
Write serve_order_test.go with TestBootBeforeDatabase as the behavior block describes. The doc comment says it pins logout revocation when Boot runs before the database is published. The test hook is set up before newServeOrderEnv (per QD-3):
|
|
|
|
|
- an atomic.Pointer[gorm.DB] for the handle and an atomic.Bool set to true, which t.Cleanup sets back to false;
|
|
|
|
|
- a hook registered through classes.RegisterHook that stores its *gorm.DB argument only while the Bool is true and always returns nil;
|
|
|
|
|
- a comment saying the registrant list is process-global, which is why the hook is gated.
|
|
|
|
|
The beforePublish callback makes the three not-published assertions with t.Fatal. Read the jti with parseToken(t, token)["jti"] and count rows with e.gdb.Raw(SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?). Run the test. It must fail against the unchanged plugin.go; record the failure line for the SUMMARY. Do not commit a red test: go test stays green at every commit, and the single commit comes at the end of Task 2.
|
|
|
|
|
|
|
|
|
|
GREEN, per D-01 and QD-1/QD-5. Create blacklist.go (package user) with the unexported type appBlacklist struct { app *backpack.App } and the compile-time assertion var _ bouncer.BlacklistStore = (*appBlacklist)(nil). A helper method store() returns the bouncer.BlacklistStore published on b.app. It returns nil in three cases: nothing is published, the published value is nil, or the published value is itself an *appBlacklist.
|
|
|
|
|
- IsBlacklisted: with no store, false and nil; otherwise the store's result and error, unchanged.
|
|
|
|
|
- Add and Sweep: with no store, nil; otherwise they delegate and return the store's error.
|
|
|
|
|
The doc comment explains why: the jwt guard is built in Boot, before serve publishes the database, so the store is resolved per call; the type must never be published itself.
|
|
|
|
|
|
|
|
|
|
GREEN, per D-02 and QD-6. In plugin.go Boot, keep JWTSecret first. Remove the RegisterHooks branch and the whole blacklist block: the direct *sql.DB and *gorm.DB lookups, the local blacklist variable, its Publish and the sweep start. At the position where RegisterHooks ran, add one call that returns its error: lagoon.OnDatabase(app, func(sqlDB *sql.DB, gdb *gorm.DB) error { ... }). The callback:
|
|
|
|
|
- runs classes.RegisterHooks(gdb) and returns its error;
|
|
|
|
|
- builds store := bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist");
|
|
|
|
|
- calls app.Publish[bouncer.BlacklistStore](store) and returns its error;
|
|
|
|
|
- calls p.startBlacklistSweep(store) and returns nil.
|
|
|
|
|
It uses Boot's app parameter, never p.app. Put a one- or two-line comment above the call saying that serve publishes the database after Boot, so this runs on lagoon.Publish, or at once when the database is already published. Comments must not quote the removed code.
|
|
|
|
|
|
|
|
|
|
Change the jwt registration to reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, &appBlacklist{app: app}, classes.SessionCookieNames()...)). Keep the registry lookup-or-publish, the api_token guard, RegisterUserAuthorizer, startBlacklistSweep (QD-2), withDB, Middlewares and the import set (database/sql and gorm stay used) as they are. gofmt both files.
|
|
|
|
|
|
|
|
|
|
Run the verify command. If an existing test fails because it relied on Boot publishing a store from a published *sql.DB without a *gorm.DB, stop and report it; planning found no such test. A failure unrelated to this change is not patched here: stop and report it.
|
|
|
|
|
</action>
|
|
|
|
|
<verify>
|
|
|
|
|
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && test -z "$(gofmt -l plugin.go blacklist.go admin_harness_test.go serve_order_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run '^(TestBootBeforeDatabase|TestLogoutEndsTheSession|TestSessionRoutesAcceptCookies|TestLogin|TestLoginRouteGroup|TestLoginBlacklistSweep)$' . && grep -qF 'lagoon.OnDatabase(app, func(' plugin.go && grep -qF '&appBlacklist{app: app}' plugin.go && grep -qF 'classes.SessionCookieNames()...)' plugin.go && grep -qF 'Publish[bouncer.BlacklistStore](store)' plugin.go && test "$(grep -v '^[[:space:]]*//' plugin.go | grep -cF '"auth_token"')" = 0 && test "$(grep -v '^[[:space:]]*//' plugin.go | grep -cF 'Lookup[*sql.DB]')" = 0 && test "$(grep -v '^[[:space:]]*//' plugin.go | grep -cF 'Lookup[*gorm.DB]')" = 1 && test "$(grep -v '^[[:space:]]*//' plugin.go | grep -cF '"jwt", bouncer.NewJWTGuard')" = 1 && grep -qF 'type appBlacklist struct' blacklist.go && grep -qF 'func newServeOrderEnv' admin_harness_test.go && grep -qF 'func adminEnvConfig' admin_harness_test.go && grep -qF 'func TestBootBeforeDatabase' serve_order_test.go</automated>
|
|
|
|
|
</verify>
|
|
|
|
|
<done>TestBootBeforeDatabase failed at 8e4b863 (failure line recorded) and now passes. It proves three things: nothing is published before Publish, the real Postgres store and the GORM hooks are installed when lagoon.Publish runs, and a token ended by logout gets 401 on jwt.auth through every carrier, at /fetch and at /refresh, with its jwt_blacklist row. Boot registers the jwt guard once with appBlacklist and classes.SessionCookieNames(), and the direct database lookups are gone from Boot. TestLogoutEndsTheSession, TestSessionRoutesAcceptCookies and the TestLogin* tests stay green. Nothing is committed yet.</done>
|
|
|
|
|
</task>
|
|
|
|
|
|
|
|
|
|
<task type="auto" tdd="true">
|
|
|
|
|
<name>Task 2: pin the lazy store without Docker, document the wiring, run every suite and commit in sm-user-plugin</name>
|
|
|
|
|
<files>../sm-demo-app/plugins/golem15/user/blacklist_test.go, ../sm-demo-app/plugins/golem15/user/README.md</files>
|
|
|
|
|
<precondition>Task 1's verify passed and the plugin tree holds only Task 1's four files as changes (git -C ../sm-demo-app/plugins/golem15/user status --short lists exactly plugin.go, admin_harness_test.go, blacklist.go and serve_order_test.go).</precondition>
|
|
|
|
|
<behavior>
|
|
|
|
|
- TestAppBlacklist (blacklist_test.go, package user) runs under -short with no Docker. Each app comes from sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret).
|
|
|
|
|
- No store published: IsBlacklisted returns (false, nil); Add and Sweep return nil (per D-01).
|
|
|
|
|
- A second app with an appBlacklist published as its bouncer.BlacklistStore: the same no-op answers come back, and the calls return instead of recursing (QD-5).
|
|
|
|
|
- A bouncer.NewMemoryBlacklist published: Add through the wrapper with a future expiresAt and validUntil makes the memory store itself report the jti blacklisted (the call is delegated, not copied); the wrapper's IsBlacklisted reports true for that jti and false for another jti.
|
|
|
|
|
- failingBlacklist published: IsBlacklisted, Add and Sweep each return an error for which errors.Is(err, errBlacklistDown) holds, so an unreachable table still fails closed through the guard.
|
|
|
|
|
</behavior>
|
|
|
|
|
<action>
|
|
|
|
|
Write blacklist_test.go with TestAppBlacklist as the behavior block describes. Reuse sessionUserApp, sessionSecret, failingBlacklist and errBlacklistDown from the package's existing test files, and use t.Context(). Run it with -short; it must pass against Task 1's blacklist.go. If it exposes a bug in appBlacklist, fix blacklist.go here.
|
|
|
|
|
|
|
|
|
|
README.md, in the same commit (CLAUDE.md documentation rule; no exported API changes, so only behaviour text):
|
|
|
|
|
- In the JWT-sessions bullet (line 20), after the sentence on Boot registering the `jwt` guard and exposing `jwt.auth`, add this. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts the sweep through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. Change "A background sweep prunes the blacklist." only if the new text makes it redundant.
|
|
|
|
|
- Do not name the unexported wrapper type in the README; describe the behaviour only.
|
|
|
|
|
- In the Testing section, after the TestLogoutEndsTheSession paragraph, add a paragraph covering two tests. `TestBootBeforeDatabase` boots in the serve order (`party.Activate` with no database, then `lagoon.Publish`, through `newServeOrderEnv` in `admin_harness_test.go`). It checks that the blacklist store and the GORM hooks are installed on Publish and not before, and that a token ended by `POST /logout` is refused on a `jwt.auth` route through every carrier, at `/fetch` and at `/refresh`, with its `jwt_blacklist` row. `TestAppBlacklist` (in `-short`) covers the guard's per-request store lookup: no store, delegation and a failing store.
|
|
|
|
|
|
|
|
|
|
Run go vet, then go test -short, then the full Docker-backed suite of the plugin.
|
|
|
|
|
|
|
|
|
|
Consumer cross-check, read-only: from /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app run go test -count=1 ./plugins/golem15/sociallogin/... ./plugins/golem15/oauthserver/... . go.work replaces sm-user-plugin with this checkout, and their harnesses publish the database before Activate, which exercises the immediate OnDatabase path. Record the result in the SUMMARY. If a failure names the user plugin's Boot, the jwt guard, bouncer.BlacklistStore or jwt_blacklist, stop and fix it in this task. Record any other failure as unrelated; do not patch those repos. Do not stage anything in sm-demo-app.
|
|
|
|
|
|
|
|
|
|
Commit in the plugin repo only, staging by path:
|
|
|
|
|
git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user add plugin.go blacklist.go blacklist_test.go serve_order_test.go admin_harness_test.go README.md
|
|
|
|
|
Then git -C <same path> commit with this subject:
|
|
|
|
|
fix: logout revokes the session under serve: the jwt blacklist and GORM hooks are wired when the database is published
|
|
|
|
|
In the body, two short paragraphs: Boot runs before serve publishes the database, so the store, hooks and sweep now go through lagoon.OnDatabase, and the guard resolves the store per request; the jwt guard reads classes.SessionCookieNames(). Add no trailer of any kind; the user's global CLAUDE.md forbids co-author tags and overrides any harness attribution reminder. Do not push, tag or bump the sm-demo-app pointer (v0.5.0 release hold). Record the short SHA, the base commit and the test results in the SUMMARY for the orchestrator's G4 entry.
|
|
|
|
|
</action>
|
|
|
|
|
<verify>
|
|
|
|
|
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && test -z "$(gofmt -l blacklist_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -short -count=1 ./... && GOWORK=off go test -short -count=1 -run '^TestAppBlacklist$' . && GOWORK=off go test -count=1 ./... && grep -qF 'lagoon.OnDatabase' README.md && grep -qF 'TestBootBeforeDatabase' README.md && grep -qF 'TestAppBlacklist' README.md && test -z "$(git status --short)" && C=$(git log -1 --format=%H --fixed-strings --grep='wired when the database is published' master) && test -n "$C" && S=$(git log -1 --format=%s "$C") && grep -qF 'wired when the database is published' <<<"$S" && B=$(git log -1 --format=%b "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet "$C^" "$C" -- go.mod go.sum && N=$(git diff --name-only "$C^" "$C") && test "$(sort <<<"$N" | tr '\n' ' ')" = "README.md admin_harness_test.go blacklist.go blacklist_test.go plugin.go serve_order_test.go " && test -z "$(git tag --points-at "$C")" && R=$(git branch -r --contains "$C") && test -z "$R"</automated>
|
|
|
|
|
</verify>
|
|
|
|
|
<done>TestAppBlacklist passes under -short. The plugin's go vet, go test -short and full Docker-backed go test are green. The sociallogin and oauthserver cross-check result is recorded. sm-user-plugin master has one new fix commit (plugin.go, blacklist.go, blacklist_test.go, serve_order_test.go, admin_harness_test.go, README.md) with no trailer, go.mod and go.sum unchanged, not on any remote branch and untagged. The tree is clean. sm-demo-app and summercms.go are untouched. The SUMMARY gives the SHA and base for the orchestrator.</done>
|
|
|
|
|
</task>
|
|
|
|
|
|
|
|
|
|
</tasks>
|
|
|
|
|
|
|
|
|
|
<threat_model>
|
|
|
|
|
## Trust Boundaries
|
|
|
|
|
|
|
|
|
|
| Boundary | Description |
|
|
|
|
|
|----------|-------------|
|
|
|
|
|
| browser/API client to jwt.auth | an untrusted bearer or cookie token is checked against the revocation list |
|
|
|
|
|
| plugin Boot to serve startup | revocation state exists only after the database is published; Boot runs earlier |
|
|
|
|
|
|
|
|
|
|
## STRIDE Threat Register
|
|
|
|
|
|
|
|
|
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
|
|
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
|
|
|
| T-usi-01 | Elevation of privilege | jwt.auth guard after POST /logout under serve (a logged-out or stolen token keeps working) | high | mitigate | The OnDatabase callback publishes the Postgres store on lagoon.Publish, so EndSession writes the row, and the guard's appBlacklist reads it per request. TestBootBeforeDatabase pins the 401 through every carrier and the jwt_blacklist row in the serve order. |
|
|
|
|
|
| T-usi-02 | Elevation of privilege | appBlacklist while no store is published (before Publish, console commands) | low | accept | serve publishes the database before BuildRouter and listen, so no HTTP request is served without the store. Console commands serve no HTTP. This matches today's nil-store behaviour. |
|
|
|
|
|
| T-usi-03 | Denial of service | appBlacklist recursion if the wrapper itself were published | low | mitigate | store() treats a published *appBlacklist as no store; Boot publishes only the *bouncer.PostgresBlacklist; TestAppBlacklist and TestBootBeforeDatabase (type assertion) pin both. |
|
|
|
|
|
| T-usi-04 | Spoofing | blacklist table unreachable (IsBlacklisted error) | medium | mitigate | appBlacklist returns the store's error unchanged, so bouncer's guard answers 401 "Authentication error" (fail closed, as today). TestAppBlacklist pins the pass-through. |
|
|
|
|
|
| T-usi-SC | Tampering | npm/pip/cargo/Go module installs | low | accept | No dependency is added: go.mod and go.sum are unchanged (Task 2 verify checks it). No package-legitimacy checkpoint is needed. |
|
|
|
|
|
</threat_model>
|
|
|
|
|
|
|
|
|
|
<verification>
|
|
|
|
|
- sm-user-plugin: GOWORK=off go vet ./..., GOWORK=off go test -short -count=1 ./... and GOWORK=off go test -count=1 ./... are green at the fix commit.
|
|
|
|
|
- TestBootBeforeDatabase was red at 8e4b863 and is green at the fix commit.
|
|
|
|
|
- The sociallogin and oauthserver suites, run through sm-demo-app's go.work against this checkout, have their result recorded in the SUMMARY.
|
|
|
|
|
- One local commit on sm-user-plugin master: no trailer, not pushed, not tagged. The sm-demo-app pointer is unchanged and summercms.go is unmodified.
|
|
|
|
|
</verification>
|
|
|
|
|
|
|
|
|
|
<success_criteria>
|
|
|
|
|
- Under the serve boot order, logout revokes the session: jwt.auth answers 401 for the logged-out token through the bearer header and both cookies, and the jwt_blacklist row exists.
|
|
|
|
|
- The plugin's GORM hooks, blacklist store and sweep are wired exactly once through lagoon.OnDatabase, whether the database is published before or after Boot.
|
|
|
|
|
- The jwt guard is registered once, with classes.SessionCookieNames() and a store resolved per request.
|
|
|
|
|
- The README describes the wiring and names both new tests.
|
|
|
|
|
</success_criteria>
|
|
|
|
|
|
|
|
|
|
<output>
|
|
|
|
|
Create `.planning/quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/261022-usi-SUMMARY.md` in summercms.go when done. Include the plugin commit's short SHA and base, the RED failure line, the suite results, the consumer cross-check result, and a confirmation that nothing was pushed, tagged or pointer-bumped. Do not commit the SUMMARY; the orchestrator commits planning docs.
|
|
|
|
|
</output>
|