From dfe85bd9dcfc875194fff99c45fb693d366627df Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 6 Aug 2026 14:03:01 +0000 Subject: [PATCH] fix(security): enforce live board-key authority Intersect scoped board keys with current owner grants and show the exact requested scope before CLI approval. Co-Authored-By: Paperclip --- .../shared/src/board-api-key-scope.test.ts | 31 ++++++++++- packages/shared/src/validators/access.ts | 18 ++++++- server/src/__tests__/cli-auth-routes.test.ts | 14 +++++ server/src/routes/access.ts | 1 + .../security/board-key-authorization.test.ts | 28 ++++++++++ .../src/security/board-key-route-registry.ts | 51 ++++++++++++++++++- server/src/services/board-auth.ts | 2 + ui/src/api/access.ts | 8 ++- ui/src/pages/CliAuth.tsx | 36 ++++++++++++- 9 files changed, 183 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/board-api-key-scope.test.ts b/packages/shared/src/board-api-key-scope.test.ts index 251cc9493c..0464a9a757 100644 --- a/packages/shared/src/board-api-key-scope.test.ts +++ b/packages/shared/src/board-api-key-scope.test.ts @@ -5,7 +5,7 @@ import { boardApiKeyScopeConfigSchema, deriveBoardApiKeyStatus, } from "./board-api-key-scope.js"; -import { createBoardApiKeySchema } from "./validators/access.js"; +import { createBoardApiKeySchema, createCliAuthChallengeSchema } from "./validators/access.js"; const COMPANY_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; @@ -89,6 +89,35 @@ describe("createBoardApiKeySchema", () => { }); }); +describe("createCliAuthChallengeSchema", () => { + it("binds the displayed company and access level to the exact minted scope", () => { + const base = { + command: "paperclipai auth login", + requestedAccess: "board" as const, + requestedCompanyId: COMPANY_ID, + scopeConfig: validScope(), + }; + expect(createCliAuthChallengeSchema.parse(base)).toEqual(base); + expect(createCliAuthChallengeSchema.safeParse({ + ...base, + requestedCompanyId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + }).success).toBe(false); + expect(createCliAuthChallengeSchema.safeParse({ + ...base, + scopeConfig: { ...validScope(), instanceCapabilities: ["instance_admin"] }, + }).success).toBe(false); + expect(createCliAuthChallengeSchema.safeParse({ + ...base, + requestedAccess: "instance_admin_required", + }).success).toBe(false); + expect(createCliAuthChallengeSchema.safeParse({ + ...base, + requestedAccess: "instance_admin_required", + scopeConfig: { ...validScope(), instanceCapabilities: ["instance_admin"] }, + }).success).toBe(true); + }); +}); + describe("deriveBoardApiKeyStatus", () => { const now = new Date("2026-08-06T12:00:00.000Z"); diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 21001d6b1a..572f7f690e 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -77,7 +77,23 @@ export const createCliAuthChallengeSchema = z.object({ requestedAccess: boardCliAuthAccessLevelSchema.default("board"), requestedCompanyId: z.string().guid().optional().nullable(), scopeConfig: boardApiKeyScopeConfigSchema, -}).strict(); +}).strict().superRefine((value, ctx) => { + const scopeRequiresInstanceAdmin = value.scopeConfig.instanceCapabilities.includes("instance_admin"); + if ((value.requestedAccess === "instance_admin_required") !== scopeRequiresInstanceAdmin) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["requestedAccess"], + message: "requestedAccess must match the scope's instance capabilities", + }); + } + if (value.requestedCompanyId && !value.scopeConfig.companyIds.includes(value.requestedCompanyId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["requestedCompanyId"], + message: "requestedCompanyId must be included in the requested scope", + }); + } +}); export type CreateCliAuthChallenge = z.infer; diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index 902b783201..dbade48b23 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -195,6 +195,13 @@ describe.sequential("cli auth routes", () => { requestedAccess: "board", requestedCompanyId: null, requestedCompanyName: null, + requestedScopeConfig: { + version: 1, + kind: "scoped", + companyIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"], + permissions: ["companies:read"], + instanceCapabilities: [], + }, approvedAt: null, cancelledAt: null, expiresAt: "2026-03-23T13:00:00.000Z", @@ -208,6 +215,13 @@ describe.sequential("cli auth routes", () => { expect(res.status).toBe(200); expect(res.body.requiresSignIn).toBe(true); expect(res.body.canApprove).toBe(false); + expect(res.body.requestedScopeConfig).toEqual({ + version: 1, + kind: "scoped", + companyIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"], + permissions: ["companies:read"], + instanceCapabilities: [], + }); }); it.sequential("approves a CLI auth challenge for a signed-in board user", async () => { diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index a266539a7c..04abdc6eba 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2801,6 +2801,7 @@ export function accessRoutes( Boolean(req.actor.userId); const canApprove = isSignedInBoardUser && + challenge.requestedScopeConfig !== null && (challenge.requestedAccess !== "instance_admin_required" || isLocalImplicit(req) || Boolean(req.actor.isInstanceAdmin)); diff --git a/server/src/security/board-key-authorization.test.ts b/server/src/security/board-key-authorization.test.ts index 8becf6a49f..a9785a422a 100644 --- a/server/src/security/board-key-authorization.test.ts +++ b/server/src/security/board-key-authorization.test.ts @@ -7,6 +7,7 @@ import { boardApiKeys, companyMemberships, instanceUserRoles, + principalPermissionGrants, } from "@paperclipai/db"; import { BOARD_API_KEY_SCOPE_PRESETS } from "@paperclipai/shared"; import { HttpError } from "../errors.js"; @@ -35,6 +36,7 @@ function createLiveAuthorityDb() { membershipRole: "owner", instanceAdmin: true, revoked: false, + permissionGrants: new Set(["agents:create", "agents:configure", "tasks:assign"]), }; const key = { id: keyId, @@ -62,6 +64,8 @@ function createLiveAuthorityDb() { ? (state.membershipActive ? [{ companyId, membershipRole: state.membershipRole, status: "active" }] : []) : table === instanceUserRoles ? (state.instanceAdmin ? [{ id: randomUUID() }] : []) + : table === principalPermissionGrants + ? [...state.permissionGrants].map((permissionKey) => ({ permissionKey })) : []; return { where: () => Promise.resolve(rows), @@ -233,4 +237,28 @@ describe("board-key effective authority", () => { authentication = await service.authenticateBoardApiKey(TOKEN); expect(authentication).toMatchObject({ ok: false, reason: "owner_deleted" }); }); + + it("applies owner permission-grant revocation on the next request", async () => { + const { db, state, companyId } = createLiveAuthorityDb(); + const authentication = await boardAuthService(db).authenticateBoardApiKey(TOKEN); + const req = requestFor(authentication); + const metadata = lookupBoardKeyRoute("PATCH", `/api/agents/${randomUUID()}`); + + await expect(authorizeBoardKey( + db, + req, + metadata.action, + async () => ({ companyId, resourceType: "agent", resourceId: randomUUID() }), + metadata, + )).resolves.toBeUndefined(); + + state.permissionGrants.delete("agents:configure"); + await expectDenied(authorizeBoardKey( + db, + req, + metadata.action, + async () => ({ companyId, resourceType: "agent", resourceId: randomUUID() }), + metadata, + ), 403); + }); }); diff --git a/server/src/security/board-key-route-registry.ts b/server/src/security/board-key-route-registry.ts index 80556178fb..8083725651 100644 --- a/server/src/security/board-key-route-registry.ts +++ b/server/src/security/board-key-route-registry.ts @@ -1,5 +1,5 @@ import type { Request, RequestHandler } from "express"; -import { eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -20,6 +20,7 @@ import { issueWorkProducts, issues, labels, + principalPermissionGrants, projects, routines, routineTriggers, @@ -34,7 +35,7 @@ import { toolRuntimeSlots, workspaceOperations, } from "@paperclipai/db"; -import { isUuidLike, type BoardPermissionKey } from "@paperclipai/shared"; +import { isUuidLike, type BoardPermissionKey, type PermissionKey } from "@paperclipai/shared"; import { HttpError, forbidden, notFound } from "../errors.js"; import { logger } from "../middleware/logger.js"; @@ -564,6 +565,44 @@ 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. Require the closest +// live owner grant for every action that has an internal grant boundary. This +// makes grant revocation effective on the next request while existing +// membership roles remain authoritative for actions without a granular grant. +const OWNER_GRANT_REQUIREMENTS: Partial> = { + "agents:write": ["agents:create", "agents:configure"], + "issues:write": ["tasks:assign"], + "issues:control": ["tasks:assign", "tasks:manage_active_checkouts"], + "skills:manage": ["skills:create"], + "environments:manage": ["environments:manage"], + "tools:manage": ["tools:admin"], + "audit:read": ["audit:view_agent_actions"], + "members:manage": ["users:invite", "users:manage_permissions", "joins:approve"], + "pipelines:write": ["pipelines:write"], +}; + +async function ownerHasRequiredGrant( + db: Db, + ownerUserId: string, + companyId: string, + action: BoardPermissionKey, +) { + const permissionKeys = OWNER_GRANT_REQUIREMENTS[action]; + if (!permissionKeys) return true; + const rows = await db + .select({ permissionKey: principalPermissionGrants.permissionKey }) + .from(principalPermissionGrants) + .where(and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, ownerUserId), + inArray(principalPermissionGrants.permissionKey, [...permissionKeys]), + )); + const liveKeys = new Set(rows.map((row) => row.permissionKey)); + return permissionKeys.every((permissionKey) => liveKeys.has(permissionKey)); +} + async function auditDecision( db: Db, req: Request, @@ -668,6 +707,14 @@ export async function authorizeBoardKey( if (isWriteAction(action as BoardPermissionKey) && membership?.membershipRole === "viewer") { await denyBoardKey(db, req, metadata, resource, "owner_role_read_only", "forbidden"); } + if (!await ownerHasRequiredGrant( + db, + req.actor.boardKeyOwnerId!, + resource.companyId, + action as BoardPermissionKey, + )) { + await denyBoardKey(db, req, metadata, resource, "owner_permission_grant_missing", "forbidden"); + } } try { diff --git a/server/src/services/board-auth.ts b/server/src/services/board-auth.ts index fca95bd16a..5fc6fb9af6 100644 --- a/server/src/services/board-auth.ts +++ b/server/src/services/board-auth.ts @@ -394,6 +394,7 @@ export function boardAuthService(db: Db) { async function describeCliAuthChallenge(id: string, token: string) { const challenge = await getCliAuthChallengeBySecret(id, token); if (!challenge) return null; + const requestedScope = boardApiKeyScopeConfigSchema.safeParse(challenge.requestedScopeConfig); const [company, approvedBy] = await Promise.all([ challenge.requestedCompanyId @@ -420,6 +421,7 @@ export function boardAuthService(db: Db) { requestedAccess: challenge.requestedAccess as "board" | "instance_admin_required", requestedCompanyId: challenge.requestedCompanyId ?? null, requestedCompanyName: company?.name ?? null, + requestedScopeConfig: requestedScope.success ? requestedScope.data : null, approvedAt: challenge.approvedAt?.toISOString() ?? null, cancelledAt: challenge.cancelledAt?.toISOString() ?? null, expiresAt: challenge.expiresAt.toISOString(), diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 35c76898e0..06b7f3a106 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -1,4 +1,9 @@ -import type { AgentAdapterType, JoinRequest, PermissionKey } from "@paperclipai/shared"; +import type { + AgentAdapterType, + BoardApiKeyScopeConfig, + JoinRequest, + PermissionKey, +} from "@paperclipai/shared"; import { api } from "./client"; export type HumanCompanyRole = "owner" | "admin" | "operator" | "viewer"; @@ -80,6 +85,7 @@ type CliAuthChallengeStatus = { requestedAccess: "board" | "instance_admin_required"; requestedCompanyId: string | null; requestedCompanyName: string | null; + requestedScopeConfig: BoardApiKeyScopeConfig | null; approvedAt: string | null; cancelledAt: string | null; expiresAt: string; diff --git a/ui/src/pages/CliAuth.tsx b/ui/src/pages/CliAuth.tsx index ae706db6be..cf386833b5 100644 --- a/ui/src/pages/CliAuth.tsx +++ b/ui/src/pages/CliAuth.tsx @@ -147,6 +147,38 @@ export function CliAuthPage() {
{challenge.requestedCompanyName}
)} + {challenge.requestedScopeConfig && ( +
+
+
Scoped companies
+
    + {challenge.requestedScopeConfig.companyIds.map((companyId) => ( +
  • {companyId}
  • + ))} +
+
+
+
Permissions
+
    + {challenge.requestedScopeConfig.permissions.map((permission) => ( +
  • {permission}
  • + ))} +
+
+
+
Instance capabilities
+ {challenge.requestedScopeConfig.instanceCapabilities.length > 0 ? ( +
    + {challenge.requestedScopeConfig.instanceCapabilities.map((capability) => ( +
  • {capability}
  • + ))} +
+ ) : ( +
None
+ )} +
+
+ )} {(approveMutation.error || cancelMutation.error) && ( @@ -159,7 +191,9 @@ export function CliAuthPage() { {!challenge.canApprove && (

- This challenge requires instance-admin access. Sign in with an instance admin account to approve it. + {challenge.requestedScopeConfig + ? "This challenge requires instance-admin access. Sign in with an instance admin account to approve it." + : "This challenge does not contain a valid board-key scope. Start the CLI auth flow again."}

)}