fix(security): bound board key creation authority
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
ac809173ed
commit
2bcaf2b7f2
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue