From d262fb5f0da9ab8d8a820a523c53e1d95dc86e6c Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 15 Aug 2026 20:05:09 +0000 Subject: [PATCH] feat(auth): complete scoped board key lifecycle Co-Authored-By: Paperclip --- cli/src/__tests__/token.test.ts | 72 ++++++++++++ cli/src/commands/client/token.ts | 116 +++++++++++++++++-- server/src/__tests__/cli-auth-routes.test.ts | 70 +++++++++++ server/src/routes/access.ts | 45 ++++--- server/src/services/board-auth.test.ts | 103 +++++++++++++++- 5 files changed, 377 insertions(+), 29 deletions(-) diff --git a/cli/src/__tests__/token.test.ts b/cli/src/__tests__/token.test.ts index 3abf1eddbb9..5225a856b9e 100644 --- a/cli/src/__tests__/token.test.ts +++ b/cli/src/__tests__/token.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { registerTokenCommands } from "../commands/client/token.js"; const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; +const SECOND_COMPANY_ID = "33333333-3333-4333-8333-333333333333"; const AGENT_ID = "11111111-1111-4111-8111-111111111111"; function createProgram(): Command { @@ -222,6 +223,77 @@ describe("token commands", () => { }); }); + it("uses the finite 30-day default and the selected shared scope preset", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-23T00:00:00.000Z")); + const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + id: "board-key-1", + name: "reader", + token: "pcp_board_plaintext", + createdAt: "2026-05-23T00:00:00.000Z", + lastUsedAt: null, + revokedAt: null, + expiresAt: "2026-06-22T00:00:00.000Z", + }), { status: 201 })); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await createProgram().parseAsync([ + "token", "board", "create", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--company-id", COMPANY_ID, + "--scope-company-id", SECOND_COMPANY_ID, + "--scope-preset", "read_only", + "--name", "reader", + "--json", + ], { from: "user" }); + + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ + name: "reader", + expiresAt: "2026-06-22T00:00:00.000Z", + scopeConfig: { + version: 1, + kind: "scoped", + companyIds: [COMPANY_ID, SECOND_COMPANY_ID], + instanceCapabilities: [], + }, + }); + }); + + it("accepts a custom scope only through the shared strict validator", async () => { + const customScope = { + version: 1, + kind: "scoped", + companyIds: [COMPANY_ID], + permissions: ["issues:read", "issues:write"], + instanceCapabilities: [], + }; + const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + id: "board-key-1", + name: "issue-bot", + token: "pcp_board_plaintext", + createdAt: "2026-05-23T00:00:00.000Z", + lastUsedAt: null, + revokedAt: null, + expiresAt: "2026-06-22T00:00:00.000Z", + }), { status: 201 })); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await createProgram().parseAsync([ + "token", "board", "create", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--company-id", COMPANY_ID, + "--scope-config-json", JSON.stringify(customScope), + "--name", "issue-bot", + "--json", + ], { from: "user" }); + + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)).scopeConfig).toEqual(customScope); + }); + it("lists and revokes board tokens", async () => { const fetchMock = vi .fn() diff --git a/cli/src/commands/client/token.ts b/cli/src/commands/client/token.ts index 50e3a928b8e..04c0690a49b 100644 --- a/cli/src/commands/client/token.ts +++ b/cli/src/commands/client/token.ts @@ -1,9 +1,13 @@ import { Command } from "commander"; import { BOARD_API_KEY_SCOPE_PRESETS, + boardApiKeyScopeConfigSchema, createAgentKeySchema, createBoardApiKeySchema, type Agent, + type BoardApiKeyScopeConfig, + type BoardApiKeyScopePreset, + type BoardApiKeyStatus, } from "@paperclipai/shared"; import { addCommonClientOptions, @@ -27,6 +31,9 @@ interface BoardTokenOptions extends BaseClientOptions { expiresAt?: string; ttlDays?: string; neverExpires?: boolean; + scopePreset?: string; + scopeCompanyId?: string[]; + scopeConfigJson?: string; } interface CreatedAgentKey { @@ -57,6 +64,10 @@ interface CreatedBoardKey { interface BoardKeyRow { id: string; name: string; + tokenPrefix: string | null; + scopeConfig: BoardApiKeyScopeConfig | null; + legacyUnrestricted: boolean; + status: BoardApiKeyStatus; createdAt: string; lastUsedAt: string | null; revokedAt: string | null; @@ -154,21 +165,29 @@ export function registerTokenCommands(program: Command): void { .option("--expires-at ", "Expiration timestamp") .option("--ttl-days ", "Expiration in days from now") .option("--never-expires", "Create a non-expiring key") + .option( + "--scope-preset ", + "Scope preset (read_only|company_automation|full_instance_admin)", + ) + .option( + "--scope-company-id ", + "Additional company ID to pin; may be repeated", + collectOptionValue, + [] as string[], + ) + .option( + "--scope-config-json ", + "Custom strict BoardApiKeyScopeConfig JSON (cannot be combined with preset/additional company flags)", + ) .action(async (opts: BoardTokenOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); const expiresAt = resolveBoardKeyExpiresAt(opts); - const preset = BOARD_API_KEY_SCOPE_PRESETS.company_automation; + const scopeConfig = resolveBoardKeyScopeConfig(opts, ctx.companyId as string); const payload = createBoardApiKeySchema.parse({ name: opts.name, expiresAt, - scopeConfig: { - version: 1, - kind: "scoped", - companyIds: [ctx.companyId as string], - permissions: [...preset.permissions], - instanceCapabilities: [...preset.instanceCapabilities], - }, + scopeConfig, }); const key = await ctx.api.post("/api/board-api-keys", payload); if (!key) throw new Error("Failed to create board API key"); @@ -196,6 +215,10 @@ export function registerTokenCommands(program: Command): void { console.log(formatInlineRecord({ id: key.id, name: key.name, + tokenPrefix: key.tokenPrefix, + status: key.status, + legacyUnrestricted: key.legacyUnrestricted, + scope: summarizeBoardKeyScope(key.scopeConfig), createdAt: key.createdAt, lastUsedAt: key.lastUsedAt, expiresAt: key.expiresAt, @@ -241,6 +264,14 @@ async function resolveAgent(api: { get(path: string): Promise }, co } function resolveBoardKeyExpiresAt(opts: BoardTokenOptions): string | null | undefined { + const selectedExpiryOptions = [ + Boolean(opts.neverExpires), + Boolean(opts.expiresAt?.trim()), + Boolean(opts.ttlDays?.trim()), + ].filter(Boolean).length; + if (selectedExpiryOptions > 1) { + throw new Error("Choose only one of --expires-at, --ttl-days, or --never-expires"); + } if (opts.neverExpires) return null; if (opts.expiresAt?.trim()) { const date = new Date(opts.expiresAt.trim()); @@ -252,5 +283,72 @@ function resolveBoardKeyExpiresAt(opts: BoardTokenOptions): string | null | unde if (!Number.isFinite(days) || days <= 0) throw new Error(`Invalid --ttl-days value: ${opts.ttlDays}`); return new Date(Date.now() + Math.floor(days * 24 * 60 * 60 * 1000)).toISOString(); } - return undefined; + return new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); +} + +const BOARD_SCOPE_PRESET_ALIASES: Record = { + read_only: "read_only", + "read-only": "read_only", + company_automation: "company_automation", + "company-automation": "company_automation", + full_instance_admin: "full_instance_admin", + "full-instance-admin": "full_instance_admin", +}; + +function resolveBoardKeyScopeConfig( + opts: BoardTokenOptions, + contextCompanyId: string, +): BoardApiKeyScopeConfig { + const additionalCompanyIds = opts.scopeCompanyId ?? []; + if (opts.scopeConfigJson?.trim()) { + if (opts.scopePreset?.trim() || additionalCompanyIds.length > 0) { + throw new Error("--scope-config-json cannot be combined with --scope-preset or --scope-company-id"); + } + let parsed: unknown; + try { + parsed = JSON.parse(opts.scopeConfigJson); + } catch { + throw new Error("Invalid --scope-config-json value: expected JSON"); + } + const scopeConfig = boardApiKeyScopeConfigSchema.parse(parsed); + if (!scopeConfig.companyIds.includes(contextCompanyId)) { + throw new Error("--scope-config-json must include --company-id in companyIds"); + } + return scopeConfig; + } + + const requestedPreset = opts.scopePreset?.trim() || "company_automation"; + const presetKey = BOARD_SCOPE_PRESET_ALIASES[requestedPreset]; + if (!presetKey) { + throw new Error( + `Invalid --scope-preset value: ${requestedPreset}. Expected read_only, company_automation, or full_instance_admin`, + ); + } + const preset = BOARD_API_KEY_SCOPE_PRESETS[presetKey]; + return boardApiKeyScopeConfigSchema.parse({ + version: 1, + kind: "scoped", + companyIds: [contextCompanyId, ...additionalCompanyIds], + permissions: [...preset.permissions], + instanceCapabilities: [...preset.instanceCapabilities], + }); +} + +function summarizeBoardKeyScope(scopeConfig: BoardApiKeyScopeConfig | null): string { + if (!scopeConfig) return "legacy_unrestricted"; + const matchingPreset = (Object.entries(BOARD_API_KEY_SCOPE_PRESETS) as Array< + [BoardApiKeyScopePreset, (typeof BOARD_API_KEY_SCOPE_PRESETS)[BoardApiKeyScopePreset]] + >).find(([, preset]) => + preset.permissions.length === scopeConfig.permissions.length + && preset.permissions.every((permission, index) => permission === scopeConfig.permissions[index]) + && preset.instanceCapabilities.length === scopeConfig.instanceCapabilities.length + && preset.instanceCapabilities.every( + (capability, index) => capability === scopeConfig.instanceCapabilities[index], + )); + const label = matchingPreset?.[0] ?? "custom"; + return `${label}:${scopeConfig.companyIds.length}_companies`; +} + +function collectOptionValue(value: string, previous: string[]): string[] { + return [...previous, value]; } diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index dbade48b239..2f440e8ac55 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -384,6 +384,27 @@ describe.sequential("cli auth routes", () => { ); }); + it.sequential("rejects omitted and null board API key scopes before creation", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: [COMPANY_ID], + }); + + const omitted = await request(app) + .post("/api/board-api-keys") + .send({ name: "missing-scope" }); + const explicitNull = await request(app) + .post("/api/board-api-keys") + .send({ name: "null-scope", scopeConfig: null }); + + expect(omitted.status).toBe(400); + expect(explicitNull.status).toBe(400); + expect(mockBoardAuthService.createNamedBoardApiKey).not.toHaveBeenCalled(); + }); + it.sequential("lists and revokes named board API keys for the current board user", async () => { const keyId = "55555555-5555-4555-8555-555555555555"; mockBoardAuthService.listBoardApiKeys.mockResolvedValue([ @@ -424,6 +445,13 @@ describe.sequential("cli auth routes", () => { { includeInactive: false }, ); + const inactiveListRes = await request(app).get("/api/board-api-keys?includeInactive=true"); + expect(inactiveListRes.status).toBe(200); + expect(mockBoardAuthService.listBoardApiKeys).toHaveBeenLastCalledWith( + "user-1", + { includeInactive: true }, + ); + const revokeRes = await request(app).delete(`/api/board-api-keys/${keyId}`); expect(revokeRes.status).toBe(200); expect(revokeRes.body).toEqual({ ok: true, keyId }); @@ -436,6 +464,48 @@ describe.sequential("cli auth routes", () => { ); }); + it.sequential("conceals a non-owned board API key during revoke", async () => { + const keyId = "55555555-5555-4555-8555-555555555555"; + mockBoardAuthService.getBoardApiKeyForUser.mockResolvedValue(null); + const app = await createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: [COMPANY_ID], + }); + + const res = await request(app).delete(`/api/board-api-keys/${keyId}`); + + expect(res.status).toBe(404); + expect(mockBoardAuthService.getBoardApiKeyForUser).toHaveBeenCalledWith(keyId, "user-1"); + expect(mockBoardAuthService.revokeBoardApiKey).not.toHaveBeenCalled(); + }); + + it.sequential("treats re-revoking an owned inactive key as idempotent", async () => { + const keyId = "55555555-5555-4555-8555-555555555555"; + mockBoardAuthService.getBoardApiKeyForUser.mockResolvedValue({ + id: keyId, + userId: "user-1", + name: "external-admin", + revokedAt: new Date("2026-08-15T12:00:00.000Z"), + }); + const app = await createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: [COMPANY_ID], + }); + + const res = await request(app).delete(`/api/board-api-keys/${keyId}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true, keyId }); + expect(mockBoardAuthService.revokeBoardApiKey).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + it.sequential("rejects malformed board API key IDs before database lookup", async () => { const app = await createApp({ type: "board", diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index d8c86ef1bc0..31f83e477cf 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2942,27 +2942,34 @@ export function accessRoutes( } const key = await boardAuth.getBoardApiKeyForUser(keyId, req.actor.userId); if (!key) throw notFound("Board API key not found"); - const revoked = await boardAuth.revokeBoardApiKey(key.id); - if (!revoked) throw notFound("Board API key not found"); + const revoked = key.revokedAt ? key : await boardAuth.revokeBoardApiKey(key.id); + if (!revoked) { + // A concurrent owned revoke is still success. Re-read through the same + // owner-scoped projection so deletion and non-ownership stay concealed. + const concurrentlyRevoked = await boardAuth.getBoardApiKeyForUser(keyId, req.actor.userId); + if (!concurrentlyRevoked?.revokedAt) throw notFound("Board API key not found"); + } - const companyIds = await boardAuth.resolveBoardActivityCompanyIds({ - userId: req.actor.userId, - boardApiKeyId: key.id, - }); - for (const companyId of companyIds) { - await logActivity(db, { - companyId, - actorType: "user", - actorId: req.actor.userId, - action: "board_api_key.revoked", - entityType: "user", - entityId: req.actor.userId, - details: { - boardApiKeyId: key.id, - name: key.name, - revokedVia: "board_api_key_lifecycle", - }, + if (!key.revokedAt && revoked) { + const companyIds = await boardAuth.resolveBoardActivityCompanyIds({ + userId: req.actor.userId, + boardApiKeyId: key.id, }); + for (const companyId of companyIds) { + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId, + action: "board_api_key.revoked", + entityType: "user", + entityId: req.actor.userId, + details: { + boardApiKeyId: key.id, + name: key.name, + revokedVia: "board_api_key_lifecycle", + }, + }); + } } res.json({ ok: true, keyId: key.id }); diff --git a/server/src/services/board-auth.test.ts b/server/src/services/board-auth.test.ts index 7a51a6f86c9..7f2917c94df 100644 --- a/server/src/services/board-auth.test.ts +++ b/server/src/services/board-auth.test.ts @@ -1,7 +1,21 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { Db } from "@paperclipai/db"; +import { BOARD_API_KEY_SCOPE_PRESETS } from "@paperclipai/shared"; import { boardAuthService } from "./board-auth.js"; +const COMPANY_ID = "11111111-1111-4111-8111-111111111111"; +const SCOPE_CONFIG = { + version: 1 as const, + kind: "scoped" as const, + companyIds: [COMPANY_ID], + permissions: [...BOARD_API_KEY_SCOPE_PRESETS.company_automation.permissions], + instanceCapabilities: [] as const, +}; + +afterEach(() => { + vi.useRealTimers(); +}); + describe("boardAuthService touchBoardApiKey", () => { it("retries the audit write after a transient failure", async () => { const writes = [ @@ -45,3 +59,90 @@ describe("boardAuthService touchBoardApiKey", () => { expect(update).toHaveBeenCalledTimes(1); }); }); + +describe("boardAuthService board API key lifecycle", () => { + it("stores only a hash, captures a safe prefix, and defaults expiry to 30 days", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-15T12:00:00.000Z")); + let inserted: Record | undefined; + const insert = vi.fn(() => ({ + values: (values: Record) => { + inserted = values; + return { + returning: () => Promise.resolve([{ + id: "22222222-2222-4222-8222-222222222222", + ...values, + legacyUnrestricted: false, + createdAt: new Date("2026-08-15T12:00:00.000Z"), + lastUsedAt: null, + revokedAt: null, + }]), + }; + }, + })); + const service = boardAuthService({ insert } as unknown as Db); + + const created = await service.createNamedBoardApiKey({ + userId: "user-1", + name: "automation", + scopeConfig: SCOPE_CONFIG, + }); + + expect(created.token).toMatch(/^pcp_board_/); + expect(inserted).toMatchObject({ + userId: "user-1", + name: "automation", + tokenPrefix: created.token.slice(0, "pcp_board_".length + 12), + scopeConfig: SCOPE_CONFIG, + expiresAt: new Date("2026-09-14T12:00:00.000Z"), + }); + expect(inserted?.keyHash).not.toBe(created.token); + expect(created.expiresAt).toEqual(new Date("2026-09-14T12:00:00.000Z")); + }); + + it("selects only safe metadata and derives status for list responses", async () => { + const projectionKeys: string[][] = []; + const select = vi.fn((projection: Record) => { + projectionKeys.push(Object.keys(projection)); + return { + from: () => ({ + where: () => ({ + orderBy: () => Promise.resolve([{ + id: "22222222-2222-4222-8222-222222222222", + name: "automation", + tokenPrefix: "pcp_board_123456789abc", + scopeConfig: SCOPE_CONFIG, + legacyUnrestricted: false, + createdAt: new Date("2026-08-15T12:00:00.000Z"), + lastUsedAt: null, + expiresAt: null, + revokedAt: null, + }]), + }), + }), + }; + }); + const service = boardAuthService({ select } as unknown as Db); + + const rows = await service.listBoardApiKeys("user-1", { includeInactive: true }); + + expect(projectionKeys[0]).toEqual([ + "id", + "name", + "tokenPrefix", + "scopeConfig", + "legacyUnrestricted", + "createdAt", + "lastUsedAt", + "revokedAt", + "expiresAt", + ]); + expect(rows[0]).toMatchObject({ + id: "22222222-2222-4222-8222-222222222222", + status: "never_expires", + }); + expect(rows[0]).not.toHaveProperty("userId"); + expect(rows[0]).not.toHaveProperty("keyHash"); + expect(rows[0]).not.toHaveProperty("token"); + }); +});