fix(security): preserve board key authority boundaries
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
a562e90397
commit
c4487fd1f6
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue