From 642388b230c6387c9661d87afbb783b41a36535e Mon Sep 17 00:00:00 2001 From: Matvey Arye Date: Thu, 25 Jun 2026 14:47:05 +0200 Subject: [PATCH] feat(auth): admit agent keys on the user RPC, authorize per-method (TNT-139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user RPC barred all agent keys at the door (403, "agents can't manage the account") — conflating authentication (who) with authorization (what). That blunt bar lumped harmless account-scoped reads (whoami, space.list) in with real account mutations, so an agent acting with ME_API_KEY couldn't even report its own identity. Flip it to "admit any authenticated principal, authorize per-method", with the server as the single source of truth: - authenticate-user: drop the kind!='u' bar; an api key now carries its principal's real kind ('u'|'a'). Agents get email=null, emailVerified=false. - router: run first-login provisioning for users only (agents are pre-provisioned; provisioning is user+email keyed). - user RPC gate (gateAgentAccess + AGENT_ALLOWED in rpc/user/index): an ALLOW-LIST. whoami and space.list are open to any principal; every other method denies a non-user caller via requireUserCaller. Default-deny: a newly-added method is off-limits to agents unless explicitly allow-listed, so a forgotten annotation denies agents rather than exposing the account. - the denial is a per-method `authorize` hook run by the dispatcher BEFORE param validation, so an agent always gets the same FORBIDDEN (never an INVALID_PARAMS that would leak the schema), and input is never parsed for a call the caller may not make. - whoami gains a `kind` field and a nullable email; `me whoami` renders "Kind: agent" and omits the email line for agents (cf. TNT-101). This makes the CLI long tail (me whoami, me space list, me group mine, me access *, me principal resolve) usable for agents — exactly what their grants + kind authorize — while admin/account-mutation stays denied as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- AUTH_DESIGN.md | 20 ++- e2e/cli.e2e.test.ts | 25 +++- packages/cli/commands/whoami.ts | 7 +- packages/protocol/user/whoami.ts | 16 ++- .../authenticate-user.integration.test.ts | 22 +++- .../server/middleware/authenticate-user.ts | 82 ++++++++----- packages/server/router.ts | 25 ++-- packages/server/rpc/handler.ts | 16 +-- packages/server/rpc/types.ts | 7 ++ .../server/rpc/user/agent.integration.test.ts | 115 ++++++++++++++++-- .../rpc/user/api-key.integration.test.ts | 3 + packages/server/rpc/user/index.ts | 65 ++++++++-- packages/server/rpc/user/types.ts | 49 ++++++-- packages/server/rpc/user/whoami.ts | 10 +- packages/server/wiring.test.ts | 8 +- 15 files changed, 369 insertions(+), 101 deletions(-) diff --git a/AUTH_DESIGN.md b/AUTH_DESIGN.md index d0661495..6b60c9e9 100644 --- a/AUTH_DESIGN.md +++ b/AUTH_DESIGN.md @@ -110,11 +110,16 @@ scopes = ["openid", "profile", "email", "offline_access"] endpoints `/oauth2/authorize` + `/oauth2/token`. - **`/api/v1/memory/rpc`** — memory data plane + space management. Auth: api key **or** OAuth access token (Bearer) **or** cookie; requires `X-Me-Space`. -- **`/api/v1/user/rpc`** — user-scoped (whoami, agent/api-key/space management). - Auth: OAuth access token (Bearer), cookie, **or the user's own api key (PAT)**. - An **agent** key is rejected here (403 — agents can't manage the account), and - `apiKey.create` / `apiKey.delete` reject *any* key-authenticated caller - (session-only: a key can't mint or revoke keys). +- **`/api/v1/user/rpc`** — account-scoped (whoami, agent/api-key/space management). + Auth: OAuth access token (Bearer), cookie, **or an api key** (a user PAT or an + agent key). Authentication establishes *who*, not *what*: the door admits any + authenticated principal and a per-method **allow-list** authorizes. An **agent** + key validates to its agent principal, so the two account-scoped *reads* on the + allow-list — `whoami` and `space.list` — work for it; every other method is + account *management* and stays user-only (the gate calls `requireUserCaller`, + so a new method is default-denied to agents unless explicitly allow-listed). + `apiKey.create` / `apiKey.delete` additionally reject *any* key-authenticated + caller (session-only: a key can't mint or revoke keys). - **`/`** (any non-`/api` GET) — the web UI (static SPA + fallback), including the `/login` page below. @@ -244,7 +249,10 @@ A key can be minted for two kinds of member (`apiKey.create({ memberId })`, gated by `requireOwnMember` — the caller's own user or an owned agent): - **Agent key** (kind `'a'`) — a headless service account, scoped to the agent's - grants. Memory RPC only; rejected on the user RPC. + grants. Drives the full memory RPC (data plane + the space management its + grants authorize), plus the account-scoped *reads* on the user RPC (`whoami`, + `space.list`) — but not account management (it owns no agents/keys/spaces and + is never an admin), which the user RPC denies per-method. - **User PAT** (kind `'u'`, the caller's own principal) — "be me, headless" (`me apikey create --self`; explicit opt-in, since it's a full-access credential). Authenticates as diff --git a/e2e/cli.e2e.test.ts b/e2e/cli.e2e.test.ts index 424124a6..b1501977 100644 --- a/e2e/cli.e2e.test.ts +++ b/e2e/cli.e2e.test.ts @@ -667,11 +667,34 @@ describe.skipIf( // Search with ONLY the api key — no session token. The agent's global key // plus X-Me-Space (ME_SPACE) selects the space; this exercises the CLI's // api-key auth path against the real server end-to-end. + const agentEnv = { ME_API_KEY: key.key, ME_SESSION_TOKEN: "" }; const res = await meJson<{ total: number }>( ["search", "--fulltext", "fox"], - { ME_API_KEY: key.key, ME_SESSION_TOKEN: "" }, + agentEnv, ); expect(res.total).toBeGreaterThan(0); + + // TNT-139: the same agent key now drives the account-scoped *reads* on the + // user RPC too — authn establishes *who*, the server authorizes per-method. + // whoami reports the agent's own identity (kind "a", no email). + const who = await meJson<{ + identity: { id: string; kind: string; email: string | null }; + }>(["whoami"], agentEnv); + expect(who.identity.id).toBe(agent.id); + expect(who.identity.kind).toBe("a"); + expect(who.identity.email).toBeNull(); + + // space.list returns the spaces the agent is admitted to. + const spaces = await meJson<{ spaces: { slug: string }[] }>( + ["space", "list"], + agentEnv, + ); + expect(spaces.spaces.some((s) => s.slug === spaceSlug)).toBe(true); + + // …but account management stays user-only: the CLI no longer pre-empts with + // a session gate, so the server's FORBIDDEN surfaces instead (non-zero exit). + const denied = await me(["agent", "list"], agentEnv); + expect(denied.code).not.toBe(0); }); test("8. `me claude import` backfills work that predates the hook", async () => { diff --git a/packages/cli/commands/whoami.ts b/packages/cli/commands/whoami.ts index d2ce0337..44005194 100644 --- a/packages/cli/commands/whoami.ts +++ b/packages/cli/commands/whoami.ts @@ -29,7 +29,12 @@ export function createWhoamiCommand(): Command { fmt, () => { console.log(` Name: ${identity.name}`); - console.log(` Email: ${identity.email}`); + console.log( + ` Kind: ${identity.kind === "a" ? "agent" : "user"}`, + ); + // Agents have no email (null); humans always have one. + if (identity.email !== null) + console.log(` Email: ${identity.email}`); console.log(` ID: ${identity.id}`); console.log(` Server: ${creds.server}`); if (creds.activeSpace) { diff --git a/packages/protocol/user/whoami.ts b/packages/protocol/user/whoami.ts index 385bd20c..fea3170f 100644 --- a/packages/protocol/user/whoami.ts +++ b/packages/protocol/user/whoami.ts @@ -1,19 +1,25 @@ /** * whoami method schema for the user RPC. * - * Returns the identity behind the session token — used by the CLI for `me login` - * confirmation and `me whoami`. Session-only (an api key never authenticates the - * user endpoint). + * Returns the identity behind the credential — used by the CLI for `me login` + * confirmation and `me whoami`. Admits any authenticated principal: a human + * (session / OAuth / user PAT) reports `kind: "u"` with their email; an agent + * (acting with its api key) reports `kind: "a"` with a null email (agents have + * no email). Account-management on the user RPC stays user-only — see the + * per-method authorization in the server's user handlers. */ import { z } from "zod"; -// whoami — the authenticated user's identity +// whoami — the authenticated principal's identity export const whoamiParams = z.object({}); export type WhoamiParams = z.infer; export const whoamiResult = z.object({ id: z.string(), - email: z.string(), + /** The authenticated principal's kind: a user ("u") or an agent ("a"). */ + kind: z.enum(["u", "a"]), + /** The user's email; null for an agent (agents have no email). */ + email: z.string().nullable(), name: z.string(), }); export type WhoamiResult = z.infer; diff --git a/packages/server/middleware/authenticate-user.integration.test.ts b/packages/server/middleware/authenticate-user.integration.test.ts index f02b1ad6..87c451d1 100644 --- a/packages/server/middleware/authenticate-user.integration.test.ts +++ b/packages/server/middleware/authenticate-user.integration.test.ts @@ -1,8 +1,9 @@ // Integration test for user-RPC authentication (authenticateUser). // -// Covers the three admitted credentials + the bars: an OAuth access token and -// the user's OWN api key (a PAT) authenticate as the user; an AGENT api key is -// rejected here (agents can't manage the account); invalid/missing → 401. +// Covers the admitted credentials: an OAuth access token and the user's OWN api +// key (a PAT) authenticate as the user (kind 'u'); an AGENT api key is admitted +// as kind 'a' (per-method authz, not a door bar, keeps it off account +// management); invalid/missing → 401. // TEST_DATABASE_URL="postgresql://postgres@127.0.0.1:5432/postgres" \ // bun test --timeout 30000 \ // packages/server/middleware/authenticate-user.integration.test.ts @@ -144,13 +145,22 @@ test("a PAT for an unverified user reports emailVerified=false (read from the DB if (result.ok) expect(result.context.emailVerified).toBe(false); }); -test("an agent api key is forbidden on the user RPC (403)", async () => { +test("an agent api key is admitted on the user RPC as kind 'a' (no email)", async () => { + // Authn establishes *who*; it no longer doubles as the authz gate. An agent + // key validates to its agent principal so the account-scoped reads (whoami, + // space.list) work; the per-method handlers still deny account management. const userId = await seedUser(); const agentId = await core.createAgent(userId, `agent-${rand()}`); const key = await core.createApiKey(agentId, "ci"); const result = await auth(engineCore.formatApiKey(key.lookupId, key.secret)); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.status).toBe(403); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.context.kind).toBe("a"); + expect(result.context.userId).toBe(agentId); + expect(result.context.viaApiKey).toBe(true); + expect(result.context.email).toBeNull(); + expect(result.context.emailVerified).toBe(false); + } }); test("an invalid api key → 401", async () => { diff --git a/packages/server/middleware/authenticate-user.ts b/packages/server/middleware/authenticate-user.ts index 51550b68..416ff7a8 100644 --- a/packages/server/middleware/authenticate-user.ts +++ b/packages/server/middleware/authenticate-user.ts @@ -1,11 +1,15 @@ /** * Authentication for the user RPC (`/api/v1/user/rpc`). * - * User-scoped: resolves the calling human (a user principal) from one of three - * credentials — an OAuth access token (CLI/MCP), the browser cookie session, or - * the user's own api key (a personal access token, for headless/CLI use). Only - * the caller's OWN (kind 'u') key is admitted: an AGENT key is barred here - * (agents can't manage the account) → 403. + * Resolves the calling principal from one of three credentials — an OAuth access + * token (CLI/MCP), the browser cookie session, or an api key (a user PAT or an + * agent key, for headless/CLI use). Authentication establishes *who*; it no + * longer doubles as the authorization gate. Any authenticated principal is + * admitted here so the account-scoped *reads* (`whoami`, `space.list`) work for + * an agent acting with `ME_API_KEY`; the account-*management* methods stay + * user-only, enforced by the user-RPC gate's allow-list (`gateAgentAccess` + + * `requireUserCaller`). Sessions / OAuth tokens are always users; an api key + * carries its principal's real kind. */ import { type CoreStore, parseApiKey } from "@memory.build/engine/core"; import { debug, span } from "@pydantic/logfire-node"; @@ -19,22 +23,29 @@ import { extractBearerToken, passesCsrfCheck } from "./authenticate"; export interface UserAuthContext { type: "user"; - /** The authenticated user id (== the core user-principal id). */ + /** The authenticated principal's kind: a user ("u") or an agent ("a"). */ + kind: "u" | "a"; + /** The authenticated principal id (a user-principal id, or an agent's). */ userId: string; - /** The user's email (powers whoami + lazy provisioning). */ - email: string; - /** The user's display name. */ + /** The user's email (powers whoami + lazy provisioning); null for an agent. */ + email: string | null; + /** + * The principal's name. From a session / OAuth token this is the human's + * display name; on the api-key path it's the core principal's name — which is + * the user's email for a user PAT, or the agent's name for an agent. + */ name: string; /** * Whether the identity provider verified the email. Gates email-keyed * provisioning steps (invitation redemption) — invitations are addressed by * email, so an unverified address must not auto-join its invited spaces. + * Always false for an agent (no email). */ emailVerified: boolean; /** - * True when authenticated by an api key (a user PAT) rather than a session / - * OAuth token. The handler layer uses this to keep key mint/revoke - * session-only (a key can't manage keys). + * True when authenticated by an api key (a user PAT or an agent key) rather + * than a session / OAuth token. The handler layer uses this to keep key + * mint/revoke session-only (a key can't manage keys). */ viaApiKey: boolean; } @@ -56,10 +67,10 @@ export async function authenticateUser( callback: async () => { const bearer = extractBearerToken(request); if (bearer) { - // A user PAT (api key). Only the caller's OWN (kind 'u') principal is - // admitted here; an agent key is a valid credential but not for the user - // API (agents can't manage the account) → 403. An api key is never an - // OAuth token, so this branch always returns. + // An api key — a user PAT (kind 'u') or an agent key (kind 'a'). Both + // are admitted; the per-method handlers authorize what each may do (an + // agent gets `whoami` / `space.list`, nothing that manages the account). + // An api key is never an OAuth token, so this branch always returns. const parsed = parseApiKey(bearer); if (parsed) { const validated = await core.validateApiKey( @@ -74,30 +85,39 @@ export async function authenticateUser( }; } const principal = await core.getPrincipal(validated.memberId); - if (!principal || principal.kind !== "u") { - debug("user auth failed: agent api key on the user RPC"); + // A key only ever resolves to a user or an agent (groups hold no key); + // a missing principal means a member torn down under a live key. + if (!principal || principal.kind === "g") { + debug("user auth failed: api key resolves to no usable principal"); return { ok: false, - error: forbidden( - "Agent API keys can't access the user API; use a session or a user key.", - ), + error: unauthorized("Invalid or expired token"), }; } - debug("user auth succeeded (user pat)", { userId: principal.id }); + const isUser = principal.kind === "u"; + debug("user auth succeeded (api key)", { + userId: principal.id, + kind: principal.kind, + }); return { ok: true, context: { type: "user", + kind: principal.kind, userId: principal.id, - // The core user principal's name is the email; the display name - // lives on auth.users (not fetched on the key path). - email: principal.name, + // For a user the core principal's name IS the email (the display + // name lives on auth.users, not fetched on the key path); an agent + // has no email — its name is its display name. + email: isUser ? principal.name : null, name: principal.name, - // Carry the real verified flag (the same fact a session reports), - // so a PAT behaves like any other credential — including the - // email-keyed redemption step. A PAT's only carve-out is that it - // can't mint/revoke keys (enforced at the handler layer). - emailVerified: await getUserEmailVerified(principal.id), + // For a user PAT, carry the real verified flag (the same fact a + // session reports), so it behaves like any other credential — + // including the email-keyed redemption step. An agent has no + // email to verify. A key's only carve-out is that it can't + // mint/revoke keys (enforced at the handler layer). + emailVerified: isUser + ? await getUserEmailVerified(principal.id) + : false, viaApiKey: true, }, }; @@ -117,6 +137,7 @@ export async function authenticateUser( ok: true, context: { type: "user", + kind: "u", userId: verified.userId, email: verified.email, name: verified.name, @@ -153,6 +174,7 @@ export async function authenticateUser( ok: true, context: { type: "user", + kind: "u", userId: user.id, email: user.email, name: user.name, diff --git a/packages/server/router.ts b/packages/server/router.ts index ea3d2fd9..aaf4e4f0 100644 --- a/packages/server/router.ts +++ b/packages/server/router.ts @@ -172,7 +172,8 @@ export function createRouter(ctx: ServerContext): Router { }; }); - // User RPC (new model): session-only, user-scoped (agent lifecycle) + // User RPC (new model): account-scoped. Admits any authenticated principal; + // the handlers authorize per-method (an agent gets whoami / space.list only). const userRpcHandler = createRpcHandler(userMethods, async (request) => { const result = await authenticateUser( request, @@ -185,19 +186,23 @@ export function createRouter(ctx: ServerContext): Router { if (!result.ok) { return result.error; } - const { userId, email, name, emailVerified, viaApiKey } = result.context; + const { kind, userId, email, name, emailVerified, viaApiKey } = + result.context; // Lazy first-login provisioning: stand up the core principal + default space // the first time a better-auth user reaches the user RPC (idempotent no-op // thereafter). The CLI hits whoami/space.list right after login, so this is // the natural first touchpoint. `emailVerified` gates the email-keyed - // invitation-redemption step. - await ensureUserProvisioned( - db, - core, - { core: coreSchema }, - { userId, email, emailVerified }, - ); - return { core, userId, email, name, db, coreSchema, viaApiKey }; + // invitation-redemption step. USERS only — an agent is already provisioned + // by its owner, and provisioning is user+email keyed (an agent has neither). + if (kind === "u" && email !== null) { + await ensureUserProvisioned( + db, + core, + { core: coreSchema }, + { userId, email, emailVerified }, + ); + } + return { core, kind, userId, email, name, db, coreSchema, viaApiKey }; }); /** diff --git a/packages/server/rpc/handler.ts b/packages/server/rpc/handler.ts index 840621f2..ac9a3ae6 100644 --- a/packages/server/rpc/handler.ts +++ b/packages/server/rpc/handler.ts @@ -136,6 +136,14 @@ export async function handleRpcRequest( return json(methodNotFound(rpcRequest.method, requestId)); } + const handlerContext: HandlerContext = { request, ...context }; + + // Authorize before validating params: a caller that may not invoke this + // method shouldn't have its input parsed — it gets a consistent + // authorization error rather than an INVALID_PARAMS that leaks the param + // schema. Throws an AppError on denial (mapped below). + method.authorize?.(handlerContext); + // Validate params const paramsResult = method.schema.safeParse(rpcRequest.params); if (!paramsResult.success) { @@ -170,13 +178,7 @@ export async function handleRpcRequest( "rpc.request_id": String(requestId), ...identityAttrs, }, - callback: async () => { - const handlerContext: HandlerContext = { - request, - ...context, - }; - return method.handler(paramsResult.data, handlerContext); - }, + callback: async () => method.handler(paramsResult.data, handlerContext), }); return json(createSuccessResponse(result, requestId ?? 0)); diff --git a/packages/server/rpc/types.ts b/packages/server/rpc/types.ts index 5caa858f..d2fac056 100644 --- a/packages/server/rpc/types.ts +++ b/packages/server/rpc/types.ts @@ -45,6 +45,13 @@ export interface RegisteredMethod { schema: z.ZodType; /** Handler function */ handler: MethodHandler; + /** + * Optional per-method authorization, run by the dispatcher BEFORE param + * validation. Throw an AppError to deny (e.g. FORBIDDEN). Gates a method on + * the caller's identity without parsing input it can't use — see the + * user-RPC agent allow-list (`gateAgentAccess`). + */ + authorize?: (context: HandlerContext) => void; } /** diff --git a/packages/server/rpc/user/agent.integration.test.ts b/packages/server/rpc/user/agent.integration.test.ts index 0874532d..86a4cf94 100644 --- a/packages/server/rpc/user/agent.integration.test.ts +++ b/packages/server/rpc/user/agent.integration.test.ts @@ -8,6 +8,7 @@ import { bootstrapSpaceDatabase, migrateCore } from "@memory.build/database"; import { ACCESS, coreStore, ROOT_PATH } from "@memory.build/engine/core"; import { type AppErrorCode, isAppError } from "@memory.build/protocol/errors"; import postgres, { type Sql } from "postgres"; +import { handleRpcRequest } from "../handler"; import type { HandlerContext } from "../types"; import { userMethods } from "./index"; @@ -28,25 +29,31 @@ let coreSchema: string; let userId: string; const createdSpaceSchemas: string[] = []; -function call( +async function call( method: string, params: unknown, asUser: string = userId, - identity?: { email?: string; name?: string }, + identity?: { email?: string; name?: string; kind?: "u" | "a" }, ): Promise { const registered = userMethods.get(method); if (!registered) throw new Error(`no handler for ${method}`); + const kind = identity?.kind ?? "u"; const context = { request: new Request("http://localhost/api/v1/user/rpc"), core: coreStore(sql, coreSchema), - userId: asUser, // Identity the middleware (authenticateUser) would have put on the context - // from the validated session; whoami echoes it. - email: identity?.email ?? `${asUser}@example.com`, + // from the validated credential; whoami echoes it. An agent carries no + // email (kind "a"); the account-management methods reject it. + kind, + userId: asUser, + email: kind === "a" ? null : (identity?.email ?? `${asUser}@example.com`), name: identity?.name ?? "Test User", db: sql, coreSchema, } as unknown as HandlerContext; + // Mirror the dispatcher: per-method authorization (the agent allow-list gate) + // runs before the handler. async, so a denial surfaces as a rejected promise. + registered.authorize?.(context); return registered.handler(params, context) as Promise; } @@ -91,19 +98,105 @@ test("whoami echoes the validated session identity", async () => { // the validated session/token (ctx.email/ctx.name) — no store lookup. Token // validity (e.g. a deleted user → 401) is the middleware's job, covered in the // authenticate-space/user integration tests. - const me = await call<{ id: string; email: string; name: string }>( - "whoami", - {}, - userId, - { email: "who@example.com", name: "Who Am I" }, - ); + const me = await call<{ + id: string; + kind: string; + email: string | null; + name: string; + }>("whoami", {}, userId, { email: "who@example.com", name: "Who Am I" }); expect(me).toEqual({ id: userId, + kind: "u", email: "who@example.com", name: "Who Am I", }); }); +// An agent acting with ME_API_KEY reaches the user RPC; the door admits it and +// the handlers authorize per-method. The account-scoped *reads* work; every +// account-*management* method is user-only (requireUserCaller). +test("an agent caller can whoami (kind 'a', null email)", async () => { + const agentId = await coreStore(sql, coreSchema).createAgent(userId, "bot"); + const me = await call<{ + id: string; + kind: string; + email: string | null; + name: string; + }>("whoami", {}, agentId, { kind: "a", name: "bot" }); + expect(me).toEqual({ id: agentId, kind: "a", email: null, name: "bot" }); +}); + +test("an agent caller can list its own spaces (space.list)", async () => { + const core = coreStore(sql, coreSchema); + const agentId = await core.createAgent(userId, "bot"); + const spaceId = await core.createSpace(rand(12), "Agent Space"); + await core.addPrincipalToSpace(spaceId, agentId); + + const res = await call<{ spaces: { id: string }[] }>( + "space.list", + {}, + agentId, + { kind: "a", name: "bot" }, + ); + expect(res.spaces.some((s) => s.id === spaceId)).toBe(true); +}); + +test("an agent caller is denied every account-management method (FORBIDDEN)", async () => { + const agentId = await coreStore(sql, coreSchema).createAgent(userId, "bot"); + const asAgent = { kind: "a" as const, name: "bot" }; + // The gate's authorize hook (mirrored in `call`) rejects an agent before the + // handler — and, in production, before param validation — so the param shapes + // below are placeholders and the denial is FORBIDDEN regardless of validity. + const denied: [string, unknown][] = [ + ["agent.create", { name: "x" }], + ["agent.list", {}], + ["agent.spaces", { id: agentId }], + ["agent.rename", { id: agentId, name: "x" }], + ["agent.delete", { id: agentId }], + ["apiKey.create", { memberId: agentId, name: "x" }], + ["apiKey.list", { memberId: agentId }], + ["apiKey.get", { id: agentId }], + ["apiKey.delete", { id: agentId }], + ["space.create", { name: "x" }], + ["space.rename", { slug: "a".repeat(12), name: "x" }], + ["space.delete", { slug: "a".repeat(12) }], + ]; + for (const [method, params] of denied) { + await expectAppError(call(method, params, agentId, asAgent), "FORBIDDEN"); + } +}); + +test("through the dispatcher, a gated method denies an agent BEFORE param validation", async () => { + // The denial is an `authorize` hook that runs before schema validation, so an + // agent gets the user-only FORBIDDEN even when its params are invalid (rather + // than an INVALID_PARAMS that would leak the param schema). Drive the real + // dispatcher (which validates params) with a deliberately invalid body. + const agentId = await coreStore(sql, coreSchema).createAgent(userId, "bot"); + const request = new Request("http://localhost/api/v1/user/rpc", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + method: "agent.create", + params: { not: "the right shape" }, // invalid for agent.create + id: 1, + }), + }); + const response = await handleRpcRequest(request, userMethods, { + core: coreStore(sql, coreSchema), + kind: "a", + userId: agentId, + email: null, + name: "bot", + db: sql, + coreSchema, + } as unknown as HandlerContext); + + const body = JSON.stringify(await response.json()); + // The user-only message proves authorize ran first; a schema-first ordering + // would instead surface the zod "params" validation error. + expect(body).toContain("user-only"); +}); + test("create / list / rename / delete the caller's agents", async () => { const { id } = await call<{ id: string }>("agent.create", { name: "bot" }); diff --git a/packages/server/rpc/user/api-key.integration.test.ts b/packages/server/rpc/user/api-key.integration.test.ts index c7300be4..34ccc7d9 100644 --- a/packages/server/rpc/user/api-key.integration.test.ts +++ b/packages/server/rpc/user/api-key.integration.test.ts @@ -41,6 +41,9 @@ function call( const context = { request: new Request("http://localhost/api/v1/user/rpc"), core: coreStore(sql, coreSchema), + // These tests exercise the user-PAT carve-out (a key-authenticated *user*); + // the agent-caller denial is covered in agent.integration.test.ts. + kind: "u", userId: asUser, db: sql, coreSchema, diff --git a/packages/server/rpc/user/index.ts b/packages/server/rpc/user/index.ts index 87e60b9c..dbf7b660 100644 --- a/packages/server/rpc/user/index.ts +++ b/packages/server/rpc/user/index.ts @@ -1,11 +1,13 @@ /** - * User RPC method registry — served at `/api/v1/user/rpc` (session-only, - * user-scoped): the lifecycle of a user's agents and their global api keys. + * User RPC method registry — served at `/api/v1/user/rpc` (account-scoped): + * identity (`whoami`), space discovery (`space.list`), and the user's + * account-management surface (agent lifecycle, api keys, space lifecycle). */ import type { MethodRegistry } from "../types"; import { agentMethods } from "./agent"; import { apiKeyMethods } from "./api-key"; import { spaceMethods } from "./space"; +import { assertUserRpcContext, requireUserCaller } from "./types"; import { whoamiMethods } from "./whoami"; export { @@ -15,12 +17,55 @@ export { } from "./types"; /** - * The user-endpoint registry: identity + agent lifecycle + api keys + space - * discovery. + * Methods any authenticated principal may call — including an agent acting with + * `ME_API_KEY`. These are account-scoped *reads* that manage nothing. + * + * This is an ALLOW-LIST: {@link gateAgentAccess} denies a non-user (agent) + * caller on every method NOT listed here. So the safe default for a newly-added + * user-RPC method is "user-only" — forgetting to list it denies agents rather + * than exposing the account. Authentication (authenticateUser) admits any + * principal; this is the per-method authorization layered on top. */ -export const userMethods: MethodRegistry = new Map([ - ...whoamiMethods, - ...agentMethods, - ...apiKeyMethods, - ...spaceMethods, -]); +const AGENT_ALLOWED: ReadonlySet = new Set(["whoami", "space.list"]); + +/** + * Gate a user-RPC registry so every method outside {@link AGENT_ALLOWED} rejects + * a non-user (agent) caller. Account management (agents, api keys, space + * lifecycle) is user-only: an agent is owned by a user, owns no + * agents/spaces/keys, and is never an admin. + * + * The denial is an `authorize` hook (run by the dispatcher BEFORE param + * validation), not a handler wrapper — so an agent always gets the same + * `FORBIDDEN` regardless of whether its params happen to be valid, and its + * input is never parsed for a call it may not make. + */ +function gateAgentAccess(registry: MethodRegistry): MethodRegistry { + const gated: MethodRegistry = new Map(); + for (const [method, registered] of registry) { + if (AGENT_ALLOWED.has(method)) { + gated.set(method, registered); + continue; + } + gated.set(method, { + ...registered, + authorize: (ctx) => { + assertUserRpcContext(ctx); + requireUserCaller(ctx); + }, + }); + } + return gated; +} + +/** + * The user-endpoint registry: identity + space discovery (open to any + * principal) + agent/api-key/space management (user-only, gated above). + */ +export const userMethods: MethodRegistry = gateAgentAccess( + new Map([ + ...whoamiMethods, + ...agentMethods, + ...apiKeyMethods, + ...spaceMethods, + ]), +); diff --git a/packages/server/rpc/user/types.ts b/packages/server/rpc/user/types.ts index ecabbafc..198d57ac 100644 --- a/packages/server/rpc/user/types.ts +++ b/packages/server/rpc/user/types.ts @@ -1,29 +1,38 @@ /** - * User RPC context — populated by authenticateUser. User-scoped (no space): - * the calling user manages their own global service accounts (agents). + * User RPC context — populated by authenticateUser. Account-scoped (no space): + * identity (`whoami`) and space discovery (`space.list`) for any authenticated + * principal, plus the account-management surface (agents, api keys, space + * lifecycle) which is user-only — see {@link requireUserCaller}. */ import type { CoreStore } from "@memory.build/engine/core"; import type { Sql } from "postgres"; +import { AppError } from "../errors"; import type { HandlerContext } from "../types"; export interface UserRpcContext extends HandlerContext { /** Core control-plane store. */ core: CoreStore; - /** The authenticated user id (== the core user-principal id). */ + /** The authenticated principal's kind: a user ("u") or an agent ("a"). */ + kind: "u" | "a"; + /** The authenticated principal id (a user-principal id, or an agent's). */ userId: string; - /** The caller's email (from the better-auth session) — powers whoami. */ - email: string; - /** The caller's display name (from the better-auth session). */ + /** The caller's email (powers whoami); null for an agent (no email). */ + email: string | null; + /** + * The caller's name: a human display name from a session / OAuth token, or + * the core principal's name on the api-key path — the user's email for a user + * PAT, the agent's name for an agent. + */ name: string; /** New-model pool — for transactional provisioning (space.create). */ db: Sql; /** The core control-plane schema name. */ coreSchema: string; /** - * True when the caller authenticated with an api key (a user PAT) rather than - * a session / OAuth token. Gates the credential-management ops: a key can't - * mint or revoke keys (preserves revocability), so apiKey.create/delete reject - * a key-authenticated caller. + * True when the caller authenticated with an api key (a user PAT or an agent + * key) rather than a session / OAuth token. Gates the credential-management + * ops: a key can't mint or revoke keys (preserves revocability), so + * apiKey.create/delete reject a key-authenticated caller. */ viaApiKey: boolean; } @@ -45,3 +54,23 @@ export function assertUserRpcContext( throw new Error("User context not initialized (authentication required)"); } } + +/** + * Reject a non-user (agent) caller from an account-management method. + * + * The user RPC admits any authenticated principal so agent keys can run the + * account-scoped *reads* (`whoami`, `space.list`) — but managing the account + * (agents, api keys, space lifecycle) is user-only: an agent is owned by a user, + * it doesn't own agents, spaces, or keys, and it is never an admin. The user-RPC + * gate (`gateAgentAccess` in ./index) calls this for every method outside its + * allow-list, so the denial is default-on and lives in one place rather than + * relying on each handler to incidentally reject an agent. + */ +export function requireUserCaller(ctx: UserRpcContext): void { + if (ctx.kind !== "u") { + throw new AppError( + "FORBIDDEN", + "This action is user-only; an agent API key can't manage the account.", + ); + } +} diff --git a/packages/server/rpc/user/whoami.ts b/packages/server/rpc/user/whoami.ts index 1aa841bf..fb14c34a 100644 --- a/packages/server/rpc/user/whoami.ts +++ b/packages/server/rpc/user/whoami.ts @@ -1,5 +1,9 @@ /** - * whoami handler for the user RPC — the identity behind the session token. + * whoami handler for the user RPC — the identity behind the credential. + * + * Open to any authenticated principal (it manages nothing): a human reports + * `kind: "u"` with their email; an agent acting with `ME_API_KEY` reports + * `kind: "a"` with a null email, so the CLI can show whose context it's in. */ import type { WhoamiParams, WhoamiResult } from "@memory.build/protocol/user"; import { whoamiParams } from "@memory.build/protocol/user"; @@ -13,8 +17,8 @@ async function whoami( ): Promise { assertUserRpcContext(context); const ctx = context as UserRpcContext; - // Identity comes straight from the validated session (better-auth getSession). - return { id: ctx.userId, email: ctx.email, name: ctx.name }; + // Identity comes straight from the validated credential (no store lookup). + return { id: ctx.userId, kind: ctx.kind, email: ctx.email, name: ctx.name }; } export const whoamiMethods = buildRegistry() diff --git a/packages/server/wiring.test.ts b/packages/server/wiring.test.ts index 3c55c110..89ddcc3e 100644 --- a/packages/server/wiring.test.ts +++ b/packages/server/wiring.test.ts @@ -146,6 +146,7 @@ describe("Server-Database Wiring", () => { test("whoami succeeds with a valid session (happy path)", async () => { const identity = { id: "01960000-0000-7000-8000-000000000000", + kind: "u", email: "test@example.com", name: "Test User", }; @@ -184,7 +185,12 @@ describe("Server-Database Wiring", () => { expect(response.status).toBe(200); const body = (await response.json()) as { jsonrpc: string; - result: { id: string; email: string; name: string }; + result: { + id: string; + kind: string; + email: string | null; + name: string; + }; }; expect(body.jsonrpc).toBe("2.0"); expect(body.result).toEqual(identity);