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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-06 14:03:01 +00:00 committed by cryppadotta
parent df8ffa3934
commit dfe85bd9dc
9 changed files with 183 additions and 6 deletions

View File

@ -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");

View File

@ -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<typeof createCliAuthChallengeSchema>;

View File

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

View File

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

View File

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

View File

@ -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<Record<BoardPermissionKey, readonly PermissionKey[]>> = {
"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 {

View File

@ -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(),

View File

@ -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;

View File

@ -147,6 +147,38 @@ export function CliAuthPage() {
<div className="text-foreground">{challenge.requestedCompanyName}</div>
</div>
)}
{challenge.requestedScopeConfig && (
<div className="space-y-3">
<div>
<div className="text-muted-foreground">Scoped companies</div>
<ul className="space-y-1">
{challenge.requestedScopeConfig.companyIds.map((companyId) => (
<li key={companyId} className="font-mono text-foreground">{companyId}</li>
))}
</ul>
</div>
<div>
<div className="text-muted-foreground">Permissions</div>
<ul className="space-y-1">
{challenge.requestedScopeConfig.permissions.map((permission) => (
<li key={permission} className="font-mono text-foreground">{permission}</li>
))}
</ul>
</div>
<div>
<div className="text-muted-foreground">Instance capabilities</div>
{challenge.requestedScopeConfig.instanceCapabilities.length > 0 ? (
<ul className="space-y-1">
{challenge.requestedScopeConfig.instanceCapabilities.map((capability) => (
<li key={capability} className="font-mono text-foreground">{capability}</li>
))}
</ul>
) : (
<div className="text-foreground">None</div>
)}
</div>
</div>
)}
</div>
{(approveMutation.error || cancelMutation.error) && (
@ -159,7 +191,9 @@ export function CliAuthPage() {
{!challenge.canApprove && (
<p className="mt-4 text-sm text-destructive">
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."}
</p>
)}