Sets up the cryptographic and IAM-level boundaries that separate AI agents from customer PII.
Creates three independent encryption zones (agent / lockbox / audit), each with its own key and its own policy. The lockbox key's policy explicitly excludes agents, so even if an agent's IAM policy is mis-written to grant decryption, AWS will refuse the operation at the cryptographic layer. This is what makes "agents never see customer PII" a structural property of the platform, not a policy promise. Also creates the agent tier templates (sor-reader, internal) that every future agent inherits from, so each new agent gets a vetted set of capabilities by name rather than a bespoke policy that has to be audited.
If skipped: No way to deploy any module that handles data (data module, observability, lambda-tools, runtime). The PII-blindness commitment becomes a policy promise with no cryptographic enforcement, which is structurally weaker and harder to defend in a security review.
Block 1 (Bootstrap) created the substrate. Block 2 (Identity) creates the trust zones — the cryptographic and IAM-level boundaries that every downstream workload organizes itself around. Without identity in place, the network module would have nowhere to attach workload role ARNs, the data module would have no CMKs to encrypt with, and the agent modules would have no tier roles to assume.
Identity creates three KMS CMKs (agent, lockbox, audit) and two IAM tier role templates (sor-reader, internal). These are the building blocks every other module references. Cheap (~$3/month) and structurally important.
Every downstream module depends on identity's outputs:
audit_kms_key_arn to encrypt VPC flow logs.agent_kms_key_arn, lockbox_kms_key_arn, and audit_kms_key_arn to encrypt the buckets and tables it creates.agent_tier_role_arns to know which role each Lambda function assumes.agent_tier_role_arns for inbound authorization.Building identity early means downstream modules can reference real ARNs from day one, with no placeholder values to retrofit later.
Ten resources, organized into two groups.
| Zone | Key + alias | What it encrypts (now and later) |
|---|---|---|
| agent | aws_kms_key.agentalias/ai-sandbox-agent |
Agent-tier workload data: AgentCore memory store, agent S3 scratch prefixes, agent-tier secrets in Secrets Manager. Agent tier roles can use this key; lockbox and audit roles cannot. |
| lockbox | aws_kms_key.lockboxalias/ai-sandbox-lockbox |
Lockbox-tier data: the PII token DDB table (when data module ships), the reveal Lambda's secrets, lockbox audit fields. Agent tier roles are explicitly excluded from this key's policy. This is the structural enforcement of PII blindness. |
| audit | aws_kms_key.auditalias/ai-sandbox-audit |
Audit-tier data: CloudWatch log groups, VPC flow logs, CloudTrail trails, GuardDuty findings exports, the audit S3 bucket (when data module ships). Read by SecOps / forensic queries; writeable by the services that emit logs. |
Two tiers, each with a role + an inline policy. The roles are templates — they cannot be assumed yet (no trust relationship to any service principal). Downstream modules (lambda-tools, runtime) reference these role ARNs when attaching workloads.
| Tier | Resource | Granted capabilities (per variables.tf) |
|---|---|---|
| sor-reader | aws_iam_role.agent_runtime_tier["sor-reader"]aws_iam_role_policy attached inline |
can_read_sors: true · can_write_sors: false · can_invoke_bedrock: true · can_read_lockbox_tokens: true · can_call_lockbox_reveal: false |
| internal | aws_iam_role.agent_runtime_tier["internal"]aws_iam_role_policy attached inline |
can_read_sors: false · can_write_sors: false · can_invoke_bedrock: true · can_read_lockbox_tokens: false · can_call_lockbox_reveal: false |
Both roles have the bootstrap permission boundary (ai-sandbox-permission-boundary) attached. Both are assumable only by the AgentCore service principal (and any other principals the gateway module wires in later); not by humans, not by other AWS accounts.
Splitting key material into three CMKs (instead of using one account-wide CMK) is the single most important structural decision in this module. The cost is $2/month extra ($3 total vs $1 with a single CMK). The benefit is that the boundary between zones becomes cryptographic, not just policy-based.
| Failure mode | Single-CMK design | Three-CMK design (this module) |
|---|---|---|
Agent tier role's policy is mis-written to grant kms:Decrypt * |
Agent role can decrypt lockbox PII and audit logs. Total compromise. | Agent role can decrypt only the agent CMK. Lockbox CMK policy doesn't list agent roles as authorized principals; KMS returns AccessDenied regardless of the IAM policy. |
| Lockbox CMK key policy is mis-written to allow agent roles | (Not applicable — only one CMK) | Bad, but contained: agents can decrypt lockbox data but still can't touch audit logs. Audit CMK policy is independent. |
| An audit-tier role is compromised | Attacker decrypts agent data + lockbox PII + audit logs. | Attacker decrypts only audit logs. Cannot reach agent or lockbox data. |
The lockbox CMK's key policy explicitly enumerates the lockbox service principal (and only that). It does not include agent tier role ARNs anywhere. KMS rejects any decrypt call from an agent role with AccessDenied — before the IAM layer is even consulted. This is the property called "structural PII-blindness of agents" in the project doctrine.
IAM policies can be mis-written. Permission boundaries can be edited. Service control policies can be relaxed. KMS key policies are the deepest enforcement layer in AWS — they apply at the cryptographic operation level, not at the API gateway. Three CMKs means three independent enforcement points; one CMK means one shared point with no internal partition.
The agent_tiers variable defines a map of tier name to capability flags. Each tier becomes an IAM role + inline policy via for_each. New tiers can be added later by extending the map; existing tier policies can be tightened by changing flag values.
Agents that need to read from PayCargo's systems of record (Salesforce, Monday, GitHub, GitBook) and reason on Bedrock. They can read PII tokens (the opaque references in the lockbox table) but cannot detokenize them — can_call_lockbox_reveal: false. This is the default tier for any net-new agent that touches SoR data.
Agents that operate purely on AWS services (Bedrock for reasoning, S3 for storage, etc.) and have no business with external SoRs or PII. The strictest of the two: no SoR access, no lockbox access, no PII reveal. Used for utility agents (summarizers, classifiers, internal coordinators) that should not touch customer data at all.
Future tiers (e.g., sor-writer with can_write_sors: true, or pii-revealer with can_call_lockbox_reveal: true) are added by extending the agent_tiers map in environments/ai-sandbox/main.tf or via tfvars override. The role templates are designed for this expansion.
| Item | Monthly |
|---|---|
| KMS CMK agent | $1.00 |
| KMS CMK lockbox | $1.00 |
| KMS CMK audit | $1.00 |
| KMS aliases (3) | free |
| IAM tier roles (2) + inline policies (2) | free |
| Total | ~$3.00/month |
Per-request KMS charges ($0.03 per 10,000 requests) are negligible at POC volumes. At sustained production volumes (~1M agent decrypt requests/day), this rises to ~$3 additional per day; still a small fraction of the overall cost picture.
Permanent. KMS CMKs cannot be deleted instantaneously — they enter a 7-30 day pending-deletion state, and during that window any data encrypted with them becomes irretrievably lost. Tier role ARNs are referenced by every downstream module; deleting them would break workloads. Treat all 10 identity resources as foundational for the life of the account.
backend.tf populated, terraform init -migrate-state succeeded).substrate/environments/ai-sandbox/terraform.tfvars exists with permission_boundary_arn set to the bootstrap output.AWS_PROFILE=ai-sandbox, aws sts get-caller-identity returns account 959228203854).cd substrate/environments/ai-sandbox
# Plan ONLY the identity module (not network, not anything else).
# -target is the right tool here because we are doing block-by-block apply
# rather than one big apply across all modules.
terraform plan -target=module.identity -out=identity.tfplan
# READ the plan output. Expected:
# Plan: 10 to add, 0 to change, 0 to destroy.
# Every resource line should have a + create marker.
terraform apply identity.tfplan
Apply takes ~30-45 seconds — KMS key creation is the slow step (~15s each), and they are created in parallel.
# 1. Three KMS keys exist with correct aliases
AWS_PROFILE=ai-sandbox aws kms list-aliases --query \
"Aliases[?starts_with(AliasName, 'alias/ai-sandbox-')].{Alias:AliasName,KeyId:TargetKeyId}" \
--output table
# Expected output: 4 rows (state from bootstrap + 3 from identity)
# alias/ai-sandbox-tfstate (bootstrap)
# alias/ai-sandbox-agent (identity)
# alias/ai-sandbox-lockbox (identity)
# alias/ai-sandbox-audit (identity)
# 2. Lockbox key policy DOES NOT include agent role ARNs
AWS_PROFILE=ai-sandbox aws kms get-key-policy \
--key-id alias/ai-sandbox-lockbox \
--policy-name default \
| jq -r '.Policy' | jq '.Statement[].Principal'
# Expected: principals should be the account root and AWS services, NOT
# any role with "sor-reader" or "internal" in the name.
# 3. Two tier roles exist with permission boundary attached
AWS_PROFILE=ai-sandbox aws iam list-roles \
--query "Roles[?starts_with(RoleName, 'ai-sandbox-agent-')].{Name:RoleName,Boundary:PermissionsBoundary.PermissionsBoundaryArn}" \
--output table
# Expected: 2 rows, both with PermissionsBoundaryArn ending in
# /ai-sandbox-permission-boundary
# 4. Tier role inline policies reflect the per-tier capability flags
AWS_PROFILE=ai-sandbox aws iam get-role-policy \
--role-name ai-sandbox-agent-sor-reader \
--policy-name agent-tier-permissions \
| jq '.PolicyDocument.Statement[] | {Sid,Effect,Action}' \
| head -40
# Expected: Allow statements for Bedrock invoke, S3 scratch read/write,
# Secrets fetch, lockbox token READ (NOT reveal).
The most consequential thing this step deployed is the lockbox CMK key policy. Most of the value of reviewing in Console comes from sitting with that key policy and verifying that agent roles really aren't in it.
| Resource | Where to look | What confirms success |
|---|---|---|
| Three trust-zone CMKs | KMS → Customer managed keys → filter ai-sandbox |
Four rows total: ai-sandbox-tfstate (from Step 1), plus ai-sandbox-agent, ai-sandbox-lockbox, ai-sandbox-audit. All Enabled. |
| Lockbox CMK key policy (the structural enforcement) | KMS → click ai-sandbox-lockbox → Key policy tab |
Two principal blocks: the account root (admin) and the storage services (s3.amazonaws.com, dynamodb.amazonaws.com). No agent role appears anywhere. This is the PII-blindness guarantee, visible. Compare against the agent CMK's key policy for contrast. |
| Audit CMK key policy | KMS → click ai-sandbox-audit → Key policy tab |
Allows AWS log-emitting services (CloudWatch Logs, CloudTrail, VPC flow logs) to encrypt with the key. Read-only access for SecOps roles is documented but not yet attached until a SecOps role exists. |
| Tier role: sor-reader | IAM → Roles → click ai-sandbox-agent-sor-reader |
Permissions tab: inline policy tier-sor-reader-permissions with 8 Sids (InvokeBedrockModels, UseGuardrail, AccessAgentMemory, InvokeGateway, ScratchS3Access, ReadLockboxTokens, WriteLogs, UseAgentCMK). Boundary tab: ai-sandbox-permission-boundary attached. Trust relationships: empty for now (downstream modules wire service principals to it). |
| Tier role: internal | IAM → Roles → click ai-sandbox-agent-internal |
Permissions tab: inline policy tier-internal-permissions — same Bedrock + memory + gateway access as sor-reader, but NO ReadLockboxTokens and NO ScratchS3Access on the SoR side. Boundary attached. |
| Compare the two tier policies side-by-side | Two browser tabs, each open to a tier role's Permissions tab | Sids that exist on sor-reader but not internal: ReadLockboxTokens, and any SoR-related actions. This is the doctrine of "internal cannot touch customer data" visible in policy form. |
Open the agent CMK key policy in one tab, lockbox CMK in another. The difference is the entire PII-blindness story. Agent CMK lists agent tier role principals; lockbox CMK does not. That's not a comment in the code, not a policy memo, not a Wiki page — it's a JSON document that AWS enforces at the cryptographic operation layer. Worth seeing it as text once so the mental model is concrete.
For Steps 1 and 2, the Console pages mostly confirm "stuff was created correctly." For Step 2, the lockbox CMK key policy is the doctrine itself, rendered as data. The structural property "agents are cryptographically blind to lockbox PII" lives in those 20 lines of JSON. Bookmark the page.
-target warning in plan outputTerraform prints a warning when -target is used:
Warning: Resource targeting is in effect
You are creating a plan with the -target option...
The -target option is not for routine use...
This is expected. We are deliberately applying block-by-block per the agreed walk; the warning is Terraform protecting users from accidental partial applies. Ignore it.
Creating three KMS keys in rapid succession is rare enough that AWS may throttle. Manifests as ThrottlingException partway through apply.
Fix: wait 60 seconds, re-run terraform apply identity.tfplan. Terraform is idempotent — it will create only the keys that didn't make it through the first time.
Same as Block 1 Gotcha D. 12-hour token boundary.
Fix: aws sso login --profile ai-sandbox, then re-run apply. Terraform resumes.
terraform apply identity.tfplan.Composition is on remote state: substrate/environments/ai-sandbox/.terraform/terraform.tfstate should reference the S3 backend (not contain local state). Verify with cat .terraform/terraform.tfstate | jq '.backend.type' — expected: "s3".
terraform plan -target=module.identity -out=identity.tfplan returns Plan: 10 to add, 0 to change, 0 to destroy. Every line is + create. Any ~ update or - destroy on a fresh apply is a no-go.
substrate/environments/ai-sandbox/terraform.tfvars contains permission_boundary_arn = "arn:aws:iam::959228203854:policy/ai-sandbox-permission-boundary". Without it, identity roles cannot attach the boundary and apply fails.
If all three pass, run terraform apply identity.tfplan.
The three-CMK design ensures the agent-vs-lockbox separation is enforced at the deepest possible layer (KMS), not at the IAM-policy layer (which is editable, audit-able-after-the-fact, but can be misconfigured). The doctrine is: when there is a choice between policy enforcement and cryptographic enforcement, pick cryptographic.
Bootstrap created one role (the CI deploy role). Identity creates two roles, but they are templates — they have no service principal trust yet. Downstream modules (lambda-tools, runtime, gateway) instantiate the tier model by wiring specific workloads to these role ARNs. This separation lets identity exist independent of workloads, and lets new workload types reuse the existing tier definitions without re-deriving them.
The lockbox CMK key policy starts from "deny everyone" and adds only the lockbox service principal. The agent CMK is more permissive (agent roles are allowed). The audit CMK is split (write-only for most callers, read for SecOps). The sensitivity gradient is reflected in the key policy gradient.
Apply ran cleanly in a single pass — no fix-and-retry required. Three small things worth recording for future engineers.
Single terraform apply identity.tfplan from the composition directory created all 10 resources in ~30 seconds:
-target warningAs documented in § 8, Terraform printed:
Warning: Applied changes may be incomplete
The plan was created with the -target option in effect...
Ignored as designed. Block-by-block apply with explicit -target is the agreed workflow.
kms get-key-policy rejects aliasesThe verification command in § 7 said:
aws kms get-key-policy --key-id alias/ai-sandbox-lockbox --policy-name default
This errored with InvalidArnException: Key Aliases are not supported for this operation. Despite many other KMS CLI verbs accepting aliases freely, get-key-policy specifically requires the actual key ID.
Fix: use the key ID directly:
aws kms get-key-policy --key-id f855a443-2993-4109-8f7b-a5b438cd6e40 --policy-name default
Or look up the ID first via aws kms describe-key --key-id alias/ai-sandbox-lockbox --query 'KeyMetadata.KeyId' --output text and pipe.
iam list-roles omits PermissionsBoundaryThe verification command in § 7 used aws iam list-roles --query "...PermissionsBoundary..." to check that both tier roles had the boundary attached. The output showed both as None, which initially looked like a real bug (boundary missing).
In fact, list-roles returns a slimmed summary that doesn't include the PermissionsBoundary field, even when one is attached. The fix is to use aws iam get-role per role:
for role in ai-sandbox-agent-sor-reader ai-sandbox-agent-internal; do
aws iam get-role --role-name $role | jq '.Role.PermissionsBoundary.PermissionsBoundaryArn'
done
Confirmed both roles have arn:aws:iam::959228203854:policy/ai-sandbox-permission-boundary attached.
The lockbox CMK's key policy was inspected and confirmed to contain only two principal entries: the account root (for admin operations) and the storage services (s3, dynamodb). No agent tier role appears anywhere. This means an agent role asking KMS to decrypt lockbox data gets AccessDenied at the cryptographic operation level, before IAM is even consulted. The structural enforcement promised in the design is now real.
After identity apply succeeds and verification passes:
backend.tf and terraform.tfvars.example updates to the repo so the canonical state of the composition reflects what was actually applied.Network (Step 3) is the next destructive step and the first block where a mistake could affect traffic rather than just metadata. Stays in alert mode through the rest of the substrate build; enforcement flip moves to Step 5 (after data, observability, and the first agent are live in Step 4). The reasoning: the alert log needs real workload traffic to be meaningful, observability needs to be in place to monitor the flip, and the audit S3 bucket from the data block is a Stage 9 dependency anyway.
The three substrate keys (agent, lockbox, audit) protect platform machinery, indexed by which type of system writes the data. They are not the only CMKs the substrate provides. Once vibe apps started landing (Step 4.5 obs app), a second axis of keys was added: a data governance grid indexed by sensitivity tier × organizational boundary (Engineering, CEO office, future Sales/Ops/etc.). Each cell in the grid is a CMK; vibe apps consume cells by name.
The two axes are orthogonal:
A single resource lands on the right axis based on what it represents. Audit logs (CloudTrail data events, VPC flow logs, NFW logs) stay on the audit CMK forever. Vibe-app frontends, Lambda env vars, application log groups land on the data governance grid. See Step 4.5.a — Data Governance CMKs for the grid pattern, the tier policy templates, and the per-vibe-app onboarding flow.