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:
parent
df8ffa3934
commit
dfe85bd9dc
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue