fix(security): bound board key creation authority

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
cryppadotta 2026-09-10 00:46:51 +00:00
parent ac809173ed
commit 2bcaf2b7f2
4 changed files with 295 additions and 119 deletions

View File

@ -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<BoardPermissionKey, OwnerAuthorityRequirement>;
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<string, Set<string>>();
for (const row of rows) {
const liveKeys = liveKeysByCompany.get(row.companyId) ?? new Set<string>();
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<BoardKeyScopeAuthorityViolation | null> {
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;
}

View File

@ -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<BoardPermissionKey, OwnerAuthorityRequirement>;
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<string, Set<string>>();
for (const row of rows) {
const liveKeys = liveKeysByCompany.get(row.companyId) ?? new Set<string>();
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(

View File

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

View File

@ -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) {