feat(security): enforce scoped board API keys

Add the revision-4 scope contract, safe migration, fresh owner authority checks, default-deny route inventory, audit controls, and focused regression coverage.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-06 13:12:55 +00:00 committed by cryppadotta
parent c9e3bb7ca4
commit df8ffa3934
30 changed files with 12098 additions and 100 deletions

View File

@ -61,6 +61,7 @@ describe("connect command", () => {
token: "board-login-token",
approvalUrl: `${API_BASE}/cli-auth/challenge-1`,
userId: "user-1",
keyId: "board-login-key-1",
});
vi.spyOn(console, "log").mockImplementation(() => {});
});
@ -106,12 +107,16 @@ describe("connect command", () => {
contextPath,
"--api-base",
API_BASE,
"--company-id",
COMPANY_ID,
"--json",
], { from: "user" });
expect(loginBoardCli).toHaveBeenCalledWith(expect.objectContaining({
apiBase: API_BASE,
requestedAccess: "board",
requestedCompanyId: COMPANY_ID,
scopeConfig: expect.objectContaining({ companyIds: [COMPANY_ID] }),
command: "paperclipai connect",
}));
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", new URL(String(call[0])).pathname])).toEqual([
@ -170,6 +175,8 @@ describe("connect command", () => {
contextPath,
"--api-base",
API_BASE,
"--company-id",
COMPANY_ID,
"--json",
], { from: "user" });

View File

@ -165,8 +165,14 @@ describe("token commands", () => {
expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3100/api/board-api-keys");
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
name: "external-admin",
requestedCompanyId: COMPANY_ID,
expiresAt: "2026-06-06T00:00:00.000Z",
scopeConfig: {
version: 1,
kind: "scoped",
companyIds: [COMPANY_ID],
permissions: expect.any(Array),
instanceCapabilities: [],
},
});
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
key: {
@ -205,8 +211,14 @@ describe("token commands", () => {
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
name: "external-admin",
requestedCompanyId: COMPANY_ID,
expiresAt: null,
scopeConfig: {
version: 1,
kind: "scoped",
companyIds: [COMPANY_ID],
permissions: expect.any(Array),
instanceCapabilities: [],
},
});
});

View File

@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import pc from "picocolors";
import type { BoardApiKeyScopeConfig } from "@paperclipai/shared";
import { buildCliCommandLabel } from "./command-label.js";
import { resolveDefaultCliAuthPath } from "../config/home.js";
@ -10,6 +11,7 @@ type RequestedAccess = "board" | "instance_admin_required";
interface BoardAuthCredential {
apiBase: string;
token: string;
keyId?: string | null;
createdAt: string;
updatedAt: string;
userId?: string | null;
@ -43,6 +45,7 @@ interface ChallengeStatusResponse {
cancelledAt: string | null;
expiresAt: string;
approvedByUser: { id: string; name: string; email: string } | null;
boardApiKeyId: string | null;
}
function defaultBoardAuthStore(): BoardAuthStore {
@ -90,6 +93,7 @@ export function readBoardAuthStore(storePath?: string): BoardAuthStore {
normalized[normalizeApiBase(key)] = {
apiBase,
token,
keyId: toStringOrNull(record.keyId),
createdAt,
updatedAt,
userId: toStringOrNull(record.userId),
@ -116,6 +120,7 @@ export function getStoredBoardCredential(apiBase: string, storePath?: string): B
export function setStoredBoardCredential(input: {
apiBase: string;
token: string;
keyId?: string | null;
userId?: string | null;
storePath?: string;
}): BoardAuthCredential {
@ -126,6 +131,7 @@ export function setStoredBoardCredential(input: {
const credential: BoardAuthCredential = {
apiBase: normalizedApiBase,
token: input.token.trim(),
keyId: input.keyId ?? existing?.keyId ?? null,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
userId: input.userId ?? existing?.userId ?? null,
@ -201,6 +207,7 @@ export async function openUrl(url: string): Promise<boolean> {
export async function loginBoardCli(params: {
apiBase: string;
requestedAccess: RequestedAccess;
scopeConfig: BoardApiKeyScopeConfig;
requestedCompanyId?: string | null;
clientName?: string | null;
command?: string;
@ -208,7 +215,7 @@ export async function loginBoardCli(params: {
print?: boolean;
openBrowser?: boolean;
publicBaseUrl?: string;
}): Promise<{ token: string; approvalUrl: string; userId?: string | null }> {
}): Promise<{ token: string; approvalUrl: string; userId?: string | null; keyId?: string | null }> {
const apiBase = normalizeApiBase(params.apiBase);
const createUrl = `${apiBase}/api/cli-auth/challenges`;
const command = params.command?.trim() || buildCliCommandLabel();
@ -220,6 +227,7 @@ export async function loginBoardCli(params: {
clientName: params.clientName?.trim() || "paperclipai cli",
requestedAccess: params.requestedAccess,
requestedCompanyId: params.requestedCompanyId?.trim() || null,
scopeConfig: params.scopeConfig,
}),
});
@ -253,24 +261,19 @@ export async function loginBoardCli(params: {
);
if (status.status === "approved") {
const me = await requestJson<{ userId: string; user?: { id: string } | null }>(
`${apiBase}/api/cli-auth/me`,
{
headers: {
authorization: `Bearer ${challenge.boardApiToken}`,
},
},
);
const userId = status.approvedByUser?.id ?? null;
setStoredBoardCredential({
apiBase,
token: challenge.boardApiToken,
userId: me.userId ?? me.user?.id ?? null,
keyId: status.boardApiKeyId,
userId,
storePath: params.storePath,
});
return {
token: challenge.boardApiToken,
approvalUrl,
userId: me.userId ?? me.user?.id ?? null,
userId,
keyId: status.boardApiKeyId,
};
}
@ -290,13 +293,13 @@ export async function loginBoardCli(params: {
export async function revokeStoredBoardCredential(params: {
apiBase: string;
token: string;
keyId: string;
}): Promise<void> {
const apiBase = normalizeApiBase(params.apiBase);
await requestJson<{ revoked: boolean }>(`${apiBase}/api/cli-auth/revoke-current`, {
method: "POST",
await requestJson<{ ok: true; keyId: string }>(`${apiBase}/api/board-api-keys/${encodeURIComponent(params.keyId)}`, {
method: "DELETE",
headers: {
authorization: `Bearer ${params.token}`,
},
body: JSON.stringify({}),
});
}

View File

@ -1,4 +1,5 @@
import type { Command } from "commander";
import { BOARD_API_KEY_SCOPE_PRESETS } from "@paperclipai/shared";
import {
getStoredBoardCredential,
loginBoardCli,
@ -36,11 +37,22 @@ export function registerClientAuthCommands(auth: Command): void {
.option("--no-browser", "Don't try to open a browser; just print the approval URL")
.action(async (opts: AuthLoginOptions) => {
try {
const ctx = resolveCommandContext(opts);
const ctx = resolveCommandContext(opts, { requireCompany: true });
const companyId = ctx.companyId as string;
const preset = opts.instanceAdmin
? BOARD_API_KEY_SCOPE_PRESETS.full_instance_admin
: BOARD_API_KEY_SCOPE_PRESETS.company_automation;
const login = await loginBoardCli({
apiBase: ctx.api.apiBase,
requestedAccess: opts.instanceAdmin ? "instance_admin_required" : "board",
requestedCompanyId: ctx.companyId ?? null,
requestedCompanyId: companyId,
scopeConfig: {
version: 1,
kind: "scoped",
companyIds: [companyId],
permissions: [...preset.permissions],
instanceCapabilities: [...preset.instanceCapabilities],
},
command: "paperclipai auth login",
openBrowser: opts.browser,
});
@ -74,9 +86,11 @@ export function registerClientAuthCommands(auth: Command): void {
}
let revoked = false;
try {
if (!credential.keyId) throw new Error("Stored credential predates self-revoke metadata");
await revokeStoredBoardCredential({
apiBase: ctx.api.apiBase,
token: credential.token,
keyId: credential.keyId,
});
revoked = true;
} catch {

View File

@ -1,5 +1,6 @@
import pc from "picocolors";
import type { Command } from "commander";
import { BOARD_API_KEY_SCOPE_PRESETS } from "@paperclipai/shared";
import { getStoredBoardCredential, loginBoardCli } from "../../client/board-auth.js";
import { buildCliCommandLabel } from "../../client/command-label.js";
import { readConfig } from "../../config/store.js";
@ -89,10 +90,25 @@ export function resolveCommandContext(
if (!shouldRecoverBoardAuth(error)) {
return null;
}
if (!companyId) {
throw new Error(
"Board authentication requires a company scope. Pass --company-id, set PAPERCLIP_COMPANY_ID, or configure a companyId in the current context.",
);
}
const preset = requestedAccess === "instance_admin_required"
? BOARD_API_KEY_SCOPE_PRESETS.full_instance_admin
: BOARD_API_KEY_SCOPE_PRESETS.company_automation;
const login = await loginBoardCli({
apiBase,
requestedAccess,
requestedCompanyId: companyId ?? null,
requestedCompanyId: companyId,
scopeConfig: {
version: 1,
kind: "scoped",
companyIds: [companyId],
permissions: [...preset.permissions],
instanceCapabilities: [...preset.instanceCapabilities],
},
command: buildCliCommandLabel(),
});
return login.token;

View File

@ -2,7 +2,11 @@ import { Command } from "commander";
import * as p from "@clack/prompts";
import pc from "picocolors";
import type { Agent, Company } from "@paperclipai/shared";
import { createAgentKeySchema, createBoardApiKeySchema } from "@paperclipai/shared";
import {
BOARD_API_KEY_SCOPE_PRESETS,
createAgentKeySchema,
createBoardApiKeySchema,
} from "@paperclipai/shared";
import { loginBoardCli } from "../../client/board-auth.js";
import { PaperclipApiClient } from "../../client/http.js";
import { resolveProfile, readContext, setCurrentProfile, upsertProfile } from "../../client/context.js";
@ -50,6 +54,7 @@ export function registerConnectCommand(program: Command): void {
handleCommandError(err);
}
}),
{ includeCompany: true },
);
}
@ -73,10 +78,25 @@ async function connectWizard(opts: ConnectOptions) {
console.log(pc.dim(`Checking ${apiBase}/api/health ...`));
await verifyHealth(apiBase);
const scopeCompanyId = opts.companyId?.trim() || resolvedProfile.profile.companyId?.trim();
if (!scopeCompanyId) {
throw new Error(
"Board authentication requires a company scope. Pass --company-id or configure a companyId in the selected profile.",
);
}
const loginPreset = BOARD_API_KEY_SCOPE_PRESETS.company_automation;
const boardLogin = await loginBoardCli({
apiBase,
requestedAccess: "board",
requestedCompanyId: opts.companyId ?? resolvedProfile.profile.companyId ?? null,
requestedCompanyId: scopeCompanyId,
scopeConfig: {
version: 1,
kind: "scoped",
companyIds: [scopeCompanyId],
permissions: [...loginPreset.permissions],
instanceCapabilities: [],
},
command: "paperclipai connect",
});
const boardApi = new PaperclipApiClient({ apiBase, apiKey: boardLogin.token });
@ -87,18 +107,25 @@ async function connectWizard(opts: ConnectOptions) {
const apiKeyEnvVarName = opts.apiKeyEnvVarName?.trim() || "PAPERCLIP_API_KEY";
if (persona === "board") {
const company = await chooseCompany(companies, opts.companyId ?? resolvedProfile.profile.companyId, {
optional: true,
const company = await chooseCompany(companies, scopeCompanyId, {
optional: false,
});
if (!company) throw new Error("Company is required for scoped board profiles");
const tokenName = opts.tokenName?.trim() || `cli-board-${new Date().toISOString()}`;
const key = await boardApi.post<CreatedBoardKey>("/api/board-api-keys", createBoardApiKeySchema.parse({
name: tokenName,
requestedCompanyId: company?.id ?? null,
scopeConfig: {
version: 1,
kind: "scoped",
companyIds: [company.id],
permissions: [...loginPreset.permissions],
instanceCapabilities: [],
},
}));
if (!key) throw new Error("Failed to create board token");
upsertProfile(profileName, {
apiBase,
companyId: company?.id,
companyId: company.id,
persona: "board",
agentId: "",
agentName: "",
@ -114,9 +141,9 @@ async function connectWizard(opts: ConnectOptions) {
profile: profileName,
persona: "board",
apiBase,
companyId: company?.id ?? null,
companyId: company.id,
key: publicKeyResult(key),
exports: buildExports({ apiBase, companyId: company?.id, agentId: undefined, envName: apiKeyEnvVarName, token: key.token }),
exports: buildExports({ apiBase, companyId: company.id, agentId: undefined, envName: apiKeyEnvVarName, token: key.token }),
};
}

View File

@ -1,5 +1,10 @@
import { Command } from "commander";
import { createAgentKeySchema, createBoardApiKeySchema, type Agent } from "@paperclipai/shared";
import {
BOARD_API_KEY_SCOPE_PRESETS,
createAgentKeySchema,
createBoardApiKeySchema,
type Agent,
} from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
@ -144,19 +149,26 @@ export function registerTokenCommands(program: Command): void {
board
.command("create")
.description("Create a named board API key")
.option("-C, --company-id <id>", "Company ID used for audit context")
.requiredOption("-C, --company-id <id>", "Company ID to pin in the key scope")
.option("--name <name>", "API key label", "cli-board")
.option("--expires-at <iso8601>", "Expiration timestamp")
.option("--ttl-days <days>", "Expiration in days from now")
.option("--never-expires", "Create a non-expiring key")
.action(async (opts: BoardTokenOptions) => {
try {
const ctx = resolveCommandContext(opts);
const ctx = resolveCommandContext(opts, { requireCompany: true });
const expiresAt = resolveBoardKeyExpiresAt(opts);
const preset = BOARD_API_KEY_SCOPE_PRESETS.company_automation;
const payload = createBoardApiKeySchema.parse({
name: opts.name,
requestedCompanyId: opts.companyId ?? ctx.companyId ?? null,
expiresAt,
scopeConfig: {
version: 1,
kind: "scoped",
companyIds: [ctx.companyId as string],
permissions: [...preset.permissions],
instanceCapabilities: [...preset.instanceCapabilities],
},
});
const key = await ctx.api.post<CreatedBoardKey>("/api/board-api-keys", payload);
if (!key) throw new Error("Failed to create board API key");
@ -228,17 +240,17 @@ async function resolveAgent(api: { get<T>(path: string): Promise<T | null> }, co
return agent;
}
function resolveBoardKeyExpiresAt(opts: BoardTokenOptions): Date | null | undefined {
function resolveBoardKeyExpiresAt(opts: BoardTokenOptions): string | null | undefined {
if (opts.neverExpires) return null;
if (opts.expiresAt?.trim()) {
const date = new Date(opts.expiresAt.trim());
if (!Number.isFinite(date.getTime())) throw new Error(`Invalid --expires-at value: ${opts.expiresAt}`);
return date;
return date.toISOString();
}
if (opts.ttlDays?.trim()) {
const days = Number(opts.ttlDays);
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));
return new Date(Date.now() + Math.floor(days * 24 * 60 * 60 * 1000)).toISOString();
}
return undefined;
}

View File

@ -0,0 +1,98 @@
import fs from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const MIGRATION_FILE = "0212_cultured_george_stacy.sql";
const cleanups: Array<() => Promise<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()?.();
});
async function migrationStatements() {
const content = await fs.promises.readFile(
new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url),
"utf8",
);
return content
.split("--> statement-breakpoint")
.map((statement) => statement.trim())
.filter(Boolean);
}
describeEmbeddedPostgres("board API key scope migration", () => {
it("marks only null-scope existing rows legacy and enforces scoped creates", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-board-key-scope-");
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
try {
await sql.unsafe(`
DROP TABLE board_api_key_authorization_events CASCADE;
DROP TABLE cli_auth_challenges CASCADE;
DROP TABLE board_api_keys CASCADE;
CREATE TABLE board_api_keys (
id uuid PRIMARY KEY,
scope_config jsonb
);
CREATE TABLE cli_auth_challenges (id uuid PRIMARY KEY);
INSERT INTO board_api_keys (id, scope_config) VALUES
('00000000-0000-4000-8000-000000000001', NULL),
('00000000-0000-4000-8000-000000000002', '{"version":999,"malformed":true}'::jsonb);
`);
for (const statement of await migrationStatements()) {
await sql.unsafe(statement);
}
const rows = await sql.unsafe<Array<{
id: string;
scope_config: unknown;
token_prefix: string | null;
legacy_unrestricted: boolean;
}>>(`
SELECT id, scope_config, token_prefix, legacy_unrestricted
FROM board_api_keys
ORDER BY id
`);
expect(rows).toEqual([
{
id: "00000000-0000-4000-8000-000000000001",
scope_config: null,
token_prefix: null,
legacy_unrestricted: true,
},
{
id: "00000000-0000-4000-8000-000000000002",
scope_config: { version: 999, malformed: true },
token_prefix: null,
legacy_unrestricted: false,
},
]);
await expect(
sql.unsafe(`INSERT INTO board_api_keys (id) VALUES ('00000000-0000-4000-8000-000000000003')`),
).rejects.toThrow();
await expect(
sql.unsafe(`
INSERT INTO board_api_keys (id, scope_config)
VALUES ('00000000-0000-4000-8000-000000000004', '{"version":1}'::jsonb)
`),
).resolves.toBeDefined();
await expect(
sql.unsafe(`
INSERT INTO board_api_keys (id, scope_config, legacy_unrestricted)
VALUES ('00000000-0000-4000-8000-000000000005', '{"version":1}'::jsonb, true)
`),
).rejects.toThrow();
} finally {
await sql.end();
}
}, 20_000);
});

View File

@ -346,13 +346,27 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
VALUES ('user-1', 'User One', 'user@example.com', true, now(), now())
`);
await sql.unsafe(`
INSERT INTO "board_api_keys" ("id", "user_id", "name", "key_hash", "created_at")
VALUES ('00000000-0000-0000-0000-000000000001', 'user-1', 'Key One', 'dup-hash', now())
INSERT INTO "board_api_keys" ("id", "user_id", "name", "key_hash", "scope_config", "created_at")
VALUES (
'00000000-0000-0000-0000-000000000001',
'user-1',
'Key One',
'dup-hash',
'{"version":1,"kind":"scoped","companyIds":["11111111-1111-4111-8111-111111111111"],"permissions":["companies:read"],"instanceCapabilities":[]}'::jsonb,
now()
)
`);
await expect(
sql.unsafe(`
INSERT INTO "board_api_keys" ("id", "user_id", "name", "key_hash", "created_at")
VALUES ('00000000-0000-0000-0000-000000000002', 'user-1', 'Key Two', 'dup-hash', now())
INSERT INTO "board_api_keys" ("id", "user_id", "name", "key_hash", "scope_config", "created_at")
VALUES (
'00000000-0000-0000-0000-000000000002',
'user-1',
'Key Two',
'dup-hash',
'{"version":1,"kind":"scoped","companyIds":["11111111-1111-4111-8111-111111111111"],"permissions":["companies:read"],"instanceCapabilities":[]}'::jsonb,
now()
)
`),
).rejects.toThrow();
} finally {

View File

@ -0,0 +1,54 @@
ALTER TABLE "board_api_keys" ADD COLUMN IF NOT EXISTS "scope_config" jsonb;--> statement-breakpoint
ALTER TABLE "board_api_keys" ADD COLUMN IF NOT EXISTS "token_prefix" text;--> statement-breakpoint
ALTER TABLE "board_api_keys" ADD COLUMN IF NOT EXISTS "legacy_unrestricted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
-- Only rows present before this migration can enter the legacy-unrestricted state.
-- Malformed non-null scope JSON is deliberately preserved so authentication fails closed.
UPDATE "board_api_keys"
SET "legacy_unrestricted" = true
WHERE "scope_config" IS NULL;--> statement-breakpoint
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'board_api_keys_scope_legacy_check'
AND conrelid = 'board_api_keys'::regclass
) THEN
ALTER TABLE "board_api_keys"
ADD CONSTRAINT "board_api_keys_scope_legacy_check"
CHECK (
("legacy_unrestricted" = true AND "scope_config" IS NULL)
OR
("legacy_unrestricted" = false AND "scope_config" IS NOT NULL)
);
END IF;
END $$;--> statement-breakpoint
-- Pending CLI auth challenges created before this migration remain nullable and
-- cannot mint a key; every new challenge must persist a validated scope.
ALTER TABLE "cli_auth_challenges" ADD COLUMN IF NOT EXISTS "requested_scope_config" jsonb;
--> statement-breakpoint
ALTER TABLE "cli_auth_challenges" ADD COLUMN IF NOT EXISTS "pending_key_prefix" text;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "board_api_key_authorization_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"board_api_key_id" uuid NOT NULL,
"owner_user_id" text NOT NULL,
"token_prefix" text,
"action" text NOT NULL,
"classification" text NOT NULL,
"authoritative_company_id" uuid,
"authoritative_resource_type" text,
"authoritative_resource_id" text,
"decision" text NOT NULL,
"reason" text NOT NULL,
"request_id" text,
"run_id" uuid,
"details" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "board_api_key_authorization_events_key_created_idx"
ON "board_api_key_authorization_events" USING btree ("board_api_key_id", "created_at" DESC);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "board_api_key_authorization_events_company_created_idx"
ON "board_api_key_authorization_events" USING btree ("authoritative_company_id", "created_at" DESC);

View File

@ -0,0 +1,37 @@
import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
/**
* Instance-level board-key security decisions. Deliberately has no FK to the
* key or owner: audit history must survive key revocation and owner deletion.
* The details object is written only by the central gate from a fixed allowlist.
*/
export const boardApiKeyAuthorizationEvents = pgTable(
"board_api_key_authorization_events",
{
id: uuid("id").primaryKey().defaultRandom(),
boardApiKeyId: uuid("board_api_key_id").notNull(),
ownerUserId: text("owner_user_id").notNull(),
tokenPrefix: text("token_prefix"),
action: text("action").notNull(),
classification: text("classification").notNull(),
authoritativeCompanyId: uuid("authoritative_company_id"),
authoritativeResourceType: text("authoritative_resource_type"),
authoritativeResourceId: text("authoritative_resource_id"),
decision: text("decision").notNull(),
reason: text("reason").notNull(),
requestId: text("request_id"),
runId: uuid("run_id"),
details: jsonb("details").$type<Record<string, string | boolean | null>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
keyCreatedIdx: index("board_api_key_authorization_events_key_created_idx").on(
table.boardApiKeyId,
table.createdAt.desc(),
),
companyCreatedIdx: index("board_api_key_authorization_events_company_created_idx").on(
table.authoritativeCompanyId,
table.createdAt.desc(),
),
}),
);

View File

@ -1,4 +1,6 @@
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { boolean, check, index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import type { BoardApiKeyScopeConfig } from "@paperclipai/shared";
import { authUsers } from "./auth.js";
export const boardApiKeys = pgTable(
@ -8,6 +10,9 @@ export const boardApiKeys = pgTable(
userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
name: text("name").notNull(),
keyHash: text("key_hash").notNull(),
scopeConfig: jsonb("scope_config").$type<BoardApiKeyScopeConfig | null>(),
tokenPrefix: text("token_prefix"),
legacyUnrestricted: boolean("legacy_unrestricted").notNull().default(false),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }),
@ -16,5 +21,9 @@ export const boardApiKeys = pgTable(
(table) => ({
keyHashIdx: uniqueIndex("board_api_keys_key_hash_idx").on(table.keyHash),
userIdx: index("board_api_keys_user_idx").on(table.userId),
scopeLegacyInvariant: check(
"board_api_keys_scope_legacy_check",
sql`(${table.legacyUnrestricted} = true and ${table.scopeConfig} is null) or (${table.legacyUnrestricted} = false and ${table.scopeConfig} is not null)`,
),
}),
);

View File

@ -1,4 +1,5 @@
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, index, jsonb } from "drizzle-orm/pg-core";
import type { BoardApiKeyScopeConfig } from "@paperclipai/shared";
import { authUsers } from "./auth.js";
import { companies } from "./companies.js";
import { boardApiKeys } from "./board_api_keys.js";
@ -12,7 +13,9 @@ export const cliAuthChallenges = pgTable(
clientName: text("client_name"),
requestedAccess: text("requested_access").notNull().default("board"),
requestedCompanyId: uuid("requested_company_id").references(() => companies.id, { onDelete: "set null" }),
requestedScopeConfig: jsonb("requested_scope_config").$type<BoardApiKeyScopeConfig | null>(),
pendingKeyHash: text("pending_key_hash").notNull(),
pendingKeyPrefix: text("pending_key_prefix"),
pendingKeyName: text("pending_key_name").notNull(),
approvedByUserId: text("approved_by_user_id").references(() => authUsers.id, { onDelete: "set null" }),
boardApiKeyId: uuid("board_api_key_id").references(() => boardApiKeys.id, { onDelete: "set null" }),

View File

@ -10,6 +10,7 @@ export { agents } from "./agents.js";
export { builtInManagedResources } from "./built_in_managed_resources.js";
export { agentMemberships } from "./agent_memberships.js";
export { boardApiKeys } from "./board_api_keys.js";
export { boardApiKeyAuthorizationEvents } from "./board_api_key_authorization_events.js";
export { cliAuthChallenges } from "./cli_auth_challenges.js";
export { companyMemberships } from "./company_memberships.js";
export { companyUserSidebarPreferences } from "./company_user_sidebar_preferences.js";

View File

@ -0,0 +1,215 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`frozen board API key vocabulary and presets > freezes all three ordered preset expansions 1`] = `
{
"company_automation": {
"instanceCapabilities": [],
"name": "Company automation",
"permissions": [
"companies:read",
"agents:read",
"projects:read",
"issues:read",
"goals:read",
"routines:read",
"approvals:read",
"costs:read",
"activity:read",
"artifacts:read",
"workspaces:read",
"skills:read",
"tools:read",
"members:read",
"decisions:read",
"settings:read",
"environments:read",
"pipelines:read",
"search:read",
"runtime:read",
"audit:read",
"companies:write",
"agents:write",
"agents:operate",
"projects:write",
"issues:write",
"issues:control",
"goals:write",
"routines:write",
"routines:run",
"approvals:write",
"costs:write",
"artifacts:write",
"workspaces:manage",
"skills:manage",
"tools:manage",
"secrets:read_metadata",
"secrets:manage",
"decisions:write",
"settings:write",
"environments:manage",
"pipelines:write",
"runtime:manage",
"board_api_keys:revoke_self",
],
},
"full_instance_admin": {
"helperText": "Grants instance-global actions; company data still requires pinned companies.",
"instanceCapabilities": [
"instance_admin",
],
"name": "Full instance admin",
"permissions": [
"companies:read",
"companies:write",
"agents:read",
"agents:write",
"agents:operate",
"projects:read",
"projects:write",
"issues:read",
"issues:write",
"issues:control",
"goals:read",
"goals:write",
"routines:read",
"routines:write",
"routines:run",
"approvals:read",
"approvals:write",
"approvals:decide",
"costs:read",
"costs:write",
"activity:read",
"artifacts:read",
"artifacts:write",
"workspaces:read",
"workspaces:manage",
"skills:read",
"skills:manage",
"tools:read",
"tools:manage",
"secrets:read_metadata",
"secrets:manage",
"members:read",
"members:manage",
"decisions:read",
"decisions:write",
"settings:read",
"settings:write",
"environments:read",
"environments:manage",
"pipelines:read",
"pipelines:write",
"search:read",
"runtime:read",
"runtime:manage",
"audit:read",
"instance:read",
"instance:manage",
"companies:create",
"companies:import_export",
"plugins:read",
"plugins:manage",
"adapters:read",
"adapters:manage",
"users:read",
"users:manage",
"catalogs:read",
"catalogs:manage",
"backups:create",
"board_api_keys:revoke_self",
],
},
"read_only": {
"instanceCapabilities": [],
"name": "Read-only (company-pinned)",
"permissions": [
"companies:read",
"agents:read",
"projects:read",
"issues:read",
"goals:read",
"routines:read",
"approvals:read",
"costs:read",
"activity:read",
"artifacts:read",
"workspaces:read",
"skills:read",
"tools:read",
"members:read",
"decisions:read",
"settings:read",
"environments:read",
"pipelines:read",
"search:read",
"runtime:read",
"audit:read",
],
},
}
`;
exports[`frozen board API key vocabulary and presets > freezes the canonical 59 permission keys in numeric order 1`] = `
[
"companies:read",
"companies:write",
"agents:read",
"agents:write",
"agents:operate",
"projects:read",
"projects:write",
"issues:read",
"issues:write",
"issues:control",
"goals:read",
"goals:write",
"routines:read",
"routines:write",
"routines:run",
"approvals:read",
"approvals:write",
"approvals:decide",
"costs:read",
"costs:write",
"activity:read",
"artifacts:read",
"artifacts:write",
"workspaces:read",
"workspaces:manage",
"skills:read",
"skills:manage",
"tools:read",
"tools:manage",
"secrets:read_metadata",
"secrets:manage",
"members:read",
"members:manage",
"decisions:read",
"decisions:write",
"settings:read",
"settings:write",
"environments:read",
"environments:manage",
"pipelines:read",
"pipelines:write",
"search:read",
"runtime:read",
"runtime:manage",
"audit:read",
"instance:read",
"instance:manage",
"companies:create",
"companies:import_export",
"plugins:read",
"plugins:manage",
"adapters:read",
"adapters:manage",
"users:read",
"users:manage",
"catalogs:read",
"catalogs:manage",
"backups:create",
"board_api_keys:revoke_self",
]
`;

View File

@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
BOARD_API_KEY_PERMISSION_KEYS,
BOARD_API_KEY_SCOPE_PRESETS,
boardApiKeyScopeConfigSchema,
deriveBoardApiKeyStatus,
} from "./board-api-key-scope.js";
import { createBoardApiKeySchema } from "./validators/access.js";
const COMPANY_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
function validScope() {
return {
version: 1 as const,
kind: "scoped" as const,
companyIds: [COMPANY_ID],
permissions: ["companies:read" as const],
instanceCapabilities: [] as const,
};
}
describe("boardApiKeyScopeConfigSchema", () => {
it("round trips the strict version-1 shape without normalization", () => {
const input = validScope();
expect(boardApiKeyScopeConfigSchema.parse(input)).toEqual(input);
});
it.each([
["unknown object key", { ...validScope(), extra: true }],
["unsupported version", { ...validScope(), version: 2 }],
["unsupported kind", { ...validScope(), kind: "legacy" }],
["empty companies", { ...validScope(), companyIds: [] }],
["duplicate companies", { ...validScope(), companyIds: [COMPANY_ID, COMPANY_ID] }],
["noncanonical company UUID", { ...validScope(), companyIds: [COMPANY_ID.toUpperCase()] }],
["empty permissions", { ...validScope(), permissions: [] }],
["duplicate permissions", { ...validScope(), permissions: ["companies:read", "companies:read"] }],
["unknown permission", { ...validScope(), permissions: ["companies:admin"] }],
["unsupported capability", { ...validScope(), instanceCapabilities: ["root"] }],
["duplicate capability", { ...validScope(), instanceCapabilities: ["instance_admin", "instance_admin"] }],
["coerced version", { ...validScope(), version: "1" }],
["trimmed permission", { ...validScope(), permissions: [" companies:read"] }],
])("rejects %s", (_label, input) => {
expect(boardApiKeyScopeConfigSchema.safeParse(input).success).toBe(false);
});
it("enforces the company and permission cardinality bounds", () => {
const tooManyCompanies = Array.from(
{ length: 51 },
(_, index) => `00000000-0000-4000-8000-${index.toString().padStart(12, "0")}`,
);
expect(boardApiKeyScopeConfigSchema.safeParse({ ...validScope(), companyIds: tooManyCompanies }).success).toBe(false);
expect(BOARD_API_KEY_PERMISSION_KEYS).toHaveLength(59);
expect(boardApiKeyScopeConfigSchema.parse({
...validScope(),
permissions: [...BOARD_API_KEY_PERMISSION_KEYS],
}).permissions).toHaveLength(59);
});
});
describe("frozen board API key vocabulary and presets", () => {
it("freezes the canonical 59 permission keys in numeric order", () => {
expect(BOARD_API_KEY_PERMISSION_KEYS).toMatchSnapshot();
});
it("freezes all three ordered preset expansions", () => {
expect(BOARD_API_KEY_SCOPE_PRESETS).toMatchSnapshot();
});
});
describe("createBoardApiKeySchema", () => {
it("requires an explicit strict non-null scope", () => {
const valid = {
name: "automation",
expiresAt: "2026-09-05T12:00:00.000Z",
scopeConfig: validScope(),
};
expect(createBoardApiKeySchema.parse(valid)).toEqual(valid);
expect(createBoardApiKeySchema.safeParse({ name: "automation" }).success).toBe(false);
expect(createBoardApiKeySchema.safeParse({ ...valid, scopeConfig: null }).success).toBe(false);
});
it.each([
["trimmed name", { name: " automation", scopeConfig: validScope() }],
["blank name", { name: " ", scopeConfig: validScope() }],
["coerced expiry", { name: "automation", expiresAt: new Date(), scopeConfig: validScope() }],
["unknown key", { name: "automation", requestedCompanyId: COMPANY_ID, scopeConfig: validScope() }],
])("rejects %s", (_label, input) => {
expect(createBoardApiKeySchema.safeParse(input).success).toBe(false);
});
});
describe("deriveBoardApiKeyStatus", () => {
const now = new Date("2026-08-06T12:00:00.000Z");
it("derives revoked, expired, finite, and never-expiring states", () => {
expect(deriveBoardApiKeyStatus({ revokedAt: now, expiresAt: null }, now)).toBe("revoked");
expect(deriveBoardApiKeyStatus({ revokedAt: null, expiresAt: now }, now)).toBe("expired");
expect(deriveBoardApiKeyStatus({ revokedAt: null, expiresAt: "2026-08-07T12:00:00.000Z" }, now)).toBe("expires");
expect(deriveBoardApiKeyStatus({ revokedAt: null, expiresAt: null }, now)).toBe("never_expires");
});
});

View File

@ -0,0 +1,213 @@
import { z } from "zod";
/**
* Frozen by the PAP-16479 revision-3 security contract. Changes to this list
* or to a preset expansion require a new SecurityEngineer-reviewed revision.
*/
export const BOARD_API_KEY_PERMISSION_KEYS = [
"companies:read",
"companies:write",
"agents:read",
"agents:write",
"agents:operate",
"projects:read",
"projects:write",
"issues:read",
"issues:write",
"issues:control",
"goals:read",
"goals:write",
"routines:read",
"routines:write",
"routines:run",
"approvals:read",
"approvals:write",
"approvals:decide",
"costs:read",
"costs:write",
"activity:read",
"artifacts:read",
"artifacts:write",
"workspaces:read",
"workspaces:manage",
"skills:read",
"skills:manage",
"tools:read",
"tools:manage",
"secrets:read_metadata",
"secrets:manage",
"members:read",
"members:manage",
"decisions:read",
"decisions:write",
"settings:read",
"settings:write",
"environments:read",
"environments:manage",
"pipelines:read",
"pipelines:write",
"search:read",
"runtime:read",
"runtime:manage",
"audit:read",
"instance:read",
"instance:manage",
"companies:create",
"companies:import_export",
"plugins:read",
"plugins:manage",
"adapters:read",
"adapters:manage",
"users:read",
"users:manage",
"catalogs:read",
"catalogs:manage",
"backups:create",
"board_api_keys:revoke_self",
] as const;
export type BoardPermissionKey = (typeof BOARD_API_KEY_PERMISSION_KEYS)[number];
export const BOARD_API_KEY_INSTANCE_CAPABILITIES = ["instance_admin"] as const;
export type BoardApiKeyInstanceCapability = (typeof BOARD_API_KEY_INSTANCE_CAPABILITIES)[number];
const canonicalLowercaseUuidSchema = z
.string()
.uuid()
.refine((value) => value === value.toLowerCase(), "Company IDs must use canonical lowercase UUID text");
function uniqueArray<T extends z.ZodTypeAny>(item: T, min: number, max: number) {
return z
.array(item)
.min(min)
.max(max)
.superRefine((values, ctx) => {
const seen = new Set<unknown>();
values.forEach((value, index) => {
if (seen.has(value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Duplicate values are not allowed",
path: [index],
});
}
seen.add(value);
});
});
}
/** Strict non-null scope accepted for every post-migration key creation. */
export const boardApiKeyScopeConfigSchema = z
.object({
version: z.literal(1),
kind: z.literal("scoped"),
companyIds: uniqueArray(canonicalLowercaseUuidSchema, 1, 50),
permissions: uniqueArray(z.enum(BOARD_API_KEY_PERMISSION_KEYS), 1, 59),
instanceCapabilities: uniqueArray(z.enum(BOARD_API_KEY_INSTANCE_CAPABILITIES), 0, 1),
})
.strict();
export type BoardApiKeyScopeConfig = z.infer<typeof boardApiKeyScopeConfigSchema>;
/** Stored values may be null only when the row is explicitly legacy-unrestricted. */
export const storedBoardApiKeyScopeConfigSchema = boardApiKeyScopeConfigSchema.nullable();
const READ_ONLY_PERMISSIONS = [
"companies:read",
"agents:read",
"projects:read",
"issues:read",
"goals:read",
"routines:read",
"approvals:read",
"costs:read",
"activity:read",
"artifacts:read",
"workspaces:read",
"skills:read",
"tools:read",
"members:read",
"decisions:read",
"settings:read",
"environments:read",
"pipelines:read",
"search:read",
"runtime:read",
"audit:read",
] as const satisfies readonly BoardPermissionKey[];
const COMPANY_AUTOMATION_PERMISSIONS = [
...READ_ONLY_PERMISSIONS,
"companies:write",
"agents:write",
"agents:operate",
"projects:write",
"issues:write",
"issues:control",
"goals:write",
"routines:write",
"routines:run",
"approvals:write",
"costs:write",
"artifacts:write",
"workspaces:manage",
"skills:manage",
"tools:manage",
"secrets:read_metadata",
"secrets:manage",
"decisions:write",
"settings:write",
"environments:manage",
"pipelines:write",
"runtime:manage",
"board_api_keys:revoke_self",
] as const satisfies readonly BoardPermissionKey[];
export const BOARD_API_KEY_SCOPE_PRESETS = {
read_only: {
name: "Read-only (company-pinned)",
permissions: READ_ONLY_PERMISSIONS,
instanceCapabilities: [] as const,
},
company_automation: {
name: "Company automation",
permissions: COMPANY_AUTOMATION_PERMISSIONS,
instanceCapabilities: [] as const,
},
full_instance_admin: {
name: "Full instance admin",
permissions: BOARD_API_KEY_PERMISSION_KEYS,
instanceCapabilities: ["instance_admin"] as const,
helperText: "Grants instance-global actions; company data still requires pinned companies.",
},
} as const;
export type BoardApiKeyScopePreset = keyof typeof BOARD_API_KEY_SCOPE_PRESETS;
export type BoardApiKeyStatus = "active" | "expires" | "never_expires" | "expired" | "revoked";
export function deriveBoardApiKeyStatus(
input: { revokedAt: Date | string | null; expiresAt: Date | string | null },
now: Date = new Date(),
): BoardApiKeyStatus {
if (input.revokedAt) return "revoked";
if (!input.expiresAt) return "never_expires";
return new Date(input.expiresAt).getTime() <= now.getTime() ? "expired" : "expires";
}
export interface BoardApiKeyResponse {
id: string;
name: string;
tokenPrefix: string | null;
scopeConfig: BoardApiKeyScopeConfig | null;
legacyUnrestricted: boolean;
status: BoardApiKeyStatus;
createdAt: string;
lastUsedAt: string | null;
expiresAt: string | null;
revokedAt: string | null;
}
export interface CreatedBoardApiKeyResponse extends BoardApiKeyResponse {
token: string;
}

View File

@ -39,6 +39,22 @@ export {
type NativeRuntimeMode,
type NativeRunTerminalState,
} from "./types/native-finalization.js";
export {
BOARD_API_KEY_PERMISSION_KEYS,
BOARD_API_KEY_INSTANCE_CAPABILITIES,
BOARD_API_KEY_SCOPE_PRESETS,
boardApiKeyScopeConfigSchema,
storedBoardApiKeyScopeConfigSchema,
deriveBoardApiKeyStatus,
type BoardPermissionKey,
type BoardApiKeyInstanceCapability,
type BoardApiKeyScopeConfig,
type BoardApiKeyScopePreset,
type BoardApiKeyStatus,
type BoardApiKeyResponse,
type CreatedBoardApiKeyResponse,
} from "./board-api-key-scope.js";
export {
decisionEffectStalenessSchema,
decisionOptionStyleSchema,

View File

@ -8,6 +8,7 @@ import {
PERMISSION_KEYS,
} from "../constants.js";
import { optionalAgentAdapterTypeSchema } from "../adapter-type.js";
import { boardApiKeyScopeConfigSchema } from "../board-api-key-scope.js";
export const createCompanyInviteSchema = z.object({
allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"),
@ -75,7 +76,8 @@ export const createCliAuthChallengeSchema = z.object({
clientName: z.string().max(120).optional().nullable(),
requestedAccess: boardCliAuthAccessLevelSchema.default("board"),
requestedCompanyId: z.string().guid().optional().nullable(),
});
scopeConfig: boardApiKeyScopeConfigSchema,
}).strict();
export type CreateCliAuthChallenge = z.infer<typeof createCliAuthChallengeSchema>;
@ -85,11 +87,17 @@ export const resolveCliAuthChallengeSchema = z.object({
export type ResolveCliAuthChallenge = z.infer<typeof resolveCliAuthChallengeSchema>;
export const createBoardApiKeySchema = z.object({
name: z.string().trim().min(1).max(120).default("paperclipai cli"),
expiresAt: z.coerce.date().optional().nullable(),
requestedCompanyId: z.string().guid().optional().nullable(),
});
export const createBoardApiKeySchema = z
.object({
name: z
.string()
.min(1)
.max(120)
.refine((value) => value === value.trim() && value.trim().length > 0, "Name must not have surrounding whitespace"),
expiresAt: z.string().datetime({ offset: true }).optional().nullable(),
scopeConfig: boardApiKeyScopeConfigSchema,
})
.strict();
export type CreateBoardApiKey = z.infer<typeof createBoardApiKeySchema>;

View File

@ -0,0 +1,194 @@
import { createHash, randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
authUsers,
boardApiKeyAuthorizationEvents,
boardApiKeys,
companyMemberships,
instanceUserRoles,
} from "@paperclipai/db";
import { BOARD_API_KEY_SCOPE_PRESETS } from "@paperclipai/shared";
import {
actorMiddleware,
resetBoardKeyAuthFailureRateLimitForTests,
} from "../middleware/auth.js";
import { errorHandler } from "../middleware/error-handler.js";
const TOKEN = "pcp_board_middleware_valid_token";
function createDbState() {
const companyId = randomUUID();
const ownerId = randomUUID();
const keyId = randomUUID();
const state = {
keyExists: true,
ownerExists: true,
membershipActive: true,
malformedScope: false,
revoked: false,
revokeOnTouch: false,
lastUsedAt: null as Date | null,
};
const audits: Array<Record<string, unknown>> = [];
const key = () => ({
id: keyId,
userId: ownerId,
name: "middleware",
keyHash: createHash("sha256").update(TOKEN).digest("hex"),
tokenPrefix: "pcp_board_middle",
scopeConfig: state.malformedScope
? { version: 1, kind: "scoped", companyIds: [companyId], permissions: ["not:a:permission"], instanceCapabilities: [] }
: {
version: 1,
kind: "scoped",
companyIds: [companyId],
permissions: [...BOARD_API_KEY_SCOPE_PRESETS.read_only.permissions],
instanceCapabilities: [],
},
legacyUnrestricted: false,
createdAt: new Date(),
lastUsedAt: state.lastUsedAt,
expiresAt: null,
revokedAt: state.revoked ? new Date() : null,
});
const db = {
select: vi.fn(() => ({
from(table: unknown) {
const rows = table === boardApiKeys
? (state.keyExists ? [key()] : [])
: table === authUsers
? (state.ownerExists ? [{ id: ownerId, name: "Owner", email: "owner@example.com" }] : [])
: table === companyMemberships
? (state.membershipActive ? [{ companyId, membershipRole: "owner", status: "active" }] : [])
: table === instanceUserRoles
? []
: [];
return {
where: () => Promise.resolve(rows),
then: (resolve: (value: unknown[]) => unknown) => Promise.resolve(rows).then(resolve),
};
},
})),
update: vi.fn(() => ({
set(values: { lastUsedAt?: Date }) {
return {
where: () => ({
returning: async () => {
if (state.revoked || state.revokeOnTouch) {
state.revoked = state.revoked || state.revokeOnTouch;
return [];
}
state.lastUsedAt = values.lastUsedAt ?? state.lastUsedAt;
return [{ id: keyId }];
},
}),
};
},
})),
insert: vi.fn((table: unknown) => ({
values: async (values: Record<string, unknown>) => {
if (table === boardApiKeyAuthorizationEvents) audits.push(values);
return [];
},
})),
} as any;
return { db, state, audits, companyId, keyId, ownerId };
}
function createApp(db: any, resolveSession = vi.fn(async () => null)) {
const app = express();
app.use(actorMiddleware(db, { deploymentMode: "authenticated", resolveSession }));
app.get("/actor", (req, res) => res.json(req.actor));
app.use(errorHandler);
return { app, resolveSession };
}
describe("board-key authentication middleware", () => {
beforeEach(() => resetBoardKeyAuthFailureRateLimitForTests());
it("builds the board-key principal from a fresh key and live owner authority read", async () => {
const { db, companyId, keyId, ownerId } = createDbState();
const { app } = createApp(db);
const response = await request(app).get("/actor").set("Authorization", `Bearer ${TOKEN}`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
type: "board",
source: "board_key",
keyId,
boardKeyOwnerId: ownerId,
boardKeyPrefix: "pcp_board_middle",
companyIds: [companyId],
});
expect(response.body).not.toHaveProperty("token");
});
it("never falls back to cookies for an invalid Bearer and returns a generic 401 for malformed stored scope", async () => {
const invalid = createDbState();
invalid.state.keyExists = false;
const invalidApp = createApp(invalid.db, vi.fn(async () => ({
session: { id: "session" },
user: { id: randomUUID() },
} as any)));
const invalidResponse = await request(invalidApp.app)
.get("/actor")
.set("Authorization", "Bearer pcp_board_unknown");
expect(invalidResponse.status).toBe(401);
expect(invalidResponse.body).toEqual({ error: "Unauthorized" });
expect(invalidApp.resolveSession).not.toHaveBeenCalled();
const malformed = createDbState();
malformed.state.malformedScope = true;
const malformedResponse = await request(createApp(malformed.db).app)
.get("/actor")
.set("Authorization", `Bearer ${TOKEN}`);
expect(malformedResponse.status).toBe(401);
expect(malformedResponse.body).toEqual({ error: "Unauthorized" });
expect(JSON.stringify(malformed.audits)).not.toContain(TOKEN);
expect(JSON.stringify(malformed.audits)).not.toContain("authorization");
});
it("rate-limits repeated failures without logging secret material", async () => {
const { db, state, audits } = createDbState();
state.keyExists = false;
const { app } = createApp(db);
const statuses: number[] = [];
for (let index = 0; index < 11; index += 1) {
const response = await request(app)
.get("/actor")
.set("Authorization", `Bearer ${TOKEN}bad`);
statuses.push(response.status);
}
expect(statuses.slice(0, 10)).toEqual(Array(10).fill(401));
expect(statuses[10]).toBe(429);
expect(JSON.stringify(audits)).not.toContain(`${TOKEN}bad`);
});
it("does not resurrect last-used state when revocation wins the authentication race", async () => {
const { db, state } = createDbState();
state.revokeOnTouch = true;
const response = await request(createApp(db).app)
.get("/actor")
.set("Authorization", `Bearer ${TOKEN}`);
expect(response.status).toBe(401);
expect(state.revoked).toBe(true);
expect(state.lastUsedAt).toBeNull();
});
it("reflects membership removal on the next request without a positive auth cache", async () => {
const { db, state, companyId } = createDbState();
const { app } = createApp(db);
const first = await request(app).get("/actor").set("Authorization", `Bearer ${TOKEN}`);
expect(first.body.companyIds).toEqual([companyId]);
state.membershipActive = false;
const second = await request(app).get("/actor").set("Authorization", `Bearer ${TOKEN}`);
expect(second.status).toBe(200);
expect(second.body.companyIds).toEqual([]);
});
});

View File

@ -1,6 +1,16 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BOARD_API_KEY_SCOPE_PRESETS } from "@paperclipai/shared";
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,
};
const mockAccessService = vi.hoisted(() => ({
isInstanceAdmin: vi.fn(),
@ -118,6 +128,8 @@ describe.sequential("cli auth routes", () => {
command: "paperclipai company import",
clientName: "paperclipai cli",
requestedAccess: "board",
requestedCompanyId: COMPANY_ID,
scopeConfig: SCOPE_CONFIG,
});
expect(res.status, res.text || JSON.stringify(res.body)).toBe(201);
@ -187,6 +199,7 @@ describe.sequential("cli auth routes", () => {
cancelledAt: null,
expiresAt: "2026-03-23T13:00:00.000Z",
approvedByUser: null,
boardApiKeyId: null,
});
const app = await createApp({ type: "none", source: "none" });
@ -315,13 +328,14 @@ describe.sequential("cli auth routes", () => {
lastUsedAt: null,
revokedAt: null,
expiresAt: new Date("2026-06-23T12:00:00.000Z"),
scopeConfig: SCOPE_CONFIG,
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["11111111-1111-4111-8111-111111111111"]);
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue([COMPANY_ID]);
const app = await createApp({
type: "board",
userId: "user-1",
source: "board_key",
source: "session",
isInstanceAdmin: false,
companyIds: ["11111111-1111-4111-8111-111111111111"],
});
@ -329,8 +343,8 @@ describe.sequential("cli auth routes", () => {
.post("/api/board-api-keys")
.send({
name: "external-admin",
requestedCompanyId: "11111111-1111-4111-8111-111111111111",
expiresAt: "2026-06-23T12:00:00.000Z",
scopeConfig: SCOPE_CONFIG,
});
expect(res.status, res.text || JSON.stringify(res.body)).toBe(201);
@ -344,6 +358,7 @@ describe.sequential("cli auth routes", () => {
userId: "user-1",
name: "external-admin",
expiresAt: new Date("2026-06-23T12:00:00.000Z"),
scopeConfig: SCOPE_CONFIG,
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
@ -382,7 +397,7 @@ describe.sequential("cli auth routes", () => {
const app = await createApp({
type: "board",
userId: "user-1",
source: "board_key",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});

View File

@ -173,6 +173,7 @@ import { COMPANY_IMPORT_API_PATH } from "./routes/company-import-paths.js";
import { apiCompression } from "./middleware/api-compression.js";
import { chatWebhookBodyParser } from "./middleware/chat-webhook-body.js";
import { createChatWebhookDiagnostics } from "./services/chat-webhook-diagnostics.js";
import { boardKeyAuthorizationMiddleware } from "./security/board-key-route-registry.js";
type UiMode = "none" | "static" | "vite-dev";
const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000;
@ -563,6 +564,7 @@ export async function createApp(
// REPLACES whatever actor the request otherwise resolved to, and only on
// the one endpoint it authorizes (see the middleware for the contract).
app.use(cloudControlMiddleware());
app.use(boardKeyAuthorizationMiddleware(db));
app.use("/api/auth", authRoutes(db));
if (opts.betterAuthHandler) {
app.all("/api/auth/{*authPath}", opts.betterAuthHandler);

View File

@ -7,6 +7,7 @@ import {
agentApiKeys,
agents,
authUsers,
boardApiKeyAuthorizationEvents,
companies,
companyMemberships,
heartbeatRuns,
@ -55,7 +56,7 @@ function pruneCloudTenantWriteDebounce(
}
import { instanceSettingsService } from "../services/instance-settings.js";
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
import { forbidden, unauthorized, unprocessable } from "../errors.js";
import { forbidden, tooManyRequests, unauthorized, unprocessable } from "../errors.js";
export { isCloudManagedInstance } from "../services/cloud-instance.js";
@ -63,6 +64,56 @@ function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
const BOARD_KEY_AUTH_FAILURE_WINDOW_MS = 60_000;
const BOARD_KEY_AUTH_FAILURE_LIMIT = 10;
const boardKeyAuthFailures = new Map<string, { count: number; resetAt: number }>();
function recordBoardKeyAuthFailure(token: string, now = Date.now()) {
const identity = hashToken(token);
const existing = boardKeyAuthFailures.get(identity);
const entry = !existing || existing.resetAt <= now
? { count: 1, resetAt: now + BOARD_KEY_AUTH_FAILURE_WINDOW_MS }
: { count: existing.count + 1, resetAt: existing.resetAt };
boardKeyAuthFailures.set(identity, entry);
if (boardKeyAuthFailures.size > 10_000) {
for (const [key, value] of boardKeyAuthFailures) {
if (value.resetAt <= now) boardKeyAuthFailures.delete(key);
}
}
return entry.count > BOARD_KEY_AUTH_FAILURE_LIMIT;
}
export function resetBoardKeyAuthFailureRateLimitForTests() {
boardKeyAuthFailures.clear();
}
async function auditBoardKeyAuthenticationFailure(
db: Db,
req: Request,
input: {
key: { id: string; userId: string; tokenPrefix: string | null } | null;
reason: string;
},
) {
if (!input.key) return;
try {
await db.insert(boardApiKeyAuthorizationEvents).values({
boardApiKeyId: input.key.id,
ownerUserId: input.key.userId,
tokenPrefix: input.key.tokenPrefix,
action: "authenticate",
classification: "authentication",
decision: "deny",
reason: input.reason,
requestId: typeof req.id === "string" ? req.id : null,
runId: isUuidLike(req.header("x-paperclip-run-id")) ? req.header("x-paperclip-run-id") : null,
details: {},
});
} catch (err) {
logger.warn({ err, boardApiKeyId: input.key.id }, "Failed to audit denied board-key authentication");
}
}
function normalizeOptionalString(value: string | null | undefined) {
return value?.trim() || null;
}
@ -304,26 +355,35 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
return;
}
const boardKey = await boardAuth.findBoardApiKeyByToken(token);
if (boardKey) {
const access = await boardAuth.resolveBoardAccess(boardKey.userId);
if (access.user) {
await boardAuth.touchBoardApiKey(boardKey.id);
req.actor = {
type: "board",
userId: boardKey.userId,
userName: access.user?.name ?? null,
userEmail: access.user?.email ?? null,
companyIds: access.companyIds,
memberships: access.memberships,
isInstanceAdmin: access.isInstanceAdmin,
keyId: boardKey.id,
runId: runIdHeader || undefined,
source: "board_key",
};
next();
if (token.startsWith("pcp_board_")) {
const authentication = await boardAuth.authenticateBoardApiKey(token);
if (!authentication.ok) {
await auditBoardKeyAuthenticationFailure(db, req, authentication);
next(recordBoardKeyAuthFailure(token) ? tooManyRequests("Too many authentication failures") : unauthorized());
return;
}
const { key: boardKey, access, scopeConfig } = authentication;
const effectiveCompanyIds = scopeConfig
? scopeConfig.companyIds.filter((companyId) => access.companyIds.includes(companyId))
: access.companyIds;
req.actor = {
type: "board",
userId: boardKey.userId,
userName: access.user.name ?? null,
userEmail: access.user.email ?? null,
companyIds: effectiveCompanyIds,
memberships: access.memberships.filter((membership) => effectiveCompanyIds.includes(membership.companyId)),
isInstanceAdmin: access.isInstanceAdmin,
keyId: boardKey.id,
boardKeyOwnerId: boardKey.userId,
boardKeyScope: scopeConfig,
boardKeyPrefix: boardKey.tokenPrefix,
boardKeyLegacyUnrestricted: boardKey.legacyUnrestricted,
runId: runIdHeader || undefined,
source: "board_key",
};
next();
return;
}
const tokenHash = hashToken(token);

View File

@ -2912,21 +2912,25 @@ export function accessRoutes(
if (req.actor.type !== "board" || !req.actor.userId) {
throw unauthorized("Board authentication required");
}
if (req.actor.source === "board_key") {
throw forbidden("Board API keys cannot create board API keys");
}
if (req.body.requestedCompanyId) {
assertCompanyAccess(req, req.body.requestedCompanyId);
for (const companyId of req.body.scopeConfig.companyIds) {
assertCompanyAccess(req, companyId);
}
const key = await boardAuth.createNamedBoardApiKey({
userId: req.actor.userId,
name: req.body.name,
expiresAt: req.body.expiresAt === undefined ? undefined : req.body.expiresAt,
});
const companyIds = await boardAuth.resolveBoardActivityCompanyIds({
userId: req.actor.userId,
requestedCompanyId: req.body.requestedCompanyId ?? null,
boardApiKeyId: key.id,
expiresAt: req.body.expiresAt === undefined
? undefined
: req.body.expiresAt === null
? null
: new Date(req.body.expiresAt),
scopeConfig: req.body.scopeConfig,
});
const companyIds = key.scopeConfig.companyIds;
for (const companyId of companyIds) {
await logActivity(db, {
companyId,
@ -2938,7 +2942,9 @@ export function accessRoutes(
details: {
boardApiKeyId: key.id,
name: key.name,
requestedCompanyId: req.body.requestedCompanyId ?? null,
scopeCompanyIds: key.scopeConfig.companyIds,
permissions: key.scopeConfig.permissions,
instanceCapabilities: key.scopeConfig.instanceCapabilities,
expiresAt: key.expiresAt?.toISOString() ?? null,
},
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,236 @@
import { randomUUID } from "node:crypto";
import type { Request } from "express";
import { describe, expect, it, vi } from "vitest";
import {
authUsers,
boardApiKeyAuthorizationEvents,
boardApiKeys,
companyMemberships,
instanceUserRoles,
} from "@paperclipai/db";
import { BOARD_API_KEY_SCOPE_PRESETS } from "@paperclipai/shared";
import { HttpError } from "../errors.js";
import { boardAuthService, hashBearerToken } from "../services/board-auth.js";
import { authorizeBoardKey, lookupBoardKeyRoute } from "./board-key-route-registry.js";
const TOKEN = "pcp_board_authorization_matrix";
function scope(companyId: string) {
return {
version: 1 as const,
kind: "scoped" as const,
companyIds: [companyId],
permissions: [...BOARD_API_KEY_SCOPE_PRESETS.full_instance_admin.permissions],
instanceCapabilities: ["instance_admin"] as const,
};
}
function createLiveAuthorityDb() {
const companyId = randomUUID();
const ownerId = randomUUID();
const keyId = randomUUID();
const state = {
ownerExists: true,
membershipActive: true,
membershipRole: "owner",
instanceAdmin: true,
revoked: false,
};
const key = {
id: keyId,
userId: ownerId,
name: "matrix",
keyHash: hashBearerToken(TOKEN),
tokenPrefix: "pcp_board_author",
scopeConfig: scope(companyId),
legacyUnrestricted: false,
createdAt: new Date(),
lastUsedAt: null,
expiresAt: null,
revokedAt: null,
};
const audit: Array<Record<string, unknown>> = [];
const db = {
select: vi.fn(() => ({
from(table: unknown) {
const rows = table === boardApiKeys
? (state.revoked ? [{ ...key, revokedAt: new Date() }] : [key])
: table === authUsers
? (state.ownerExists ? [{ id: ownerId, name: "Owner", email: "owner@example.com" }] : [])
: table === companyMemberships
? (state.membershipActive ? [{ companyId, membershipRole: state.membershipRole, status: "active" }] : [])
: table === instanceUserRoles
? (state.instanceAdmin ? [{ id: randomUUID() }] : [])
: [];
return {
where: () => Promise.resolve(rows),
then: (resolve: (value: unknown[]) => unknown) => Promise.resolve(rows).then(resolve),
};
},
})),
update: vi.fn(() => ({
set: () => ({
where: () => ({
returning: () => Promise.resolve(state.revoked ? [] : [{ id: keyId }]),
then: (resolve: (value: unknown[]) => unknown) => Promise.resolve([]).then(resolve),
}),
}),
})),
insert: vi.fn((table: unknown) => ({
values: async (values: Record<string, unknown>) => {
if (table === boardApiKeyAuthorizationEvents) audit.push(values);
return [];
},
})),
} as any;
return { db, state, key, companyId, ownerId, audit };
}
function requestFor(authentication: Awaited<ReturnType<ReturnType<typeof boardAuthService>["authenticateBoardApiKey"]>>) {
if (!authentication.ok) throw new Error("Expected successful board-key authentication");
const { key, scopeConfig, access } = authentication;
const companyIds = scopeConfig
? scopeConfig.companyIds.filter((id) => access.companyIds.includes(id))
: access.companyIds;
return {
id: randomUUID(),
actor: {
type: "board",
source: "board_key",
userId: key.userId,
keyId: key.id,
boardKeyOwnerId: key.userId,
boardKeyPrefix: key.tokenPrefix,
boardKeyScope: scopeConfig,
boardKeyLegacyUnrestricted: key.legacyUnrestricted,
companyIds,
memberships: access.memberships.filter((membership) => companyIds.includes(membership.companyId)),
isInstanceAdmin: access.isInstanceAdmin,
},
} as unknown as Request;
}
async function expectDenied(run: Promise<unknown>, status: number) {
await expect(run).rejects.toMatchObject({ status } satisfies Partial<HttpError>);
}
describe("board-key effective authority", () => {
it("audits an allow before the protected side effect runs", async () => {
const { db, companyId, audit } = createLiveAuthorityDb();
const authentication = await boardAuthService(db).authenticateBoardApiKey(TOKEN);
const req = requestFor(authentication);
const metadata = lookupBoardKeyRoute("POST", `/api/companies/${companyId}/issues`);
const sideEffect = vi.fn();
await authorizeBoardKey(
db,
req,
metadata.action,
async () => ({ companyId, resourceType: "company", resourceId: companyId }),
metadata,
);
expect(audit.at(-1)).toMatchObject({ decision: "allow", action: "issues:write" });
sideEffect();
expect(sideEffect).toHaveBeenCalledOnce();
});
it("fails closed before side effects when the allow audit cannot be persisted", async () => {
const { db, companyId } = createLiveAuthorityDb();
const authentication = await boardAuthService(db).authenticateBoardApiKey(TOKEN);
const req = requestFor(authentication);
const metadata = lookupBoardKeyRoute("POST", `/api/companies/${companyId}/issues`);
db.insert = vi.fn(() => ({ values: vi.fn().mockRejectedValue(new Error("audit unavailable")) }));
const sideEffect = vi.fn();
try {
await authorizeBoardKey(
db,
req,
metadata.action,
async () => ({ companyId, resourceType: "company", resourceId: companyId }),
metadata,
);
sideEffect();
} catch (error) {
expect(error).toMatchObject({ status: 500 });
}
expect(sideEffect).not.toHaveBeenCalled();
});
it("allows only self-revoke without requiring instance admin or a company pin", async () => {
const { db, key } = createLiveAuthorityDb();
const authentication = await boardAuthService(db).authenticateBoardApiKey(TOKEN);
const req = requestFor(authentication);
req.actor.isInstanceAdmin = false;
req.actor.companyIds = [];
req.actor.memberships = [];
const ownMetadata = lookupBoardKeyRoute("DELETE", `/api/board-api-keys/${key.id}`);
await expect(authorizeBoardKey(
db,
req,
ownMetadata.action,
async () => ({ companyId: null, resourceType: "board_api_key", resourceId: key.id }),
ownMetadata,
)).resolves.toBeUndefined();
const otherKeyId = randomUUID();
const otherMetadata = lookupBoardKeyRoute("DELETE", `/api/board-api-keys/${otherKeyId}`);
await expectDenied(authorizeBoardKey(
db,
req,
otherMetadata.action,
async () => ({ companyId: null, resourceType: "board_api_key", resourceId: otherKeyId }),
otherMetadata,
), 404);
});
it("applies owner suspension, removal, demotion, admin loss, and deletion on the next request", async () => {
const { db, state, companyId } = createLiveAuthorityDb();
const service = boardAuthService(db);
const companyMetadata = lookupBoardKeyRoute("POST", `/api/companies/${companyId}/issues`);
const instanceMetadata = lookupBoardKeyRoute("POST", "/api/instance/settings");
state.membershipActive = false; // suspension
let authentication = await service.authenticateBoardApiKey(TOKEN);
await expectDenied(authorizeBoardKey(
db,
requestFor(authentication),
companyMetadata.action,
async () => ({ companyId, resourceType: "company", resourceId: companyId }),
companyMetadata,
), 404);
state.membershipActive = false; // removal has the same effective-authority result
authentication = await service.authenticateBoardApiKey(TOKEN);
expect(authentication.ok && authentication.access.companyIds).toEqual([]);
state.membershipActive = true;
state.membershipRole = "viewer"; // demotion
authentication = await service.authenticateBoardApiKey(TOKEN);
await expectDenied(authorizeBoardKey(
db,
requestFor(authentication),
companyMetadata.action,
async () => ({ companyId, resourceType: "company", resourceId: companyId }),
companyMetadata,
), 403);
state.membershipRole = "owner";
state.instanceAdmin = false; // instance-admin role removed
authentication = await service.authenticateBoardApiKey(TOKEN);
await expectDenied(authorizeBoardKey(
db,
requestFor(authentication),
instanceMetadata.action,
async () => ({ companyId: null, resourceType: "instance", resourceId: null }),
instanceMetadata,
), 403);
state.ownerExists = false; // owner deletion rejects authentication itself
authentication = await service.authenticateBoardApiKey(TOKEN);
expect(authentication).toMatchObject({ ok: false, reason: "owner_deleted" });
});
});

View File

@ -0,0 +1,105 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { BOARD_API_KEY_PERMISSION_KEYS } from "@paperclipai/shared";
import { lookupBoardKeyRoute } from "./board-key-route-registry.js";
const ROUTES_DIR = path.resolve(import.meta.dirname, "../routes");
const ROUTE_REGISTRATION = /\b(?:router|routes)\.(get|post|put|patch|delete)\s*\(\s*(["'`])([^"'`]+)\2/g;
const ROUTE_CALL = /\b(?:router|routes)\.(?:get|post|put|patch|delete)\s*\(/g;
const SAMPLE_UUID = "11111111-1111-4111-8111-111111111111";
type InventoriedRoute = { file: string; method: string; path: string };
function mountedPath(file: string, routePath: string) {
const mount = file === "companies.ts"
? "/api/companies"
: file === "auth.ts"
? "/api/auth"
: file === "health.ts"
? "/api/health"
: file === "cloud.ts"
? "/api/cloud"
: routePath.startsWith("/mcp")
? ""
: "/api";
const joined = `${mount}${routePath === "/" ? "" : routePath}` || "/";
return joined
.replace(/:[A-Za-z][A-Za-z0-9_]*/g, SAMPLE_UUID)
.replace(/\{\*[^}]+\}/g, "inventory-tail");
}
function inventoryRoutes() {
const inventory: InventoriedRoute[] = [];
const unsupported: string[] = [];
for (const file of fs.readdirSync(ROUTES_DIR).filter((name) => name.endsWith(".ts") && !name.includes(".test."))) {
const source = fs.readFileSync(path.join(ROUTES_DIR, file), "utf8");
const calls = source.match(ROUTE_CALL)?.length ?? 0;
let captured = 0;
for (const match of source.matchAll(ROUTE_REGISTRATION)) {
captured += 1;
const routePath = match[3]!;
if (routePath.includes("${")) {
unsupported.push(`${file}: dynamic template route ${routePath}`);
continue;
}
inventory.push({
file,
method: match[1]!.toUpperCase(),
path: mountedPath(file, routePath),
});
}
// companies.ts has one frozen constant route: COMPANY_IMPORT_ROUTE_PATH.
const allowedNonLiteralCalls = file === "companies.ts" ? 1 : 0;
if (calls - captured !== allowedNonLiteralCalls) {
unsupported.push(`${file}: ${calls - captured} route registration(s) do not use a literal path`);
}
}
inventory.push({ file: "companies.ts", method: "POST", path: "/api/companies/import" });
const unique = new Map<string, InventoriedRoute>();
const duplicates: InventoriedRoute[] = [];
for (const route of inventory) {
const key = `${route.method} ${route.path}`;
if (unique.has(key)) duplicates.push(route);
else unique.set(key, route);
}
return { inventory: [...unique.values()], unsupported, duplicates };
}
describe("board-key route registry", () => {
it.each([
["GET", "/api/companies/11111111-1111-4111-8111-111111111111/issues", "issues:read", "company"],
["POST", "/api/issues/11111111-1111-4111-8111-111111111111/checkout", "issues:control", "company"],
["DELETE", "/api/board-api-keys/11111111-1111-4111-8111-111111111111", "board_api_keys:revoke_self", "key_self"],
["POST", "/api/board-api-keys", "deny", "board_key_denied"],
["GET", "/api/cli-auth/me", "deny", "board_key_denied"],
["GET", "/api/not-yet-registered", "deny", "undeclared"],
] as const)("classifies %s %s", (method, routePath, action, classification) => {
expect(lookupBoardKeyRoute(method, routePath)).toMatchObject({ action, classification });
});
it("inventories every checked-in route registration with an explicit policy", () => {
const { inventory, unsupported, duplicates } = inventoryRoutes();
const undeclared = inventory
.map((route) => ({ ...route, metadata: lookupBoardKeyRoute(route.method, route.path) }))
.filter((route) => route.metadata.classification === "undeclared");
const unsafeDuplicates = duplicates.filter(
(route) => lookupBoardKeyRoute(route.method, route.path).classification !== "board_key_denied",
);
expect(inventory.length).toBeGreaterThan(500);
expect(unsupported).toEqual([]);
// cases.ts and pipelines.ts intentionally overlap while both experimental
// surfaces are hard-denied. A duplicate on an allowed route is unsafe.
expect(unsafeDuplicates).toEqual([]);
expect(undeclared).toEqual([]);
expect(inventory.map((route) => ({
...route,
metadata: lookupBoardKeyRoute(route.method, route.path),
}))).toMatchSnapshot();
for (const route of inventory) {
const { action } = lookupBoardKeyRoute(route.method, route.path);
expect(action === "deny" || BOARD_API_KEY_PERMISSION_KEYS.includes(action)).toBe(true);
}
});
});

View File

@ -0,0 +1,701 @@
import type { Request, RequestHandler } from "express";
import { eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
approvals,
assets,
boardApiKeyAuthorizationEvents,
boardApiKeys,
companySecretProviderConfigs,
companySecrets,
companies,
decisionTrainingExamples,
decisions,
executionWorkspaces,
folders,
goals,
heartbeatRuns,
issueAttachments,
issueWorkProducts,
issues,
labels,
projects,
routines,
routineTriggers,
statusCards,
toolActionRequests,
toolApplications,
toolConnections,
toolMcpGateways,
toolMcpGatewayTokens,
toolProfileEntries,
toolProfiles,
toolRuntimeSlots,
workspaceOperations,
} from "@paperclipai/db";
import { isUuidLike, type BoardPermissionKey } from "@paperclipai/shared";
import { HttpError, forbidden, notFound } from "../errors.js";
import { logger } from "../middleware/logger.js";
export type BoardKeyRouteClassification =
| "company"
| "company_collection"
| "instance_global"
| "key_self"
| "board_key_denied"
| "undeclared";
export type BoardKeyAuthoritativeResolver =
| "none"
| "company"
| "issue"
| "agent"
| "project"
| "goal"
| "routine"
| "approval"
| "execution_workspace"
| "asset"
| "attachment"
| "work_product"
| "heartbeat_run"
| "secret"
| "secret_provider"
| "tool_application"
| "tool_connection"
| "tool_profile"
| "tool_profile_entry"
| "tool_gateway"
| "status_card"
| "label"
| "folder"
| "routine_trigger"
| "decision"
| "decision_training"
| "workspace_operation"
| "key_self"
| "downstream_company";
export interface BoardKeyRouteMetadata {
method: string;
routePattern: string;
action: BoardPermissionKey | "deny";
classification: BoardKeyRouteClassification;
resolver: BoardKeyAuthoritativeResolver;
resourceId?: string;
companyId?: string;
concealment: "forbidden" | "not_found";
}
type AuthoritativeResource = {
companyId: string | null;
resourceType: string | null;
resourceId: string | null;
};
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const ID = "{id}";
function permissionForMethod(
method: string,
read: BoardPermissionKey,
write: BoardPermissionKey,
): BoardPermissionKey {
return SAFE_METHODS.has(method) ? read : write;
}
function declared(
method: string,
routePattern: string,
action: BoardPermissionKey | "deny",
classification: BoardKeyRouteClassification,
resolver: BoardKeyAuthoritativeResolver,
extra: Partial<Pick<BoardKeyRouteMetadata, "resourceId" | "companyId" | "concealment">> = {},
): BoardKeyRouteMetadata {
return {
method,
routePattern,
action,
classification,
resolver,
concealment: extra.concealment ?? (resolver === "company" ? "forbidden" : "not_found"),
...(extra.resourceId ? { resourceId: extra.resourceId } : {}),
...(extra.companyId ? { companyId: extra.companyId } : {}),
};
}
function denied(method: string, routePattern: string): BoardKeyRouteMetadata {
return declared(method, routePattern, "deny", "board_key_denied", "none", { concealment: "forbidden" });
}
function normalizePath(rawPath: string) {
const withoutQuery = rawPath.split("?", 1)[0] || "/";
return withoutQuery.length > 1 ? withoutQuery.replace(/\/+$/, "") : withoutQuery;
}
function queryCompanyId(rawPath: string) {
try {
const value = new URL(rawPath, "http://paperclip.invalid").searchParams.get("companyId");
return value && isUuidLike(value) ? value : undefined;
} catch {
return undefined;
}
}
function companySubresourcePermission(method: string, subresource: string | undefined, tail: string[]) {
const write = !SAFE_METHODS.has(method);
switch (subresource) {
case "agents":
case "org":
return write ? "agents:write" : "agents:read";
case "projects": return write ? "projects:write" : "projects:read";
case "issues": return write ? "issues:write" : "issues:read";
case "goals": return write ? "goals:write" : "goals:read";
case "routines": return write ? "routines:write" : "routines:read";
case "approvals": return write ? "approvals:write" : "approvals:read";
case "costs":
case "finance-events":
case "budgets": return write ? "costs:write" : "costs:read";
case "activity": return "activity:read";
case "artifacts":
case "assets":
case "attachments":
case "work-products": return write ? "artifacts:write" : "artifacts:read";
case "execution-workspaces":
case "project-workspaces":
case "workspaces": return write ? "workspaces:manage" : "workspaces:read";
case "skills": return write ? "skills:manage" : "skills:read";
case "tools":
case "tool-applications":
case "tool-connections":
case "tool-profiles":
case "tool-gateway": return write ? "tools:manage" : "tools:read";
case "secrets":
case "secret-provider-configs": return write ? "secrets:manage" : "secrets:read_metadata";
case "members":
case "users":
case "invites":
case "join-requests": return write ? "members:manage" : "members:read";
case "decisions":
case "decision-queues":
case "decision-triage":
case "decision-retention":
case "decision-training": return write ? "decisions:write" : "decisions:read";
case "labels":
case "folders":
case "summary-slots":
case "status-cards":
case "sidebar-preferences":
case "resource-memberships": return write ? "settings:write" : "settings:read";
case "environments": return write ? "environments:manage" : "environments:read";
case "search": return "search:read";
case "heartbeat-runs":
case "runtime": return write ? "runtime:manage" : "runtime:read";
case "audit": return "audit:read";
case "import":
case "export":
case "exports": return "companies:import_export";
default:
if (tail.some((segment) => segment === "search" || segment === "extract")) return "search:read";
return write ? "companies:write" : "companies:read";
}
}
function issuePermission(method: string, tail: string[]) {
if (tail.some((segment) => [
"checkout",
"release",
"force-release",
"retry-now",
"check-now",
"tree-control",
"tree-holds",
"watchdog",
"recovery-actions",
"scheduled-retry",
"monitor",
"inbox-archive",
"read",
"low-trust",
].includes(segment))) return "issues:control" as const;
return permissionForMethod(method, "issues:read", "issues:write");
}
function agentPermission(method: string, tail: string[]) {
if (tail.some((segment) => [
"heartbeat",
"wakeup",
"pause",
"resume",
"terminate",
"clear-error",
"reset-session",
"rollback",
].includes(segment))) return "agents:operate" as const;
return permissionForMethod(method, "agents:read", "agents:write");
}
function isDeniedCompanyRoute(method: string, tail: string[]) {
const joined = tail.join("/");
if (
joined.startsWith("adapters/")
|| joined === "agent-configurations"
|| joined.startsWith("built-in-agents")
|| joined === "feedback-traces"
|| joined.startsWith("inbox-dismissals")
|| joined.startsWith("me/user-secrets")
|| joined.startsWith("resource-memberships/me")
|| joined === "sidebar-preferences/me"
|| joined.startsWith("skill-policy")
|| joined.startsWith("teams/catalog")
|| joined.startsWith("openclaw/invite-prompt")
|| joined === "agent-hires"
) return true;
if (tail[0] === "users" && (tail.at(-1) === "inbox-agent-policy" || tail.at(-1) === "profile")) return true;
if (tail[0] === "skill-test-run-templates") return true;
return method === "POST" && tail[0] === "skills" && tail.includes("test-runs") && tail.at(-1) === "run";
}
function isDeniedAgentRoute(resourceId: string | undefined, tail: string[]) {
if (resourceId === "me") return true;
return tail[0] === "keys"
|| (tail[0] === "instructions-bundle" && tail[1] === "file")
|| tail[0] === "claude-login";
}
function isDeniedPluginRoute(method: string, segments: string[]) {
const tail = segments.slice(3);
if (segments[2] === "tools" && tail[0] === "execute") return true;
if (["actions", "bridge", "data", "webhooks"].includes(tail[0] ?? "")) return true;
if (tail[0] === "companies" && tail[2] === "local-folders") return true;
return method === "GET" && tail[0] === "bridge" && tail[1] === "stream";
}
function isDeniedToolGatewayRoute(method: string, segments: string[]) {
const tail = segments.slice(2);
if (tail[0] === "sessions") return true;
if (tail[0] === "tools") return method === "GET" || tail[1] === "call";
return false;
}
/**
* Central route registry. It intentionally returns `undeclared` rather than a
* permissive default. The inventory test exercises every checked-in route and
* fails when a new first-class route family is not classified here.
*/
export function lookupBoardKeyRoute(methodInput: string, rawPath: string): BoardKeyRouteMetadata {
const method = methodInput.toUpperCase();
const path = normalizePath(rawPath);
const segments = path.split("/").filter(Boolean);
if (segments[0] === "mcp") return denied(method, "/mcp/{*path}");
if (segments[0] !== "api") {
return declared(method, path, "deny", "undeclared", "none", { concealment: "forbidden" });
}
const top = segments[1];
if (!top) return denied(method, "/api");
if (top === "board-api-keys") {
if (method === "DELETE" && segments.length === 3) {
return declared(method, `/api/board-api-keys/${ID}`, "board_api_keys:revoke_self", "key_self", "key_self", {
resourceId: segments[2],
concealment: "not_found",
});
}
return denied(method, `/api/board-api-keys${segments.length > 2 ? `/${ID}` : ""}`);
}
if ([
"auth",
"cli-auth",
"health",
"openapi.json",
"invites",
"join-requests",
"board-claim",
"profile",
"get-session",
"sidebar-preferences",
"llms",
"cloud",
"stacks",
"smoke-lab",
"cases",
"pipelines",
"_plugins",
].includes(top)) return denied(method, `/api/${top}/{*path}`);
if (top === "companies") {
const companyId = segments[2];
if (!companyId) {
if (method === "GET") {
return declared(method, "/api/companies", "companies:read", "company_collection", "none", { concealment: "forbidden" });
}
if (method === "POST") {
return declared(method, "/api/companies", "companies:create", "instance_global", "none", { concealment: "forbidden" });
}
return denied(method, "/api/companies");
}
if (["import", "imports", "export", "exports"].includes(companyId)) {
return declared(method, `/api/companies/${companyId}/{*path}`, "companies:import_export", "instance_global", "none", {
concealment: "forbidden",
});
}
if (isDeniedCompanyRoute(method, segments.slice(3))) {
return denied(method, `/api/companies/${ID}/${segments.slice(3).join("/")}`);
}
const action = companySubresourcePermission(method, segments[3], segments.slice(4));
return declared(method, `/api/companies/${ID}/{*path}`, action, "company", "company", {
companyId,
concealment: segments.length > 3 ? "not_found" : "forbidden",
});
}
const resourceId = segments[2];
const tail = segments.slice(3);
const collectionCompanyId = resourceId ? undefined : queryCompanyId(rawPath);
const companyCollectionRoute = (
action: BoardPermissionKey,
routePattern: string,
) => declared(method, routePattern, action, "company", "company", {
companyId: collectionCompanyId,
concealment: "not_found",
});
switch (top) {
case "issues":
if (!resourceId) return companyCollectionRoute(issuePermission(method, tail), "/api/issues?companyId={companyId}");
return declared(method, `/api/issues/${ID}/{*path}`, issuePermission(method, tail), "company", "issue", { resourceId });
case "agents":
if (isDeniedAgentRoute(resourceId, tail)) return denied(method, `/api/agents/${ID}/{*path}`);
if (!resourceId) return companyCollectionRoute(agentPermission(method, tail), "/api/agents?companyId={companyId}");
return declared(method, `/api/agents/${ID}/{*path}`, agentPermission(method, tail), "company", "agent", { resourceId });
case "projects":
if (!resourceId) return companyCollectionRoute(permissionForMethod(method, "projects:read", "projects:write"), "/api/projects?companyId={companyId}");
return declared(method, `/api/projects/${ID}/{*path}`, permissionForMethod(method, "projects:read", "projects:write"), "company", "project", { resourceId });
case "goals":
if (!resourceId) return companyCollectionRoute(permissionForMethod(method, "goals:read", "goals:write"), "/api/goals?companyId={companyId}");
return declared(method, `/api/goals/${ID}/{*path}`, permissionForMethod(method, "goals:read", "goals:write"), "company", "goal", { resourceId });
case "routines": {
const action = tail.includes("run") ? "routines:run" : permissionForMethod(method, "routines:read", "routines:write");
return declared(method, `/api/routines/${ID}/{*path}`, action, "company", "routine", { resourceId });
}
case "approvals": {
const action = tail.some((segment) => segment === "approve" || segment === "reject")
? "approvals:decide"
: permissionForMethod(method, "approvals:read", "approvals:write");
return declared(method, `/api/approvals/${ID}/{*path}`, action, "company", "approval", { resourceId });
}
case "execution-workspaces":
return declared(method, `/api/execution-workspaces/${ID}/{*path}`, permissionForMethod(method, "workspaces:read", "workspaces:manage"), "company", "execution_workspace", { resourceId });
case "attachments":
return declared(method, `/api/attachments/${ID}/{*path}`, permissionForMethod(method, "artifacts:read", "artifacts:write"), "company", "attachment", { resourceId });
case "assets":
return declared(method, `/api/assets/${ID}/{*path}`, permissionForMethod(method, "artifacts:read", "artifacts:write"), "company", "asset", { resourceId });
case "work-products":
return declared(method, `/api/work-products/${ID}/{*path}`, permissionForMethod(method, "artifacts:read", "artifacts:write"), "company", "work_product", { resourceId });
case "heartbeat-runs":
return declared(method, `/api/heartbeat-runs/${ID}/{*path}`, permissionForMethod(method, "runtime:read", "runtime:manage"), "company", "heartbeat_run", { resourceId });
case "environments":
case "environment-leases":
case "environment-custom-image-setup-sessions":
return declared(method, `/api/${top}/${ID}/{*path}`, permissionForMethod(method, "environments:read", "environments:manage"), "company", "downstream_company", { resourceId });
case "secrets":
return declared(method, `/api/secrets/${ID}/{*path}`, permissionForMethod(method, "secrets:read_metadata", "secrets:manage"), "company", "secret", { resourceId });
case "secret-provider-configs":
return declared(method, `/api/secret-provider-configs/${ID}/{*path}`, permissionForMethod(method, "secrets:read_metadata", "secrets:manage"), "company", "secret_provider", { resourceId });
case "tool-connections":
return declared(method, `/api/tool-connections/${ID}/{*path}`, permissionForMethod(method, "tools:read", "tools:manage"), "company", "tool_connection", { resourceId });
case "tool-profiles":
return declared(method, `/api/tool-profiles/${ID}/{*path}`, permissionForMethod(method, "tools:read", "tools:manage"), "company", "tool_profile", { resourceId });
case "tool-profile-entries":
return declared(method, `/api/tool-profile-entries/${ID}/{*path}`, permissionForMethod(method, "tools:read", "tools:manage"), "company", "tool_profile_entry", { resourceId });
case "tool-applications":
return declared(method, `/api/tool-applications/${ID}/{*path}`, permissionForMethod(method, "tools:read", "tools:manage"), "company", "tool_application", { resourceId });
case "status-cards":
return declared(method, `/api/status-cards/${ID}/{*path}`, permissionForMethod(method, "settings:read", "settings:write"), "company", "status_card", { resourceId });
case "labels":
return declared(method, `/api/labels/${ID}/{*path}`, permissionForMethod(method, "settings:read", "settings:write"), "company", "label", { resourceId });
case "folders":
return declared(method, `/api/folders/${ID}/{*path}`, permissionForMethod(method, "settings:read", "settings:write"), "company", "folder", { resourceId });
case "routine-triggers":
if (resourceId === "public") return denied(method, "/api/routine-triggers/public/{id}/fire");
return declared(method, `/api/routine-triggers/${ID}/{*path}`, "routines:write", "company", "routine_trigger", { resourceId });
case "plugins":
if (isDeniedPluginRoute(method, segments)) return denied(method, "/api/plugins/{*path}");
return declared(method, `/api/plugins/${ID}/{*path}`, permissionForMethod(method, "plugins:read", "plugins:manage"), "instance_global", "none", { resourceId, concealment: "forbidden" });
case "adapters":
return declared(method, `/api/adapters/${ID}/{*path}`, permissionForMethod(method, "adapters:read", "adapters:manage"), "instance_global", "none", { resourceId, concealment: "forbidden" });
case "instance":
case "dev-server":
return declared(method, `/api/${top}/{*path}`, permissionForMethod(method, "instance:read", "instance:manage"), "instance_global", "none", { concealment: "forbidden" });
case "admin":
return declared(method, "/api/admin/{*path}", permissionForMethod(method, "users:read", "users:manage"), "instance_global", "none", { concealment: "forbidden" });
case "teams":
case "skills":
case "built-in-agents":
return declared(method, `/api/${top}/{*path}`, permissionForMethod(method, "catalogs:read", "catalogs:manage"), "instance_global", "none", { concealment: "forbidden" });
case "feedback-traces":
return denied(method, "/api/feedback-traces/{*path}");
case "decision-training":
return declared(method, `/api/decision-training/${ID}/{*path}`, permissionForMethod(method, "decisions:read", "decisions:write"), "company", "decision_training", { resourceId });
case "decisions":
return declared(method, `/api/decisions/${ID}/{*path}`, permissionForMethod(method, "decisions:read", "decisions:write"), "company", "decision", { resourceId });
case "workspace-operations":
return declared(method, `/api/workspace-operations/${ID}/{*path}`, permissionForMethod(method, "workspaces:read", "workspaces:manage"), "company", "workspace_operation", { resourceId });
case "stats":
return declared(method, "/api/stats", "companies:read", "company_collection", "none", { concealment: "forbidden" });
case "import":
return declared(method, "/api/import/{*path}", "companies:import_export", "instance_global", "none", { concealment: "forbidden" });
case "board":
case "bootstrap":
return denied(method, `/api/${top}/{*path}`);
case "sidebar-preferences":
return denied(method, "/api/sidebar-preferences/{*path}");
case "tool-gateway":
if (isDeniedToolGatewayRoute(method, segments)) return denied(method, "/api/tool-gateway/{*path}");
return declared(method, "/api/tool-gateway/{*path}", permissionForMethod(method, "tools:read", "tools:manage"), "company", "tool_gateway", { resourceId: segments[3] });
case "tools":
if (segments[2] === "oauth") return denied(method, "/api/tools/oauth/{*path}");
return declared(method, "/api/tools/{*path}", permissionForMethod(method, "tools:read", "tools:manage"), "company", "downstream_company", { resourceId });
default:
return declared(method, path, "deny", "undeclared", "none", { concealment: "forbidden" });
}
}
async function resolveAuthoritativeResource(
db: Db,
metadata: BoardKeyRouteMetadata,
): Promise<AuthoritativeResource | null> {
const id = metadata.resourceId;
const resolveCompanyTableRow = async (
table: { id: any; companyId: any },
resourceType: string,
): Promise<AuthoritativeResource | null> => {
if (!id || !isUuidLike(id)) return null;
const row = await db.select({ id: table.id, companyId: table.companyId }).from(table as any)
.where(eq(table.id, id)).then((rows) => rows[0] as { id: string; companyId: string } | undefined);
return row ? { companyId: row.companyId, resourceType, resourceId: row.id } : null;
};
switch (metadata.resolver) {
case "none": return { companyId: null, resourceType: null, resourceId: null };
case "company": {
if (!metadata.companyId) return null;
const row = await db.select({ id: companies.id }).from(companies).where(eq(companies.id, metadata.companyId)).then((rows) => rows[0] ?? null);
return row ? { companyId: row.id, resourceType: "company", resourceId: row.id } : null;
}
case "issue": {
if (!id) return null;
const row = await db.select({ id: issues.id, companyId: issues.companyId }).from(issues)
.where(isUuidLike(id) ? eq(issues.id, id) : eq(issues.identifier, id)).then((rows) => rows[0] ?? null);
return row ? { companyId: row.companyId, resourceType: "issue", resourceId: row.id } : null;
}
case "agent": {
if (!id || !isUuidLike(id)) return null;
const row = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, id)).then((rows) => rows[0] ?? null);
return row ? { companyId: row.companyId, resourceType: "agent", resourceId: row.id } : null;
}
case "project": {
if (!id || !isUuidLike(id)) return null;
const row = await db.select({ id: projects.id, companyId: projects.companyId }).from(projects).where(eq(projects.id, id)).then((rows) => rows[0] ?? null);
return row ? { companyId: row.companyId, resourceType: "project", resourceId: row.id } : null;
}
case "goal": {
if (!id || !isUuidLike(id)) return null;
const row = await db.select({ id: goals.id, companyId: goals.companyId }).from(goals).where(eq(goals.id, id)).then((rows) => rows[0] ?? null);
return row ? { companyId: row.companyId, resourceType: "goal", resourceId: row.id } : null;
}
case "routine": {
if (!id || !isUuidLike(id)) return null;
const row = await db.select({ id: routines.id, companyId: routines.companyId }).from(routines).where(eq(routines.id, id)).then((rows) => rows[0] ?? null);
return row ? { companyId: row.companyId, resourceType: "routine", resourceId: row.id } : null;
}
case "approval": {
if (!id || !isUuidLike(id)) return null;
const row = await db.select({ id: approvals.id, companyId: approvals.companyId }).from(approvals).where(eq(approvals.id, id)).then((rows) => rows[0] ?? null);
return row ? { companyId: row.companyId, resourceType: "approval", resourceId: row.id } : null;
}
case "execution_workspace": {
if (!id || !isUuidLike(id)) return null;
const row = await db.select({ id: executionWorkspaces.id, companyId: executionWorkspaces.companyId }).from(executionWorkspaces).where(eq(executionWorkspaces.id, id)).then((rows) => rows[0] ?? null);
return row ? { companyId: row.companyId, resourceType: "execution_workspace", resourceId: row.id } : null;
}
case "asset": return resolveCompanyTableRow(assets, "asset");
case "attachment": return resolveCompanyTableRow(issueAttachments, "attachment");
case "work_product": return resolveCompanyTableRow(issueWorkProducts, "work_product");
case "heartbeat_run": return resolveCompanyTableRow(heartbeatRuns, "heartbeat_run");
case "secret": return resolveCompanyTableRow(companySecrets, "secret");
case "secret_provider": return resolveCompanyTableRow(companySecretProviderConfigs, "secret_provider_config");
case "tool_application": return resolveCompanyTableRow(toolApplications, "tool_application");
case "tool_connection": return resolveCompanyTableRow(toolConnections, "tool_connection");
case "tool_profile": return resolveCompanyTableRow(toolProfiles, "tool_profile");
case "tool_profile_entry": return resolveCompanyTableRow(toolProfileEntries, "tool_profile_entry");
case "tool_gateway": {
for (const [table, resourceType] of [
[toolMcpGateways, "tool_gateway"],
[toolMcpGatewayTokens, "tool_gateway_token"],
[toolActionRequests, "tool_action_request"],
[toolRuntimeSlots, "tool_runtime_slot"],
] as const) {
const row = await resolveCompanyTableRow(table, resourceType);
if (row) return row;
}
return null;
}
case "status_card": return resolveCompanyTableRow(statusCards, "status_card");
case "label": return resolveCompanyTableRow(labels, "label");
case "folder": return resolveCompanyTableRow(folders, "folder");
case "routine_trigger": return resolveCompanyTableRow(routineTriggers, "routine_trigger");
case "decision": return resolveCompanyTableRow(decisions, "decision");
case "decision_training": return resolveCompanyTableRow(decisionTrainingExamples, "decision_training");
case "workspace_operation": return resolveCompanyTableRow(workspaceOperations, "workspace_operation");
case "key_self": {
if (!id || !isUuidLike(id)) return null;
const row = await db.select({ id: boardApiKeys.id, userId: boardApiKeys.userId }).from(boardApiKeys).where(eq(boardApiKeys.id, id)).then((rows) => rows[0] ?? null);
return row ? { companyId: null, resourceType: "board_api_key", resourceId: row.id } : null;
}
case "downstream_company":
return { companyId: null, resourceType: null, resourceId: id ?? null };
}
}
function isWriteAction(action: BoardPermissionKey) {
return /:(?:write|manage|control|operate|run|decide|create|import_export)$/.test(action);
}
async function auditDecision(
db: Db,
req: Request,
metadata: BoardKeyRouteMetadata,
resource: AuthoritativeResource | null,
decision: "allow" | "deny",
reason: string,
) {
if (req.actor.source !== "board_key" || !req.actor.keyId || !req.actor.boardKeyOwnerId) return;
await db.insert(boardApiKeyAuthorizationEvents).values({
boardApiKeyId: req.actor.keyId,
ownerUserId: req.actor.boardKeyOwnerId,
tokenPrefix: req.actor.boardKeyPrefix ?? null,
action: metadata.action,
classification: metadata.classification,
authoritativeCompanyId: resource?.companyId ?? null,
authoritativeResourceType: resource?.resourceType ?? null,
authoritativeResourceId: resource?.resourceId ?? null,
decision,
reason,
requestId: typeof req.id === "string" ? req.id : null,
runId: isUuidLike(req.actor.runId) ? req.actor.runId : null,
details: {},
});
}
async function denyBoardKey(
db: Db,
req: Request,
metadata: BoardKeyRouteMetadata,
resource: AuthoritativeResource | null,
reason: string,
conceal = metadata.concealment,
): Promise<never> {
try {
await auditDecision(db, req, metadata, resource, "deny", reason);
} catch (err) {
logger.warn({ err, boardApiKeyId: req.actor.keyId, action: metadata.action }, "Failed to audit denied board-key authorization");
}
if (conceal === "not_found") throw notFound();
throw forbidden();
}
export async function authorizeBoardKey(
db: Db,
req: Request,
action: BoardPermissionKey | "deny",
authoritativeResourceResolver: () => Promise<AuthoritativeResource | null>,
metadata: BoardKeyRouteMetadata,
) {
if (req.actor.source !== "board_key") return;
if (metadata.classification === "undeclared") {
await denyBoardKey(db, req, metadata, null, "route_undeclared", "forbidden");
}
if (metadata.classification === "board_key_denied" || action === "deny") {
await denyBoardKey(db, req, metadata, null, "route_denied", "forbidden");
}
const resource = await authoritativeResourceResolver();
if (!resource) await denyBoardKey(db, req, metadata, null, "resource_not_found", "not_found");
const scope = req.actor.boardKeyScope;
const legacy = req.actor.boardKeyLegacyUnrestricted === true;
const permissions = new Set(scope?.permissions ?? []);
if (!legacy && !permissions.has(action as BoardPermissionKey)) {
await denyBoardKey(db, req, metadata, resource, "permission_missing", "forbidden");
}
if (metadata.classification === "key_self") {
if (resource?.resourceId !== req.actor.keyId) {
await denyBoardKey(db, req, metadata, resource, "key_self_mismatch", "not_found");
}
const row = await db.select({ userId: boardApiKeys.userId }).from(boardApiKeys)
.where(eq(boardApiKeys.id, req.actor.keyId!)).then((rows) => rows[0] ?? null);
if (!row || row.userId !== req.actor.boardKeyOwnerId) {
await denyBoardKey(db, req, metadata, resource, "key_owner_mismatch", "not_found");
}
} else if (metadata.classification === "instance_global") {
if (!legacy && !scope?.instanceCapabilities.includes("instance_admin")) {
await denyBoardKey(db, req, metadata, resource, "instance_capability_missing", "forbidden");
}
if (!req.actor.isInstanceAdmin) {
await denyBoardKey(db, req, metadata, resource, "owner_instance_admin_missing", "forbidden");
}
} else if (metadata.classification === "company_collection") {
if ((req.actor.companyIds ?? []).length === 0) {
await denyBoardKey(db, req, metadata, resource, "owner_company_membership_missing", "forbidden");
}
} else if (metadata.resolver === "downstream_company") {
// A family may be inventoried before it has a safe generic resolver. It is
// available to migrated legacy keys through existing route-level checks,
// but scoped keys fail closed until an authoritative resolver is added.
if (!legacy) await denyBoardKey(db, req, metadata, resource, "authoritative_resolver_unavailable", "not_found");
} else if (resource?.companyId) {
const membership = req.actor.memberships?.find(
(item) => item.companyId === resource.companyId && item.status === "active",
);
if (!membership || !(req.actor.companyIds ?? []).includes(resource.companyId)) {
await denyBoardKey(db, req, metadata, resource, "company_scope_mismatch", "not_found");
}
if (isWriteAction(action as BoardPermissionKey) && membership?.membershipRole === "viewer") {
await denyBoardKey(db, req, metadata, resource, "owner_role_read_only", "forbidden");
}
}
try {
await auditDecision(db, req, metadata, resource, "allow", "authorized");
} catch (err) {
logger.error({ err, boardApiKeyId: req.actor.keyId, action }, "Board-key authorization audit failed closed");
throw new HttpError(500, "Internal server error");
}
}
export function boardKeyAuthorizationMiddleware(db: Db): RequestHandler {
return async (req, _res, next) => {
if (req.actor.source !== "board_key") {
next();
return;
}
const metadata = lookupBoardKeyRoute(req.method, req.originalUrl);
try {
await authorizeBoardKey(
db,
req,
metadata.action,
() => resolveAuthoritativeResource(db, metadata),
metadata,
);
next();
} catch (err) {
next(err);
}
};
}

View File

@ -1,6 +1,11 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { and, eq, gt, isNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
boardApiKeyScopeConfigSchema,
deriveBoardApiKeyStatus,
type BoardApiKeyScopeConfig,
} from "@paperclipai/shared";
import {
authUsers,
boardApiKeys,
@ -32,6 +37,10 @@ export function createBoardApiToken() {
return `pcp_board_${randomBytes(24).toString("hex")}`;
}
export function boardApiKeyTokenPrefix(token: string) {
return token.slice(0, "pcp_board_".length + 12);
}
export function createCliAuthSecret() {
return `pcp_cli_auth_${randomBytes(24).toString("hex")}`;
}
@ -52,7 +61,10 @@ function challengeStatusForRow(row: typeof cliAuthChallenges.$inferSelect): CliA
}
export function boardAuthService(db: Db) {
const touchedBoardApiKeys = new Map<string, { completedAt: number | null; inFlight: Promise<void> | null }>();
const touchedBoardApiKeys = new Map<
string,
{ completedAt: number | null; inFlight: Promise<{ id: string } | null> | null }
>();
function pruneTouchedBoardApiKeys(nowMs: number) {
for (const [id, entry] of touchedBoardApiKeys) {
@ -149,35 +161,43 @@ export function boardAuthService(db: Db) {
async function findBoardApiKeyByToken(token: string) {
const tokenHash = hashBearerToken(token);
const now = new Date();
return db
.select()
.from(boardApiKeys)
.where(
and(
eq(boardApiKeys.keyHash, tokenHash),
isNull(boardApiKeys.revokedAt),
),
)
.then((rows) => rows.find((row) => !row.expiresAt || row.expiresAt.getTime() > now.getTime()) ?? null);
.where(eq(boardApiKeys.keyHash, tokenHash))
.then((rows) => rows[0] ?? null);
}
async function touchBoardApiKey(id: string) {
const nowMs = Date.now();
async function touchBoardApiKey(id: string, now: Date = new Date()) {
const nowMs = now.getTime();
pruneTouchedBoardApiKeys(nowMs);
const cached = touchedBoardApiKeys.get(id);
if (cached?.inFlight) return cached.inFlight;
if (cached?.completedAt !== null && cached?.completedAt !== undefined
&& cached.completedAt > nowMs - BOARD_API_KEY_TOUCH_DEBOUNCE_MS) return;
if (
cached?.completedAt !== null
&& cached?.completedAt !== undefined
&& cached.completedAt > nowMs - BOARD_API_KEY_TOUCH_DEBOUNCE_MS
) {
return { id };
}
const inFlight = db
.update(boardApiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(boardApiKeys.id, id))
.then(() => {
.set({ lastUsedAt: now })
.where(and(
eq(boardApiKeys.id, id),
isNull(boardApiKeys.revokedAt),
or(isNull(boardApiKeys.expiresAt), gt(boardApiKeys.expiresAt, now)),
))
.returning({ id: boardApiKeys.id })
.then((rows) => {
const touched = rows[0] ?? null;
touchedBoardApiKeys.delete(id);
touchedBoardApiKeys.set(id, { completedAt: Date.now(), inFlight: null });
pruneTouchedBoardApiKeys(Date.now());
if (touched) {
touchedBoardApiKeys.set(id, { completedAt: Date.now(), inFlight: null });
pruneTouchedBoardApiKeys(Date.now());
}
return touched;
})
.catch((error) => {
if (touchedBoardApiKeys.get(id)?.inFlight === inFlight) touchedBoardApiKeys.delete(id);
@ -187,11 +207,41 @@ export function boardAuthService(db: Db) {
return inFlight;
}
async function authenticateBoardApiKey(token: string) {
const key = await findBoardApiKeyByToken(token);
if (!key) return { ok: false as const, reason: "unknown_key" as const, key: null };
if (key.revokedAt) return { ok: false as const, reason: "revoked" as const, key };
if (key.expiresAt && key.expiresAt.getTime() <= Date.now()) {
return { ok: false as const, reason: "expired" as const, key };
}
let scopeConfig: BoardApiKeyScopeConfig | null;
if (key.legacyUnrestricted === true && key.scopeConfig === null) {
scopeConfig = null;
} else if (key.legacyUnrestricted === false && key.scopeConfig !== null) {
const parsed = boardApiKeyScopeConfigSchema.safeParse(key.scopeConfig);
if (!parsed.success) return { ok: false as const, reason: "malformed_scope" as const, key };
scopeConfig = parsed.data;
} else {
return { ok: false as const, reason: "malformed_scope" as const, key };
}
const access = await resolveBoardAccess(key.userId);
if (!access.user) return { ok: false as const, reason: "owner_deleted" as const, key };
// This conditional update is the final authentication commit point. A
// concurrent revocation/expiry cannot be overwritten or authenticated.
const touched = await touchBoardApiKey(key.id);
if (!touched) return { ok: false as const, reason: "revoked" as const, key };
return { ok: true as const, key, scopeConfig, access };
}
async function revokeBoardApiKey(id: string) {
const now = new Date();
return db
.update(boardApiKeys)
.set({ revokedAt: now, lastUsedAt: now })
.set({ revokedAt: now })
.where(and(eq(boardApiKeys.id, id), isNull(boardApiKeys.revokedAt)))
.returning()
.then((rows) => rows[0] ?? null);
@ -201,14 +251,18 @@ export function boardAuthService(db: Db) {
userId: string;
name: string;
expiresAt?: Date | null;
scopeConfig: BoardApiKeyScopeConfig;
}) {
const token = createBoardApiToken();
const scopeConfig = boardApiKeyScopeConfigSchema.parse(input.scopeConfig);
const created = await db
.insert(boardApiKeys)
.values({
userId: input.userId,
name: input.name.trim(),
name: input.name,
keyHash: hashBearerToken(token),
tokenPrefix: boardApiKeyTokenPrefix(token),
scopeConfig,
expiresAt: input.expiresAt === undefined ? boardApiKeyExpiresAt() : input.expiresAt,
})
.returning()
@ -218,6 +272,10 @@ export function boardAuthService(db: Db) {
id: created.id,
name: created.name,
token,
tokenPrefix: created.tokenPrefix,
scopeConfig,
legacyUnrestricted: false,
status: deriveBoardApiKeyStatus(created),
createdAt: created.createdAt,
lastUsedAt: created.lastUsedAt,
revokedAt: created.revokedAt,
@ -244,6 +302,9 @@ export function boardAuthService(db: Db) {
.select({
id: boardApiKeys.id,
name: boardApiKeys.name,
tokenPrefix: boardApiKeys.tokenPrefix,
scopeConfig: boardApiKeys.scopeConfig,
legacyUnrestricted: boardApiKeys.legacyUnrestricted,
createdAt: boardApiKeys.createdAt,
lastUsedAt: boardApiKeys.lastUsedAt,
revokedAt: boardApiKeys.revokedAt,
@ -251,7 +312,8 @@ export function boardAuthService(db: Db) {
})
.from(boardApiKeys)
.where(and(...conditions))
.orderBy(sql`${boardApiKeys.createdAt} desc`);
.orderBy(sql`${boardApiKeys.createdAt} desc`)
.then((rows) => rows.map((row) => ({ ...row, status: deriveBoardApiKeyStatus(row) })));
}
async function getBoardApiKeyForUser(keyId: string, userId: string) {
@ -260,6 +322,9 @@ export function boardAuthService(db: Db) {
id: boardApiKeys.id,
userId: boardApiKeys.userId,
name: boardApiKeys.name,
tokenPrefix: boardApiKeys.tokenPrefix,
scopeConfig: boardApiKeys.scopeConfig,
legacyUnrestricted: boardApiKeys.legacyUnrestricted,
createdAt: boardApiKeys.createdAt,
lastUsedAt: boardApiKeys.lastUsedAt,
revokedAt: boardApiKeys.revokedAt,
@ -275,9 +340,11 @@ export function boardAuthService(db: Db) {
clientName?: string | null;
requestedAccess: "board" | "instance_admin_required";
requestedCompanyId?: string | null;
scopeConfig: BoardApiKeyScopeConfig;
}) {
const challengeSecret = createCliAuthSecret();
const pendingBoardToken = createBoardApiToken();
const scopeConfig = boardApiKeyScopeConfigSchema.parse(input.scopeConfig);
const expiresAt = cliAuthChallengeExpiresAt();
const labelBase = input.clientName?.trim() || "paperclipai cli";
const pendingKeyName =
@ -293,7 +360,9 @@ export function boardAuthService(db: Db) {
clientName: input.clientName?.trim() || null,
requestedAccess: input.requestedAccess,
requestedCompanyId: input.requestedCompanyId?.trim() || null,
requestedScopeConfig: scopeConfig,
pendingKeyHash: hashBearerToken(pendingBoardToken),
pendingKeyPrefix: boardApiKeyTokenPrefix(pendingBoardToken),
pendingKeyName,
expiresAt,
})
@ -354,6 +423,7 @@ export function boardAuthService(db: Db) {
approvedAt: challenge.approvedAt?.toISOString() ?? null,
cancelledAt: challenge.cancelledAt?.toISOString() ?? null,
expiresAt: challenge.expiresAt.toISOString(),
boardApiKeyId: challenge.boardApiKeyId ?? null,
approvedByUser: approvedBy
? {
id: approvedBy.id,
@ -388,6 +458,11 @@ export function boardAuthService(db: Db) {
throw forbidden("Instance admin required");
}
const requestedScope = boardApiKeyScopeConfigSchema.safeParse(challenge.requestedScopeConfig);
if (!requestedScope.success || !challenge.pendingKeyPrefix) {
throw conflict("CLI auth challenge must be recreated with an explicit board-key scope");
}
let boardKeyId = challenge.boardApiKeyId;
if (!boardKeyId) {
const createdKey = await tx
@ -396,6 +471,8 @@ export function boardAuthService(db: Db) {
userId,
name: challenge.pendingKeyName,
keyHash: challenge.pendingKeyHash,
tokenPrefix: challenge.pendingKeyPrefix,
scopeConfig: requestedScope.data,
expiresAt: boardApiKeyExpiresAt(),
})
.returning()
@ -455,6 +532,7 @@ export function boardAuthService(db: Db) {
return {
resolveBoardAccess,
authenticateBoardApiKey,
findBoardApiKeyByToken,
touchBoardApiKey,
revokeBoardApiKey,

View File

@ -1,6 +1,6 @@
export {};
import type { AgentApiKeyScope } from "@paperclipai/shared";
import type { AgentApiKeyScope, BoardApiKeyScopeConfig } from "@paperclipai/shared";
declare global {
namespace Express {
@ -27,6 +27,10 @@ declare global {
isInstanceAdmin?: boolean;
keyId?: string;
keyScope?: AgentApiKeyScope;
boardKeyScope?: BoardApiKeyScopeConfig | null;
boardKeyOwnerId?: string;
boardKeyPrefix?: string | null;
boardKeyLegacyUnrestricted?: boolean;
runId?: string;
onBehalfOfUserId?: string | null;
identityContextId?: string | null;