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

@@ -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");
});
});