fix(security): preserve board key authority boundaries

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
cryppadotta 2026-09-10 17:50:05 +00:00
parent a562e90397
commit c4487fd1f6
5 changed files with 159 additions and 71 deletions

View File

@ -16,75 +16,84 @@ export function isBoardKeyWriteAction(action: BoardPermissionKey) {
}
type OwnerAuthorityRequirement =
| { kind: "membership" }
| { kind: "membership"; roles: readonly string[] }
| { kind: "instance_admin" }
| { kind: "grants"; permissionKeys: readonly PermissionKey[] };
const membershipAuthority = { kind: "membership" } as const;
const readMembershipAuthority = {
kind: "membership",
roles: ["owner", "admin", "operator", "member", "viewer"],
} as const;
const administrativeMembershipAuthority = {
kind: "membership",
roles: ["owner", "admin"],
} as const;
const instanceAdminAuthority = { kind: "instance_admin" } as const;
const grantAuthority = (...permissionKeys: PermissionKey[]) => ({
kind: "grants" as const,
permissionKeys,
});
const OWNER_AUTHORITY_REQUIREMENTS = {
"companies:read": membershipAuthority,
"companies:write": membershipAuthority,
"agents:read": membershipAuthority,
"companies:read": readMembershipAuthority,
"companies:write": administrativeMembershipAuthority,
"agents:read": readMembershipAuthority,
"agents:write": grantAuthority("agents:create", "agents:configure"),
"agents:operate": grantAuthority("agents:configure"),
"projects:read": membershipAuthority,
"projects:write": membershipAuthority,
"issues:read": membershipAuthority,
"projects:read": readMembershipAuthority,
"projects:write": administrativeMembershipAuthority,
"issues:read": readMembershipAuthority,
"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,
"goals:read": readMembershipAuthority,
"goals:write": administrativeMembershipAuthority,
"routines:read": readMembershipAuthority,
"routines:write": administrativeMembershipAuthority,
"routines:run": administrativeMembershipAuthority,
"approvals:read": readMembershipAuthority,
"approvals:write": administrativeMembershipAuthority,
"approvals:decide": administrativeMembershipAuthority,
"costs:read": readMembershipAuthority,
"costs:write": administrativeMembershipAuthority,
"activity:read": readMembershipAuthority,
"artifacts:read": readMembershipAuthority,
"artifacts:write": administrativeMembershipAuthority,
"workspaces:read": readMembershipAuthority,
"workspaces:manage": administrativeMembershipAuthority,
"skills:read": readMembershipAuthority,
"skills:manage": grantAuthority("skills:create"),
"tools:read": membershipAuthority,
"tools:read": readMembershipAuthority,
"tools:manage": grantAuthority("tools:admin"),
"secrets:read_metadata": membershipAuthority,
"secrets:manage": membershipAuthority,
"members:read": membershipAuthority,
"secrets:read_metadata": readMembershipAuthority,
"secrets:manage": administrativeMembershipAuthority,
"members:read": readMembershipAuthority,
"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,
"decisions:read": readMembershipAuthority,
"decisions:write": administrativeMembershipAuthority,
"settings:read": readMembershipAuthority,
"settings:write": administrativeMembershipAuthority,
"environments:read": readMembershipAuthority,
"environments:manage": grantAuthority("environments:manage"),
"pipelines:read": membershipAuthority,
"pipelines:read": readMembershipAuthority,
"pipelines:write": grantAuthority("pipelines:write"),
"search:read": membershipAuthority,
"runtime:read": membershipAuthority,
"runtime:manage": membershipAuthority,
"search:read": readMembershipAuthority,
"runtime:read": readMembershipAuthority,
"runtime:manage": administrativeMembershipAuthority,
"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,
"instance:read": instanceAdminAuthority,
"instance:manage": instanceAdminAuthority,
"companies:create": instanceAdminAuthority,
"companies:import_export": instanceAdminAuthority,
"plugins:read": instanceAdminAuthority,
"plugins:manage": instanceAdminAuthority,
"adapters:read": instanceAdminAuthority,
"adapters:manage": instanceAdminAuthority,
"users:read": instanceAdminAuthority,
"users:manage": instanceAdminAuthority,
"catalogs:read": instanceAdminAuthority,
"catalogs:manage": instanceAdminAuthority,
"backups:create": instanceAdminAuthority,
"board_api_keys:revoke_self": readMembershipAuthority,
} satisfies Record<BoardPermissionKey, OwnerAuthorityRequirement>;
export async function ownerHasRequiredGrant(
@ -94,7 +103,33 @@ export async function ownerHasRequiredGrant(
action: BoardPermissionKey,
) {
const requirement = OWNER_AUTHORITY_REQUIREMENTS[action];
if (requirement.kind === "membership") return true;
if (requirement.kind === "instance_admin") {
const row = await db
.select({ id: instanceUserRoles.id })
.from(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, ownerUserId), eq(instanceUserRoles.role, "instance_admin")))
.then((rows) => rows[0] ?? null);
return row !== null;
}
if (requirement.kind === "membership") {
const rows = await 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, [...companyIds]),
));
const rolesByCompany = new Map(rows.map((row) => [row.companyId, row.membershipRole]));
return companyIds.every((companyId) => {
const role = rolesByCompany.get(companyId);
return typeof role === "string" && requirement.roles.includes(role);
});
}
const { permissionKeys } = requirement;
const rows = await db
.select({

View File

@ -263,7 +263,7 @@ export const BOARD_KEY_ROUTE_INVENTORY: readonly string[] = [
"board_key_denied | deny | /api/routine-triggers/public/{id}/fire",
"board_key_denied | deny | /api/sidebar-preferences/{*path}",
"board_key_denied | deny | /api/tool-gateway/{*path}",
"board_key_denied | deny | /api/tools/oauth/{*path}",
"board_key_denied | deny | /api/tools/{*path}",
"board_key_denied | deny | /llms/{*path}",
"board_key_denied | deny | /mcp/{*path}",
"board_key_denied | deny | /runtime-tools/{*path}",
@ -297,12 +297,8 @@ export const BOARD_KEY_ROUTE_INVENTORY: readonly string[] = [
"company | decisions:write | /api/decision-training/{id}/{*path}",
"company | decisions:write | /api/decisions/{id}/{*path}",
"company | environments:manage | /api/companies/{id}/{*path}",
"company | environments:manage | /api/environment-custom-image-setup-sessions/{id}/{*path}",
"company | environments:manage | /api/environments/{id}/{*path}",
"company | environments:read | /api/companies/{id}/{*path}",
"company | environments:read | /api/environment-custom-image-setup-sessions/{id}/{*path}",
"company | environments:read | /api/environment-leases/{id}/{*path}",
"company | environments:read | /api/environments/{id}/{*path}",
"company | goals:read | /api/companies/{id}/{*path}",
"company | goals:read | /api/goals/{id}/{*path}",
"company | goals:write | /api/companies/{id}/{*path}",
@ -352,18 +348,21 @@ export const BOARD_KEY_ROUTE_INVENTORY: readonly string[] = [
"company | tools:read | /api/tool-connections/{id}/{*path}",
"company | tools:read | /api/tool-gateway/{*path}",
"company | tools:read | /api/tool-profiles/{id}/{*path}",
"company | tools:read | /api/tools/{*path}",
"company | workspaces:manage | /api/execution-workspaces/{id}/{*path}",
"company | workspaces:read | /api/companies/{id}/{*path}",
"company | workspaces:read | /api/execution-workspaces/{id}/{*path}",
"company | workspaces:read | /api/workspace-operations/{id}/{*path}",
"company_collection | companies:read | /api/companies",
"company_collection | environments:read | /api/environment-custom-image-setup-sessions/{id}/{*path}",
"company_collection | environments:read | /api/environments/{id}/{*path}",
"instance_global | adapters:manage | /api/adapters/{id}/{*path}",
"instance_global | adapters:read | /api/adapters/{id}/{*path}",
"instance_global | catalogs:read | /api/skills/{*path}",
"instance_global | catalogs:read | /api/teams/{*path}",
"instance_global | companies:create | /api/companies",
"instance_global | companies:import_export | /api/companies/import/{*path}",
"instance_global | environments:manage | /api/environment-custom-image-setup-sessions/{id}/{*path}",
"instance_global | environments:manage | /api/environments/{id}/{*path}",
"instance_global | instance:manage | /api/instance/{*path}",
"instance_global | instance:read | /api/instance/{*path}",
"instance_global | plugins:manage | /api/plugins/{id}/{*path}",

View File

@ -52,6 +52,13 @@ function runtimeInventory(extra: readonly RuntimeRoute[] = []): string[] {
describe("board-key route registry", () => {
it.each([
["GET", "/api/companies/11111111-1111-4111-8111-111111111111/issues", "issues:read", "company"],
["GET", "/api/companies/11111111-1111-4111-8111-111111111111/environments", "environments:read", "company"],
["POST", "/api/companies/11111111-1111-4111-8111-111111111111/tools/applications", "tools:manage", "company"],
["GET", "/api/companies/11111111-1111-4111-8111-111111111111/tools/runtime-slots", "tools:read", "company"],
["GET", "/api/environments/11111111-1111-4111-8111-111111111111", "environments:read", "company_collection"],
["PATCH", "/api/environments/11111111-1111-4111-8111-111111111111", "environments:manage", "instance_global"],
["GET", "/api/environment-leases/11111111-1111-4111-8111-111111111111", "environments:read", "company"],
["GET", "/api/tools/vercel-connect/callback", "deny", "board_key_denied"],
["POST", "/api/issues/11111111-1111-4111-8111-111111111111/checkout", "issues:control", "company"],
["DELETE", "/api/board-api-keys/11111111-1111-4111-8111-111111111111", "board_api_keys:revoke_self", "key_self"],
["POST", "/api/board-api-keys", "deny", "board_key_denied"],

View File

@ -12,6 +12,7 @@ import {
companies,
decisionTrainingExamples,
decisions,
environmentLeases,
executionWorkspaces,
folders,
goals,
@ -85,8 +86,8 @@ export type BoardKeyAuthoritativeResolver =
| "decision"
| "decision_training"
| "workspace_operation"
| "key_self"
| "downstream_company";
| "environment_lease"
| "key_self";
export interface BoardKeyRouteMetadata {
method: string;
@ -460,10 +461,18 @@ export function lookupBoardKeyRoute(methodInput: string, rawPath: string): Board
return declared(method, `/api/work-products/${ID}/{*path}`, permissionForMethod(method, "artifacts:read", "artifacts:write"), "company", "work_product", { resourceId });
case "heartbeat-runs":
return declared(method, `/api/heartbeat-runs/${ID}/{*path}`, permissionForMethod(method, "runtime:read", "runtime:manage"), "company", "heartbeat_run", { resourceId });
case "environments":
case "environment-leases":
return declared(method, `/api/environment-leases/${ID}/{*path}`, "environments:read", "company", "environment_lease", { resourceId });
case "environments":
case "environment-custom-image-setup-sessions":
return declared(method, `/api/${top}/${ID}/{*path}`, permissionForMethod(method, "environments:read", "environments:manage"), "company", "downstream_company", { resourceId });
// Environment rows and custom-image setup sessions are instance-owned,
// not company-owned. Reads preserve the existing board-member view;
// mutations require the explicit instance-admin capability. Company-
// scoped environment operations use /api/companies/:companyId/... and
// are resolved by the authoritative company route above.
return SAFE_METHODS.has(method)
? declared(method, `/api/${top}/${ID}/{*path}`, "environments:read", "company_collection", "none", { resourceId, concealment: "forbidden" })
: declared(method, `/api/${top}/${ID}/{*path}`, "environments:manage", "instance_global", "none", { resourceId, concealment: "forbidden" });
case "secrets":
return declared(method, `/api/secrets/${ID}/{*path}`, permissionForMethod(method, "secrets:read_metadata", "secrets:manage"), "company", "secret", { resourceId });
case "secret-provider-configs":
@ -520,8 +529,10 @@ export function lookupBoardKeyRoute(methodInput: string, rawPath: string): Board
if (isDeniedToolGatewayRoute(method, segments)) return denied(method, "/api/tool-gateway/{*path}");
return declared(method, "/api/tool-gateway/{*path}", permissionForMethod(method, "tools:read", "tools:manage"), "company", "tool_gateway", { resourceId: segments[3] });
case "tools":
if (segments[2] === "oauth") return denied(method, "/api/tools/oauth/{*path}");
return declared(method, "/api/tools/{*path}", permissionForMethod(method, "tools:read", "tools:manage"), "company", "downstream_company", { resourceId });
// Company tool APIs live below /api/companies/:companyId/tools and use
// the authoritative company resolver. These unscoped paths are OAuth
// callbacks and enrollment flows, so board keys must never reach them.
return denied(method, "/api/tools/{*path}");
default:
return declared(method, path, "deny", "undeclared", "none", { concealment: "forbidden" });
}
@ -613,13 +624,12 @@ async function resolveAuthoritativeResource(
case "decision": return resolveCompanyTableRow(decisions, "decision");
case "decision_training": return resolveCompanyTableRow(decisionTrainingExamples, "decision_training");
case "workspace_operation": return resolveCompanyTableRow(workspaceOperations, "workspace_operation");
case "environment_lease": return resolveCompanyTableRow(environmentLeases, "environment_lease");
case "key_self": {
if (!id || !isUuidLike(id)) return null;
const row = await db.select({ id: boardApiKeys.id, userId: boardApiKeys.userId }).from(boardApiKeys).where(eq(boardApiKeys.id, id)).then((rows) => rows[0] ?? null);
return row ? { companyId: null, resourceType: "board_api_key", resourceId: row.id } : null;
}
case "downstream_company":
return { companyId: null, resourceType: null, resourceId: id ?? null };
}
}
@ -734,11 +744,6 @@ export async function authorizeBoardKey(
)) {
await denyBoardKey(db, req, metadata, resource, "owner_permission_grant_missing", "forbidden");
}
} else if (metadata.resolver === "downstream_company") {
// A family may be inventoried before it has a safe generic resolver. It is
// available to migrated legacy keys through existing route-level checks,
// but scoped keys fail closed until an authoritative resolver is added.
if (!legacy) await denyBoardKey(db, req, metadata, resource, "authoritative_resolver_unavailable", "not_found");
} else if (resource?.companyId) {
const membership = req.actor.memberships?.find(
(item) => item.companyId === resource.companyId && item.status === "active",

View File

@ -142,6 +142,27 @@ describe("boardAuthService createNamedBoardApiKey", () => {
expect(insert).not.toHaveBeenCalled();
});
it("rejects administrative membership permissions for an operator", async () => {
const { db, insert } = creationDb({
membershipRole: "operator",
grants: ["tasks:assign"],
});
const service = boardAuthService(db);
await expect(service.createNamedBoardApiKey({
userId,
name: "automation",
scopeConfig: {
version: 1,
kind: "scoped",
companyIds: [companyId],
permissions: ["secrets:manage"],
instanceCapabilities: [],
},
})).rejects.toMatchObject({ status: 403 });
expect(insert).not.toHaveBeenCalled();
});
it("accepts the recommended company-automation preset for a default owner", async () => {
const { db, insert } = creationDb({
membershipRole: "owner",
@ -162,4 +183,25 @@ describe("boardAuthService createNamedBoardApiKey", () => {
})).resolves.toMatchObject({ id: "key-1" });
expect(insert).toHaveBeenCalledOnce();
});
it("accepts the recommended company-automation preset for a default admin", async () => {
const { db, insert } = creationDb({
membershipRole: "admin",
grants: grantsForHumanRole("admin").map((grant) => grant.permissionKey),
});
const service = boardAuthService(db);
await expect(service.createNamedBoardApiKey({
userId,
name: "automation",
scopeConfig: {
version: 1,
kind: "scoped",
companyIds: [companyId],
permissions: [...BOARD_API_KEY_SCOPE_PRESETS.company_automation.permissions],
instanceCapabilities: [],
},
})).resolves.toMatchObject({ id: "key-1" });
expect(insert).toHaveBeenCalledOnce();
});
});