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