diff --git a/server/src/security/board-key-owner-authority.ts b/server/src/security/board-key-owner-authority.ts index 52506b6485..391ac5aec7 100644 --- a/server/src/security/board-key-owner-authority.ts +++ b/server/src/security/board-key-owner-authority.ts @@ -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; 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({ diff --git a/server/src/security/board-key-route-inventory.ts b/server/src/security/board-key-route-inventory.ts index e2f0612ebe..168ce30712 100644 --- a/server/src/security/board-key-route-inventory.ts +++ b/server/src/security/board-key-route-inventory.ts @@ -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}", diff --git a/server/src/security/board-key-route-registry.test.ts b/server/src/security/board-key-route-registry.test.ts index 140a967a28..060eb63790 100644 --- a/server/src/security/board-key-route-registry.test.ts +++ b/server/src/security/board-key-route-registry.test.ts @@ -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"], diff --git a/server/src/security/board-key-route-registry.ts b/server/src/security/board-key-route-registry.ts index 4e08541d5d..129b338ea4 100644 --- a/server/src/security/board-key-route-registry.ts +++ b/server/src/security/board-key-route-registry.ts @@ -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", diff --git a/server/src/services/board-auth.test.ts b/server/src/services/board-auth.test.ts index f90c2e2e27..f62a49cee0 100644 --- a/server/src/services/board-auth.test.ts +++ b/server/src/services/board-auth.test.ts @@ -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(); + }); });