mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
feat(auth): complete scoped board key lifecycle
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user