docs(quick-261028-fsq): sm-user-plugin MCPDispatch credential carries the personal token id
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
---
|
||||
phase: quick-261028-fsq
|
||||
plan: 01
|
||||
quick_id: 261028-fsq
|
||||
slug: sm-user-plugin-mcpdispatch-credential-ca
|
||||
type: execute
|
||||
mode: quick
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements:
|
||||
- fsq-1 # MCPUserGuard.MCPDispatch: for a personal-token subject (ClientID empty, as MCPResolvePersonalToken builds it) the in-memory *models.APIToken credential carries ID = subject.TokenID, so a handler under MCP dispatch can record which personal token made a change
|
||||
- fsq-2 # an OAuth subject (ClientID set) keeps credential ID 0 even when its TokenID is non-zero (that id is the OAuth server's token row, not a user_api_tokens row); the credential is still never saved
|
||||
- fsq-3 # MCPDispatch doc comment and the README "MCP guard" paragraph describe the new credential id; one fix commit in sm-user-plugin, no trailer, not pushed, not tagged, the fonoteka.go submodule pointer untouched
|
||||
files_modified:
|
||||
# sm-user-plugin (module git.golem15.com/golem15/sm-user-plugin), a separate git repo checked out as the
|
||||
# fonoteka.go submodule plugins/golem15/user (clean at v0.1.13 = 07196a4 on master). Edit and commit THERE only.
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go
|
||||
- ../fonoteka.go/plugins/golem15/user/mcp_guard_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
|
||||
estimate:
|
||||
tokens: 45000
|
||||
raw_tokens: 45000
|
||||
tasks: 2
|
||||
confidence: low # estimate-calibration: factor 1, sample_count 0
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A subject resolved from a personal usr_ token by MCPResolvePersonalToken (ClientID empty, TokenID = the token row id) is dispatched by MCPUserGuard.MCPDispatch with a *models.APIToken credential whose ID equals that token's id, whose UserID is the user's and whose scopes are the session's."
|
||||
- "That credential, carried by the bouncer dispatch marker, still passes the plugin's own route chain: GET /_user/api/v1/token/me answers 200 with the user's email, and the user's user_api_tokens row count is unchanged afterwards (the credential is not saved)."
|
||||
- "A subject with ClientID set and a non-zero TokenID (even one equal to an existing user_api_tokens id) is dispatched with credential ID 0, UserID the user's and the session's scopes."
|
||||
- "A subject with neither ClientID nor TokenID (the existing tests' shape) still gets credential ID 0; TestUserMCPGuardSmoke, TestUserMCPGuardClientIP and the existing TestUserMCPGuard subtests pass unchanged."
|
||||
- "MCPDispatch's doc comment and the README's MCP guard paragraph say the credential carries a personal token's id and keeps ID 0 for an OAuth subject, and that it is never saved."
|
||||
- "One new commit on sm-user-plugin master (descendant of 07196a4), subject starting with 'fix:', touching exactly README.md, classes/mcp_guard.go and mcp_guard_test.go, no Co-Authored-By trailer, go.mod/go.sum unchanged, green on gofmt, GOWORK=off go vet, go test -short and the full Docker-backed go test; not pushed, not tagged; the fonoteka.go repo has nothing staged or committed for plugins/golem15/user."
|
||||
artifacts:
|
||||
- path: ../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go
|
||||
provides: "MCPDispatch sets credential.ID = subject.TokenID when subject.ClientID is empty; updated doc comment"
|
||||
contains: "subject.ClientID == \"\""
|
||||
- path: ../fonoteka.go/plugins/golem15/user/mcp_guard_test.go
|
||||
provides: "TestUserMCPGuard subtests 'dispatch credential carries the personal token id' and 'dispatch credential of an OAuth subject has no id' (Docker-backed, skip under -short)"
|
||||
contains: "dispatch credential carries the personal token id"
|
||||
- path: ../fonoteka.go/plugins/golem15/user/README.md
|
||||
provides: "MCP guard paragraph describes the credential id"
|
||||
contains: "the credential's ID is that token's id"
|
||||
key_links:
|
||||
- from: "classes/mcp_guard.go MCPResolvePersonalToken"
|
||||
to: "classes/mcp_guard.go MCPDispatch"
|
||||
via: "pact.MCPSubject{ClientID: \"\", TokenID: token.ID} built by the resolver and handed back by the framework MCP endpoint"
|
||||
pattern: "TokenID: token.ID"
|
||||
- from: "MCPDispatch credential"
|
||||
to: "bouncer.Credential(r.Context()) in route handlers / middleware.Scope"
|
||||
via: "pact.MCPDispatch.Credential -> bouncer.Dispatch.Credential"
|
||||
pattern: "Credential: credential"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make the in-memory token credential that `MCPUserGuard.MCPDispatch` (sm-user-plugin) builds for an MCP tool call carry the personal API token's id when the MCP session was opened with a personal `usr_` token, so a consuming application's handler that reads `bouncer.Credential` under MCP dispatch can record which personal token made a change. For an OAuth subject the credential keeps ID 0, because that subject's `TokenID` is the OAuth server plugin's own token row id, not a `user_api_tokens` row.
|
||||
|
||||
Purpose: today the credential is always built with ID 0, so attribution of changes to a personal token is impossible under MCP dispatch (the HTTP `user.api_token` guard path already hands handlers the real row).
|
||||
Output: one `fix:` commit in the sm-user-plugin repo (code, tests, README), local only.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user/classes/mcp_guard.go
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user/mcp_guard_test.go
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user/mcp_guard_smoke_test.go
|
||||
|
||||
Established facts (do not re-investigate):
|
||||
|
||||
- Work directory for all code edits, tests and the commit: `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user` (its own git repo, `origin` = git.golem15.com/golem15/sm-user-plugin, branch master, clean at tag v0.1.13 = `07196a4`). It is a submodule of fonoteka.go; committing in it makes fonoteka.go show the pointer as modified. Do NOT stage or commit that pointer in fonoteka.go. Nothing in summercms.go is edited by this plan (the orchestrator commits the planning docs).
|
||||
- Run Go commands with `GOWORK=off` from the plugin directory (fonoteka.go/go.work lists this plugin; GOWORK=off tests it against its own go.mod requirement, summercms v0.5.0, as the previous user-plugin quick tasks did). Docker is reachable directly (no sudo needed).
|
||||
- Interfaces in play (framework, `git.golem15.com/golem15/summercms/modules/pact`): `MCPSubject{Guard string; UserID uint; Scopes []string; ClientID string; TokenID uint}`; `MCPDispatch{Principal any; Credential any; Guards []string}`. `MCPPersonalTokenResolver` doc: a personal-token subject's UserID and TokenID are both set and TokenID identifies the token row. Framework convention (modules/pier/backend_guard.go): `subject.ClientID == "" && subject.TokenID != 0` identifies a personal-token subject.
|
||||
- `models.APIToken.ID` is `uint` (column id, table `user_api_tokens`), the same type as `pact.MCPSubject.TokenID`.
|
||||
- `MCPResolvePersonalToken` (same file) returns `pact.MCPSubject{Guard: SubjectGuardUser, UserID: token.UserID, Scopes: ..., TokenID: token.ID}` with ClientID empty.
|
||||
- Test harness (package `user`, root of the plugin): `newAdminEnv(t)` skips under `-short` (adminHarnessDB: "requires testcontainers postgres"); `env.seedUser(t, label, nil)`, `env.mintUserToken(t, user, scopes...) (secret, *models.APIToken)` (stores the row), `env.gdb` (*gorm.DB), `env.h` (http.Handler of the app), `adminStamp()` (unique suffix). `TestUserMCPGuard` already looks up `guard` (`guards.Lookup("user")`) and `resolver` (`env.app.Lookup[pact.MCPPersonalTokenResolver]()`) and imports bouncer, models, pact, httptest, http, strings. Its subtest "dispatch credential against user.scope" shows how to run a route in-process with the dispatch marker (`bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: d.Guards, Principal: principal, Credential: d.Credential})` then `env.h.ServeHTTP`).
|
||||
- Existing assertions that the credential has ID 0 (`mustDispatchCredential` in mcp_guard_test.go, line ~113; TestUserMCPGuardSmoke line ~121) use subjects without TokenID and without ClientID; they stay correct and must not be changed in meaning.
|
||||
- Within the plugin, nothing reads the credential's ID (only `middleware.Scope` reads its scopes) and nothing saves it; the user-api throttle keys on the JWT subject, not the credential.
|
||||
- The plugin keeps no CHANGELOG and no version file; releases are git tags only. No changelog entry is planned; the v0.1.14 tag needs the user's go-ahead and is out of scope.
|
||||
- Commit style in this repo: `fix: <sentence>` subject plus a short prose body; fix commits include their tests and README edits; no trailers.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: Personal-token MCP dispatch credential carries the token id, end to end (tests first, then fix, doc comment, README)</name>
|
||||
<files>../fonoteka.go/plugins/golem15/user/mcp_guard_test.go, ../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go, ../fonoteka.go/plugins/golem15/user/README.md</files>
|
||||
<behavior>
|
||||
- Subtest "dispatch credential carries the personal token id" (in TestUserMCPGuard): seed a user (label "mcp-token-id"), mint a personal token with scope read via env.mintUserToken, resolve its secret with resolver.MCPResolvePersonalToken (expect matched, nil error, ClientID empty, TokenID equal to the minted row's ID), dispatch that resolved subject with guard.MCPDispatch(ctx, subject, http.MethodGet); the credential is a *models.APIToken with ID equal to the minted token's ID, UserID equal to the user's, HasScope("read") true, HasScope("write") false. Running GET /_user/api/v1/token/me in-process with the bouncer dispatch marker carrying that principal and credential answers 200 and contains the user's email. Afterwards the count of user_api_tokens rows with that user_id is exactly 1 (counted through env.gdb with the models.APIToken model), proving the credential was not saved.
|
||||
- Subtest "dispatch credential of an OAuth subject has no id": seed a user (label "mcp-oauth-id"), mint a personal token for it (only to obtain a real user_api_tokens id), then dispatch pact.MCPSubject with Guard "user", that user's ID, Scopes read, ClientID "oauth-client-" plus adminStamp(), and TokenID deliberately equal to the minted token's ID; the credential is a *models.APIToken with ID 0, UserID the user's and HasScope("read") true.
|
||||
- Unchanged: a subject without ClientID and without TokenID still yields credential ID 0 (mustDispatchCredential and TestUserMCPGuardSmoke keep asserting this).
|
||||
- RED expectation: before the fix the first subtest fails on the credential ID (0 instead of the token id); the OAuth subtest already passes (it guards against over-applying the fix).
|
||||
</behavior>
|
||||
<action>
|
||||
All paths relative to /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user. Do not commit in this task (Task 2 commits after the full gates).
|
||||
|
||||
1. RED (mcp_guard_test.go): add the two subtests from the behavior block to TestUserMCPGuard, after the existing "dispatch credential against user.scope" subtest, with exactly those t.Run names. Reuse ctx, guard, resolver and env from the enclosing test; build the in-process request the same way the existing scope subtest does (type-assert d.Principal to *bouncer.Principal, wrap the request context with bouncer.WithDispatch, serve through env.h with httptest). Fail with t.Fatalf messages that print the got values (credential via %+v). Extend TestUserMCPGuard's doc comment with one clause saying the dispatch credential carries a personal token's id (TokenID) and keeps ID 0 for an OAuth subject. Update the doc comment of mustDispatchCredential to say it builds the credential for a read subject without a TokenID, which therefore has ID 0 and is not saved; keep its assertion unchanged. Run the RED command `GOWORK=off go test -count=1 -run '^TestUserMCPGuard$' .` and confirm the personal-token subtest fails on the ID and the OAuth subtest passes.
|
||||
|
||||
2. GREEN (classes/mcp_guard.go, MCPDispatch): keep the credential literal as is (UserID and the cloned Scopes); right after it, when subject.ClientID is empty, set the credential's ID to subject.TokenID (a personal-token subject; with TokenID 0 this leaves ID 0, matching the framework convention in pier's backend guard). For a subject with ClientID set leave ID 0: its TokenID belongs to the OAuth server plugin's token table. Do not load the token row from the database, do not copy other columns (Name, CollectionIDs, hash), do not change Guards or the principal. Keep the credential in memory only.
|
||||
|
||||
3. Doc comment of MCPDispatch: keep the first sentence (starting "MCPDispatch builds the principal") and the last one about the marker; replace the sentence in between that states the credential always has ID 0 with this text, wrapped to the file's comment width: "For a personal-token subject (ClientID empty) the credential's ID is the subject's TokenID, the user_api_tokens row the session was opened with, so a handler can record which token made a change; an OAuth subject's TokenID is the OAuth server's own token row, not one of this plugin's, and its credential keeps ID 0. The credential is never saved."
|
||||
|
||||
4. README.md, the "MCP guard and the `user_me` tool." paragraph (Features list): replace the parenthetical that follows "an in-memory token credential carrying the session's scopes" (it currently states the id is always 0) so the sentence reads: "A tool call runs the route with the user and an in-memory token credential carrying the session's scopes; for a session opened with a personal token the credential's ID is that token's id (the subject's `TokenID`), so a handler can record which token made a change, while an OAuth session's credential has ID 0. The credential is never saved." Leave the rest of the paragraph and the Testing section unchanged.
|
||||
|
||||
5. gofmt the two Go files, then run the verify command below until green.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user && test -z "$(gofmt -l classes/mcp_guard.go mcp_guard_test.go)" && GOWORK=off go vet ./... && O=$(GOWORK=off go test -count=1 -v -run '^TestUserMCPGuard$/^dispatch_credential_(carries_the_personal_token_id|of_an_OAuth_subject_has_no_id)$' .) && grep -qF -- '--- PASS: TestUserMCPGuard/dispatch_credential_carries_the_personal_token_id ' <<<"$O" && grep -qF -- '--- PASS: TestUserMCPGuard/dispatch_credential_of_an_OAuth_subject_has_no_id ' <<<"$O" && GOWORK=off go test -count=1 -run '^(TestUserMCPGuard|TestUserMCPGuardSmoke|TestUserMCPGuardClientIP)$' . && F=$(awk '/^func \(g \*MCPUserGuard\) MCPDispatch\(/,/^}/' classes/mcp_guard.go) && grep -qF 'subject.ClientID == ""' <<<"$F" && grep -qF 'subject.TokenID' <<<"$F" && ! grep -qF 'Find(' <<<"$F" && D=$(awk '/^\/\/ MCPDispatch builds the principal/,/^func \(g \*MCPUserGuard\) MCPDispatch\(/' classes/mcp_guard.go) && grep -qF 'TokenID' <<<"$D" && grep -qF 'keeps ID 0' <<<"$D" && grep -qF 'never saved' <<<"$D" && ! grep -qF 'has ID 0 and is never saved' <<<"$D" && R=$(grep -F '**MCP guard and the `user_me` tool.**' README.md) && grep -qF "the credential's ID is that token's id" <<<"$R" && grep -qF "OAuth session's credential has ID 0" <<<"$R" && ! grep -qF '(ID 0, never saved)' <<<"$R" && grep -qF 'tok.ID != 0' mcp_guard_test.go</automated>
|
||||
</verify>
|
||||
<done>Both new subtests exist and pass against Docker Postgres (not skipped); the personal-token subtest failed before the fix; MCPDispatch sets the credential ID from TokenID only for a subject without ClientID; doc comment and README paragraph describe the id and the OAuth exception; TestUserMCPGuardSmoke, TestUserMCPGuardClientIP and the existing TestUserMCPGuard subtests pass unchanged; nothing committed yet.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Full gates and the single fix commit in sm-user-plugin (local only)</name>
|
||||
<files>../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go, ../fonoteka.go/plugins/golem15/user/mcp_guard_test.go, ../fonoteka.go/plugins/golem15/user/README.md</files>
|
||||
<action>
|
||||
In /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user:
|
||||
|
||||
1. Confirm `git status --short` lists only README.md, classes/mcp_guard.go and mcp_guard_test.go as modified (if anything else is dirty, another session is working here: stop and report instead of committing) and that HEAD is still 07196a4 or a descendant of it.
|
||||
2. Run the gates: gofmt on the two Go files, `GOWORK=off go vet ./...`, `GOWORK=off go test -short -count=1 ./...`, then the full Docker-backed `GOWORK=off go test -count=1 ./...`. Fix any failure caused by Task 1's change before committing (do not weaken an existing test).
|
||||
3. Stage exactly the three files by path (never `git add -A` or `git add .`) and commit on master with subject `fix: the MCP dispatch credential carries the personal token id` and this body (wrapped at ~72 columns): "MCPUserGuard.MCPDispatch now sets the in-memory credential's ID to the subject's TokenID when the subject came from a personal usr_ token (no ClientID), so a handler running under MCP dispatch can record which token made a change. An OAuth subject's TokenID names the OAuth server's token row, so its credential keeps ID 0. The credential is still never saved." No attribution trailers of any kind (user and project rule).
|
||||
4. Do not push, do not tag (v0.1.14 needs the user's go-ahead), do not touch go.mod/go.sum. In the parent fonoteka.go repo do not stage or commit the plugins/golem15/user pointer; leave it showing as modified.
|
||||
5. In the summary, record the new commit's short SHA, the gate results (and whether the Docker suite ran rather than skipped), and that the release tag and the fonoteka.go pointer bump are pending the user's go-ahead.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user && test -z "$(gofmt -l classes/mcp_guard.go mcp_guard_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -short -count=1 ./... && GOWORK=off go test -count=1 ./... && test -z "$(git status --short)" && C=$(git log -1 --format=%H --fixed-strings --grep='fix: the MCP dispatch credential carries the personal token id' master) && test -n "$C" && test "$C" = "$(git rev-parse master)" && git merge-base --is-ancestor 07196a4bcbfee1f200c17f66cad147a4c091cb26 "$C" && B=$(git log -1 --format=%B "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet "$C^" "$C" -- go.mod go.sum && F=$(git diff --name-only "$C^" "$C") && test "$(LC_ALL=C sort <<<"$F" | tr '\n' ' ')" = "README.md classes/mcp_guard.go mcp_guard_test.go " && test -z "$(git tag --points-at "$C")" && test -z "$(git branch -r --contains "$C")" && ! git -C /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go diff --cached --name-only | grep -qxF plugins/golem15/user && test -z "$(git -C /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go log --format=%h e36577a..HEAD -- plugins/golem15/user)"</automated>
|
||||
</verify>
|
||||
<done>sm-user-plugin master has one new local `fix:` commit (descendant of 07196a4) touching exactly README.md, classes/mcp_guard.go and mcp_guard_test.go, with no trailer; gofmt, vet, the -short suite and the full Docker-backed suite are green; the commit is neither pushed nor tagged; fonoteka.go has no staged or committed pointer change for plugins/golem15/user.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| MCP endpoint -> guard provider | The framework MCP endpoint hands MCPDispatch a subject it resolved earlier (personal token via this plugin's resolver, or an OAuth token via the OAuth server plugin); the subject is trusted in-process data, not client input. |
|
||||
| guard provider -> route handlers | The in-memory credential is placed on the request context through the bouncer dispatch marker; handlers of this plugin and of the consuming application read it. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-fsq-01 | Spoofing / Repudiation | MCPDispatch credential ID for OAuth subjects | medium | mitigate | The ID is copied only when subject.ClientID is empty, so an OAuth subject's TokenID (an OAuth server row id) is never presented as a user_api_tokens id and a change is never attributed to an unrelated personal token; the OAuth subtest uses a TokenID equal to a real user_api_tokens id and asserts credential ID 0. |
|
||||
| T-fsq-02 | Tampering | in-memory credential with a real row id | medium | mitigate | With a non-zero ID a handler that saved the credential would overwrite the real token row (empty hash, session scopes). The plugin never saves it (only middleware.Scope reads scopes); the doc comment and README state it is never saved; the personal-token subtest asserts the user's user_api_tokens row count is unchanged after an in-process route run. Consumer handlers are documented to treat it as read-only. |
|
||||
| T-fsq-03 | Information Disclosure | token id visible to in-process handlers | low | accept | The id is a non-secret row id that the HTTP user.api_token guard already exposes to the same handlers through the loaded row; no hash or secret is copied into the credential. |
|
||||
| T-fsq-04 | Elevation of Privilege | scopes on the dispatch credential | low | mitigate | Scopes stay the session's (cloned from the subject) and are not reloaded from the token row; the existing user.scope subtest and the new subtest's HasScope("write") false check keep the scope boundary. |
|
||||
| T-fsq-SC | Tampering | npm/pip/cargo installs | low | accept | No package installs and no go.mod/go.sum change in this plan; Task 2's verify asserts go.mod and go.sum are unchanged in the commit. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1 verify: the two new TestUserMCPGuard subtests are reported as PASS (not skipped) against testcontainers Postgres; TestUserMCPGuard, TestUserMCPGuardSmoke and TestUserMCPGuardClientIP pass; the MCPDispatch body gates the ID on an empty ClientID without a DB lookup; doc comment and README carry the new wording and no longer claim the ID is always 0.
|
||||
- Task 2 verify: gofmt, GOWORK=off go vet, go test -short and the full Docker-backed go test are green on the committed tree; exactly one new local fix commit with exactly the three files, no trailer, untagged, unpushed; the fonoteka.go pointer is neither staged nor committed.
|
||||
- Note for the summary: the new tests are Docker-backed and skip under -short (newAdminEnv), like the rest of TestUserMCPGuard.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- A personal-token MCP session's tool call gives handlers a credential whose ID is the personal token's id (fsq-1).
|
||||
- An OAuth MCP session's credential keeps ID 0 (fsq-2).
|
||||
- Docs match the behaviour; one clean local `fix:` commit in sm-user-plugin, no push, no tag, no pointer bump (fsq-3).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261028-fsq-sm-user-plugin-mcpdispatch-credential-ca/261028-fsq-SUMMARY.md` when done (planning docs stay in summercms.go; the orchestrator commits them).
|
||||
</output>
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
phase: quick-261028-fsq
|
||||
plan: 01
|
||||
quick_id: 261028-fsq
|
||||
subsystem: sm-user-plugin (MCP guard)
|
||||
tags: [mcp, api-tokens, attribution, sm-user-plugin]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- "MCPUserGuard.MCPDispatch credential carries the personal token id (subject.TokenID) when subject.ClientID is empty"
|
||||
affects:
|
||||
- "consuming applications' handlers reading bouncer.Credential under MCP dispatch"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "personal-token subject = ClientID empty (framework convention from pier's backend guard)"
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go
|
||||
- ../fonoteka.go/plugins/golem15/user/mcp_guard_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
decisions:
|
||||
- "The MCP dispatch credential's ID comes from subject.TokenID only when ClientID is empty; an OAuth subject's TokenID (an OAuth server row) is never presented as a user_api_tokens id"
|
||||
metrics:
|
||||
duration: "~4 min"
|
||||
completed: 2026-10-28
|
||||
requirements: [fsq-1, fsq-2, fsq-3]
|
||||
repo: sm-user-plugin (fonoteka.go/plugins/golem15/user)
|
||||
commits: 1
|
||||
plan_head_before: 07196a4bcbfee1f200c17f66cad147a4c091cb26
|
||||
plan_head_after: a1b78b8
|
||||
actuals:
|
||||
tokens: 25113
|
||||
tasks: 2
|
||||
commits: 1
|
||||
---
|
||||
|
||||
# Quick 261028-fsq Plan 01: MCP dispatch credential carries the personal token id Summary
|
||||
|
||||
`MCPUserGuard.MCPDispatch` in sm-user-plugin now sets the in-memory `*models.APIToken` credential's `ID` to `subject.TokenID` for a personal `usr_` token subject (`ClientID` empty). This lets handlers under MCP dispatch record which personal token made a change. OAuth subjects keep ID 0, and the credential is still never saved.
|
||||
|
||||
## What was done
|
||||
|
||||
- **classes/mcp_guard.go:** after the credential literal, `if subject.ClientID == "" { credential.ID = subject.TokenID }`. There is no DB lookup and no other column is copied. Guards and the principal are unchanged. The doc comment now describes the token id, the OAuth exception and that the credential is never saved.
|
||||
- **mcp_guard_test.go:** two new `TestUserMCPGuard` subtests:
|
||||
- `dispatch credential carries the personal token id` resolves a minted token through `MCPResolvePersonalToken` and dispatches it. It asserts the credential's ID, UserID and scopes (read yes, write no), then runs `GET /_user/api/v1/token/me` in-process with the dispatch marker and expects 200 with the user's email. It finally asserts the user still has exactly 1 `user_api_tokens` row, which shows the credential was not saved.
|
||||
- `dispatch credential of an OAuth subject has no id` uses a subject with ClientID set and TokenID equal to a real `user_api_tokens` id, and asserts the credential ID is 0.
|
||||
|
||||
The doc comments of `TestUserMCPGuard` and `mustDispatchCredential` were updated. The meaning of the existing assertion did not change.
|
||||
- **README.md:** the "MCP guard and the `user_me` tool." paragraph now describes the credential id and the OAuth exception.
|
||||
|
||||
## Commits (sm-user-plugin, master, local only)
|
||||
|
||||
- `a1b78b8`: fix: the MCP dispatch credential carries the personal token id (README.md, classes/mcp_guard.go, mcp_guard_test.go; no trailer)
|
||||
|
||||
## TDD gate
|
||||
|
||||
- RED: `GOWORK=off go test -count=1 -run '^TestUserMCPGuard$' .` failed. The personal-token subtest got credential `ID:0` instead of the token id 3, and the OAuth subtest passed, as the plan predicted. RED was not committed separately because the plan asks for one fix commit that includes its tests.
|
||||
- GREEN: both subtests pass after the fix.
|
||||
|
||||
## Gate results
|
||||
|
||||
- gofmt on both Go files: clean
|
||||
- `GOWORK=off go vet ./...`: clean
|
||||
- `GOWORK=off go test -short -count=1 ./...`: ok
|
||||
- `GOWORK=off go test -count=1 ./...` (full, Docker-backed testcontainers Postgres; ran, not skipped, root package 81.8s): ok
|
||||
- Task 1 verify and Task 2 post-commit verify both passed. Task 2 checked: the commit is a descendant of 07196a4, it touches exactly 3 files, go.mod and go.sum are unchanged, there is no co-author trailer, no tag and no remote branch, the working tree is clean, and the fonoteka.go pointer is neither staged nor committed.
|
||||
|
||||
Note: the new subtests are Docker-backed and skip under `-short` (`newAdminEnv`), like the rest of `TestUserMCPGuard`.
|
||||
|
||||
## Pending (user's go-ahead)
|
||||
|
||||
- Push sm-user-plugin master and tag `v0.1.14`.
|
||||
- Bump the `plugins/golem15/user` submodule pointer in fonoteka.go. It currently shows as modified and is left unstaged on purpose.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The plan was executed exactly as written. The pre-commit protected-branch assertion was overridden for this repo because the orchestrator's submodule guard and the plan both require the commit on sm-user-plugin `master`.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The change stays within the plan's threat model: T-fsq-01 is covered by the OAuth subtest, T-fsq-02 by the row-count assertion and the docs, and T-fsq-04 by the HasScope("write") false check.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: classes/mcp_guard.go, mcp_guard_test.go, README.md (modified, committed)
|
||||
- FOUND: commit a1b78b8 on sm-user-plugin master
|
||||
Reference in New Issue
Block a user