Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,16 @@ const makeEvent = (fieldName: string, args: Record<string, unknown>) => ({
// 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() {
Expand Down
93 changes: 93 additions & 0 deletions backend/src/lambda/__tests__/fabricator-request-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const makeEvent = (
identity: Record<string, unknown> = {
sub: "user-123",
"custom:organization": "org-caller",
"custom:role": "architect",
},
) => ({
info: { fieldName },
Expand Down Expand Up @@ -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", {
Expand Down
45 changes: 43 additions & 2 deletions backend/src/lambda/fabricator-request-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down Expand Up @@ -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,
Expand All @@ -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));

Expand All @@ -228,6 +261,7 @@ export const handler = async (event: FabricatorRequestResolverEvent) => {
event.arguments.input as CreateAgentRequest,
requestedBy,
orgId,
event,
);
}

Expand All @@ -236,6 +270,7 @@ export const handler = async (event: FabricatorRequestResolverEvent) => {
event.arguments.input as CreateToolRequest,
requestedBy,
orgId,
event,
);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading