Skip to content

Add MCP remote access: OAuth-minted tokens with SpiceDB-native authorization - #17

Open
josephschorr wants to merge 22 commits into
mainfrom
feat/mcp-remote-access-phase1
Open

josephschorr wants to merge 22 commits into
mainfrom
feat/mcp-remote-access-phase1

Conversation

@josephschorr

Copy link
Copy Markdown
Member

Adds a remote access plane so people can reach shared-cluster agents from local tools (coding agents, editors) over MCP, with OAuth-minted tokens whose authorization truth lives entirely in SpiceDB.

What's included

SpiceDB model — a new accesstoken definition: one role tuple binds owner + role + expiry (expiration trait), scope_class holds the agentclass filter (wildcard for unfiltered), and $sameperm mirror permissions (read_transcript, read, view, interact, approve, start_session) spell out a can_read ⊂ can_interact ⊂ can_full ladder. Every /mcp operation is authorized by one CheckBulkPermissions with three legs: token grants the permission, token covers the session's class, owner holds the real resource permission. Go carries no role→permission table; denied and owner-offboarding win automatically through leg 3. Also adds platform#view_tokens / #revoke_token.

AccessToken CRD + reconciler — the CRD is the authentication record only (sha256 token hash, owner, client metadata, expiry; immutability enforced via CEL). The reconciler owns finalizer cleanup (deleting the SpiceDB tuples = revocation) and expiry GC. Fail-closed in both directions: tuples without CR → no hash match → 401; CR without tuples → 403.

OAuth authorization server in identityd — RFC 8414 metadata, RFC 7591 dynamic client registration with stateless signed client_ids (no storage, replica-safe), PKCE-only (S256) authorize → consent → token. The consent screen defaults to read-only and offers role + agent-class scoping backed by live SpiceDB lookups. Minting writes tuples first, then the CR, so a fresh token is usable the moment the endpoint responds; CR-create failure compensates by deleting the tuples.

/mcp endpoint in webd (pkg/web/mcpfront) — bearer middleware with uniform 401s (no failure-mode oracle), a 30s token cache with a re-list freshness floor + singleflight (bounds unauthenticated apiserver List pressure), per-token rate limiting that survives cache rebuilds, and status.lastUsedAt observation writes debounced to once per hour.

Six read-role MCP tools — list_sessions, get_session, get_transcript, search_memory, list_artifacts, get_artifact. Denial and not-found return identical text (no existence oracle). Memory search passes only pre-authorized session scopes — the scope list is the authorization boundary.

Admin console tokens page — list every token (owner, client, role read back from SpiceDB, scope, expiry, last used, revoked) and revoke; the token hash never leaves the server. Gated by the new platform permissions.

oap setup-mcp — emits local-tool MCP config (claude-code / cursor / generic). OAuth-native clients get just the URL and run their own consent; --mint runs the flow and embeds the bearer in the emitted config.

Testing

  • Unit, integration, and e2e suites all green (mage test:unit / test:integration / test:e2e).
  • New integration coverage drives the full wire protocol end to end against real SpiceDB: discovery → DCR → authorize → consent → PKCE exchange → MCP tool calls, plus revocation-by-tuple-delete, revocation-by-CR-delete (401), owner-denied-kills-token (exercised twice), and scope-filter exclusion with byte-equal denial text.
  • Schema subject closures pinned by exact-line tests; RBAC minimality covered by sufficiency tests in both directions (webd cannot delete tokens; the operator cannot mint).

Deliberate scope choices (follow-ups filed)

  • Decision audit entries for /mcp ops are structured logs (outcome, failed leg, token id) in this phase; routing them into the append-only authzdecision ledger needs a new write-capable memory-token class and is written up separately for review before the write-capable tools land.
  • get_transcript is pagination-only (tail=true streaming deferred); list_sessions filters by agent/state (time range deferred). Both recorded in code comments.
  • The OAuth authorize→consent→token stores are process-local; manifests pin webd to one replica and the constraint is documented. /mcp bearer auth itself is replica-safe.
  • Interact/full-role tools, the CLI remote backend, and oap token management are the next phase per the design doc.

TestCleanCoversEveryCRD has been red since the AccessToken CRD landed
(cab8b0c) without a matching crGroupVersionResources entry. AccessToken
carries FinalizerAccessToken (cleared only by the operator), so omitting
it from clean's CR sweep would also wedge teardown the same way the
PublicEndpoint comment describes.
…ered re-lists bounded by a freshness floor + singleflight
admind: GET /admin/v1/tokens (view_tokens) lists AccessToken CRs in the
system namespace joined with each token's SpiceDB grant via a new optional
AccessTokenGrantReader config seam (ReadAccessTokenGrant); a per-item read
error degrades that row to role "unknown" and found=false marks it revoked
— one bad token never 500s the page, and spec.tokenHash never reaches the
response. POST /admin/v1/tokens/revoke (revoke_token) deletes the CR; the
finalizer tears down the SpiceDB tuples.

adminui: exact proxy rows /admin/api/tokens (view_tokens) and
/admin/api/tokens/revoke (revoke_token), plus the TokensPanel React view
registered in the shell nav and client router.

operator: wires AccessTokenGrants (spiceDBClient) and AccessTokenNamespace
(systemNS) into admind.Config.

webassets/dist regenerated (committed build output; mage web:check guard).
The RBAC test-then-minimize harness (TestRBACSufficiency) flagged every
accesstokens verb as OVER-BROAD because the hand-maintained required
tables never learned the new resource. Fix per the harness's own
convention — trim the roles to actual client calls, then declare them:

- operator marker drops main-resource patch (the reconciler's only patch
  targets accesstokens/status); required rows get/list/watch/update/delete
  plus the status-patch sufficiency row; create pinned denied (minting is
  webd's alone).
- webd clusterrole drops watch (direct client.New, no informer; the admin
  tokens page reads through admind in the operator binary); required rows
  create/get/list plus the status-patch sufficiency row; delete pinned
  denied (a browser-facing pod must not destroy token records).

config/manager/role.yaml and install.yaml regenerated via mage gen:api +
mage manifests.
@vercel

vercel Bot commented Oct 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
openagentprimitives Ready Ready Preview Oct 2, 2026 8:14pm UTC

Request Review

This branch was successfully deployed

1 active deployment
Preview — b053151b Deployed Oct 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant