Files
clawmates/migrations/0081_auth_session_scope.sql
T
Omar SobhandClaude Opus 5 2668191e30 feat(auth): a credential narrow enough to hand to an agent
`docs/TOOL-CALL-ARCHITECTURE.md` §3 calls deploying the MCP door "config,
not code". It is not, and the reason is authentication.

`/mcp/skills` authenticates with `AuthService::authenticate`, which
returns a full `AuthedUser` carrying the user's role. There is no
narrower credential in the system. So pointing a mission container at the
door means writing a bearer token into a file inside that container — and
mission agents run arbitrary `Bash` with egress and no read gate, which
is this platform's own documented security posture. An owner-scoped token
there turns "the agent runs commands in a sandbox" into "the agent drives
the whole ClawMates API as the owner".

Checked before building this rather than assumed: no such credential is
in a mission container today. The runtime's config.toml has no
`[mcp.servers]` block and no bearer, so the door would have been a NEW
exposure, not an existing one.

So: `auth_sessions.scope`, defaulting to `full`. `authenticate` now
delegates to `authenticate_scoped(token, SCOPE_FULL)`, which means **every
existing caller rejects a narrow token** and a route must opt in by naming
the scope it accepts. `/mcp/skills` is the only opt-in.

Fail closed on purpose. The likely mistake here is adding a scope and
forgetting to wire its check; this way that mistake grants nothing rather
than granting everything.

`mint_scoped` refuses to mint a `full` token — a caller reaching for it
wants a narrow credential, and handing back a full one because an
argument was wrong is exactly the failure the column exists to prevent,
and it would be invisible because the token would work.

The test that matters is not that the door accepts the token, it is that
nothing else does. Negative-controlled: removing the scope comparison
fails `a_scoped_token_is_refused_by_every_unscoped_caller`.

`.sqlx` regenerated — `authenticate` is a compile-checked query and CI
builds with SQLX_OFFLINE=true.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
2026-08-21 11:43:25 -07:00

38 lines
2.1 KiB
SQL

-- Give a session a SCOPE, so a credential can be handed to something that is
-- not a person.
--
-- `AuthService::authenticate` returns a full `AuthedUser` carrying the user's
-- role. There is no narrower credential in the system, so any component that
-- needs to call the ClawMates API must be given one that can do everything the
-- user can.
--
-- That is the blocker on deploying the MCP door to mission agents
-- (`docs/TOOL-CALL-ARCHITECTURE.md` §3, which calls it "config, not code").
-- Reaching `/mcp/skills` from a mission container means putting a bearer token
-- in a file inside that container — and mission agents run arbitrary `Bash`
-- with egress and no read gate, which is the platform's own documented
-- security posture. An owner-scoped token there turns "the agent runs commands
-- in a sandbox" into "the agent drives the whole API as the owner".
--
-- Verified before building this: no such credential is in a mission container
-- today. The runtime's config.toml has no `[mcp.servers]` block and no bearer,
-- so this would be a NEW exposure rather than an existing one.
--
-- FAIL CLOSED. The default is 'full', so every existing row and every existing
-- caller behaves exactly as before; `authenticate` REJECTS anything else, and a
-- route must opt in by asking for the scope it accepts. A scope added later and
-- wired nowhere therefore grants nothing, which is the safe direction for the
-- mistake most likely to be made here.
ALTER TABLE auth_sessions
ADD COLUMN IF NOT EXISTS scope TEXT NOT NULL DEFAULT 'full';
COMMENT ON COLUMN auth_sessions.scope IS
'full = a person''s session, accepted everywhere. Anything else is a narrow credential accepted only by routes that name that scope (see AuthService::authenticate_scoped). Never widen a token in place; mint a new one.';
-- The lookup is by token_hash and already indexed; this supports auditing and
-- revoking a whole class of narrow credential at once (e.g. every skills token
-- for a workspace after a leak).
CREATE INDEX IF NOT EXISTS auth_sessions_scope_idx
ON auth_sessions (scope)
WHERE scope <> 'full';