diff --git a/migrations/0022_mcp_authorization_foundation.sql b/migrations/0022_mcp_authorization_foundation.sql new file mode 100644 index 0000000..861ee5c --- /dev/null +++ b/migrations/0022_mcp_authorization_foundation.sql @@ -0,0 +1,233 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE mcp_authorization_grants ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id), + agent_id TEXT NOT NULL REFERENCES agents(id), + scopes TEXT NOT NULL CHECK (scopes = 'feed:read'), + oauth_client_id TEXT NOT NULL CHECK ( + length(trim(oauth_client_id)) BETWEEN 1 AND 255 + ), + oauth_client_label TEXT NOT NULL CHECK ( + length(trim(oauth_client_label)) BETWEEN 1 AND 120 + ), + created_at INTEGER NOT NULL, + last_used_at INTEGER, + expires_at INTEGER, + revoked_at INTEGER, + revoked_reason TEXT, + CHECK (last_used_at IS NULL OR last_used_at >= created_at), + CHECK (expires_at IS NULL OR expires_at > created_at), + CHECK (revoked_at IS NULL OR revoked_at >= created_at), + CHECK ( + (revoked_at IS NULL AND revoked_reason IS NULL) + OR + ( + revoked_at IS NOT NULL + AND revoked_reason IS NOT NULL + AND length(trim(revoked_reason)) BETWEEN 1 AND 120 + ) + ) +); + +CREATE INDEX mcp_authorization_grants_account_state_idx + ON mcp_authorization_grants (account_id, revoked_at, expires_at, created_at DESC); + +CREATE INDEX mcp_authorization_grants_agent_state_idx + ON mcp_authorization_grants (agent_id, revoked_at, expires_at, created_at DESC); + +CREATE INDEX mcp_authorization_grants_client_idx + ON mcp_authorization_grants (oauth_client_id, created_at DESC); + +CREATE TRIGGER mcp_authorization_grants_validate +BEFORE INSERT ON mcp_authorization_grants +BEGIN + SELECT RAISE(ABORT, 'mcp_authorization_account_inactive') + WHERE NOT EXISTS ( + SELECT 1 + FROM accounts account + WHERE account.id = NEW.account_id + AND account.status = 'active' + ); + + SELECT RAISE(ABORT, 'mcp_authorization_agent_not_manageable') + WHERE NOT EXISTS ( + SELECT 1 + FROM agents agent + WHERE agent.id = NEW.agent_id + AND ( + EXISTS ( + SELECT 1 + FROM agent_memberships membership + WHERE membership.agent_id = agent.id + AND membership.account_id = NEW.account_id + AND membership.role = 'primary_sponsor' + AND membership.revoked_at IS NULL + ) + OR EXISTS ( + SELECT 1 + FROM account_roles role + WHERE role.account_id = NEW.account_id + AND role.role = 'platform_owner' + AND role.revoked_at IS NULL + ) + ) + ); +END; + +CREATE TRIGGER mcp_authorization_grants_identity_immutable +BEFORE UPDATE OF account_id, agent_id, scopes, oauth_client_id, oauth_client_label, created_at, expires_at +ON mcp_authorization_grants +BEGIN + SELECT RAISE(ABORT, 'mcp_authorization_grant_identity_immutable'); +END; + +CREATE TABLE mcp_delegation_codes ( + id TEXT PRIMARY KEY, + secret_digest TEXT NOT NULL UNIQUE, + hash_version INTEGER NOT NULL CHECK (hash_version > 0), + grant_id TEXT NOT NULL REFERENCES mcp_authorization_grants(id), + authorization_request_id TEXT NOT NULL UNIQUE CHECK ( + length(trim(authorization_request_id)) BETWEEN 1 AND 200 + ), + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at INTEGER, + CHECK (expires_at > created_at), + CHECK (expires_at <= created_at + 600000), + CHECK (consumed_at IS NULL OR consumed_at >= created_at) +); + +CREATE INDEX mcp_delegation_codes_grant_state_idx + ON mcp_delegation_codes (grant_id, consumed_at, expires_at); + +CREATE TRIGGER mcp_delegation_codes_validate +BEFORE INSERT ON mcp_delegation_codes +BEGIN + SELECT RAISE(ABORT, 'mcp_authorization_grant_unavailable') + WHERE NOT EXISTS ( + SELECT 1 + FROM mcp_authorization_grants grant_row + WHERE grant_row.id = NEW.grant_id + AND grant_row.revoked_at IS NULL + AND (grant_row.expires_at IS NULL OR grant_row.expires_at > NEW.created_at) + ); +END; + +CREATE TRIGGER mcp_delegation_codes_identity_immutable +BEFORE UPDATE OF secret_digest, hash_version, grant_id, authorization_request_id, created_at, expires_at +ON mcp_delegation_codes +BEGIN + SELECT RAISE(ABORT, 'mcp_delegation_code_identity_immutable'); +END; + +CREATE TABLE mcp_delegation_redemptions ( + code_id TEXT PRIMARY KEY REFERENCES mcp_delegation_codes(id), + grant_id TEXT NOT NULL REFERENCES mcp_authorization_grants(id), + authorization_request_id TEXT NOT NULL UNIQUE, + redeemed_at INTEGER NOT NULL +); + +CREATE TRIGGER mcp_delegation_redemptions_validate +BEFORE INSERT ON mcp_delegation_redemptions +BEGIN + SELECT RAISE(ABORT, 'invalid_mcp_delegation_code') + WHERE NOT EXISTS ( + SELECT 1 + FROM mcp_delegation_codes code + JOIN mcp_authorization_grants grant_row + ON grant_row.id = code.grant_id + WHERE code.id = NEW.code_id + AND code.grant_id = NEW.grant_id + AND code.authorization_request_id = NEW.authorization_request_id + AND code.consumed_at IS NULL + AND code.expires_at > NEW.redeemed_at + AND grant_row.revoked_at IS NULL + AND (grant_row.expires_at IS NULL OR grant_row.expires_at > NEW.redeemed_at) + ); +END; + +CREATE TRIGGER mcp_delegation_redemptions_consume +AFTER INSERT ON mcp_delegation_redemptions +BEGIN + UPDATE mcp_delegation_codes + SET consumed_at = NEW.redeemed_at + WHERE id = NEW.code_id; +END; + +CREATE TRIGGER mcp_delegation_redemptions_no_update +BEFORE UPDATE ON mcp_delegation_redemptions +BEGIN + SELECT RAISE(ABORT, 'mcp_delegation_redemptions_are_append_only'); +END; + +CREATE TRIGGER mcp_delegation_redemptions_no_delete +BEFORE DELETE ON mcp_delegation_redemptions +BEGIN + SELECT RAISE(ABORT, 'mcp_delegation_redemptions_are_append_only'); +END; + +CREATE TABLE mcp_authorization_revocations ( + grant_id TEXT PRIMARY KEY REFERENCES mcp_authorization_grants(id), + actor_account_id TEXT NOT NULL REFERENCES accounts(id), + reason TEXT NOT NULL CHECK (length(trim(reason)) BETWEEN 1 AND 120), + revoked_at INTEGER NOT NULL +); + +CREATE TRIGGER mcp_authorization_revocations_validate +BEFORE INSERT ON mcp_authorization_revocations +BEGIN + SELECT RAISE(ABORT, 'mcp_authorization_grant_unavailable') + WHERE NOT EXISTS ( + SELECT 1 + FROM mcp_authorization_grants grant_row + WHERE grant_row.id = NEW.grant_id + AND grant_row.revoked_at IS NULL + ); + + SELECT RAISE(ABORT, 'mcp_authorization_revoke_forbidden') + WHERE NOT EXISTS ( + SELECT 1 + FROM mcp_authorization_grants grant_row + WHERE grant_row.id = NEW.grant_id + AND ( + grant_row.account_id = NEW.actor_account_id + OR EXISTS ( + SELECT 1 + FROM agent_memberships membership + WHERE membership.agent_id = grant_row.agent_id + AND membership.account_id = NEW.actor_account_id + AND membership.role = 'primary_sponsor' + AND membership.revoked_at IS NULL + ) + OR EXISTS ( + SELECT 1 + FROM account_roles role + WHERE role.account_id = NEW.actor_account_id + AND role.role = 'platform_owner' + AND role.revoked_at IS NULL + ) + ) + ); +END; + +CREATE TRIGGER mcp_authorization_revocations_apply +AFTER INSERT ON mcp_authorization_revocations +BEGIN + UPDATE mcp_authorization_grants + SET revoked_at = NEW.revoked_at, + revoked_reason = trim(NEW.reason) + WHERE id = NEW.grant_id; +END; + +CREATE TRIGGER mcp_authorization_revocations_no_update +BEFORE UPDATE ON mcp_authorization_revocations +BEGIN + SELECT RAISE(ABORT, 'mcp_authorization_revocations_are_append_only'); +END; + +CREATE TRIGGER mcp_authorization_revocations_no_delete +BEFORE DELETE ON mcp_authorization_revocations +BEGIN + SELECT RAISE(ABORT, 'mcp_authorization_revocations_are_append_only'); +END; diff --git a/package.json b/package.json index cc23828..c93b8ed 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "staging:slice5:images-cpu": "tsx scripts/orbit-slice5-images-cpu-staging-e2e.ts", "orbit:import:verify": "tsx scripts/orbit-slice3-manifest.ts", "orbit:import:local": "tsx scripts/orbit-slice3-import.ts --local --database=orbit-v6-local --config=wrangler.test.jsonc", - "test:d1": "tsx --test --test-concurrency=5 scripts/orbit-api-contract-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts scripts/orbit-slice4-tests.ts scripts/orbit-slice5-tests.ts", + "test:d1": "tsx --test --test-concurrency=5 scripts/orbit-api-contract-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-mcp-authorization-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts scripts/orbit-slice4-tests.ts scripts/orbit-slice5-tests.ts", "test:d1:core": "tsx --test --test-concurrency=5 scripts/orbit-api-contract-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts", "test:d1:publication": "tsx --test scripts/orbit-slice4-tests.ts", "test:d1:platform": "tsx --test scripts/orbit-slice5-tests.ts", diff --git a/scripts/orbit-d1-test-worker.ts b/scripts/orbit-d1-test-worker.ts index ee9ba87..f23e21c 100644 --- a/scripts/orbit-d1-test-worker.ts +++ b/scripts/orbit-d1-test-worker.ts @@ -2,6 +2,8 @@ import { D1FoundationRepository, type D1DatabaseLike, } from '../src/server/repositories/d1/d1-foundation-repository'; +import { createDynamicBackup } from '../src/server/backup/dynamic-backup'; +import { D1McpAuthorizationRepository } from '../src/server/repositories/d1/d1-mcp-authorization-repository'; interface TestStatement { bind(...values: unknown[]): TestStatement; @@ -66,6 +68,31 @@ async function seedOwner(db: TestDatabase, accountId: string, now: number): Prom id, handle, handle_normalized, display_name, status, created_at, updated_at ) VALUES (?, ?, ?, ?, 'active', ?, ?) `).bind(accountId, accountId, accountId, accountId, now, now).run(); + await db.prepare(` + INSERT OR IGNORE INTO account_quotas ( + account_id, quota_key, limit_value, updated_by_account_id, updated_at + ) VALUES (?, 'agents.max_active', -1, ?, ?) + `).bind(accountId, accountId, now).run(); +} + +async function seedMcpAgent( + db: TestDatabase, + accountId: string, + agentId: string, + now: number, +): Promise { + await seedOwner(db, accountId, now); + await db.prepare(` + INSERT OR IGNORE INTO agents ( + id, handle, handle_normalized, display_name, bio, avatar_asset, + publication_mode, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, '', 'test.svg', 'direct_publish', 'active', ?, ?) + `).bind(agentId, agentId, agentId, agentId, now, now).run(); + await db.prepare(` + INSERT OR IGNORE INTO agent_memberships ( + id, agent_id, account_id, role, created_by_account_id, created_at + ) VALUES (?, ?, ?, 'primary_sponsor', ?, ?) + `).bind(`${agentId}:${accountId}:primary`, agentId, accountId, accountId, now).run(); } async function seedAgent( @@ -94,6 +121,7 @@ async function seedAgent( async function handleAction(body: ActionRequest, env: Environment): Promise { const data = body.data ?? {}; const repository = new D1FoundationRepository(env.DB); + const mcpRepository = new D1McpAuthorizationRepository(env.DB); switch (body.action) { case 'health': @@ -171,6 +199,161 @@ async function handleAction(body: ActionRequest, env: Environment): Promise { + return await new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject(new Error('Could not allocate a local test port.')); + return; + } + const { port } = address; + server.close((error) => (error ? reject(error) : resolve(port))); + }); + }); +} + +async function waitForWorker(process: ChildProcessWithoutNullStreams): Promise { + const deadline = Date.now() + 20_000; + let output = ''; + process.stdout.on('data', (chunk) => { output += String(chunk); }); + process.stderr.on('data', (chunk) => { output += String(chunk); }); + while (Date.now() < deadline) { + if (process.exitCode !== null) { + throw new Error(`Wrangler exited before becoming ready:\n${output}`); + } + try { + const response = await fetch(baseUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'health' }), + }); + if (response.ok) return; + } catch { + // Wrangler has not bound the local port yet. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Wrangler did not become ready within 20 seconds:\n${output}`); +} + +async function callAction( + action: string, + data: Record = {}, + expectedStatus = 200, +): Promise { + const response = await fetch(baseUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action, data }), + }); + const body = await response.json() as T; + assert.equal( + response.status, + expectedStatus, + `${action} returned ${response.status}: ${JSON.stringify(body)}`, + ); + return body; +} + +function grantData(prefix: string, accountId: string, agentId: string, now: number) { + return { + grantId: `${prefix}-grant`, + accountId, + agentId, + scope: 'feed:read', + oauthClientId: `${prefix}-client`, + oauthClientLabel: `${prefix} ChatGPT client`, + codeId: `${prefix}-code`, + codeDigest: `${prefix}-digest`, + authorizationRequestId: `${prefix}-authorization-request`, + now, + codeExpiresAt: now + 5 * 60 * 1000, + grantExpiresAt: now + 30 * 24 * 60 * 60 * 1000, + auditEventId: `${prefix}-created-audit`, + requestId: `${prefix}-created-request`, + }; +} + +before(async () => { + persistDirectory = await mkdtemp(path.join(tmpdir(), 'orbit-mcp-auth-d1-')); + migrationOutput = runMigrations(); + const port = await availablePort(); + let inspectorPort = await availablePort(); + while (inspectorPort === port) inspectorPort = await availablePort(); + baseUrl = `http://127.0.0.1:${port}`; + worker = spawn( + process.execPath, + [ + WRANGLER, + 'dev', + '--config', + CONFIG, + '--local', + `--port=${port}`, + `--inspector-port=${inspectorPort}`, + `--persist-to=${persistDirectory}`, + ], + { + cwd: ROOT, + env: { ...process.env, CI: '1', NO_COLOR: '1' }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + await waitForWorker(worker); +}); + +after(async () => { + if (worker && worker.exitCode === null) { + worker.kill('SIGTERM'); + await Promise.race([ + new Promise((resolve) => worker?.once('exit', () => resolve())), + new Promise((resolve) => setTimeout(resolve, 3_000)), + ]); + if (worker.exitCode === null) worker.kill('SIGKILL'); + } + if (persistDirectory) { + await rm(persistDirectory, { recursive: true, force: true }); + } +}); + +describe('Orbit MCP authorization foundation', { concurrency: false }, () => { + test('migration and delegation token family are available', async () => { + assert.match(migrationOutput, /0022_mcp_authorization_foundation\.sql/u); + const generated = await createOpaqueToken('delegation', 'test-delegation-pepper'); + assert.match(generated.token, /^orb_mcp_v1_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{43}$/u); + assert.equal(parseOpaqueToken(generated.token)?.family, 'delegation'); + const verified = await verifyOpaqueToken( + generated.token, + 'delegation', + generated.digest, + 'test-delegation-pepper', + ); + assert.equal(verified?.selector, generated.selector); + assert.equal( + await verifyOpaqueToken(generated.token, 'delegation', generated.digest, 'wrong-pepper'), + null, + ); + }); + + test('a primary sponsor creates a least-privilege grant and single-use code', async () => { + const now = Date.now(); + const accountId = 'mcp-sponsor-account'; + const agentId = 'mcp-sponsor-agent'; + await callAction('seedMcpAgent', { accountId, agentId, now }); + const input = grantData('mcp-sponsor', accountId, agentId, now + 1); + const created = await callAction<{ + grant: { + id: string; + accountId: string; + agentId: string; + handle: string; + scopes: string[]; + oauthClientId: string; + oauthClientLabel: string; + revokedAt: number | null; + }; + code: { + id: string; + secretDigest: string; + authorizationRequestId: string; + consumedAt: number | null; + }; + }>('createMcpGrant', input, 201); + + assert.deepEqual(created.grant.scopes, ['feed:read']); + assert.equal(created.grant.accountId, accountId); + assert.equal(created.grant.agentId, agentId); + assert.equal(created.grant.handle, agentId); + assert.equal(created.grant.revokedAt, null); + assert.equal(created.code.secretDigest, input.codeDigest); + assert.equal(created.code.authorizationRequestId, input.authorizationRequestId); + assert.equal(created.code.consumedAt, null); + + const listed = await callAction<{ grants: Array<{ id: string }> }>( + 'listMcpGrants', + { accountId }, + ); + assert.deepEqual(listed.grants.map((grant) => grant.id), [input.grantId]); + }); + + test('unrelated accounts and elevated scopes are rejected', async () => { + const now = Date.now(); + const sponsorId = 'mcp-policy-sponsor'; + const intruderId = 'mcp-policy-intruder'; + const agentId = 'mcp-policy-agent'; + await callAction('seedMcpAgent', { accountId: sponsorId, agentId, now }); + await callAction('seedAccount', { accountId: intruderId, now }); + + const unrelated = grantData('mcp-unrelated', intruderId, agentId, now + 1); + const unrelatedError = await callAction<{ error: string }>('createMcpGrant', unrelated, 409); + assert.match(unrelatedError.error, /mcp_authorization_agent_not_manageable/u); + + const elevated = { + ...grantData('mcp-elevated', sponsorId, agentId, now + 2), + scope: 'records:write', + }; + const scopeError = await callAction<{ error: string }>('createMcpGrant', elevated, 409); + assert.match(scopeError.error, /mcp_authorization_scope_invalid/u); + }); + + test('a platform owner may authorize a managed agent without becoming its sponsor', async () => { + const now = Date.now(); + const sponsorId = 'mcp-owner-sponsor'; + const ownerId = 'mcp-platform-owner'; + const agentId = 'mcp-owner-agent'; + await callAction('seedMcpAgent', { accountId: sponsorId, agentId, now }); + await callAction('grantPlatformOwner', { accountId: ownerId, now }); + const input = grantData('mcp-owner', ownerId, agentId, now + 1); + const created = await callAction<{ grant: { accountId: string; agentId: string } }>( + 'createMcpGrant', + input, + 201, + ); + assert.equal(created.grant.accountId, ownerId); + assert.equal(created.grant.agentId, agentId); + }); + + test('delegation redemption is request-bound, expiring, and single-use', async () => { + const now = Date.now(); + const accountId = 'mcp-redemption-sponsor'; + const agentId = 'mcp-redemption-agent'; + await callAction('seedMcpAgent', { accountId, agentId, now }); + + const mismatch = grantData('mcp-mismatch', accountId, agentId, now + 1); + await callAction('createMcpGrant', mismatch, 201); + const mismatchError = await callAction<{ error: string }>('redeemMcpCode', { + codeId: mismatch.codeId, + grantId: mismatch.grantId, + authorizationRequestId: 'different-authorization-request', + auditEventId: 'mcp-mismatch-redeem-audit', + requestId: 'mcp-mismatch-redeem-request', + now: now + 2, + }, 409); + assert.match(mismatchError.error, /invalid_mcp_delegation_code/u); + + const singleUse = grantData('mcp-single-use', accountId, agentId, now + 3); + await callAction('createMcpGrant', singleUse, 201); + const redeemed = await callAction<{ + grant: { id: string; scopes: string[] }; + code: { consumedAt: number }; + }>('redeemMcpCode', { + codeId: singleUse.codeId, + grantId: singleUse.grantId, + authorizationRequestId: singleUse.authorizationRequestId, + auditEventId: 'mcp-single-use-redeem-audit', + requestId: 'mcp-single-use-redeem-request', + now: now + 4, + }); + assert.equal(redeemed.grant.id, singleUse.grantId); + assert.deepEqual(redeemed.grant.scopes, ['feed:read']); + assert.equal(redeemed.code.consumedAt, now + 4); + + const replayError = await callAction<{ error: string }>('redeemMcpCode', { + codeId: singleUse.codeId, + grantId: singleUse.grantId, + authorizationRequestId: singleUse.authorizationRequestId, + auditEventId: 'mcp-single-use-replay-audit', + requestId: 'mcp-single-use-replay-request', + now: now + 5, + }, 409); + assert.match(replayError.error, /invalid_mcp_delegation_code|UNIQUE constraint/u); + + const expired = { + ...grantData('mcp-expired', accountId, agentId, now + 6), + codeExpiresAt: now + 7, + }; + await callAction('createMcpGrant', expired, 201); + const expiredError = await callAction<{ error: string }>('redeemMcpCode', { + codeId: expired.codeId, + grantId: expired.grantId, + authorizationRequestId: expired.authorizationRequestId, + auditEventId: 'mcp-expired-redeem-audit', + requestId: 'mcp-expired-redeem-request', + now: now + 8, + }, 409); + assert.match(expiredError.error, /invalid_mcp_delegation_code/u); + }); + + test('grant usage is monotonic and revocation blocks later redemption', async () => { + const now = Date.now(); + const sponsorId = 'mcp-revoke-sponsor'; + const intruderId = 'mcp-revoke-intruder'; + const agentId = 'mcp-revoke-agent'; + await callAction('seedMcpAgent', { accountId: sponsorId, agentId, now }); + await callAction('seedAccount', { accountId: intruderId, now }); + const input = grantData('mcp-revoke', sponsorId, agentId, now + 1); + await callAction('createMcpGrant', input, 201); + + const touched = await callAction<{ + touched: boolean; + grant: { lastUsedAt: number }; + }>('touchMcpGrant', { grantId: input.grantId, now: now + 2 }); + assert.equal(touched.touched, true); + assert.equal(touched.grant.lastUsedAt, now + 2); + + const olderTouch = await callAction<{ touched: boolean }>( + 'touchMcpGrant', + { grantId: input.grantId, now: now + 1 }, + ); + assert.equal(olderTouch.touched, false); + + const forbidden = await callAction<{ error: string }>('revokeMcpGrant', { + grantId: input.grantId, + actorAccountId: intruderId, + reason: 'not allowed', + auditEventId: 'mcp-forbidden-revoke-audit', + requestId: 'mcp-forbidden-revoke-request', + now: now + 3, + }, 409); + assert.match(forbidden.error, /mcp_authorization_revoke_forbidden/u); + + const revoked = await callAction<{ + grant: { revokedAt: number; revokedReason: string }; + }>('revokeMcpGrant', { + grantId: input.grantId, + actorAccountId: sponsorId, + reason: 'user_revoked', + auditEventId: 'mcp-revoke-audit', + requestId: 'mcp-revoke-request', + now: now + 4, + }); + assert.equal(revoked.grant.revokedAt, now + 4); + assert.equal(revoked.grant.revokedReason, 'user_revoked'); + + const afterRevoke = await callAction<{ touched: boolean }>( + 'touchMcpGrant', + { grantId: input.grantId, now: now + 5 }, + ); + assert.equal(afterRevoke.touched, false); + + const redemptionError = await callAction<{ error: string }>('redeemMcpCode', { + codeId: input.codeId, + grantId: input.grantId, + authorizationRequestId: input.authorizationRequestId, + auditEventId: 'mcp-revoked-redeem-audit', + requestId: 'mcp-revoked-redeem-request', + now: now + 6, + }, 409); + assert.match(redemptionError.error, /invalid_mcp_delegation_code/u); + + const state = await callAction<{ + redemption: unknown; + revocation: { actor_account_id: string; reason: string }; + audits: Array<{ event_type: string; actor_type: string; actor_id: string | null }>; + }>('mcpAuthorizationState', { grantId: input.grantId, codeId: input.codeId }); + assert.equal(state.redemption, null); + assert.equal(state.revocation.actor_account_id, sponsorId); + assert.equal(state.revocation.reason, 'user_revoked'); + assert.deepEqual( + state.audits.map((event) => event.event_type), + ['mcp.authorization_created', 'mcp.authorization_revoked'], + ); + }); + + test('durable grants and revocations enter backups without transient codes', async () => { + const backup = await callAction<{ + schemaVersion: number; + security: { containsPlaintextSecrets: boolean }; + tables: Record>>; + counts: Record; + }>('exportBackup', { now: Date.now(), includeSessions: false }); + + assert.equal(backup.schemaVersion, 9); + assert.equal(backup.security.containsPlaintextSecrets, false); + assert.ok(backup.tables.mcpAuthorizationGrants.length > 0); + assert.ok(backup.tables.mcpAuthorizationRevocations.length > 0); + assert.equal( + backup.counts.mcpAuthorizationGrants, + backup.tables.mcpAuthorizationGrants.length, + ); + assert.equal( + backup.counts.mcpAuthorizationRevocations, + backup.tables.mcpAuthorizationRevocations.length, + ); + assert.equal('mcpDelegationCodes' in backup.tables, false); + assert.equal('mcpDelegationRedemptions' in backup.tables, false); + assert.equal(JSON.stringify(backup).includes('orb_mcp_v1_'), false); + }); +}); diff --git a/src/server/backup/dynamic-backup.ts b/src/server/backup/dynamic-backup.ts index c1048c1..c4b3b49 100644 --- a/src/server/backup/dynamic-backup.ts +++ b/src/server/backup/dynamic-backup.ts @@ -4,7 +4,7 @@ import { randomBase64Url, sha256Base64Url } from '../identity/tokens'; import type { D1DatabaseLike, D1PreparedStatementLike } from '../repositories/d1/d1-foundation-repository'; export const BACKUP_SCHEMA = 'equinox.orbit.dynamic-backup.v1'; -export const BACKUP_SCHEMA_VERSION = 8; +export const BACKUP_SCHEMA_VERSION = 9; export const MAX_RESTORE_INPUT_BYTES = 4 * 1024 * 1024; export const MAX_RESTORE_STATEMENTS = 2_000; @@ -40,6 +40,8 @@ const SPECS: TableSpec[] = [ { exportName: 'invitations', table: 'invitations', columns: ['id','secret_digest','hash_version','expected_github_user_id','expected_github_login_snapshot','agent_quota','created_by_account_id','created_at','expires_at','redeemed_at','redeemed_by_account_id','revoked_at','revoked_by_account_id'], orderBy: 'id' }, { exportName: 'agents', table: 'agents', columns: ['id','handle','handle_normalized','display_name','bio','avatar_asset','publication_mode','status','created_at','updated_at','version','role','short_bio','motto','accent','responsibility','links_json','avatar_media_id','onboarding_state','onboarding_completed_at','pinned_record_id'], orderBy: 'id' }, { exportName: 'agentMemberships', table: 'agent_memberships', columns: ['id','agent_id','account_id','role','created_by_account_id','created_at','revoked_at'], orderBy: 'id' }, + { exportName: 'mcpAuthorizationGrants', table: 'mcp_authorization_grants', columns: ['id','account_id','agent_id','scopes','oauth_client_id','oauth_client_label','created_at','last_used_at','expires_at','revoked_at','revoked_reason'], orderBy: 'created_at, id' }, + { exportName: 'mcpAuthorizationRevocations', table: 'mcp_authorization_revocations', columns: ['grant_id','actor_account_id','reason','revoked_at'], orderBy: 'revoked_at, grant_id' }, { exportName: 'agentCredentials', table: 'agent_credentials', columns: ['id','agent_id','secret_digest','hash_version','scopes','created_by_account_id','created_at','last_used_at','expires_at','revoked_at','revoked_reason','replaced_by_credential_id'], orderBy: 'created_at, id' }, { exportName: 'sessions', table: 'sessions', columns: ['id','account_id','secret_digest','hash_version','csrf_digest','created_at','last_seen_at','idle_expires_at','absolute_expires_at','revoked_at','revoked_reason'], orderBy: 'created_at, id', optional: true }, { exportName: 'projects', table: 'projects', columns: ['id','slug','name','status','created_at','updated_at','label','footer_label','description','href','accent'], orderBy: 'id' }, @@ -253,12 +255,16 @@ export async function restoreDynamicBackup( const statements: D1PreparedStatementLike[] = []; const seedSafe = new Set(['accounts', 'authIdentities', 'accountRoles', 'accountQuotas', 'auditEvents']); - const first = ['accounts','authIdentities','accountRoles','accountQuotas','invitations','agents','agentMemberships','agentCredentials','sessions','projects','topics']; + const first = ['accounts','authIdentities','accountRoles','accountQuotas','invitations','agents','agentMemberships','mcpAuthorizationGrants','agentCredentials','sessions','projects','topics']; for (const name of first) { const item = spec(name); for (const row of backup.tables[name]) { const adjusted = { ...row }; if (name === 'agentCredentials') adjusted.replaced_by_credential_id = null; + if (name === 'mcpAuthorizationGrants') { + adjusted.revoked_at = null; + adjusted.revoked_reason = null; + } if (name === 'accounts') { // Closed accounts may retain revoked session history. Keep the normal // active-account session trigger enabled, restore dependencies while @@ -270,6 +276,9 @@ export async function restoreDynamicBackup( statements.push(insert(db, item, adjusted, seedSafe.has(name))); } } + for (const row of backup.tables.mcpAuthorizationRevocations) { + statements.push(insert(db, spec('mcpAuthorizationRevocations'), row)); + } const recordsSpec = spec('records'); for (const row of orderedRecords(backup.tables.records)) { @@ -440,6 +449,7 @@ export async function restoreDynamicBackup( statements.push( db.prepare(`UPDATE sessions SET revoked_at = COALESCE(revoked_at, ?), revoked_reason = COALESCE(revoked_reason, 'restore_bulk_revoke')`).bind(now), db.prepare(`UPDATE agent_credentials SET revoked_at = COALESCE(revoked_at, ?), revoked_reason = COALESCE(revoked_reason, 'restore_bulk_revoke')`).bind(now), + db.prepare(`UPDATE mcp_authorization_grants SET revoked_at = COALESCE(revoked_at, ?), revoked_reason = COALESCE(revoked_reason, 'restore_bulk_revoke')`).bind(now), ); } @@ -447,6 +457,8 @@ export async function restoreDynamicBackup( accounts: backup.counts.accounts, agents: backup.counts.agents, agentMemberships: backup.counts.agentMemberships, + mcpAuthorizationGrants: backup.counts.mcpAuthorizationGrants, + mcpAuthorizationRevocations: backup.counts.mcpAuthorizationRevocations, projects: backup.counts.projects, topics: backup.counts.topics, records: backup.counts.records, diff --git a/src/server/identity/tokens.ts b/src/server/identity/tokens.ts index f375fee..e3f1856 100644 --- a/src/server/identity/tokens.ts +++ b/src/server/identity/tokens.ts @@ -1,15 +1,16 @@ import { TOKEN_HASH_VERSION } from './constants'; const encoder = new TextEncoder(); -const TOKEN_PATTERN = /^(orb_(?:inv|sess|agent|reg)_v1)_([A-Za-z0-9_-]{22})_([A-Za-z0-9_-]{43})$/; +const TOKEN_PATTERN = /^(orb_(?:inv|sess|agent|reg|mcp)_v1)_([A-Za-z0-9_-]{22})_([A-Za-z0-9_-]{43})$/; -export type TokenFamily = 'invitation' | 'session' | 'agent' | 'registration'; +export type TokenFamily = 'invitation' | 'session' | 'agent' | 'registration' | 'delegation'; const PREFIXES: Record = { invitation: 'orb_inv_v1', session: 'orb_sess_v1', agent: 'orb_agent_v1', registration: 'orb_reg_v1', + delegation: 'orb_mcp_v1', }; const FAMILIES_BY_PREFIX = new Map( diff --git a/src/server/repositories/d1/d1-mcp-authorization-repository.ts b/src/server/repositories/d1/d1-mcp-authorization-repository.ts new file mode 100644 index 0000000..380b730 --- /dev/null +++ b/src/server/repositories/d1/d1-mcp-authorization-repository.ts @@ -0,0 +1,278 @@ +import type { + McpAuthorizationGrantView, + McpAuthorizationRepository, + McpAuthorizationScope, + McpDelegationCodeView, +} from '../mcp-authorization-repository'; +import type { + D1DatabaseLike, + D1RunResultLike, +} from './d1-foundation-repository'; + +interface GrantSqlRow { + id: string; + account_id: string; + agent_id: string; + handle: string; + scopes: string; + oauth_client_id: string; + oauth_client_label: string; + created_at: number; + last_used_at: number | null; + expires_at: number | null; + revoked_at: number | null; + revoked_reason: string | null; +} + +interface DelegationCodeSqlRow { + id: string; + secret_digest: string; + hash_version: number; + grant_id: string; + authorization_request_id: string; + created_at: number; + expires_at: number; + consumed_at: number | null; +} + +const GRANT_SELECT = ` + SELECT grant_row.id, grant_row.account_id, grant_row.agent_id, + agent.handle, grant_row.scopes, grant_row.oauth_client_id, + grant_row.oauth_client_label, grant_row.created_at, + grant_row.last_used_at, grant_row.expires_at, + grant_row.revoked_at, grant_row.revoked_reason + FROM mcp_authorization_grants grant_row + JOIN agents agent ON agent.id = grant_row.agent_id +`; + +function serializeScopes(scopes: McpAuthorizationScope[]): string { + const normalized = [...new Set(scopes)].sort(); + if (normalized.length !== 1 || normalized[0] !== 'feed:read') { + throw new Error('mcp_authorization_scope_invalid'); + } + return normalized.join(' '); +} + +function parseScopes(value: string): McpAuthorizationScope[] { + if (value !== 'feed:read') throw new Error('mcp_authorization_scope_invalid'); + return ['feed:read']; +} + +function grantFromSql(row: GrantSqlRow): McpAuthorizationGrantView { + return { + id: row.id, + accountId: row.account_id, + agentId: row.agent_id, + handle: row.handle, + scopes: parseScopes(row.scopes), + oauthClientId: row.oauth_client_id, + oauthClientLabel: row.oauth_client_label, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + revokedReason: row.revoked_reason, + }; +} + +function delegationCodeFromSql(row: DelegationCodeSqlRow): McpDelegationCodeView { + return { + id: row.id, + secretDigest: row.secret_digest, + hashVersion: row.hash_version, + grantId: row.grant_id, + authorizationRequestId: row.authorization_request_id, + createdAt: row.created_at, + expiresAt: row.expires_at, + consumedAt: row.consumed_at, + }; +} + +function auditMetadata(value: Record): string { + return JSON.stringify(value); +} + +export class D1McpAuthorizationRepository implements McpAuthorizationRepository { + readonly #db: D1DatabaseLike; + + constructor(db: D1DatabaseLike) { + this.#db = db; + } + + async createGrantWithCode( + input: Parameters[0], + ): Promise { + if (input.code.grantId !== input.grant.id) { + throw new Error('mcp_delegation_code_grant_mismatch'); + } + + const scopes = serializeScopes(input.grant.scopes); + await this.#db.batch([ + this.#db.prepare(` + INSERT INTO mcp_authorization_grants ( + id, account_id, agent_id, scopes, oauth_client_id, + oauth_client_label, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).bind( + input.grant.id, + input.grant.accountId, + input.grant.agentId, + scopes, + input.grant.oauthClientId.trim(), + input.grant.oauthClientLabel.trim(), + input.grant.createdAt, + input.grant.expiresAt, + ), + this.#db.prepare(` + INSERT INTO mcp_delegation_codes ( + id, secret_digest, hash_version, grant_id, + authorization_request_id, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).bind( + input.code.id, + input.code.secretDigest, + input.code.hashVersion, + input.code.grantId, + input.code.authorizationRequestId, + input.code.createdAt, + input.code.expiresAt, + ), + this.#db.prepare(` + INSERT INTO audit_events ( + id, event_type, actor_type, actor_id, subject_type, + subject_id, request_id, metadata_json, created_at + ) VALUES (?, 'mcp.authorization_created', 'account', ?, + 'mcp_authorization_grant', ?, ?, ?, ?) + `).bind( + input.auditEventId, + input.grant.accountId, + input.grant.id, + input.requestId, + auditMetadata({ + agentId: input.grant.agentId, + scopes: input.grant.scopes, + oauthClientId: input.grant.oauthClientId, + oauthClientLabel: input.grant.oauthClientLabel, + authorizationRequestId: input.code.authorizationRequestId, + }), + input.grant.createdAt, + ), + ]); + } + + async getGrant(grantId: string): Promise { + const row = await this.#db.prepare(` + ${GRANT_SELECT} + WHERE grant_row.id = ? + `).bind(grantId).first(); + return row ? grantFromSql(row) : null; + } + + async listAccountGrants(accountId: string): Promise { + const result = await this.#db.prepare(` + ${GRANT_SELECT} + WHERE grant_row.account_id = ? + ORDER BY grant_row.created_at DESC, grant_row.id DESC + `).bind(accountId).all(); + return result.results.map(grantFromSql); + } + + async getDelegationCode(codeId: string): Promise { + const row = await this.#db.prepare(` + SELECT id, secret_digest, hash_version, grant_id, + authorization_request_id, created_at, expires_at, consumed_at + FROM mcp_delegation_codes + WHERE id = ? + `).bind(codeId).first(); + return row ? delegationCodeFromSql(row) : null; + } + + async redeemDelegationCode( + input: Parameters[0], + ): Promise { + await this.#db.batch([ + this.#db.prepare(` + INSERT INTO mcp_delegation_redemptions ( + code_id, grant_id, authorization_request_id, redeemed_at + ) VALUES (?, ?, ?, ?) + `).bind( + input.codeId, + input.grantId, + input.authorizationRequestId, + input.redeemedAt, + ), + this.#db.prepare(` + INSERT INTO audit_events ( + id, event_type, actor_type, actor_id, subject_type, + subject_id, request_id, metadata_json, created_at + ) VALUES (?, 'mcp.delegation_code_redeemed', 'system', NULL, + 'mcp_authorization_grant', ?, ?, ?, ?) + `).bind( + input.redemptionAuditEventId, + input.grantId, + input.requestId, + auditMetadata({ + codeId: input.codeId, + authorizationRequestId: input.authorizationRequestId, + }), + input.redeemedAt, + ), + ]); + + const grant = await this.getGrant(input.grantId); + if (!grant) throw new Error('mcp_authorization_grant_missing_after_redemption'); + return grant; + } + + async touchGrant( + input: Parameters[0], + ): Promise { + const result = await this.#db.prepare(` + UPDATE mcp_authorization_grants + SET last_used_at = ? + WHERE id = ? + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > ?) + AND created_at <= ? + AND (last_used_at IS NULL OR last_used_at < ?) + `).bind( + input.usedAt, + input.grantId, + input.usedAt, + input.usedAt, + input.usedAt, + ).run(); + return (result.meta?.changes ?? 0) === 1; + } + + async revokeGrant( + input: Parameters[0], + ): Promise { + await this.#db.batch([ + this.#db.prepare(` + INSERT INTO mcp_authorization_revocations ( + grant_id, actor_account_id, reason, revoked_at + ) VALUES (?, ?, ?, ?) + `).bind( + input.grantId, + input.actorAccountId, + input.reason.trim(), + input.revokedAt, + ), + this.#db.prepare(` + INSERT INTO audit_events ( + id, event_type, actor_type, actor_id, subject_type, + subject_id, request_id, metadata_json, created_at + ) VALUES (?, 'mcp.authorization_revoked', 'account', ?, + 'mcp_authorization_grant', ?, ?, ?, ?) + `).bind( + input.auditEventId, + input.actorAccountId, + input.grantId, + input.requestId, + auditMetadata({ reason: input.reason.trim() }), + input.revokedAt, + ), + ]); + } +} diff --git a/src/server/repositories/mcp-authorization-repository.ts b/src/server/repositories/mcp-authorization-repository.ts new file mode 100644 index 0000000..a92f7fc --- /dev/null +++ b/src/server/repositories/mcp-authorization-repository.ts @@ -0,0 +1,76 @@ +export const MCP_AUTHORIZATION_SCOPES = ['feed:read'] as const; + +export type McpAuthorizationScope = typeof MCP_AUTHORIZATION_SCOPES[number]; + +export interface McpAuthorizationGrantView { + id: string; + accountId: string; + agentId: string; + handle: string; + scopes: McpAuthorizationScope[]; + oauthClientId: string; + oauthClientLabel: string; + createdAt: number; + lastUsedAt: number | null; + expiresAt: number | null; + revokedAt: number | null; + revokedReason: string | null; +} + +export interface McpDelegationCodeView { + id: string; + secretDigest: string; + hashVersion: number; + grantId: string; + authorizationRequestId: string; + createdAt: number; + expiresAt: number; + consumedAt: number | null; +} + +export interface McpAuthorizationRepository { + createGrantWithCode(input: { + grant: { + id: string; + accountId: string; + agentId: string; + scopes: McpAuthorizationScope[]; + oauthClientId: string; + oauthClientLabel: string; + createdAt: number; + expiresAt: number | null; + }; + code: McpDelegationCodeView; + auditEventId: string; + requestId: string; + }): Promise; + + getGrant(grantId: string): Promise; + + listAccountGrants(accountId: string): Promise; + + getDelegationCode(codeId: string): Promise; + + redeemDelegationCode(input: { + codeId: string; + grantId: string; + authorizationRequestId: string; + redemptionAuditEventId: string; + requestId: string; + redeemedAt: number; + }): Promise; + + touchGrant(input: { + grantId: string; + usedAt: number; + }): Promise; + + revokeGrant(input: { + grantId: string; + actorAccountId: string; + reason: string; + auditEventId: string; + requestId: string; + revokedAt: number; + }): Promise; +}