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
@@ -0,0 +1,98 @@
"""Wave 2b (branch fix/vender-org-scoping) — workerWrapper forwards orgId
from the Supervisor dispatch payload to the credential vender.

The Supervisor's ``process_agent_call`` dispatch payload already carries a
server-derived, non-empty ``orgId`` (see supervisor/index.py ~L1024). This
slice makes ``process_event`` read that field off the SQS event and forward
it as ``org`` on the credential-vender invoke payload built by
``get_scoped_credentials``. Fail CLOSED — no credential request at all,
plus a clear log — when the dispatch payload lacks orgId, rather than
silently vending without an org (which is exactly the gap wave 2b closes).
"""

import sys
import os
import json
from unittest.mock import patch, MagicMock

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

os.environ.setdefault("AGENT_CONFIG_TABLE", "fake-table")
os.environ.setdefault("AGENT_BUCKET_NAME", "fake-bucket")
os.environ.setdefault("COMPLETION_BUS_NAME", "fake-bus")
os.environ.setdefault("CREDENTIAL_VENDER_FUNCTION", "fake-vender-fn")

import index # noqa: E402


def _base_agent_config():
return {
"config": json.dumps({
"tools": [],
"filename": "agent.zip",
"description": "test agent",
"requiredPermissions": {"dataStores": ["ds-1"]},
})
}


def _run_process_event(event, agent_config=None):
agent_config = agent_config or _base_agent_config()
with patch.object(index, "load_config_from_dynamodb", return_value=agent_config), \
patch.object(index, "load_file_from_s3_into_tmp"), \
patch.object(index, "run_agent_in_subprocess", return_value="ok"), \
patch.object(index, "post_task_complete"), \
patch.object(index, "CREDENTIAL_VENDER_FUNCTION", "fake-vender-fn"), \
patch.object(index, "TOOLS_CONFIG_TABLE", None):
index.process_event(event, {})


class TestOrgIdForwardedToVender:
def test_org_id_forwarded_on_vender_invoke_payload(self):
event = {
"orchestration_id": "orch-1",
"agent_use_id": "use-1",
"agent_input": {"x": 1},
"node": "agent1",
"orgId": "org-abc",
}
mock_lambda_client = MagicMock()
mock_lambda_client.invoke.return_value = {
"Payload": MagicMock(read=lambda: json.dumps({"credentials": {}}).encode())
}
with patch.object(index, "_get_lambda_client", return_value=mock_lambda_client):
_run_process_event(event)

assert mock_lambda_client.invoke.called
payload = json.loads(mock_lambda_client.invoke.call_args.kwargs["Payload"])
assert payload["org"] == "org-abc"

def test_missing_org_id_fails_closed_no_vender_call(self):
"""No orgId on the dispatch payload -> get_scoped_credentials must
refuse to invoke the vender at all (fail closed), not silently vend
org-less credentials."""
event = {
"orchestration_id": "orch-2",
"agent_use_id": "use-2",
"agent_input": {"x": 1},
"node": "agent1",
}
mock_lambda_client = MagicMock()
with patch.object(index, "_get_lambda_client", return_value=mock_lambda_client):
_run_process_event(event)

assert not mock_lambda_client.invoke.called

def test_empty_string_org_id_fails_closed(self):
event = {
"orchestration_id": "orch-3",
"agent_use_id": "use-3",
"agent_input": {"x": 1},
"node": "agent1",
"orgId": "",
}
mock_lambda_client = MagicMock()
with patch.object(index, "_get_lambda_client", return_value=mock_lambda_client):
_run_process_event(event)

assert not mock_lambda_client.invoke.called
32 changes: 29 additions & 3 deletions arbiter/workerWrapper/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,12 +471,18 @@ def _merge_required_permissions(agent_permissions: dict | None, tool_bindings: d

return merged if merged else None

def get_scoped_credentials(agent_name: str, required_permissions: dict, app_id: str | None = None) -> dict | None:
def get_scoped_credentials(agent_name: str, required_permissions: dict, app_id: str | None = None, org_id: str | None = None) -> dict | None:
"""
Invoke the credential vender Lambda to get scoped IAM credentials
for this agent based on its declared permissions.
When app_id is provided, the credential vender uses the app-scoped IAM role
(citadel-agent-{appId}) instead of the agent-level role (Req 4 AC 5).

``org_id`` is the SERVER-DERIVED org carried on the Supervisor's worker
dispatch payload (never trusted from agent/subprocess output). Wave 2b
(branch fix/vender-org-scoping): fail CLOSED — issue no credential
request at all, with a clear log — when org_id is missing/empty, rather
than letting the vender silently grant org-blind AssumeRole access.
"""
if not CREDENTIAL_VENDER_FUNCTION:
print("CREDENTIAL_VENDER_FUNCTION not set, skipping credential vending")
Expand All @@ -485,10 +491,22 @@ def get_scoped_credentials(agent_name: str, required_permissions: dict, app_id:
if not required_permissions:
return None

if not isinstance(org_id, str) or not org_id:
print(json.dumps({
'level': 'ERROR',
'component': 'WorkerWrapper',
'action': 'credential_vend_refused_no_org',
'agentId': agent_name,
'error': 'org_id missing/empty on dispatch payload — refusing to '
'request scoped credentials (fail closed, no vender call).',
}))
return None

try:
payload_data = {
'agentId': agent_name,
'requiredPermissions': required_permissions,
'org': org_id,
}
if app_id:
payload_data['appId'] = app_id
Expand Down Expand Up @@ -1271,7 +1289,10 @@ def _process_workflow_node(event, message_attributes=None):
)

required_permissions = config.get('requiredPermissions')
scoped_credentials = get_scoped_credentials(msg.agent_id, required_permissions)
scoped_credentials = get_scoped_credentials(
msg.agent_id, required_permissions,
org_id=_resolve_execution_org_id(msg.execution_id),
)

fileName = config['filename']
load_file_from_s3_into_tmp(os.environ["AGENT_BUCKET_NAME"], fileName)
Expand Down Expand Up @@ -1420,6 +1441,11 @@ def process_event(event, context, message_attributes=None):
model_override = event.get('modelOverride')
system_prompt_addition = event.get('systemPromptAddition')
app_id = event.get('appId') # App-scoped credential vending (Req 4 AC 5)
# Wave 2b (fix/vender-org-scoping): the Supervisor's process_agent_call
# dispatch payload already carries a server-derived, non-empty orgId
# (supervisor/index.py process_agent_call). Forward it verbatim to the
# credential vender — never re-derive or default it here.
org_id = event.get('orgId')

# CIT-102 Pass B: frozen contract keys (Pass A dispatch payload —
# supervisor.process_agent_call). Absent-tolerant reads: a non-eval
Expand Down Expand Up @@ -1553,7 +1579,7 @@ def process_event(event, context, message_attributes=None):
# Vend scoped credentials based on merged permissions
# When appId is present, use app-scoped IAM role (Req 4 AC 5)
# Eventual consistency: binding updates are picked up on next invocation (Req 10.8)
scoped_credentials = get_scoped_credentials(agent_name, required_permissions, app_id=app_id)
scoped_credentials = get_scoped_credentials(agent_name, required_permissions, app_id=app_id, org_id=org_id)

fileName = config['filename']
print("loading file from s3...")
Expand Down
5 changes: 5 additions & 0 deletions backend/bin/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ const arbiterStack = new ArbiterStack(app, `citadel-arbiter-${environment}`, {
// forward-compatible wiring for the fabricator's
// design-assessment precondition gate.
agentDesignAssessmentsTable: backendStack.agentDesignAssessmentsTable,
// Wave 2b (fix/vender-org-scoping): the AgentCredentialVender Lambda
// resolves declared dataStore/integration ids to their owning org
// against these tables before granting any AssumeRole policy.
dataStoresTable: backendStack.dataStoresTable,
integrationsTable: backendStack.integrationsTable,
registryArn: backendStack.registryArn,
registryId: backendStack.registryId,
// Governance UI Wave 1: the new governance-ui-resolver lives in
Expand Down
27 changes: 27 additions & 0 deletions backend/lib/arbiter-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ interface ArbiterStackProps extends cdk.StackProps {
// gate is forward-compatible -- when the table/prop is absent the
// gate's env-var fallback simply no-ops.
agentDesignAssessmentsTable?: dynamodb.Table;
// Wave 2b (fix/vender-org-scoping): optional read handles so the
// AgentCredentialVender Lambda can resolve declared dataStore/integration
// ids to their owning org before granting any AssumeRole policy. Optional
// because some test paths construct ArbiterStack without them; when
// absent the vender's lookups resolve nothing and every org-scoped
// request that declares a dataStore/integration id fails closed.
dataStoresTable?: dynamodb.Table;
integrationsTable?: dynamodb.Table;
registryArn?: string;
registryId?: string;
// Governance UI Wave 1: optional AppSync API handle so the new
Expand Down Expand Up @@ -543,6 +551,17 @@ export class ArbiterStack extends cdk.Stack {
memorySize: 256,
environment: {
ENVIRONMENT: props.environment,
AGENT_CONFIG_TABLE: props.agentConfigTable.tableName,
// Wave 2b (fix/vender-org-scoping): omitted entirely when the
// stack is constructed without these optional tables (test paths);
// the vender's org-resolution lookups then find nothing and
// every request declaring a dataStore/integration id fails closed.
...(props.dataStoresTable && {
DATASTORES_TABLE: props.dataStoresTable.tableName,
}),
...(props.integrationsTable && {
INTEGRATIONS_TABLE: props.integrationsTable.tableName,
}),
},
initialPolicy: [
new PolicyStatement({
Expand Down Expand Up @@ -571,6 +590,14 @@ export class ArbiterStack extends cdk.Stack {
},
);

// Wave 2b (fix/vender-org-scoping): read-only access to the agent
// config table (agent's own orgId) and, when provisioned, the
// datastore/integration tables the vender resolves declared ids
// against before granting any AssumeRole policy.
props.agentConfigTable.grantReadData(credentialVenderLambda);
props.dataStoresTable?.grantReadData(credentialVenderLambda);
props.integrationsTable?.grantReadData(credentialVenderLambda);

// Tool-call idempotency ledger (PR1). Org-scoped, TTL'd operational
// dedupe table — NOT an audit artifact (distinct from the 90-day
// governance ledger). PK = orgId#executionId, SK =
Expand Down
9 changes: 9 additions & 0 deletions backend/lib/backend-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ export class BackendStack extends cdk.Stack {
public readonly accessLogsBucket: Bucket;
public readonly workflowsTable: dynamodb.Table;
public readonly appsTable: dynamodb.Table;
/**
* Wave 2b (fix/vender-org-scoping): exposed publicly so ArbiterStack's
* AgentCredentialVender Lambda can be granted read-only access and
* resolve declared dataStore/integration ids to their owning org.
*/
public readonly dataStoresTable: dynamodb.Table;
public readonly integrationsTable: dynamodb.Table;
/**
* Model-config table — exposed publicly (CIT-026 replay package,
* design §4) so TelemetryStack can grant read-only access for the
Expand Down Expand Up @@ -357,6 +364,7 @@ export class BackendStack extends cdk.Stack {
},
projectionType: dynamodb.ProjectionType.ALL,
});
this.integrationsTable = integrationsTable;

// Workflows Table
this.workflowsTable = new dynamodb.Table(this, "WorkflowsTable", {
Expand Down Expand Up @@ -2745,6 +2753,7 @@ export class BackendStack extends cdk.Stack {
removalPolicy: cdk.RemovalPolicy.DESTROY,
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
});
this.dataStoresTable = dataStoresTable;

dataStoresTable.addGlobalSecondaryIndex({
indexName: "OrgIndex",
Expand Down
Loading
Loading