feat(auth): complete scoped board key lifecycle

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-19 20:00:46 +00:00
1 parent 08adecda83
commit d262fb5f0d
5 files changed
+377 -29

No files matched your search

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