Commit Graph

12 Commits

Author SHA1 Message Date
xarmian 5035168e5c fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1)

r1 rulings (lead, on BUG-3982):
- F1: docs/api.md says details.token_workspace is absent for a deleted pin.
- F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's
  tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes
  only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one
  statement); any other id is the missing-id 404.
- Q1: an MCP tool mapped to a global route (pad_workspace create) is
  refused for a pinned PAT, tested through wireMCP.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* test(e2e): the suite token is unpinned (BUG-3982 CI)

global-setup minted the browser suite's token with a workspace_id on a
stale premise (api_tokens once required one). Under BUG-3982 a pinned
token is refused the global routes the UI calls on every page, so the
E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and
/auth/export. task-3415's deliberate pin is kept.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3)

GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter,
but the token route gate read "import-status" as a slug and admitted a
pinned token, which could then read the user's import outcome for any
workspace by key. It joins reorder, import and deleted as a refused
segment.

The route walk skipped every route the gate admits, so it could not
catch a route admitted wrongly. Each admitted route must now sit inside
the workspace subrouter or be in a hand-written expected list, and the
list must match what the gate admits.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4)

r4 rulings (lead, on BUG-3982):
- On every door that takes a workspace by name (RequireWorkspaceAccess,
  SSE /events?workspace=, search ?workspace=), a pinned token naming any
  other workspace is refused before the name is resolved
  (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug
  get byte-identical refusals. The resolved-ID compares stay behind it.
- The email-restricted share link and the invitation preview read the
  caller through signedInFor: a token pinned to another workspace counts
  as signed out there.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4)

The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant
rerun showed that mutant surviving. This direct test, shaped like
TestBUG3982_WorkspaceAccessComparesThePinByID, holds it.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5)

r5 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace refuses a pinned token naming any other
  target_workspace before resolving it, so copy and preflight give a
  missing, non-member and member target the same refusal. The OAuth
  allow-list check, reported after the role check, is unchanged.
- The /-/r/ resolver checks the pin by name before resolving; its
  answer was already the same 404.
- Both join the identical-bytes test; the resolver also gets a by-ID
  test, since its resolved-ID compare is now behind the by-name one.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6)

r6 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace compares the pin by resolved ID behind its
  by-name check, like RequireWorkspaceAccess, SSE, the resolver and
  collab, with a by-ID test.
- Collab's resolved-ID compare gets the by-ID test the others have.
- docs/api.md: five BUG-3982 sentences appended without a separator
  now end the sentence before them.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
2026-10-21 02:43:37 -04:00
xarmian a6f96107f4 feat(lease): an opaque worker id holds item leases, so two agents under one name do not share one (TASK-3613) (#2102)
A lease was "ours" when its free-text label and the account matched, and the
label was the agent's display name, so names carried correctness: two
unregistered Claude Code sessions on one account both held as "claude-code",
and two stateless remote MCP clients shared their bare client name.

A lease is now held under a WORKER ID plus the account (lead rulings on the
item); the label is display only.

- Format: "wk_" + 32 lowercase hex (128 random bits), minted by the CLIENT.
  The server checks the shape (400 invalid_worker_id) and always matches it
  together with the account; a worker id with no signed-in user is refused.
- Store: one predicate, leaseOurs, shared by claim, release and the
  next/ready foreign filter, COALESCEd so its negation is exact. A
  worker-bound lease is ours iff worker id + account match; a name-only one
  keeps the label + account rule, and a worker refreshing its own name-only
  lease binds it. A name-only caller never matches a worker-bound lease,
  even under the same label (the collision fixed). Migrations SQLite 142 /
  PG 112 add items.lease_worker_id (NUL-excluded: format-validated in the
  store before any SQL).
- Server: the worker id comes from the body, then X-Pad-Worker, then a
  ?worker_id query, then the remote MCP caller. The claim answers with the
  caller's own worker_id; reads and the 409 show only the SHORT id
  ("wk_3f2a"); a name-only caller colliding under its own name is told how to
  get a worker id.
- CLI: `pad session register` mints the id into the session's registry
  record, keeps it when the same process re-registers (a /clear: same pid
  and start token) and mints a new one for a new process; older records get
  one lazily; $PAD_WORKER_ID overrides; X-Pad-Worker is sent on every call;
  --worker-id on claim, release, project next and ready (the local-stdio MCP
  path for worker_id).
- MCP: the remote default holder label is the bare client name (dropping
  BUG-2772's "#xxxxxxxx" suffix); the worker id is derived from the account
  and the MCP session; an explicit `worker_id` (pad_item claim/release,
  pad_project next/ready) overrides it and is the resume path across
  connections. ToolSurfaceVersion 0.97 (additive). The catalog text pays for
  itself: tools/list is 4 bytes smaller (the stale holder text is fixed and
  the ttl text trimmed).
- Docs: a claim/release section in docs/api.md (it had none), the
  docs/mcp.md Leases paragraph and changelog, SKILL.md, CLI help. The web UI
  renders no lease today; the TS type gains the short worker for a future
  badge.

Tests: store worker-id rules; server (two workers under one name, account
scoping, the name-only 409 and its message, adopting one's own name-only
lease, 400s, next agreeing with the claim across a matrix); MCP (bare-name
holder, the claim's worker_id, resume via worker_id in claim and next,
malformed refused, session-derived ids stable and distinct); CLI (the id
survives a re-register by the same process, a new process gets a new one,
lazy mint, PAD_WORKER_ID, SetWorkerID). The two CLI rules each kill a
mutant.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 09:24:33 -04:00
xarmian 58be45b35e feat(mcp): remote /mcp writes are agent writes named by clientInfo; refused claims say lease_held (BUG-2772, BUG-3496) (#1903)
* feat(mcp): remote /mcp writes are agent writes, named by the client's declared clientInfo (BUG-2772)

A write over remote /mcp reached the handlers with no X-Pad-Agent and
was recorded as the human. It is now an agent write, named by the
client's own MCP clientInfo.name: a modern request's _meta clientInfo,
else the clientInfo its session sent at initialize (remembered by the
Mcp-Session-Id pad issued; mcp-go's session state does not survive
pad's stateless transport), else unnamed; never the human, never a
guessed name. Each initialize logs the declared client (name, version,
protocol, transport; no tokens).

A remote call's default lease holder is that name plus a short session
id (claude-code#3f9a12), inside defaultLeaseHolder so claim and
next/ready agree; two connections of one client no longer share a
lease. Folds TASK-3495. ToolSurfaceVersion 0.77.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mcp): a refused claim reaches both transports as lease_held with its holder (BUG-3496)

* test(mcp): register WriteLeaseHeldError in the stdio marker vocabulary table (BUG-3496)

* fix(mcp): a remembered client name is bound to the account that declared it (BUG-2772 codex r1)

* fix(mcp): the remote lease holder carries eight hex digits of the session id (BUG-2772 lead ruling)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 15:15:26 -04:00
xarmian 6118f7065c feat(mcp): mount the ChatGPT catalog at /mcp/chatgpt, OAuth-only, off by default (TASK-3321 U2b) (#1719)
The ChatGPT projection (U1) gets its own transport at <MCP URL>/chatgpt,
behind PAD_CHATGPT_MCP_ENABLED=true and served only while OAuth is
available. It is its own OAuth resource (U2a's second canonical): its
path-aware protected-resource document is
/.well-known/oauth-protected-resource/mcp/chatgpt, its 401s name that
document, and tokens do not cross between the two mounts. Personal access
tokens are refused there with a 401 naming OAuth-only, ahead of the PAT
branch that would accept them without the audience check.

The consent screen names the ChatGPT surface when the requested resource
is the ChatGPT one. The mount is wired only while the door versions
content (ChatGPTDoorVersionsContent, held to the behaviour by a test) and
every catalog tool has a response shape (ChatGPTCatalogReady); otherwise
startup logs and skips it. /mcp is byte-identical.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 03:34:43 -04:00
xarmian bc2a982591 feat: every content change through the ChatGPT door is versioned first (TASK-3321 U1b) (#1711)
* feat(items): every content change through the ChatGPT door is versioned first (TASK-3321 U1b)

Ruling (b) on TASK-3321: ChatGPT's update_item is non-destructive because
every content change can be undone from History. An in-process context
marker (server.WithChatGPTSurface, unreachable from the wire) marks the
ChatGPT door. For a content write through it, the update handler sets
ForceVersion past the 1h throttle, labels the version "chatgpt", and sets
RefusePendingEdits: a tab's unflushed edits refuse the write with 409
content_pending_flush rather than be missing from the version and lost to
an undo. That refusal speaks to a chat user ("being edited in Pad right
now; ... Try again shortly") and names no API override. Field-only updates
write no version. A plain API body may not claim the chatgpt label. With a
tab open and caught up, the version is written through BUG-3327's
ExternalContent.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* feat(mcp): the ChatGPT catalog marks its door, so update_item versions every content change (TASK-3321 U1b)

Every ChatGPT tool call now reaches its source handler with
server.WithChatGPTSurface on the context. The HTTP dispatcher builds its
in-process request from that context, and the item update handler saves a
version before every content change from that door. The equivalence test
now checks the one intended difference: every ChatGPT dispatch carries the
marker (all 21 tools fail without it) and no /mcp dispatch does.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* test(items): a collab-snapshot flush cannot wear the chatgpt label; simplify the strip (TASK-3321 U1b review)

Codex round 1 suspected a ?source=collab-snapshot flush could keep a
body's chatgpt label. It cannot: the collab-snapshot branch overwrites the
label first. That made the strip's collab guard dead code, so it is
removed, and the case is pinned by a test leg.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 01:01:15 -04:00
xarmian 919c2975c2 fix(items): note and decide append server-side under the write lock, so they no longer revert a concurrent field write (BUG-3056) (#1458)
* wip(BUG-3056): server-side note/decide append, capability-gated CLI

* test(BUG-3056): server append tests

* test(BUG-3056): CLI capability gate tests

* test(BUG-3056): store append tests on both backends; CLAUDE.md API note

* fix(BUG-3056): keep the remote /mcp door's created_by label (codex r1)

* docs(BUG-3056): CLAUDE.md names the remote door's author label
2026-09-23 07:46:45 -04:00
xarmian 8d3e389088 fix(server): a stream ends when the credential that opened it stops being valid (BUG-3007) (#1323)
* fix(server): a stream ends when the credential that opened it stops being valid (BUG-3007)

Three long-lived connections kept running after the credential that
opened them was destroyed. Measured on a live instance before any code
was written, not inferred from the handlers:

  - GET /api/v1/events        delivering 165s after a logout,  64s after a PAT revocation
  - GET /api/v1/events/stream open      180s after a logout, 120s after a PAT revocation
  - GET /api/v1/collab/{id}   open      150s after a logout, 100s after a PAT revocation

In every leg `GET /auth/me` on that same credential answered 401 while
the connection was still live, which is what rules out "the credential
was still valid" — the rival explanation a code reading cannot exclude.
The collab leg also carries a control: the same upgrade with no
Authorization header, and with a bogus bearer, both get 401, so the
socket that outlived its credential was genuinely authorized by it.

All three already revalidate SOMETHING on a jittered ~60s tick, and none
of them asked this question. `/events` revalidates the connecting user's
MEMBERSHIP, which a sign-out does not change. `/events/stream` is
stricter — `refreshUser` re-fetches by USER ID and fails closed on a
deleted or disabled user — and still misses it, because a logout
destroys a SESSION and leaves a live enabled user behind. Collab
re-fetches the ITEM and re-runs access for the principal captured at
UPGRADE time. Every one of those is a question about the PRINCIPAL; none
of them changes when the credential dies.

So there was no in-tree reference to copy. `streamCredentialStillValid`
is new, and the three ticks call it before their existing checks.

Scope is deliberately wider than "the session was destroyed": a revoked
PAT still streaming is the same defect for a CLI or MCP caller that a
destroyed session is for a browser, so the invariant is "the credential
that opened this connection is still valid". Stated here rather than
discovered later as a side effect. The PAT legs were REPRODUCED rather
than inferred from the session legs — a PAT authenticates through
`TokenAuth`, a different path with scopes and an allowed-workspace set,
so either endpoint could plausibly have failed closed on one and not the
other. Neither does.

No new plumbing: all three loops already retain the original
*http.Request for their existing checks, verified at each call site
(handlers_events.go:567, handlers_watch_events.go:420,
handlers_collab.go:344), so the credential is re-read from there rather
than threading a session id through a context that carries none today.

On the tick rather than per delivery: all three already have a tick and
a fail-closed branch to reuse, and a store lookup per delivered event
would sit on the hot path of a fan-out. One tick of bounded staleness is
what these loops already promise for membership changes.

A request carrying NO credential returns true. On the fresh-install
window and the legacy no-auth path there is nothing to invalidate, and
closing there would turn a security fix into an availability regression
on exactly the deployments least able to diagnose it. Pinned by its own
test.

Eleven tests. Six assert a stream STOPS (three connections × session
destroyed / PAT revoked); four are counterfactuals asserting it STAYS
OPEN, because all six would pass against a predicate that returned false
unconditionally — which would close every stream on its first tick and
be a worse defect than the one being fixed. The eleventh pins the
`padsess_` branch in both directions: a CLI session bearer is a SESSION,
not an API token, and validating it as a PAT would answer "no such
token" for a live session and close every CLI stream.

Three preconditions caught three false failures while these were being
written — a 503 with no watch bus, a 503 with no collab room manager,
and a 403 for a PAT-authenticated caller who was not a workspace member.
Each would have made a test pass for the wrong reason the moment the fix
landed. That is the fifth, sixth and seventh instance today of the same
shape: an absence asserted with no assertion that the thing producing it
happened.

Mutants, all confirmed to BUILD first:
  - predicate always permits -> 7 tests fail (all six STOPS + the bearer)
  - predicate always refuses -> 5 tests fail (all four STAYS OPEN + the bearer)
  - `padsess_` branch removed -> the bearer test fails, alone

Prose the change falsified, swept and corrected (CONVE-23):
`collabMembershipRevalInterval`'s doc comment and `handleCollab`'s header
both described the tick as covering revocation only; `refreshUser`'s
comment enumerated its fail-closed set without noting what it cannot
see. CLAUDE.md gains the invariant next to the two SSE endpoints.

Gates: make test exit 0, 0 FAIL; make lint 0 issues; make test-pg exit 0,
0 FAIL (run despite no SQL changing, because this is server-side and the
Postgres leg exercises a different store).

* fix(server): three-valued credential liveness, a closed default, and a read-only token door (BUG-3007, codex round 1)

Round 1 found two, and a lead ruling landed on a third — the gap I had
flagged myself.

1. HIGH — a transient store error was read as a revocation. The
   predicate was a bool, so `ValidateSession`/`ValidateToken` returning
   an error meant "invalid": one database blip would have closed every
   affected stream on the next tick, with SSE emitting `unauthorized`,
   watch SSE exiting and collab sending a policy-violation close. A
   fleet-wide reconnect storm for users whose credentials were fine.

   "The lookup says invalid" and "the lookup could not be performed" are
   opposite facts and a bool collapses them, so the predicate is
   three-valued now: valid / invalid / unknown. Unknown KEEPS the
   connection and logs — one tick of extra staleness, bounded by the
   store recovering, which is what these loops already accept for
   membership changes.

2. RULED — the default branch fails CLOSED. I had flagged that an
   unrecognised credential kind fell through to "still valid", and the
   ruling is that unknown-kind-keeps-streaming is the same defect in a
   different coat.

   Proving completeness needed a fact the request did not carry: HOW the
   principal was established. `ctxAuthKind` is set once, by the
   middleware branch that accepts the credential, and the predicate
   switches on it instead of re-sniffing the wire — so there is exactly
   one place on this server deciding whether a bearer is a session or an
   API token, rather than two that can drift. A future auth path that
   forgets to set it now ends long-lived connections loudly on the first
   tick and says why.

   The no-credential case is its own explicit branch rather than the
   default, so it cannot absorb a future kind by accident: the
   fresh-install window and the legacy no-auth path have nothing to
   revoke, and closing them would turn a security fix into an
   availability regression on the deployments least able to diagnose it.

3. MEDIUM — the tick was a database WRITE. `ValidateToken` bumps
   `last_used_at` unconditionally, so at the default 1000-connection
   admission limit this fix would have added ~16 sustained writes/second
   forever. `ValidateTokenForLiveness` is the read-only door, sharing one
   implementation with the ordinary one so the two cannot drift on what
   "valid" means.

   The cost is the smaller half of the argument. `last_used_at` is what
   an operator reads to decide whether a token is still in use before
   revoking it, and a background liveness probe is not use — letting it
   bump the field would make every idle-but-connected token look active
   and quietly defeat the audit the column exists for.

Four new tests, each pinning a branch the eleven connection tests cannot
reach on demand: unknown kind closes, no credential stays open, a store
error keeps the connection, and the liveness door leaves `last_used_at`
alone. That last one asserts the ORDINARY door moves it first — without
that precondition it would pass against a column nothing ever writes.

Mutants, all confirmed to BUILD first: default fails open again -> the
unknown-kind test fails; store error treated as revocation -> the
store-error test fails; liveness door touches `last_used_at` -> its test
fails. Each dies to exactly one test, so the three branches are pinned
independently.

Codex also confirmed clean this round: credential-kind coverage for the
three routes (OAuth and MCP tokens never reach them), the no-credential
path, session UA/IP binding, and the collab close/teardown race.

Gates: make test exit 0, 0 FAIL; make lint 0 issues; make test-pg exit 0,
0 FAIL — the Postgres leg matters more this round, since the store now
has a second validation door.

* fix(server): an accept point that resolves a principal without recording its credential kind now fails closed (BUG-3007, codex round 2)

Round 2 found the one hole the round-1 design left: the empty-kind branch
means "no credential on the wire" and KEEPS the connection, and both MCP
accept points landed there. They resolve a user through WithCurrentUser
without going through TokenAuth, so they recorded who but not how — the
exact silent exemption the fail-closed default branch exists to prevent,
reached by a different door.

Not reachable today: no MCP route is long-lived (dispatch_http_routes.go
maps nothing under /api/v1/events or /api/v1/collab), so this is a guard
rather than an incident.

Three changes:

- The empty-kind branch now distinguishes "nobody authenticated" from "an
  accept point authenticated somebody and did not say how". The first keeps
  the connection (fresh install, legacy no-auth); the second closes it. One
  guard covers every present and future accept point without naming any.
- The MCP PAT path records `api_token`. Its credential IS a PAT in the
  Authorization header, so the existing liveness door revalidates it
  verbatim — no new machinery.
- The MCP OAuth path deliberately records nothing. An opaque fosite token
  needs an introspection-backed door that distinguishes ErrInactiveToken
  from a storage error, and writing one for a route that does not exist is
  speculative; it fails closed and the comment says why.

WithCurrentUser now documents that it cannot record an auth kind and that a
long-lived route dispatched through it will be closed, not exempted.

Three tests. The principal-without-a-kind guard and the MCP PAT kind each
die to exactly one mutant (drop the guard; drop the WithValue). The third
answers round 2's other point: the existing store-error test closes the
whole store, which is permanent, so it proves the classification and not
the promise the classification is made for — that the tick tries again.
This one breaks the seam transiently (rename the table ValidateSession
reads, ask, put it back, ask again) and ends with a revocation, so a
predicate that returned valid unconditionally after an error would fail it.
It is a fence rather than a discriminating test: the mutant only it would
kill is a latching implementation, which this tree does not have.

Gates green including make test-pg; all three new tests run under Postgres.

* test(server): the transient-outage test restores its schema on failure, and gets a Postgres leg (BUG-3007, codex round 3)

Round 3 found no P0/P1 and confirmed the enumeration this fix rests on:
every production path that reaches the three long-lived routes with a
principal now records an auth kind, and nothing sets a user on top of a
different kind. Two P2s, both about the new test and the latent MCP half.

CORRECTION to the round-2 commit message and the PR body, which claimed the
three new tests were "confirmed to run under Postgres". They ran during
`make test-pg` and they ran on SQLite: `testServer` calls
storetest.NewSQLite unconditionally, so no test built on it is ever
Postgres-backed regardless of PAD_TEST_POSTGRES_URL. The claim was false as
written and I did not check the helper before making it.

So the outage test now has a real Postgres leg, via the existing
testServerPostgres helper, sharing one body with the SQLite one so they
cannot drift. The predicate is dialect-independent but the INSTRUMENT is
not — `ALTER TABLE ... RENAME TO` and the error a missing table produces
are both dialect-specific, and "a store error classifies as unknown" would
be worth nothing on the backend it was never asked on. Verified failing-
into-passing on a real container: `make test-pg TEST_PG_PKGS=./internal/server/`
runs the leg rather than skipping it, and it passes.

The restore is now registered with t.Cleanup the moment the table is
renamed, not left to a line after four assertions that can t.Fatalf past
it. Idempotent, so the in-test restore still happens where the test wants
it. Counterfactual run with an injected mid-outage failure: the only
failure reported is the injected one, so the cleanup restored the schema.

`buildHTTPRequest` gains the note round 3 asked for: it forwards the
principal and not the credential, so a synthesized MCP request carries
`api_token` with no bearer to re-check. Unreachable today (routeTable maps
no long-lived route) and closed either way, but the fix when that changes
is to forward the credential, not to widen the predicate.

Gates: make test exit 0, 0 FAIL; make lint 0 issues; make test-pg exit 0,
0 FAIL.

* docs: the BUG-3007 invariant said a no-credential request is unaffected; a resolved principal with no auth kind is closed

Rounds 2 and 3 changed what the empty-kind branch means, and the CLAUDE.md
paragraph written in round 0 still described the old reading. "A request
carrying NO credential is unaffected" is true only when there is also no
resolved principal; a request that names a user without recording how that
user was established is now CLOSED, because an accept point that records
who without recording what leaves a connection nobody can re-check.

Also says what an operator will otherwise learn from an incident: a store
error is not a revocation and keeps the connection to the next tick.
2026-09-10 16:47:31 -04:00
xarmian 9f6c1d8f47 fix(security): scope MCP workspace-global reads by OAuth consent allow-list (BUG-2102) (#935)
The OAuth token consent allow-list (TokenAllowedWorkspaces) was enforced only
by RequireWorkspaceAccess, which fires solely for /{slug} path-param routes.
Every MCP-reachable read that is workspace-global or takes the workspace as a
query/body param bypassed the gate, so a token consented to workspace A could
reach data in other co-membership workspaces. Investigation found five
bypasses; this closes all of them:

- pad_search (HIGH): fan-out (no workspace) searched ALL memberships; naming a
  workspace returned its item titles + content. Now the fan-out is restricted
  to the allow-list and a named non-consented workspace returns empty (no
  existence leak).
- pad_workspace.list (the original BUG-2102): filtered by the allow-list.
- pad_workspace.deleted: filtered by the allow-list.
- pad_workspace.audit-log: platform-wide admin surface; denied for
  consent-scoped tokens.
- pad_workspace.restore: gated by the allow-list (404 for out-of-consent slugs).

All gates are no-ops for nil/wildcard allow-lists, so PAT auth, web sessions,
and local stdio are unchanged.

The allow-set semantics move into internal/server as the canonical
TokenAllowedWorkspaceSet(ctx) (promoted from internal/mcp's buildAllowSet);
the two mcp call sites (error-hint lister, workspaces resource) and its unit
tests migrate with it, so server handlers and MCP filters share one
implementation instead of drifting per-surface (the pattern that caused this
bug: TASK-977 and TASK-2101 each point-fixed one surface).

Tests: per-handler regression tests carrying the WithTokenAllowedWorkspaces
context MCPBearerAuth produces; each asserts the consent layer (not membership)
drives exclusion, with nil/wildcard baselines guarding against over-blocking.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-14 20:37:03 -04:00
xarmian d8b1d98e08 feat(mcp): persistent audit log for /mcp tool calls (TASK-960) (#389)
* feat(mcp): persistent audit log for /mcp tool calls (TASK-960)

Adds a 90-day-retention audit log of every MCP request. Drives the
"last used" + "30-day calls" columns the connected-apps page (TASK-954)
will read, and gives ops + on-call a forensics surface via a new
admin /console/admin/mcp-audit page.

Schema deviation from the spec, documented in migration 049:
the original task body called for `token_id REFERENCES oauth_tokens(id)`
but pad has no `oauth_tokens` table — instead an OAuth grant chain is
identified by `request_id` (preserved across refresh-token rotations,
see migration 048), and PAT-authenticated MCP requests have no OAuth
identity at all. The audit row therefore carries `(token_kind,
token_ref)` — `oauth` + request_id for OAuth, or `pat` + api_tokens.id
for PATs. The connected-apps page in TASK-954 will filter on
token_kind='oauth' to surface third-party connections only.

Pieces:
- internal/store/migrations/049_mcp_audit.sql + pgmigrations/028 — table.
- internal/models/mcp_audit.go — typed entry + 30-day stats DTO.
- internal/store/mcp_audit.go — insert / list-by-user / list-by-connection
  / list-all / per-connection-stats aggregator / 90-day retention sweeper.
- internal/server/middleware_mcp_audit.go — async writer + sweeper +
  middleware that wraps /mcp behind MCPBearerAuth. Hot path is
  non-blocking enqueue with drop-on-overflow + atomic drop counter.
- internal/server/middleware_mcp_auth.go — both PAT + OAuth branches now
  stash WithMCPTokenIdentity so the audit row attributes correctly.
- internal/server/handlers_mcp_audit.go — read endpoints:
  GET /api/v1/connected-apps/{id}/audit (owner-scoped) +
  GET /api/v1/admin/mcp-audit (admin-only).
- web/src/routes/console/admin/mcp-audit/+page.svelte + tab in admin layout.
- Tests cover required-field validation, round-trip, pagination,
  owner-only filtering, last-used + 30-day aggregates, retention sweep,
  body-sniff parser, canonical-JSON arg hashing, buffer-full drop path,
  status-to-result classification, admin gate, DTO field shape.

`make check` clean (lint + go test ./... + svelte-kit build).

Parent: PLAN-943.

* fix(mcp-audit): emit denied row on rate-limit reject per Codex review (round 1)

PR #389 round 1 caught: MCPAuditLog is mounted INSIDE MCPBearerAuth, so
when bearer auth's per-token rate-limit fires (429) it returns before
next.ServeHTTP — and the wrapping audit middleware never sees the
response. classifyMCPResult mapped 401/403/429 with no path that could
actually reach it.

Fix: emitMCPAuditDenied helper called directly from the rate-limit
deny branches of both PAT + OAuth paths. Resolved user + token
identity are already in scope at that point, so the audit row gets
attributed correctly. Pre-auth rejections (no/invalid bearer) stay
un-audited because there's no user to attribute them to and the
audit_trail table covers those auth-event signals already.

Threading: handleMCPPATAuth + handleMCPOAuthAuth now take the entry
timestamp so the denied row carries real latency.

Test: TestMCPAudit_RateLimited_RecordsDeniedRow drives a real PAT
through the rate limiter, drains to 429, and asserts the audit row
lands with status="denied" + error_kind="rate_limited" + the right
tool_name from the request body.
2026-05-02 22:56:10 -04:00
xarmian d01bbf6bf1 feat(oauth): live workspace allow-list + role enforcement (TASK-953) (#377)
Closes the third leg of PLAN-943's OAuth permission model:

  (token capability tier) × (live workspace role) × (consent allow-list)

The first two were already in place — TASK-1027 wired the tier
scope check (pad:read / pad:write / pad:admin via tokenScopeAllows)
and RequireWorkspaceAccess does the live role lookup. This PR adds
the third gate: the workspace-allow-list set at consent time
(TASK-952) actually denies workspaces NOT in the user's selection.

## What's new

- `oauth.Session.AllowedWorkspaces()` / `SetAllowedWorkspaces()` —
  typed accessors on session.Extra. Handle BOTH the in-memory
  []string shape (consent-decide path) AND the JSON-decoded
  []interface{} shape (post-storage round-trip path).
- `WithTokenAllowedWorkspaces` / `TokenAllowedWorkspacesFromContext` —
  context helpers in internal/server with defensive copies so
  callers can't corrupt the per-request token state.
- `MCPBearerAuth` (OAuth path) reads the token's allow-list from
  session.Extra and stashes it in context.
- `RequireWorkspaceAccess` checks the allow-list against the
  resolved workspace's slug. Three behaviours match
  TokenAllowedWorkspacesFromContext's return shapes:
    - nil → no token-level gate (PAT auth, pre-TASK-952 OAuth
      tokens). Standard membership applies.
    - ["*"] → wildcard. Every membership the user has passes.
    - [slug-a, slug-b, ...] → only listed slugs. Anything else
      gets 403 permission_denied BEFORE the membership check.

## Live role + revocation

Membership revocation takes effect immediately. RequireWorkspaceAccess
calls GetWorkspaceMember on every request — if the user lost
membership in workspace X, the token's allow-list including X no
longer helps; the request is rejected at the standard membership
gate. Tested explicitly via TestWorkspaceAllowList_LiveMembershipRevocation.

## Tier × role

The natural intersection of tokenScopeAllows (tier-based HTTP-method
gate) and per-handler role checks (e.g. requireEditPermission) handles
the tier × role table from the PLAN-943 spec:

  - pad:write tier passes tokenScopeAllows for POST.
  - But Viewer role fails requireEditPermission's role check.
  - Net: 403 — tested explicitly via
    TestWorkspaceAllowList_TierTimesRole_WriteByViewer.

## Tests

Unit (no I/O):
- TestTokenAllowedWorkspaceMatches — policy table for the helper.
- TestWithTokenAllowedWorkspaces_DefensiveCopy + 1 reader counterpart.
- TestSession_AllowedWorkspaces_*: setter/getter, nil-clear, defensive
  copy, JSON round-trip ([]string + []interface{} branches),
  wildcard JSON round-trip, not-set, nil-session.

Integration (full chain, real OAuth flow):
- TestWorkspaceAllowList_AllowsListedSlug — listed workspace passes.
- TestWorkspaceAllowList_DeniesUnlistedSlug — unlisted gets 403
  even though user is owner.
- TestWorkspaceAllowList_WildcardAllowsAnyMembership — wildcard
  passes for every membership.
- TestWorkspaceAllowList_LiveMembershipRevocation — token works,
  then membership revoked, then same token denied.
- TestWorkspaceAllowList_PATPathUnaffected — PAT regression: PATs
  don't carry an allow-list, must NOT hit the gate.
- TestWorkspaceAllowList_TierTimesRole_WriteByViewer — pad:write
  tier × Viewer role on POST item → 403.
2026-05-02 14:29:17 -04:00
xarmian 521853e0a1 feat(mcp): mount /mcp Streamable HTTP transport + OAuth discovery (TASK-950) (#369)
* feat(mcp): mount /mcp Streamable HTTP transport + OAuth discovery (TASK-950)

First public cut of pad-cloud as a remote MCP server (PLAN-943). Mounts
the Streamable HTTP transport on /mcp, the RFC 9728 protected-resource
discovery doc on /.well-known/oauth-protected-resource, and a 501 stub
for RFC 8414 auth-server metadata that TASK-951 will fill in.

- internal/server/handlers_mcp.go — Server.SetMCPTransport + chi route
  registration under cloud-mode gate (self-host stays free of MCP
  overhead unless explicitly opted in).
- internal/server/middleware_mcp_auth.go — Bearer auth that produces
  the spec-shape 401 + WWW-Authenticate (resource_metadata pointer)
  MCP clients expect, distinct from /api/v1's JSON-only 401 envelope.
  Reuses the existing PAT (api_tokens) validation path; OAuth-issued
  tokens layer in via this same middleware in TASK-951.
- internal/server/handlers_well_known.go — RFC 9728 discovery doc +
  RFC 8414 stub. URLs come from PAD_MCP_PUBLIC_URL +
  PAD_AUTH_SERVER_URL with request-host fallback for local dev.
- internal/server/handlers_mcp_test.go — 7 tests covering cloud-off
  routes-absent, cloud-on-no-transport routes-absent, discovery doc
  shape, 501 stub, no-token 401+WWW-Authenticate, bad-format-token
  401+WWW-Authenticate, and the valid-PAT happy path with user
  attached to transport context.
- cmd/pad/main.go — wires mcpserver.NewServer + HTTPHandlerDispatcher
  + StreamableHTTPServer in cloud mode, after SetCloudMode.
- internal/config — adds PAD_MCP_PUBLIC_URL and PAD_AUTH_SERVER_URL.

Resources are intentionally skipped in this v1 — they require an
HTTPResourceFetcher equivalent of ExecResourceFetcher and that's a
follow-up task. Tools, prompts, instructions, and meta all flow
through identically to the stdio surface (verified via spike against
mcp-go v0.50.0's StreamableHTTPServer before writing the real PR).

* fix(mcp): enforce PAT scopes on /mcp + WWW-Authenticate fallback per Codex review (round 1)

Two findings from PR #369 round 1:

1. SECURITY: A PAT with scopes ["read"] could drive write MCP tools.
   MCPBearerAuth skipped tokenScopeAllows entirely; the dispatcher's
   synthesized in-process request bypassed TokenAuth's chain-level
   check (because WithCurrentUser was already set), so a read-scoped
   token could POST item create / PATCH update / DELETE silently.

   Fix: stash apiToken.Scopes via server.WithTokenScopes in
   MCPBearerAuth; re-check per synthesized request in
   HTTPHandlerDispatcher.executeRequest using the public
   server.TokenScopeAllows wrapper. Read-scoped tokens can still
   drive read-only tools (their HTTP method is GET) — only writes
   are rejected, with a structured permission_denied envelope.

2. DISCOVERY: writeMCPUnauthorized dropped the WWW-Authenticate header
   when PAD_MCP_PUBLIC_URL was unset. Cloud-mode deploys without that
   env var mounted /mcp but broke the discovery handshake — fresh
   MCP clients rely on the header to find /.well-known/oauth-protected-
   resource.

   Fix: pass *http.Request through to writeMCPUnauthorized, derive
   "https://" + r.Host as the fallback (matches handleOAuthProtected-
   Resource's existing fallback).

Tests:
- handlers_mcp_test.go: TestMCP_NoToken_FallsBackToHostWhenPublicURLUnset
  pins the WWW-Authenticate fallback. TestMCP_ReadScopedPAT_StashesScopes-
  InContext + TestTokenScopeAllows_PublicWrapper pin the scope-stash
  side.
- dispatch_http_test.go: TestHTTPHandlerDispatcher_ScopeEnforcement_*
  pin the dispatcher-side enforcement (read-on-write rejected,
  read-on-read allowed, no-scope-context allows-all).
- recordingHandler updated to handle nil r.Body so the read-only
  GET path can be exercised.

* fix(mcp): move scope check to buildAuthedRequest so bulk-update can't bypass it per Codex review (round 2)

Round 1 enforced scopes in executeRequest, but dispatch_http_project.go's
item bulk-update path constructs each per-item PATCH directly via
buildAuthedRequest + d.Handler.ServeHTTP, skipping executeRequest. Net
result: a PAT with ["read"] scope could still mutate items through
bulk-update even after the round-1 fix.

Move the scope check from executeRequest into buildAuthedRequest so
every synthesized request — main writes, RMW prefetches, bulk-update
per-item PATCHes, link-create POSTs, attachment HEADs — passes
through the same gate uniformly. The check is dropped from
executeRequest to avoid double-checking; buildAuthedRequest is the
universal funnel everything calls.

Reads (GET/HEAD/OPTIONS) under ["read"] scope still pass — bulk-
update's per-item GET prefetch succeeds, the subsequent PATCH fails
at request-build time with permission_denied. The bulk operation
returns successfully with all-errors recorded per ref (the "no abort
on per-item failure" contract is unchanged).

Test: TestHTTPHandlerDispatcher_ScopeEnforcement_BulkUpdateBlockedOnReadScope
spies on the test handler; asserts the PATCH never reaches it under
["read"] scope and that each per-item entry carries permission_denied.
2026-05-02 09:25:25 -04:00
xarmian d84f1180a7 feat(mcp): pluggable Dispatcher + HTTPHandlerDispatcher for remote MCP (TASK-965) (#343)
* feat(mcp): pluggable Dispatcher + HTTPHandlerDispatcher for remote MCP (TASK-965)

Architectural prerequisite for PLAN-943's remote MCP at /mcp. The
existing ExecDispatcher (PLAN-942) shells out to the pad binary and
inherits credentials from ~/.pad/credentials.json — fine for local
stdio MCP where the user IS the subprocess owner, but unworkable for
a multi-tenant /mcp endpoint where the dispatcher must serve many
OAuth-authenticated users from a single process.

This PR ships the alternative path: HTTPHandlerDispatcher calls
pad-cloud's existing HTTP handler chain in-process, with the
requesting user attached via context. Same handlers, same audit /
event-bus / webhook plumbing — just no fork().

## What's in

- internal/mcp/dispatch.go: keeps the existing Dispatcher interface
  (so ExecDispatcher unchanged) and adds a context-keyed
  WithDispatchInput helper. The registry attaches the original JSON
  input map to the dispatch context so dispatchers that prefer
  structured data over reverse-parsed cliArgs can use it.
- internal/mcp/registry.go: forwards the merged input (user-supplied
  values + session workspace + root flags) to the dispatcher via
  WithDispatchInput. ExecDispatcher ignores it.
- internal/mcp/dispatch_http.go (new): HTTPHandlerDispatcher
  implementation with a routeTable[cmdPath]→RouteMapper mapping. Seed
  entry: `item create`. Adding more commands is one RouteMapper per
  cmdPath plus a routeTable insert.
- internal/server/context.go (new): exported WithCurrentUser /
  WithAPITokenAuth / WithTokenWorkspaceID + read-only
  CurrentUserFromContext / IsAPITokenFromContext. Lets internal/mcp
  synthesize an authenticated request without reaching the
  package-private context keys.
- internal/server/middleware_csrf.go: extends the existing "Bearer
  token requests skip CSRF" rule to also honor the ctxIsAPIToken
  context flag. Same semantic — non-cookie auth means no CSRF risk —
  but covers the in-process dispatch path where TokenAuth never sets
  the Authorization header. Safe because ctxIsAPIToken can only be
  set by trusted in-process code (TokenAuth on the live Bearer path,
  or server.WithAPITokenAuth from the dispatcher).

## What's tested

- Unit:
  - TestHTTPHandlerDispatcher_RoutesItemCreate — full happy path
    with a recordingHandler asserting method/path/body/user-context.
  - TestHTTPHandlerDispatcher_UnsupportedToolReturnsErrorResult —
    tools not yet in the routeTable produce IsError-flagged results
    rather than panicking.
  - TestHTTPHandlerDispatcher_NoUserReturnsErrorResult — UserResolver
    returning nil produces an IsError, never a nil-deref.
  - TestHTTPHandlerDispatcher_HandlerErrorSurfacesAsToolError — 4xx
    handler responses come back as IsError MCP results matching
    ExecDispatcher's `pad <cmd> failed: <stderr>` format.
  - mapItemCreate validation + parseFieldKVP variants.
- Integration: TestHTTPHandlerDispatcher_Integration drives the full
  *server.Server (real chi router, real SQLite store, full middleware
  chain) with a synthesized OAuth user and asserts the item lands in
  the DB.

## Scope discipline

The DoD called for "dispatch item.create end-to-end" — that's the seed
entry. Wiring the remaining ~70 MCP-exposed commands into routeTable
is naturally a follow-up before TASK-950 ships /mcp to real users
(captured as a separate task post-merge).

Audit-log assertion in the integration test is deferred until TASK-960
(B6b) lands the audit log itself.

Parent: PLAN-943.

* fix(mcp): roll status/priority/category/parent into fields JSON per Codex review (round 1)

Codex caught: mapItemCreate placed status / priority / category /
parent at the top level of the JSON body, but handleCreateItem only
reads them after unmarshalling the Fields string from the request. As
written, MCP-driven `item create` would silently drop those flags —
breaking parity with the CLI for almost every realistic call (parent-
linked tasks, priority-set items, status-overridden ideas, etc.).

Mirrored the CLI's behaviour (cmd/pad/main.go ~L2200): build a fields
map from the named flags, overlay the repeatable --field entries on
top, JSON-encode into ItemCreate.Fields. The handler's existing
schema-validation + parent-resolution path now runs unchanged.

Repeatable --field still wins last-write — locked into a new test so
it doesn't drift.

Also rejects --assign / --role with a clear error rather than silently
dropping them. The CLI resolves user-name → user-ID and role-slug →
role-ID via additional API calls before posting; replicating that
pre-resolution belongs in a follow-up that expands the route table for
production use. Failing loudly is better than partial parity.

Tests:
- TestHTTPHandlerDispatcher_RoutesItemCreate now asserts the
  status/priority/category/parent values land in fields, not the top
  level — guards against the regression directly.
- TestMapItemCreate_ExplicitFieldOverridesNamedFlag locks the
  last-write-wins precedence between --status and --field status=...
- TestMapItemCreate_RejectsUnsupportedAssignRole asserts the
  defensive error path for the deferred flags.

Parent: PLAN-943.

* fix(mcp): persist source=cli for HTTPHandlerDispatcher calls per Codex review (round 2)

Codex caught: actorFromRequest derives source from the Authorization
header — without one, dispatcher-driven calls would persist
source="web" instead of source="cli", regressing dashboard/standup/
audit attribution vs. ExecDispatcher.

Same pattern as the round-1 CSRF fix: extend actorFromRequest to also
honor the ctxIsAPIToken context flag (which TokenAuth sets on the live
Bearer-auth path and HTTPHandlerDispatcher sets via
server.WithAPITokenAuth on synthesized requests). Both signals mean
"non-cookie authenticated, attribute as CLI/agent traffic".

Integration test now asserts source="cli" on the created item, so any
future regression of this attribution surfaces immediately.

Parent: PLAN-943.

* fix(mcp): normalize collection aliases in HTTPHandlerDispatcher per Codex review (round 3)

Codex caught: CLI's `item create task ...` works because
cmd/pad/main.go's normalizeCollectionSlug maps singular/short forms
("task" → "tasks", "doc" → "docs", etc.) to the canonical slug
before posting. HTTPHandlerDispatcher's mapItemCreate skipped that
step, so the same documented call shape would 404 through the HTTP
transport even though it worked through ExecDispatcher.

Extracted the alias map to internal/collections.NormalizeSlug so the
two transports stay in lockstep without duplication. cmd/pad/main.go's
normalizeCollectionSlug now delegates to it; the in-process
dispatcher calls it from mapItemCreate after pulling the collection
out of input.

TestMapItemCreate_NormalizesCollectionAliases locks every documented
alias plus a passthrough case for custom collections.

Parent: PLAN-943.

* fix(server): WithTokenWorkspaceID actually clears on empty input per Codex review (round 4)

Codex caught: the docstring said "Pass an empty string to clear" but
the implementation early-returned `ctx` unchanged in that case,
leaving any stale ctxTokenWorkspaceID set further up the chain
active. Always overwrite so the contract holds: passing "" produces
a context where tokenWorkspaceID(r) returns "", same as a never-set
context.

Parent: PLAN-943.
2026-05-01 11:51:28 -04:00