From e845f44d741f764316bce34cf743a995ca9f3c25 Mon Sep 17 00:00:00 2001 From: Oliver Gibbs Date: Fri, 18 Sep 2026 11:24:22 +0000 Subject: [PATCH] fix(fabricator): require an architect or admin role to request fabrication requestAgentCreation and requestToolCreation now refuse callers that hold neither the architect nor the admin role, after the existing required organisation check and before anything is sent to the fabricator queue. Triggering fabrication drives model spend and creates agent records, so it is gated like the agent-code and import mutations. Supersedes the earlier in-code tenancy-only decision. --- ...or-request-resolver-source-project.test.ts | 12 ++- .../fabricator-request-resolver.test.ts | 93 +++++++++++++++++++ .../src/lambda/fabricator-request-resolver.ts | 45 ++++++++- 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/backend/src/lambda/__tests__/fabricator-request-resolver-source-project.test.ts b/backend/src/lambda/__tests__/fabricator-request-resolver-source-project.test.ts index 85db4d2..2c54c44 100644 --- a/backend/src/lambda/__tests__/fabricator-request-resolver-source-project.test.ts +++ b/backend/src/lambda/__tests__/fabricator-request-resolver-source-project.test.ts @@ -20,8 +20,16 @@ const makeEvent = (fieldName: string, args: Record) => ({ // Tenancy fail-closed (design evidence, section C): the resolver now // derives org via requireOrgId before any dispatch, so every fixture in // this file must carry a resolvable caller org to reach the - // sourceProjectId-propagation behaviour under test. - identity: { sub: "user-123", "custom:organization": "org-caller" }, + // sourceProjectId-propagation behaviour under test. `custom:role: + // "architect"` satisfies the decision-2763e85f role gate (added after + // this file was written) so these unrelated propagation tests keep + // exercising their intended behaviour rather than being rejected by the + // role check. + identity: { + sub: "user-123", + "custom:organization": "org-caller", + "custom:role": "architect", + }, }); function parsePayload() { diff --git a/backend/src/lambda/__tests__/fabricator-request-resolver.test.ts b/backend/src/lambda/__tests__/fabricator-request-resolver.test.ts index eeaa2b6..40a2cf6 100644 --- a/backend/src/lambda/__tests__/fabricator-request-resolver.test.ts +++ b/backend/src/lambda/__tests__/fabricator-request-resolver.test.ts @@ -25,6 +25,7 @@ const makeEvent = ( identity: Record = { sub: "user-123", "custom:organization": "org-caller", + "custom:role": "architect", }, ) => ({ info: { fieldName }, @@ -116,6 +117,98 @@ describe("fabricator-request-resolver", () => { }); }); + describe("role gate (decision 2763e85f — architect or admin, after the org check)", () => { + test("requestAgentCreation rejects a non-architect non-admin caller with a valid org, before any SQS send", async () => { + await expect( + handler( + makeEvent( + "requestAgentCreation", + { agentName: "DeveloperAgent", taskDescription: "desc" }, + { + sub: "user-dev", + "custom:organization": "org-caller", + "custom:role": "developer", + }, + ), + ), + ).rejects.toThrow( + "Access denied: requires architect or admin role to request agent creation", + ); + + expect(sqsMock.commandCalls(SendMessageCommand)).toHaveLength(0); + expect(ddbMock.commandCalls(PutCommand)).toHaveLength(0); + }); + + test("requestToolCreation rejects a non-architect non-admin caller with a valid org, before any SQS send", async () => { + await expect( + handler( + makeEvent( + "requestToolCreation", + { toolName: "DeveloperTool", toolDescription: "desc" }, + { + sub: "user-dev", + "custom:organization": "org-caller", + "custom:role": "developer", + }, + ), + ), + ).rejects.toThrow( + "Access denied: requires architect or admin role to request tool creation", + ); + + expect(sqsMock.commandCalls(SendMessageCommand)).toHaveLength(0); + expect(ddbMock.commandCalls(PutCommand)).toHaveLength(0); + }); + + test("requestAgentCreation still rejects a role-less caller missing an org (org check runs first)", async () => { + await expect( + handler( + makeEvent( + "requestAgentCreation", + { agentName: "NoOrgAgent", taskDescription: "desc" }, + { sub: "user-no-org", "custom:role": "developer" }, + ), + ), + ).rejects.toThrow("Access denied: no organization is provisioned"); + + expect(sqsMock.commandCalls(SendMessageCommand)).toHaveLength(0); + }); + + test("architect caller (custom:role) passes for requestAgentCreation", async () => { + const result = await handler( + makeEvent( + "requestAgentCreation", + { agentName: "ArchitectAgent", taskDescription: "desc" }, + { + sub: "user-arch", + "custom:organization": "org-caller", + "custom:role": "architect", + }, + ), + ); + + expect(result.success).toBe(true); + expect(sqsMock.commandCalls(SendMessageCommand)).toHaveLength(1); + }); + + test("admin caller (cognito:groups) passes for requestToolCreation", async () => { + const result = await handler( + makeEvent( + "requestToolCreation", + { toolName: "AdminTool", toolDescription: "desc" }, + { + sub: "user-admin", + "custom:organization": "org-caller", + "cognito:groups": ["admin"], + }, + ), + ); + + expect(result.success).toBe(true); + expect(sqsMock.commandCalls(SendMessageCommand)).toHaveLength(1); + }); + }); + test("writes a PENDING row after the SQS send for agent creation", async () => { const result = await handler( makeEvent("requestAgentCreation", { diff --git a/backend/src/lambda/fabricator-request-resolver.ts b/backend/src/lambda/fabricator-request-resolver.ts index 6fdd2cc..f917953 100644 --- a/backend/src/lambda/fabricator-request-resolver.ts +++ b/backend/src/lambda/fabricator-request-resolver.ts @@ -7,7 +7,11 @@ import { } from "@aws-sdk/lib-dynamodb"; import { randomUUID } from "crypto"; import type { RegistryRecord } from "../services/registry-service"; -import { extractOrgFromEvent } from "../utils/auth-event"; +import { + extractOrgFromEvent, + isAdminFromEvent, + hasRoleFromEvent, +} from "../utils/auth-event"; const sqsClient = new SQSClient({}); const dynamoClient = new DynamoDBClient({}); @@ -198,7 +202,7 @@ interface FabricatorRequestResolverEvent { * write, and never fall back to client input or a default org. * * Throws (fails closed) when no organisation resolves — never returns - * null. Owner decision: tenancy-only, NO platform-role gate. + * null. */ async function requireOrgId( event: FabricatorRequestResolverEvent, @@ -212,6 +216,35 @@ async function requireOrgId( return orgId; } +/** + * Platform-role gate (decision 2763e85f, 2026-09-18): requestAgentCreation + * and requestToolCreation require the caller be an admin or hold the + * architect role, in addition to the server-derived org check above. + * Fabrication drives Bedrock spend and creates agent/tool Registry + * records, the same trust tier already required for comparable + * agent-lifecycle mutations elsewhere in this codebase (see + * agent-code-resolver.ts's `assertAgentCodeAccess` + * `requiredWriteRole`/`REQUIRED_WRITE_ROLE` gate and + * agent-import-resolver.ts's `requireDiscoveryRole`, both of which gate on + * `isAdminFromEvent(event) || hasRoleFromEvent(event, "architect")`). + * + * Applied AFTER the org check (`requireOrgId`) in both operations, so a + * cross-org caller never learns whether they merely lack the right role. + * `action` names the operation in the error message so a rejected caller + * knows which mutation was denied. + */ +function requireArchitectOrAdmin( + event: FabricatorRequestResolverEvent, + action: "request agent creation" | "request tool creation", +): void { + if (isAdminFromEvent(event) || hasRoleFromEvent(event, "architect")) { + return; + } + throw new Error( + `Access denied: requires architect or admin role to ${action}`, + ); +} + export const handler = async (event: FabricatorRequestResolverEvent) => { console.log("Event:", JSON.stringify(event, null, 2)); @@ -228,6 +261,7 @@ export const handler = async (event: FabricatorRequestResolverEvent) => { event.arguments.input as CreateAgentRequest, requestedBy, orgId, + event, ); } @@ -236,6 +270,7 @@ export const handler = async (event: FabricatorRequestResolverEvent) => { event.arguments.input as CreateToolRequest, requestedBy, orgId, + event, ); } @@ -356,7 +391,10 @@ async function requestAgentCreation( input: CreateAgentRequest, requestedBy: string, orgId: string, + event: FabricatorRequestResolverEvent, ) { + requireArchitectOrAdmin(event, "request agent creation"); + const requestId = randomUUID(); // Build the task details with all the information @@ -400,7 +438,10 @@ async function requestToolCreation( input: CreateToolRequest, requestedBy: string, orgId: string, + event: FabricatorRequestResolverEvent, ) { + requireArchitectOrAdmin(event, "request tool creation"); + const requestId = randomUUID(); // Build the task details for tool creation