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:
parent
c9e3bb7ca4
commit
df8ffa3934
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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({}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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(),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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)`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
`;
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue