mcp-access.ts594 lines · main
1import { createHash, randomBytes } from 'node:crypto';
2
3import { newId, NotFoundError } from '@briven/shared';
4
5import { desc, eq, inArray, isNull, like } from 'drizzle-orm';
6
7import { getDb } from '../db/client.js';
8import {
9 mcpKeys,
10 platformSettings,
11 projects,
12 type McpKey,
13 type McpKeyScope,
14 type NewMcpKey,
15 type ProjectTier,
16} from '../db/schema.js';
17import { audit, listAuditByActionPrefix, type AuditEntry, type AuditRow } from './audit.js';
18import { getTierForOrg } from './billing.js';
19import { getPlatformSetting, setPlatformSetting } from './platform-settings.js';
20
21/**
22 * B Phase 5 — MCP / Agent-Access control surface.
23 *
24 * This is the on/off + key-issuing + audit SURFACE for the future MCP server.
25 * It does NOT speak the MCP wire protocol — the socket server that consumes
26 * these keys is a separate track. What lives here:
27 * - a GLOBAL kill-switch (platform_settings `mcp.enabled`) that gates every
28 * agent's access at once;
29 * - per-project enablement (platform_settings `mcp.project.<id>`), behind a
30 * SERVER-SIDE plan gate — only paying Pro/Team projects qualify;
31 * - per-key issue (one-time plaintext reveal, sha-256 hash stored) + revoke;
32 * - the mcp.* audit trail, written through the shared audit() helper into the
33 * existing audit_logs table (no second audit table).
34 *
35 * Nothing security-sensitive is cached: the global flag read goes through
36 * platform-settings' short TTL cache (a flag flip, not a secret), but key
37 * lookups and the plan gate always hit the DB fresh.
38 */
39
40/* ─── constants ──────────────────────────────────────────────────────────── */
41
42/** Platform-settings key for the global MCP kill-switch. */
43export const MCP_GLOBAL_FLAG = 'mcp.enabled';
44/** Plaintext prefix for issued keys — recognisable in logs + grep. */
45export const MCP_KEY_PREFIX = 'pk_briven_mcp_';
46const KEY_ENTROPY_BYTES = 32; // 256 bits
47/** Tiers that may turn on MCP access. Free never qualifies. */
48export const MCP_PAID_TIERS = ['pro', 'team'] as const satisfies readonly ProjectTier[];
49
50/** Per-project platform-settings key. */
51function projectFlagKey(projectId: string): string {
52 return `mcp.project.${projectId}`;
53}
54
55/* ─── typed errors ───────────────────────────────────────────────────────── */
56
57/**
58 * Thrown when MCP enable / key-issue is attempted for a project that is NOT on
59 * a paying plan. The route maps this to a 4xx so a direct API call can't slip
60 * past the UI gate.
61 */
62export class McpPlanRequiredError extends Error {
63 readonly code = 'mcp_plan_required' as const;
64 constructor(
65 readonly projectId: string,
66 readonly tier: ProjectTier | null,
67 ) {
68 super('MCP access requires a Pro or Team plan');
69 this.name = 'McpPlanRequiredError';
70 }
71}
72
73/**
74 * Thrown when a DELETE is attempted on a key that is still ACTIVE (revoked_at is
75 * null). Delete is a tidy-up of an already-cut key — a live key must be revoked
76 * first, so it can never be removed by accident. Routes map this to a 409.
77 */
78export class McpKeyNotRevokedError extends Error {
79 readonly code = 'mcp_key_not_revoked' as const;
80 constructor(readonly keyId: string) {
81 super('cannot delete an active key — revoke it first');
82 this.name = 'McpKeyNotRevokedError';
83 }
84}
85
86/* ─── pure helpers (unit-testable without a DB) ──────────────────────────── */
87
88/** The plan gate, as a pure rule: only Pro / Team qualify. */
89export function isPlanEligibleForMcp(tier: ProjectTier | null | undefined): boolean {
90 return tier === 'pro' || tier === 'team';
91}
92
93/**
94 * Generate a fresh MCP key. The plaintext is returned to the caller exactly
95 * once; only the sha-256 hash is ever persisted. Mirrors api-keys.ts.
96 */
97export function generateMcpKey(): {
98 plaintext: string;
99 hash: string;
100 prefix: string;
101 suffix: string;
102} {
103 const raw = randomBytes(KEY_ENTROPY_BYTES).toString('base64url');
104 const plaintext = `${MCP_KEY_PREFIX}${raw}`;
105 const hash = createHash('sha256').update(plaintext).digest('hex');
106 const suffix = plaintext.slice(-4);
107 return { plaintext, hash, prefix: MCP_KEY_PREFIX, suffix };
108}
109
110export interface MaskedMcpKey {
111 id: string;
112 name: string;
113 prefix: string;
114 suffix: string;
115 scope: McpKeyScope;
116 enabled: boolean;
117 createdAt: Date;
118 lastUsedAt: Date | null;
119 revokedAt: Date | null;
120}
121
122/** Strip the hash; never return it. The dashboard only ever sees prefix…suffix. */
123export function maskKey(row: McpKey): MaskedMcpKey {
124 return {
125 id: row.id,
126 name: row.name,
127 prefix: row.prefix,
128 suffix: row.suffix,
129 scope: row.scope,
130 enabled: row.enabled,
131 createdAt: row.createdAt,
132 lastUsedAt: row.lastUsedAt,
133 revokedAt: row.revokedAt,
134 };
135}
136
137/* ─── injectable seam (so the mutators are unit-testable without a DB) ────── */
138
139/** The actor behind a state change — threaded into the audit row. */
140export interface McpActor {
141 id: string | null;
142 ipHash: string | null;
143 userAgent: string | null;
144}
145
146export interface McpAccessDeps {
147 /** Persist the global kill-switch flag. */
148 setGlobalSetting(on: boolean, actorId: string | null): Promise<void>;
149 /**
150 * Effective paid plan tier for a project, from the SAME source Phase 3 used
151 * (the org's non-canceled subscriptions table). Returns null when the
152 * project does not exist / is deleted; 'free' when no paid subscription.
153 */
154 getProjectPlanTier(projectId: string): Promise<ProjectTier | null>;
155 /** Persist per-project enablement. */
156 setProjectEnabled(projectId: string, on: boolean, actorId: string | null): Promise<void>;
157 insertKey(row: NewMcpKey): Promise<McpKey>;
158 getKeyById(keyId: string): Promise<McpKey | null>;
159 /** Set revoked_at = now() AND enabled = false. */
160 setKeyRevoked(keyId: string): Promise<void>;
161 /** Hard-delete the key row. Only ever called on an already-revoked key. */
162 deleteKey(keyId: string): Promise<void>;
163 audit(entry: AuditEntry): Promise<void>;
164}
165
166/** Real, DB-backed dependencies. */
167export const defaultMcpAccessDeps: McpAccessDeps = {
168 async setGlobalSetting(on, actorId) {
169 await setPlatformSetting(MCP_GLOBAL_FLAG, on, actorId);
170 },
171 async getProjectPlanTier(projectId) {
172 const db = getDb();
173 const [proj] = await db
174 .select({ orgId: projects.orgId, deletedAt: projects.deletedAt })
175 .from(projects)
176 .where(eq(projects.id, projectId))
177 .limit(1);
178 if (!proj || proj.deletedAt) return null;
179 // Resolve through getTierForOrg so the founder comp (owner-email → team)
180 // counts for MCP exactly as it does for /billing + project creation. A real
181 // customer still resolves to their live subscription tier; a non-comped free
182 // org still resolves to 'free' and stays gated.
183 return getTierForOrg(proj.orgId);
184 },
185 async setProjectEnabled(projectId, on, actorId) {
186 await setPlatformSetting(projectFlagKey(projectId), on, actorId);
187 },
188 async insertKey(row) {
189 const db = getDb();
190 const [record] = await db.insert(mcpKeys).values(row).returning();
191 if (!record) throw new Error('mcp key insert returned no row');
192 return record;
193 },
194 async getKeyById(keyId) {
195 const db = getDb();
196 const [row] = await db.select().from(mcpKeys).where(eq(mcpKeys.id, keyId)).limit(1);
197 return row ?? null;
198 },
199 async setKeyRevoked(keyId) {
200 const db = getDb();
201 await db
202 .update(mcpKeys)
203 .set({ revokedAt: new Date(), enabled: false })
204 .where(eq(mcpKeys.id, keyId));
205 },
206 async deleteKey(keyId) {
207 const db = getDb();
208 await db.delete(mcpKeys).where(eq(mcpKeys.id, keyId));
209 },
210 async audit(entry) {
211 await audit(entry);
212 },
213};
214
215/* ─── global kill-switch ─────────────────────────────────────────────────── */
216
217/** Read the global MCP on/off flag (defaults OFF). */
218export async function getGlobalEnabled(): Promise<boolean> {
219 const value = await getPlatformSetting<unknown>(MCP_GLOBAL_FLAG, false);
220 return value === true;
221}
222
223/** Flip the global MCP kill-switch + audit it. */
224export async function setGlobalEnabled(
225 on: boolean,
226 actor: McpActor,
227 deps: McpAccessDeps = defaultMcpAccessDeps,
228): Promise<{ enabled: boolean }> {
229 await deps.setGlobalSetting(on, actor.id);
230 await deps.audit({
231 actorId: actor.id,
232 projectId: null,
233 action: 'mcp.global.toggle',
234 ipHash: actor.ipHash,
235 userAgent: actor.userAgent,
236 metadata: { enabled: on },
237 });
238 return { enabled: on };
239}
240
241/* ─── per-project enablement (server-side plan gate) ─────────────────────── */
242
243/**
244 * Turn MCP access ON for a project. SERVER-SIDE plan gate: a free-tier (or
245 * unknown) project is rejected with McpPlanRequiredError even when called
246 * directly, so the UI hiding the button is defence-in-depth, not the gate.
247 */
248export async function enableForProject(
249 projectId: string,
250 actor: McpActor,
251 deps: McpAccessDeps = defaultMcpAccessDeps,
252): Promise<{ projectId: string; enabled: true }> {
253 const tier = await deps.getProjectPlanTier(projectId);
254 if (!isPlanEligibleForMcp(tier)) {
255 throw new McpPlanRequiredError(projectId, tier);
256 }
257 await deps.setProjectEnabled(projectId, true, actor.id);
258 await deps.audit({
259 actorId: actor.id,
260 projectId,
261 action: 'mcp.project.enable',
262 ipHash: actor.ipHash,
263 userAgent: actor.userAgent,
264 metadata: { projectId, tier },
265 });
266 return { projectId, enabled: true };
267}
268
269/** Turn MCP access OFF for a project (no plan gate — disabling is always allowed). */
270export async function disableForProject(
271 projectId: string,
272 actor: McpActor,
273 deps: McpAccessDeps = defaultMcpAccessDeps,
274): Promise<{ projectId: string; enabled: false }> {
275 await deps.setProjectEnabled(projectId, false, actor.id);
276 await deps.audit({
277 actorId: actor.id,
278 projectId,
279 action: 'mcp.project.disable',
280 ipHash: actor.ipHash,
281 userAgent: actor.userAgent,
282 metadata: { projectId },
283 });
284 return { projectId, enabled: false };
285}
286
287/* ─── key issue / revoke ─────────────────────────────────────────────────── */
288
289export interface IssuedMcpKey {
290 key: MaskedMcpKey;
291 /** Full plaintext — returned EXACTLY once, never stored, never returned again. */
292 plaintext: string;
293}
294
295/**
296 * Issue a new MCP key for a project. Re-checks the plan gate (defence in
297 * depth — a key must never exist for a non-paying project) before generating.
298 * The plaintext is returned once; only the hash is persisted.
299 */
300export async function issueKey(
301 input: { projectId: string; name: string; scope: McpKeyScope },
302 actor: McpActor,
303 deps: McpAccessDeps = defaultMcpAccessDeps,
304): Promise<IssuedMcpKey> {
305 const tier = await deps.getProjectPlanTier(input.projectId);
306 if (!isPlanEligibleForMcp(tier)) {
307 throw new McpPlanRequiredError(input.projectId, tier);
308 }
309 if (!actor.id) throw new Error('issueKey requires an actor id (created_by)');
310
311 const { plaintext, hash, prefix, suffix } = generateMcpKey();
312 const record = await deps.insertKey({
313 id: newId('mck'),
314 projectId: input.projectId,
315 name: input.name,
316 hash,
317 prefix,
318 suffix,
319 scope: input.scope,
320 enabled: true,
321 createdBy: actor.id,
322 });
323 await deps.audit({
324 actorId: actor.id,
325 projectId: input.projectId,
326 action: 'mcp.key.issue',
327 ipHash: actor.ipHash,
328 userAgent: actor.userAgent,
329 metadata: { projectId: input.projectId, keyId: record.id, scope: input.scope, suffix },
330 });
331 return { key: maskKey(record), plaintext };
332}
333
334/** Revoke a key: stamps revoked_at + flips enabled false. Idempotent-safe. */
335export async function revokeKey(
336 keyId: string,
337 actor: McpActor,
338 deps: McpAccessDeps = defaultMcpAccessDeps,
339): Promise<{ keyId: string; revoked: true }> {
340 const row = await deps.getKeyById(keyId);
341 if (!row) throw new NotFoundError('mcp_key', keyId);
342 if (!row.revokedAt) {
343 await deps.setKeyRevoked(keyId);
344 }
345 await deps.audit({
346 actorId: actor.id,
347 projectId: row.projectId,
348 action: 'mcp.key.revoke',
349 ipHash: actor.ipHash,
350 userAgent: actor.userAgent,
351 metadata: { projectId: row.projectId, keyId },
352 });
353 return { keyId, revoked: true };
354}
355
356/**
357 * Hard-delete a key — but ONLY if it is already revoked. REVOKE-THEN-DELETE: a
358 * live key must be cut (revoked) before it can be tidied away, so a deletion can
359 * never remove an in-use key by accident.
360 * - unknown key → NotFoundError (route → 404)
361 * - still active → McpKeyNotRevokedError (route → 409)
362 * - already revoked → row hard-deleted + mcp.key.deleted audited
363 */
364export async function deleteRevokedKey(
365 keyId: string,
366 actor: McpActor,
367 deps: McpAccessDeps = defaultMcpAccessDeps,
368): Promise<{ keyId: string; deleted: true }> {
369 const row = await deps.getKeyById(keyId);
370 if (!row) throw new NotFoundError('mcp_key', keyId);
371 if (!row.revokedAt) throw new McpKeyNotRevokedError(keyId);
372 await deps.deleteKey(keyId);
373 await deps.audit({
374 actorId: actor.id,
375 projectId: row.projectId,
376 action: 'mcp.key.deleted',
377 ipHash: actor.ipHash,
378 userAgent: actor.userAgent,
379 metadata: { projectId: row.projectId, keyId },
380 });
381 return { keyId, deleted: true };
382}
383
384/* ─── read surfaces (cockpit) ────────────────────────────────────────────── */
385
386export interface ProjectAccessRow {
387 projectId: string;
388 projectName: string;
389 /** Effective paid tier (free | pro | team) — drives the UI enable gate. */
390 planTier: ProjectTier;
391 /** Whether MCP is currently enabled for this project. */
392 mcpEnabled: boolean;
393 /** Whether the plan qualifies (Pro/Team) — the UI offers "enable" only when true. */
394 eligible: boolean;
395 keys: MaskedMcpKey[];
396}
397
398/**
399 * Every non-deleted project with its effective plan tier, MCP-enabled flag, and
400 * issued keys (masked). One payload the cockpit renders the whole per-project
401 * list from: enabled projects show keys + disable; paying-but-not-enabled show
402 * an enable button; free-tier show a muted "requires Pro/Team" note.
403 */
404export async function listProjectAccess(limit = 500): Promise<ProjectAccessRow[]> {
405 const db = getDb();
406
407 const projectRows = await db
408 .select({
409 id: projects.id,
410 name: projects.name,
411 orgId: projects.orgId,
412 })
413 .from(projects)
414 .where(isNull(projects.deletedAt))
415 .orderBy(desc(projects.createdAt))
416 .limit(limit);
417 if (projectRows.length === 0) return [];
418
419 const orgIds = [...new Set(projectRows.map((p) => p.orgId))];
420 const projectIds = projectRows.map((p) => p.id);
421
422 // Resolve each org's tier through getTierForOrg so the founder comp
423 // (owner-email → team) is honoured here exactly as on the gate + /billing —
424 // otherwise a comped owner's projects wrongly show "requires Pro/Team" and get
425 // no enable button. Real customers resolve to their live subscription tier.
426 const tierByOrg = new Map<string, ProjectTier>();
427 await Promise.all(
428 orgIds.map(async (orgId) => {
429 tierByOrg.set(orgId, await getTierForOrg(orgId));
430 }),
431 );
432
433 // Per-project enablement lives in platform_settings under mcp.project.<id>.
434 const flagRows = await db
435 .select({ key: platformSettings.key, value: platformSettings.value })
436 .from(platformSettings)
437 .where(like(platformSettings.key, 'mcp.project.%'));
438 const enabledByProject = new Set<string>();
439 for (const r of flagRows) {
440 if (r.value === true) enabledByProject.add(r.key.slice('mcp.project.'.length));
441 }
442
443 const keyRows = await db
444 .select()
445 .from(mcpKeys)
446 .where(inArray(mcpKeys.projectId, projectIds))
447 .orderBy(desc(mcpKeys.createdAt));
448 const keysByProject = new Map<string, MaskedMcpKey[]>();
449 for (const k of keyRows) {
450 const list = keysByProject.get(k.projectId) ?? [];
451 list.push(maskKey(k));
452 keysByProject.set(k.projectId, list);
453 }
454
455 return projectRows.map((p) => {
456 const planTier = tierByOrg.get(p.orgId) ?? 'free';
457 return {
458 projectId: p.id,
459 projectName: p.name,
460 planTier,
461 mcpEnabled: enabledByProject.has(p.id),
462 eligible: isPlanEligibleForMcp(planTier),
463 keys: keysByProject.get(p.id) ?? [],
464 };
465 });
466}
467
468/**
469 * Masked keys for a SINGLE project, newest first. Powers the per-project user
470 * dashboard (the project admin's own Agent-Access tab) — the project-scoped
471 * read that mirrors `listProjectAccess`'s key column without loading every
472 * project. Same query style + `maskKey()` so a hash never leaves the service.
473 */
474export async function listKeysForProject(projectId: string): Promise<MaskedMcpKey[]> {
475 const db = getDb();
476 const rows = await db
477 .select()
478 .from(mcpKeys)
479 .where(eq(mcpKeys.projectId, projectId))
480 .orderBy(desc(mcpKeys.createdAt));
481 return rows.map(maskKey);
482}
483
484/** Recent mcp.* audit rows for the cockpit audit-trail table. */
485export async function listMcpAudit(limit = 200): Promise<AuditRow[]> {
486 return listAuditByActionPrefix('mcp.', limit);
487}
488
489/* ─── key verification (the live MCP-server auth gate) ───────────────────── */
490
491/**
492 * Hash a presented plaintext key EXACTLY the way `generateMcpKey()` does, so a
493 * lookup by hash finds the row issued for that plaintext. sha-256 hex of the
494 * full plaintext (prefix included). Kept as its own helper so the hashing rule
495 * lives in one place and the verifier can never drift from the issuer.
496 */
497export function hashMcpKey(plaintext: string): string {
498 return createHash('sha256').update(plaintext).digest('hex');
499}
500
501/** Why a presented key was refused. 401 = auth (bad key); 403 = gate (plan/switch). */
502export type McpVerifyFailure =
503 | 'missing_key'
504 | 'malformed_key'
505 | 'unknown_key'
506 | 'revoked_key'
507 | 'global_disabled'
508 | 'project_disabled'
509 | 'plan_ineligible';
510
511/**
512 * Result of verifying a presented MCP key. On success it carries the ONLY
513 * facts the MCP server is allowed to trust: which key, which project it is
514 * hard-locked to, and what it may do. There is deliberately no way for a
515 * caller to widen `projectId` — it comes from the stored row, never the wire.
516 */
517export type McpVerifyResult =
518 | { ok: true; keyId: string; projectId: string; scope: McpKeyScope }
519 | { ok: false; status: 401 | 403; reason: McpVerifyFailure };
520
521/** Injectable seam so the verifier is unit-testable without a live DB. */
522export interface McpVerifyDeps {
523 getKeyByHash(hash: string): Promise<McpKey | null>;
524 touchKeyLastUsed(keyId: string): Promise<void>;
525 isGlobalEnabled(): Promise<boolean>;
526 isProjectEnabled(projectId: string): Promise<boolean>;
527 getProjectPlanTier(projectId: string): Promise<ProjectTier | null>;
528}
529
530/** Real, DB-backed verification dependencies. */
531export const defaultMcpVerifyDeps: McpVerifyDeps = {
532 async getKeyByHash(hash) {
533 const db = getDb();
534 const [row] = await db.select().from(mcpKeys).where(eq(mcpKeys.hash, hash)).limit(1);
535 return row ?? null;
536 },
537 async touchKeyLastUsed(keyId) {
538 const db = getDb();
539 await db.update(mcpKeys).set({ lastUsedAt: new Date() }).where(eq(mcpKeys.id, keyId));
540 },
541 isGlobalEnabled() {
542 return getGlobalEnabled();
543 },
544 async isProjectEnabled(projectId) {
545 const value = await getPlatformSetting<unknown>(projectFlagKey(projectId), false);
546 return value === true;
547 },
548 getProjectPlanTier(projectId) {
549 return defaultMcpAccessDeps.getProjectPlanTier(projectId);
550 },
551};
552
553/**
554 * Verify a presented plaintext key and resolve its project binding + scope.
555 *
556 * Order matters — auth first (is this a real, live key?), then the gates (is
557 * MCP even on for this project, and does the plan still qualify?):
558 * 1. present + well-formed prefix → else 401 missing/malformed
559 * 2. hash matches a stored row → else 401 unknown
560 * 3. not revoked and still enabled → else 401 revoked
561 * 4. global kill-switch is ON → else 403 global_disabled
562 * 5. project is enabled for MCP → else 403 project_disabled
563 * 6. project's plan is Pro/Team → else 403 plan_ineligible
564 *
565 * The plaintext key is NEVER logged or returned. On success `lastUsedAt` is
566 * stamped. The caller binds the returned `projectId`/`scope` to the request —
567 * no tool ever sees a project id from the wire.
568 */
569export async function verifyMcpKey(
570 presented: string | null | undefined,
571 deps: McpVerifyDeps = defaultMcpVerifyDeps,
572): Promise<McpVerifyResult> {
573 if (!presented) return { ok: false, status: 401, reason: 'missing_key' };
574 if (!presented.startsWith(MCP_KEY_PREFIX)) {
575 return { ok: false, status: 401, reason: 'malformed_key' };
576 }
577 const row = await deps.getKeyByHash(hashMcpKey(presented));
578 if (!row) return { ok: false, status: 401, reason: 'unknown_key' };
579 if (row.revokedAt || !row.enabled) return { ok: false, status: 401, reason: 'revoked_key' };
580
581 if (!(await deps.isGlobalEnabled())) {
582 return { ok: false, status: 403, reason: 'global_disabled' };
583 }
584 if (!(await deps.isProjectEnabled(row.projectId))) {
585 return { ok: false, status: 403, reason: 'project_disabled' };
586 }
587 const tier = await deps.getProjectPlanTier(row.projectId);
588 if (!isPlanEligibleForMcp(tier)) {
589 return { ok: false, status: 403, reason: 'plan_ineligible' };
590 }
591
592 await deps.touchKeyLastUsed(row.id);
593 return { ok: true, keyId: row.id, projectId: row.projectId, scope: row.scope };
594}