From 2bcaf2b7f2f5d9e14433c7fa1286733a9b6adf58 Mon Sep 17 00:00:00 2001 From: cryppadotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:46:51 +0000 Subject: [PATCH] fix(security): bound board key creation authority Co-Authored-By: Paperclip --- .../src/security/board-key-owner-authority.ts | 173 ++++++++++++++++++ .../src/security/board-key-route-registry.ts | 123 +------------ server/src/services/board-auth.test.ts | 97 +++++++++- server/src/services/board-auth.ts | 21 ++- 4 files changed, 295 insertions(+), 119 deletions(-) create mode 100644 server/src/security/board-key-owner-authority.ts diff --git a/server/src/security/board-key-owner-authority.ts b/server/src/security/board-key-owner-authority.ts new file mode 100644 index 0000000000..52506b6485 --- /dev/null +++ b/server/src/security/board-key-owner-authority.ts @@ -0,0 +1,173 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { + companyMemberships, + instanceUserRoles, + principalPermissionGrants, + type Db, +} from "@paperclipai/db"; +import type { + BoardApiKeyScopeConfig, + BoardPermissionKey, + PermissionKey, +} from "@paperclipai/shared"; + +export function isBoardKeyWriteAction(action: BoardPermissionKey) { + return /:(?:write|manage|control|operate|run|decide|create|import_export)$/.test(action); +} + +type OwnerAuthorityRequirement = + | { kind: "membership" } + | { kind: "grants"; permissionKeys: readonly PermissionKey[] }; + +const membershipAuthority = { kind: "membership" } as const; +const grantAuthority = (...permissionKeys: PermissionKey[]) => ({ + kind: "grants" as const, + permissionKeys, +}); + +const OWNER_AUTHORITY_REQUIREMENTS = { + "companies:read": membershipAuthority, + "companies:write": membershipAuthority, + "agents:read": membershipAuthority, + "agents:write": grantAuthority("agents:create", "agents:configure"), + "agents:operate": grantAuthority("agents:configure"), + "projects:read": membershipAuthority, + "projects:write": membershipAuthority, + "issues:read": membershipAuthority, + "issues:write": grantAuthority("tasks:assign"), + "issues:control": grantAuthority("tasks:assign", "tasks:manage_active_checkouts"), + "goals:read": membershipAuthority, + "goals:write": membershipAuthority, + "routines:read": membershipAuthority, + "routines:write": membershipAuthority, + "routines:run": membershipAuthority, + "approvals:read": membershipAuthority, + "approvals:write": membershipAuthority, + "approvals:decide": membershipAuthority, + "costs:read": membershipAuthority, + "costs:write": membershipAuthority, + "activity:read": membershipAuthority, + "artifacts:read": membershipAuthority, + "artifacts:write": membershipAuthority, + "workspaces:read": membershipAuthority, + "workspaces:manage": membershipAuthority, + "skills:read": membershipAuthority, + "skills:manage": grantAuthority("skills:create"), + "tools:read": membershipAuthority, + "tools:manage": grantAuthority("tools:admin"), + "secrets:read_metadata": membershipAuthority, + "secrets:manage": membershipAuthority, + "members:read": membershipAuthority, + "members:manage": grantAuthority("users:invite", "users:manage_permissions", "joins:approve"), + "decisions:read": membershipAuthority, + "decisions:write": membershipAuthority, + "settings:read": membershipAuthority, + "settings:write": membershipAuthority, + "environments:read": membershipAuthority, + "environments:manage": grantAuthority("environments:manage"), + "pipelines:read": membershipAuthority, + "pipelines:write": grantAuthority("pipelines:write"), + "search:read": membershipAuthority, + "runtime:read": membershipAuthority, + "runtime:manage": membershipAuthority, + "audit:read": grantAuthority("audit:view_agent_actions"), + "instance:read": membershipAuthority, + "instance:manage": membershipAuthority, + "companies:create": membershipAuthority, + "companies:import_export": membershipAuthority, + "plugins:read": membershipAuthority, + "plugins:manage": membershipAuthority, + "adapters:read": membershipAuthority, + "adapters:manage": membershipAuthority, + "users:read": membershipAuthority, + "users:manage": membershipAuthority, + "catalogs:read": membershipAuthority, + "catalogs:manage": membershipAuthority, + "backups:create": membershipAuthority, + "board_api_keys:revoke_self": membershipAuthority, +} satisfies Record; + +export async function ownerHasRequiredGrant( + db: Db, + ownerUserId: string, + companyIds: readonly string[], + action: BoardPermissionKey, +) { + const requirement = OWNER_AUTHORITY_REQUIREMENTS[action]; + if (requirement.kind === "membership") return true; + const { permissionKeys } = requirement; + const rows = await db + .select({ + companyId: principalPermissionGrants.companyId, + permissionKey: principalPermissionGrants.permissionKey, + }) + .from(principalPermissionGrants) + .where(and( + inArray(principalPermissionGrants.companyId, [...companyIds]), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, ownerUserId), + inArray(principalPermissionGrants.permissionKey, [...permissionKeys]), + )); + const liveKeysByCompany = new Map>(); + for (const row of rows) { + const liveKeys = liveKeysByCompany.get(row.companyId) ?? new Set(); + liveKeys.add(row.permissionKey); + liveKeysByCompany.set(row.companyId, liveKeys); + } + return companyIds.every((companyId) => { + const liveKeys = liveKeysByCompany.get(companyId); + return permissionKeys.every((permissionKey) => liveKeys?.has(permissionKey)); + }); +} + +export type BoardKeyScopeAuthorityViolation = + | "instance_admin_required" + | "company_access_missing" + | "owner_role_read_only" + | "owner_permission_grant_missing"; + +export async function validateBoardKeyScopeOwnerAuthority( + db: Db, + ownerUserId: string, + scope: BoardApiKeyScopeConfig, +): Promise { + const [adminRole, memberships] = await Promise.all([ + db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, ownerUserId), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows) => rows[0] ?? null), + db + .select({ + companyId: companyMemberships.companyId, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where(and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, ownerUserId), + eq(companyMemberships.status, "active"), + inArray(companyMemberships.companyId, scope.companyIds), + )), + ]); + + if (scope.instanceCapabilities.length > 0 && !adminRole) return "instance_admin_required"; + + const membershipByCompany = new Map(memberships.map((row) => [row.companyId, row])); + if (scope.companyIds.some((companyId) => !membershipByCompany.has(companyId))) { + return "company_access_missing"; + } + if ( + scope.permissions.some(isBoardKeyWriteAction) + && scope.companyIds.some((companyId) => membershipByCompany.get(companyId)?.membershipRole === "viewer") + ) { + return "owner_role_read_only"; + } + + for (const permission of scope.permissions) { + if (!await ownerHasRequiredGrant(db, ownerUserId, scope.companyIds, permission)) { + return "owner_permission_grant_missing"; + } + } + return null; +} diff --git a/server/src/security/board-key-route-registry.ts b/server/src/security/board-key-route-registry.ts index a58bca56c1..1a7cd52937 100644 --- a/server/src/security/board-key-route-registry.ts +++ b/server/src/security/board-key-route-registry.ts @@ -20,7 +20,6 @@ import { issueWorkProducts, issues, labels, - principalPermissionGrants, projects, routines, routineTriggers, @@ -35,7 +34,7 @@ import { toolRuntimeSlots, workspaceOperations, } from "@paperclipai/db"; -import { isUuidLike, type BoardPermissionKey, type PermissionKey } from "@paperclipai/shared"; +import { isUuidLike, type BoardPermissionKey } from "@paperclipai/shared"; import { HttpError, forbidden, notFound } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { @@ -45,6 +44,10 @@ import { settleBoardKeyAuditContext, stageBoardKeyAllowAudit, } from "./board-key-audit-coupling.js"; +import { + isBoardKeyWriteAction, + ownerHasRequiredGrant, +} from "./board-key-owner-authority.js"; export type BoardKeyRouteClassification = | "company" @@ -617,120 +620,6 @@ async function resolveAuthoritativeResource( } } -function isWriteAction(action: BoardPermissionKey) { - return /:(?:write|manage|control|operate|run|decide|create|import_export)$/.test(action); -} - -// Board-key actions intentionally use a stable public vocabulary that is -// broader than the internal principal-grant vocabulary. Keep this exhaustive: -// actions with a granular grant boundary require every named live grant, while -// membership-backed actions are re-authorized by the fresh active membership -// and role checks in authorizeBoardKey. There is no permissive unmapped case. -type OwnerAuthorityRequirement = - | { kind: "membership" } - | { kind: "grants"; permissionKeys: readonly PermissionKey[] }; - -const membershipAuthority = { kind: "membership" } as const; -const grantAuthority = (...permissionKeys: PermissionKey[]) => ({ - kind: "grants" as const, - permissionKeys, -}); - -const OWNER_AUTHORITY_REQUIREMENTS = { - "companies:read": membershipAuthority, - "companies:write": membershipAuthority, - "agents:read": membershipAuthority, - "agents:write": grantAuthority("agents:create", "agents:configure"), - "agents:operate": grantAuthority("agents:configure"), - "projects:read": membershipAuthority, - "projects:write": membershipAuthority, - "issues:read": membershipAuthority, - "issues:write": grantAuthority("tasks:assign"), - "issues:control": grantAuthority("tasks:assign", "tasks:manage_active_checkouts"), - "goals:read": membershipAuthority, - "goals:write": membershipAuthority, - "routines:read": membershipAuthority, - "routines:write": membershipAuthority, - "routines:run": membershipAuthority, - "approvals:read": membershipAuthority, - "approvals:write": membershipAuthority, - "approvals:decide": membershipAuthority, - "costs:read": membershipAuthority, - "costs:write": membershipAuthority, - "activity:read": membershipAuthority, - "artifacts:read": membershipAuthority, - "artifacts:write": membershipAuthority, - "workspaces:read": membershipAuthority, - "workspaces:manage": membershipAuthority, - "skills:read": membershipAuthority, - "skills:manage": grantAuthority("skills:create"), - "tools:read": membershipAuthority, - "tools:manage": grantAuthority("tools:admin"), - "secrets:read_metadata": membershipAuthority, - "secrets:manage": membershipAuthority, - "members:read": membershipAuthority, - "members:manage": grantAuthority("users:invite", "users:manage_permissions", "joins:approve"), - "decisions:read": membershipAuthority, - "decisions:write": membershipAuthority, - "settings:read": membershipAuthority, - "settings:write": membershipAuthority, - "environments:read": membershipAuthority, - "environments:manage": grantAuthority("environments:manage"), - "pipelines:read": membershipAuthority, - "pipelines:write": grantAuthority("pipelines:write"), - "search:read": membershipAuthority, - "runtime:read": membershipAuthority, - "runtime:manage": membershipAuthority, - "audit:read": grantAuthority("audit:view_agent_actions"), - "instance:read": membershipAuthority, - "instance:manage": membershipAuthority, - "companies:create": membershipAuthority, - "companies:import_export": membershipAuthority, - "plugins:read": membershipAuthority, - "plugins:manage": membershipAuthority, - "adapters:read": membershipAuthority, - "adapters:manage": membershipAuthority, - "users:read": membershipAuthority, - "users:manage": membershipAuthority, - "catalogs:read": membershipAuthority, - "catalogs:manage": membershipAuthority, - "backups:create": membershipAuthority, - "board_api_keys:revoke_self": membershipAuthority, -} satisfies Record; - -async function ownerHasRequiredGrant( - db: Db, - ownerUserId: string, - companyIds: readonly string[], - action: BoardPermissionKey, -) { - const requirement = OWNER_AUTHORITY_REQUIREMENTS[action]; - if (requirement.kind === "membership") return true; - const { permissionKeys } = requirement; - const rows = await db - .select({ - companyId: principalPermissionGrants.companyId, - permissionKey: principalPermissionGrants.permissionKey, - }) - .from(principalPermissionGrants) - .where(and( - inArray(principalPermissionGrants.companyId, [...companyIds]), - eq(principalPermissionGrants.principalType, "user"), - eq(principalPermissionGrants.principalId, ownerUserId), - inArray(principalPermissionGrants.permissionKey, [...permissionKeys]), - )); - const liveKeysByCompany = new Map>(); - for (const row of rows) { - const liveKeys = liveKeysByCompany.get(row.companyId) ?? new Set(); - liveKeys.add(row.permissionKey); - liveKeysByCompany.set(row.companyId, liveKeys); - } - return companyIds.every((companyId) => { - const liveKeys = liveKeysByCompany.get(companyId); - return permissionKeys.every((permissionKey) => liveKeys?.has(permissionKey)); - }); -} - async function auditDecision( db: Db, req: Request, @@ -854,7 +743,7 @@ export async function authorizeBoardKey( 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") { + if (isBoardKeyWriteAction(action as BoardPermissionKey) && membership?.membershipRole === "viewer") { await denyBoardKey(db, req, metadata, resource, "owner_role_read_only", "forbidden"); } if (!await ownerHasRequiredGrant( diff --git a/server/src/services/board-auth.test.ts b/server/src/services/board-auth.test.ts index 7a51a6f86c..f0883ae991 100644 --- a/server/src/services/board-auth.test.ts +++ b/server/src/services/board-auth.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import type { Db } from "@paperclipai/db"; +import { + boardApiKeys, + companyMemberships, + instanceUserRoles, + principalPermissionGrants, + type Db, +} from "@paperclipai/db"; import { boardAuthService } from "./board-auth.js"; describe("boardAuthService touchBoardApiKey", () => { @@ -45,3 +51,92 @@ describe("boardAuthService touchBoardApiKey", () => { expect(update).toHaveBeenCalledTimes(1); }); }); + +describe("boardAuthService createNamedBoardApiKey", () => { + const companyId = "00000000-0000-4000-8000-000000000001"; + const userId = "user-1"; + + function creationDb(options: { + instanceAdmin?: boolean; + membershipRole?: "viewer" | "operator"; + grants?: string[]; + }) { + const insert = vi.fn((table: unknown) => { + expect(table).toBe(boardApiKeys); + return { + values: () => ({ + returning: () => Promise.resolve([{ + id: "key-1", + name: "automation", + tokenPrefix: "pcp_board_prefix", + createdAt: new Date(), + lastUsedAt: null, + revokedAt: null, + expiresAt: new Date(Date.now() + 60_000), + }]), + }), + }; + }); + const select = vi.fn(() => ({ + from: (table: unknown) => ({ + where: () => { + if (table === instanceUserRoles) { + return Promise.resolve(options.instanceAdmin ? [{ id: "admin-role" }] : []); + } + if (table === companyMemberships) { + return Promise.resolve(options.membershipRole + ? [{ companyId, membershipRole: options.membershipRole }] + : []); + } + if (table === principalPermissionGrants) { + return Promise.resolve((options.grants ?? []).map((permissionKey) => ({ + companyId, + permissionKey, + }))); + } + throw new Error("Unexpected authority table"); + }, + }), + })); + return { db: { select, insert } as unknown as Db, insert }; + } + + it("rejects an instance-admin capability for a non-admin owner", async () => { + const { db, insert } = creationDb({ membershipRole: "operator" }); + const service = boardAuthService(db); + + await expect(service.createNamedBoardApiKey({ + userId, + name: "automation", + scopeConfig: { + version: 1, + kind: "scoped", + companyIds: [companyId], + permissions: ["companies:read"], + instanceCapabilities: ["instance_admin"], + }, + })).rejects.toMatchObject({ status: 403 }); + expect(insert).not.toHaveBeenCalled(); + }); + + it("rejects permissions outside the owner's live granular grants", async () => { + const { db, insert } = creationDb({ + membershipRole: "operator", + grants: ["agents:create"], + }); + const service = boardAuthService(db); + + await expect(service.createNamedBoardApiKey({ + userId, + name: "automation", + scopeConfig: { + version: 1, + kind: "scoped", + companyIds: [companyId], + permissions: ["agents:write"], + instanceCapabilities: [], + }, + })).rejects.toMatchObject({ status: 403 }); + expect(insert).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/services/board-auth.ts b/server/src/services/board-auth.ts index 5fc6fb9af6..612e3dde92 100644 --- a/server/src/services/board-auth.ts +++ b/server/src/services/board-auth.ts @@ -15,6 +15,7 @@ import { instanceUserRoles, } from "@paperclipai/db"; import { conflict, forbidden, notFound } from "../errors.js"; +import { validateBoardKeyScopeOwnerAuthority } from "../security/board-key-owner-authority.js"; export const BOARD_API_KEY_TTL_MS = 30 * 24 * 60 * 60 * 1000; export const CLI_AUTH_CHALLENGE_TTL_MS = 10 * 60 * 1000; @@ -253,8 +254,15 @@ export function boardAuthService(db: Db) { expiresAt?: Date | null; scopeConfig: BoardApiKeyScopeConfig; }) { - const token = createBoardApiToken(); const scopeConfig = boardApiKeyScopeConfigSchema.parse(input.scopeConfig); + const authorityViolation = await validateBoardKeyScopeOwnerAuthority(db, input.userId, scopeConfig); + if (authorityViolation === "instance_admin_required") { + throw forbidden("Instance admin required for the requested board API key scope"); + } + if (authorityViolation) { + throw forbidden("Board API key scope exceeds the owner's current authority"); + } + const token = createBoardApiToken(); const created = await db .insert(boardApiKeys) .values({ @@ -464,6 +472,17 @@ export function boardAuthService(db: Db) { if (!requestedScope.success || !challenge.pendingKeyPrefix) { throw conflict("CLI auth challenge must be recreated with an explicit board-key scope"); } + const authorityViolation = await validateBoardKeyScopeOwnerAuthority( + tx as unknown as Db, + userId, + requestedScope.data, + ); + if (authorityViolation === "instance_admin_required") { + throw forbidden("Instance admin required for the requested board API key scope"); + } + if (authorityViolation) { + throw forbidden("Board API key scope exceeds the owner's current authority"); + } let boardKeyId = challenge.boardApiKeyId; if (!boardKeyId) {