Skip to content

sec(iac/aws): gate deploy-role IAM writes on a permissions boundary - #1722

Merged
cristim merged 2 commits into
mainfrom
fix/1705-deploy-role-permissions-boundary
Aug 6, 2026
Merged

cristim merged 2 commits into
mainfrom
fix/1705-deploy-role-permissions-boundary

Conversation

@cristim

@cristim cristim commented Aug 5, 2026 •

Copy link
Copy Markdown
Member

Closes #1705

⚠️ This PR does not fix anything until it is applied, and the apply must come FIRST

Nothing here changes live AWS state. The escalation stays fully open until a human applies terraform/environments/aws/ci-cd-permissions. No terraform apply was run and none should be run by CI: that root is human-applied by design.

Apply the bootstrap root BEFORE merging, not after. deploy-aws-lambda.yml triggers on pushes to main under terraform/environments/aws/** and the AWS compute/database/secrets/networking modules, all of which this PR touches. On merge, the next deploy calls iam:PutRolePermissionsBoundary to attach the boundary to the roles that already exist, and that grant lives in the new cudly-deploy-iam policy in the bootstrap root. Merge first and every AWS deploy goes red with AccessDenied until the apply happens. Recoverable, not destructive, but avoidable. This is written into ci-cd-permissions/README.md too so it is not only in a PR body.

I could not produce a terraform plan: no AWS credentials are reachable from this environment (aws sts get-caller-identity returns NoCredentials). What I could verify is below.

The defect

cudly-terraform-deploy held iam:CreateRole, iam:AttachRolePolicy and iam:PutRolePolicy on arn:aws:iam::*:role/cudly-* with no iam:PermissionsBoundary condition and no iam:PolicyARN condition. Create a role, attach AdministratorAccess, then either assume it (same-account, so a trust policy naming the deploy role suffices with no identity-side grant) or pass it to Lambda under the existing IAMPassRoleScopedByService. Full account administrator from a GitHub Actions OIDC token, persisting past rotation of that token. ref:refs/heads/main is a trusted subject and main takes direct pushes with red CI, so the precondition is repo write access.

Why a permissions boundary and not a Deny

I evaluated three alternatives before settling:

  • Deny iam:CreateRole / PutRolePolicy / AttachRolePolicy. Takes the pipeline down. The deploy path creates twelve roles across terraform/modules on every apply (Lambda execution, Fargate task and task-execution, four EventBridge invokers, fck-nat, VPC flow logs, RDS proxy, secret rotation, cleanup Lambda).
  • Deny by role name. Does not help. The escalation target is a role, so every name the deploy role may legitimately use is one it may also abuse.
  • iam:PolicyARN allowlist alone. Closes the path the issue names and nothing else. iam:PutRolePolicy writes an inline document that supports no condition key at all, so {"Action":"*","Resource":"*"} inline on a new role reaches the same administrator with the managed-policy door shut.

A permissions boundary is the only AWS mechanism that caps a principal regardless of what its identity policy says, and therefore the only thing that closes the inline path. Both layers are here, because the boundary alone would leave a created role capped-but-broad while the allowlist alone leaves the inline path open.

What changed

policy_boundary.tf (new) cudly-deploy-boundary, the ceiling. Per-service allow list derived from the IAM policy documents in terraform/modules plus the four AWS managed policies those modules attach, with iam: absent apart from a cudly-*-scoped PassRole
policy_iam.tf (new) cudly-deploy-iam. Re-grants the three actions plus iam:PutRolePermissionsBoundary under StringEquals on iam:PermissionsBoundary; ArnNotEquals Deny allowlisting the four attachable managed policies; Denies stripping a boundary and Denies the deploy role boundarying itself
policy_data.tf The three actions removed from the unconditioned IAMRolesAndPolicies Allow, with a comment explaining why they must not come back
6 modules, 12 roles permissions_boundary = var.permissions_boundary_arn, required variable with no default
policy_guard_test.go (new) 9 tests converting drift from a silent runtime 403 into a CI failure

The new statements went into a separate managed policy rather than into cudly-deploy-data because of the 6144-character limit: cudly-deploy-data renders to ~4.6 KB and cudly-deploy-compute is close enough to the ceiling that an earlier fix had to be split into policy_compute_b.tf. New sizes, reconstructed as compact JSON, are 966 and 1083 characters. Both are far under the 6144 limit. The role now carries five of the ten managed policies AWS permits.

Escalation paths closed

Path Closed by
Create role + attach AdministratorAccess IAMDenyAttachUnapprovedManagedPolicy — ArnNotEquals on iam:PolicyARN, four exact ARNs, no wildcards
Create role + inline * on * IAMRoleMutationRequiresBoundary — the role cannot exist without the ceiling, so the inline document is capped
Weaponize an existing cudly-* role (UpdateAssumeRolePolicy + PutRolePolicy) PutRolePolicy requires the target to already carry the boundary
PassRole to Lambda/ECS/EC2/EventBridge and run code The passed role is boundaried; the boundary grants no IAM writes
AddRoleToInstanceProfile + RunInstances Same: RunInstances with a profile needs PassRole, and the contained role is boundaried
Rewrite the boundary itself cudly-deploy-boundary matches cudly-deploy-*, so the existing IAMDenyModifyDeployRoleAndPolicies already denies CreatePolicyVersion / SetDefaultPolicyVersion / DeletePolicy / DeletePolicyVersion on it — the complete mutation set for a managed policy. The name is load-bearing and a test asserts it
Strip or swap a boundary IAMDenyStripRoleBoundary; PutRolePermissionsBoundary is pinned to the one ARN
Org takeover from a boundaried role organizations narrowed to the three read actions the modules use (organizations:* reaches CreateAccount and SCPs); sts:AssumeRole scoped to arn:aws:iam::*:role/CUDly* (on * it escapes the boundary outright, since the assumed session is a different principal and OrganizationAccountAccessRole in member accounts trusts the management account root)

Left open, deliberately

Read this section before closing #1705 on the wider claim.

  • The ceiling is broader than cudly-terraform-deploy's own reach. It grants s3:*, kms:*, rds:*, secretsmanager:* and similar on Resource: "*", while the deploy role itself is scoped to cudly-* resources. A compromised deploy role can therefore still read every secret, delete RDS instances, schedule KMS key deletion and commit RI/SP spend. What this PR removes is IAM writes, and with them persistence and in-account administrator. Narrowing further is genuinely risky in the wrong direction: a too-narrow boundary fails at runtime, in production, whereas everything this PR can get wrong fails at apply time in CI. I would rather land the escalation fix and narrow the ceiling as a separate, separately-verified change. Happy to file that as a follow-up issue.
  • The ceiling still permits further privilege escalation out of the boundary, not just data exposure. lambda:*, ssm:* and ecs:* are granted at full service width, and each lets a boundaried role run as a different principal with no iam:PassRole involved (lambda:UpdateFunctionCode + invoke runs as the function's execution role; ssm:SendCommand / StartSession runs as an SSM-managed instance's instance profile; ecs:UpdateService onto an existing task definition, or ecs:ExecuteCommand, runs inside a running task). PassRoleCeiling's cudly-* scoping does not apply to any of these, because none of them uses PassRole. This is a materially different residual from the data-exposure bullet above and sec(iac/aws): deploy role can create an unbounded role and attach AdministratorAccess to it #1705 should not be closed on the wider claim without it: tracked in sec(iac/aws): deploy boundary still permits escaping to another principal via lambda/ssm/ecs at service:* #1723.
  • iam:CreatePolicy / iam:CreatePolicyVersion stay unconditioned. iam:CreatePolicy supports aws:RequestTag/${TagKey} and aws:TagKeys (it accepts an optional Tags parameter), and iam:CreatePolicyVersion supports no condition key at all. Neither constrains the policy document, which is the point here. The deploy role can still mint a cudly-* managed policy whose document is * on *; it is inert, because attaching it to any role is denied by the ARN allowlist and no AttachUserPolicy / AttachGroupPolicy is granted anywhere. Denying policy creation would break the managed policy modules/secrets/aws declares.
  • #1692 is untouched. main still takes direct pushes with red CI while being a trusted OIDC subject. That is the reachability half of sec(iac/aws): deploy role can create an unbounded role and attach AdministratorAccess to it #1705 and needs a repo-settings change, not a Terraform one.
  • A cudly-* role created by hand or from the console carries no boundary and stays a legal PassRole target. Closing that needs a tag or a naming split; not worth the coupling.
  • iam:AddRoleToInstanceProfile supports no condition keys and stays unconditioned, same as the bullet above. Not exploitable today on its own: reaching it still needs iam:PassRole, which is scoped to cudly-* roles by PassRoleCeiling. Its precondition is identical to the hand-made-cudly-*-role residual just above, so it belongs in the same list rather than the escalation-paths-closed table.

Evidence that legitimate deployments still work

  • All twelve roles are covered. Independently re-grepped, not trusted from a count: 12 aws_iam_role under terraform/modules/**/aws, 12 permissions_boundary lines. ci-cd-permissions/role.tf is correctly the only uncovered one. TestEveryModuleRoleHasPermissionsBoundary asserts it, requiring the value to be exactly var.permissions_boundary_arn, and TestNoIAMRolesOutsideGuardedModules asserts none is declared outside those modules.
  • The transition apply orders correctly. iam:PermissionsBoundary on PutRolePolicy / AttachRolePolicy means the boundary must already be on the target role. Every aws_iam_role_policy and aws_iam_role_policy_attachment in the AWS modules references its role via aws_iam_role.X.name/.id (all 42 role = lines checked, zero hardcoded names), so Terraform's graph puts PutRolePermissionsBoundary ahead of them.
  • New roles get the boundary in the create call. Verified against provider source internal/service/iam/role.go: resourceRoleCreate populates CreateRoleInput.PermissionsBoundary directly, so there is no create-then-attach window that the condition would deny.
  • The three Update* actions are deliberately unconditioned. Same source: resourceRoleUpdate's d.HasChange blocks run assume_role_policy → description → max_session_duration → permissions_boundary. Conditioning them would have 403'd the first apply against a role whose trust policy also changed. This was the one place where "condition everything that supports the key" would have broken the pipeline.
  • The destroy path is untouched. DeleteRole, DeleteRolePolicy and DetachRolePolicy remain unconditioned, so cleanup-staging.yml and destroy-fargate-dev.yml are unaffected.
  • The boundary removes nothing any role has today. Its allow list is derived from the modules' own policy documents; TestBoundaryCoversWorkloadServices re-derives that set from source and fails if a service is missing.
  • terraform fmt -recursive -check clean. terraform validate passes in both ci-cd-permissions and environments/aws. go test ./terraform/... green (17 assertions across 2 packages), go vet clean, golangci-lint at the CI-pinned v2.10.1 clean.

One rollback caveat

rollback.yml applies the environment root from the dispatched ref's checkout. A ref predating this change has no permissions_boundary in config while the live roles have one, so the provider issues DeleteRolePermissionsBoundary and IAMDenyStripRoleBoundary fails the rollback. Roll back to a ref at or after this change. Documented in ci-cd-permissions/README.md and in the policy comment next to that Deny.

Guard tests

The reviewer's finding that mattered most was that a test suite can be green while the fix is gone. Four single-token edits reopened the escalation with the first draft passing: StringEquals → StringEqualsIfExists, ArnNotEquals → ArnEquals, adding arn:aws:iam::aws:policy/* to the ARN list, and flipping that statement's Effect to Allow. The tests now assert both statements structurally (exact action sets, exact condition operators, wildcard-free ARNs, exactly one match per lookup so a renamed or split statement fails closed), and each of those four edits was empirically confirmed to fail the intended test and then reverted. The guarded-file list is globbed rather than hardcoded and every Statement = [ array in a file is parsed, so a new policy_*.tf or a second policy appended to an existing one cannot slip past.

The cudly-terraform-deploy role held iam:CreateRole, iam:AttachRolePolicy
and iam:PutRolePolicy on arn:aws:iam::*:role/cudly-* with no
iam:PermissionsBoundary condition and no iam:PolicyARN condition. It could
therefore create a role, attach AdministratorAccess to it, and either
assume it (same-account, so a trust policy naming the deploy role suffices)
or pass it to Lambda under the existing IAMPassRoleScopedByService grant:
full account administrator from a GitHub Actions OIDC token, persisting
past rotation of that token.

Denying the three actions is not an option. The deploy path legitimately
creates roles on every apply, so a blanket Deny 403s every deployment, and
a name-based Deny cannot help because the escalation target is a role and
any name the deploy role may legitimately use is one it may also abuse.
What separates a legitimate role from an escalation vehicle is its ceiling,
and a permissions boundary is the only AWS mechanism that caps a principal
regardless of its identity policy. It is also the only thing that closes
iam:PutRolePolicy, whose inline document supports no condition key at all.

Every role in terraform/modules now carries cudly-deploy-boundary, and the
three actions plus iam:PutRolePermissionsBoundary are re-granted in a new
cudly-deploy-iam policy only under StringEquals on iam:PermissionsBoundary,
so a role without the ceiling can be neither created nor written to.
iam:AttachRolePolicy additionally carries an ArnNotEquals Deny allowlisting
the four AWS managed policies the modules actually attach, which is what
makes AdministratorAccess unattachable rather than merely capped. Stripping
a boundary is denied outright, and the deploy role cannot boundary itself.

iam:UpdateAssumeRolePolicy, iam:UpdateRole and iam:UpdateRoleDescription
are deliberately left unconditioned even though they accept the key: the
provider's resourceRoleUpdate issues all three BEFORE the boundary call in
the same function, so conditioning them would 403 the first apply against a
role that does not carry the boundary yet.

The boundary is a ceiling, not a grant, so it removes no permission any
role holds today: its allow list is derived from the IAM policy documents
in terraform/modules plus the four attached AWS managed policies. It is
per-service rather than per-action because a too-narrow boundary fails at
runtime rather than at apply time. organizations and sts are the two
exceptions, pinned to the exact actions and role prefix the modules use:
organizations:* reaches CreateAccount and SCPs, and sts:AssumeRole on "*"
escapes the boundary entirely, since the assumed session is a different
principal.

policy_guard_test.go turns the remaining drift into a CI failure rather
than a production 403: it re-derives the service set, the attached
managed-policy ARNs and the cross-account role prefix from the modules,
asserts every module role is boundaried and that none is declared outside
them, and asserts the structure of both new statements so the condition
operators, the Deny effect and the wildcard-free ARN list cannot be
weakened silently.

Closes #1705
@cristim cristim added triaged Item has been triaged priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens urgency/now Drop other things impact/all-users Affects every user effort/m Days type/security Security finding labels Aug 5, 2026
@cristim

cristim commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Adversarial review — independent, at 5d86f152

Reviewed as an independent adversary against the committed diff only (gh pr diff / git show in a private detached worktree at 5d86f152, the current head). No edits, no commits, no terraform apply, no terraform plan. Separating verified by execution from concluded by reading throughout.

Verdict: the named escalation in #1705 is genuinely closed, and the ordering constraint in the PR body is correct. One medium finding (an incomplete escape analysis stated as complete in a load-bearing comment) and three low ones. Nothing blocking; nothing that reopens the deploy-role → account-admin path.


What I ran

Command Result
terraform fmt -recursive -check terraform/ exit 0
terraform init -backend=false && terraform validate in ci-cd-permissions Success, exit 0
terraform init -backend=false && terraform validate in environments/aws Success, exit 0
go test -v -count=1 ./terraform/environments/aws/ci-cd-permissions 9/9 PASS, exit 0
10 independent mutations, each reverted (below) 10/10 caught
curl servicereference.us-east-1.amazonaws.com/v1/iam/iam.json + per-action condition-key extraction see §1
Rendered-JSON size computation from the statement structures see L2

Mutation testing (the four you cite, plus six I designed)

# Mutation Caught by
M1 StringEquals → StringEqualsIfExists TestDeployPolicyGatesRoleMutationOnBoundary
M2 ArnNotEquals → ArnEquals TestDeployPolicyDeniesUnapprovedManagedPolicyAttachment
M3 add arn:aws:iam::aws:policy/* to the allowlist same
M4 that statement's Effect → Allow same
M5 drop permissions_boundary from the Lambda role TestEveryModuleRoleHasPermissionsBoundary
M6 rename cudly-deploy-boundary → cudly-workload-boundary TestBoundaryPolicyNameStaysInProtectedNamespace
M7 re-add iam:CreateRole to the unconditioned IAMRolesAndPolicies TestBoundaryGatedActionsAreNotUnconditionallyGranted
M8 add "iam:*" to WorkloadServiceCeiling TestBoundaryCoversWorkloadServices
M9 drop kms:* from the ceiling same
M10 module role grants dynamodb:GetItem (service absent from ceiling) same

M5–M10 are mine, not from the PR body. The suite is genuinely mutation-resistant in both directions — it fails on weakening the guard and on a drift that would 403 at runtime. Working tree was verified clean after every revert.


1. Does the boundary actually cap an inline policy? — verified, the central claim holds

Pulled the AWS Service Authorization Reference and extracted ActionConditionKeys per action:

PutRolePolicy                 -> ['iam:PermissionsBoundary']
CreateRole                    -> ['aws:RequestTag/${TagKey}', 'aws:TagKeys', 'iam:PermissionsBoundary']
AttachRolePolicy              -> ['iam:PermissionsBoundary', 'iam:PolicyARN']
PutRolePermissionsBoundary    -> ['iam:PermissionsBoundary']
CreatePolicyVersion           -> None
SetDefaultPolicyVersion       -> None
DeletePolicy                  -> None
DeletePolicyVersion           -> None

Both halves check out, and this was the one that could have taken the pipeline down rather than merely leaving a hole: iam:PermissionsBoundary is supported on all four actions in IAMRoleMutationRequiresBoundary, so the conditioned Allow will actually match at apply time. Had PutRolePolicy supported no condition key (as the PR body's prose says in one place), that Allow would never match and every apply would 403.

PutRolePolicy supports no key that constrains the inline document, so the boundary is indeed the only mechanism that caps it. And the four managed-policy mutation actions genuinely have zero condition keys, which is what makes the resource-scoped Deny in IAMDenyModifyDeployRoleAndPolicies the only way to protect the boundary document — policy_boundary.tf:26-29 is correct.

2. Bypass rather than break — enumerated independently, none found

grep -rn 'resource "aws_iam_role"' terraform/ returns 13: 12 under terraform/modules/**/aws, all 12 carrying permissions_boundary = var.permissions_boundary_arn, plus ci-cd-permissions/role.tf:3 (the deploy role itself), correctly excluded. No module was missed.

Also checked and cleared:

  • No other root instantiates these modules. Only environments/aws/{compute,secrets,networking,database}.tf source them (5 invocations; cleanup-lambda is uninstantiated). Every new variable is required, no default, so a missed invocation fails terraform validate — apply-time, not runtime. Confirmed by running validate.
  • Role names are in scope. All names derive from local.stack_name = "${var.project_name}-${var.environment}-${random_id.suffix.hex}" with project_name = "cudly" in all three github-*.tfvars and as the variable default. No role sets path =, so every ARN is role/cudly-… and matches role/cudly-*.
  • Delete-and-recreate is closed. iam:DeleteRole stays unconditioned, but CreateRole requires the boundary, so the round-trip gains nothing.
  • CreateServiceLinkedRole is not a door. policy_data.tf:186-206 scopes it to five specific aws-service-role/ ARNs and a StringLike on iam:AWSServiceName; SLR policies are AWS-owned and unmodifiable.
  • UpdateAssumeRolePolicy unconditioned is safe. Rewriting the trust policy of a cudly-* role yields a role that is itself boundaried; a role that does not exist yet cannot be created without one. The provider-ordering argument for leaving it unconditioned is sound and matches resourceRoleUpdate's block order.
  • The deploy role cannot detach its own gate. iam:DetachRolePolicy is in IAMDenyModifyDeployRoleAndPolicies' action list against role/cudly-terraform-deploy, so cudly-deploy-iam cannot be detached.
  • AddRoleToInstanceProfile supports no condition keys and stays unconditioned — see L4.

3. Is the ARN allowlist exact? — verified exact, and complete

Derived the attachment set independently from the modules:

arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore                          networking (fck-nat)
arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole              lambda, cleanup-lambda, secrets rotation
arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole          lambda, cleanup-lambda, secrets rotation
arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy         fargate task-execution

Exactly the four in the ArnNotEquals list, no wildcards, no patterns. aws_iam_policy.secret_read in modules/secrets/aws is indeed only exported via outputs and attached nowhere — the "created but never attached" claim holds. None of the four is escalation-capable (logs, ECR pull, ENI management, SSM agent core), and each is capped by the boundary anyway.

Bypass attempts on the operator itself, all fail closed: an attacker cannot mint a policy in the aws namespace; a different-partition ARN (arn:aws-us-gov:…) is not equal to any listed value so the Deny fires; ARN matching is case-sensitive.

4. Deny-vs-Allow evaluation order — each Deny matches the request it targets

  • IAMDenyAttachUnapprovedManagedPolicy — Resource: "*", so it cannot be sidestepped by a role name outside cudly-*. iam:PolicyARN is always present on AttachRolePolicy, and if it somehow weren't, a negated operator returns true on an absent key, so the Deny still fires. Fails closed either way.
  • IAMDenyStripRoleBoundary — DeleteRolePermissionsBoundary's resource type is role, so role/cudly-* matches. Note it is belt-and-braces: swapping the boundary for a weaker one is already blocked by the absence of any Allow (PutRolePermissionsBoundary is granted only under StringEquals on the one ARN, and I confirmed by grep that no other attached policy grants that action).
  • IAMDenyBoundaryOnDeployRole — separate statement, correctly. Folding it into the strip Deny would cross-product PutRolePermissionsBoundary × role/cudly-* and block the transition apply. That reasoning is right.
  • The "name is load-bearing" claim is correct and pinned. cudly-deploy-boundary matches policy/cudly-deploy-* in IAMDenyModifyDeployRoleAndPolicies (policy_data.tf:146-165), whose action list is the complete managed-policy mutation set. Mutation M6 confirms TestBoundaryPolicyNameStaysInProtectedNamespace fails on a rename.

5. Will the deploy actually still work? — no runtime gap found in any declared grant

This is the one that breaks production, so I derived it from source rather than trusting the list. Every service prefix appearing in an Action/actions assignment anywhere under terraform/modules/**/aws:

ce, ec2, ecr, ecs, elasticache, es, kms, lambda, logs, memorydb,
organizations, rds, redshift, savingsplans, secretsmanager, ses, ssmmessages, sts, iam

Every one is covered by the ceiling. The three narrowed services match exactly what the modules grant: organizations → DescribeAccount / DescribeOrganization / ListAccounts (exactly the three in OrganizationsDiscoveryCeiling); sts → AssumeRole only; iam → PassRole only, covered by PassRoleCeiling. The four managed policies add logs, ec2, ecr, ssm, ssmmessages, ec2messages, s3 — all present.

cross_account_role_name_prefix = "CUDly" at compute.tf:105 and compute.tf:226 matches arn:aws:iam::*:role/CUDly* in CrossAccountAssumeRoleCeiling; TestBoundaryMatchesCrossAccountRolePrefix pins the module default.

I could not name a single action a module role policy grants that the boundary does not. Caveat stated plainly: this covers the declared grants. It cannot cover an AWS call the application makes that has no matching grant today — such a call is already failing on main, so the boundary does not regress it.

6. Managed-policy size limits — conclusion holds, the cited numbers do not reproduce (see L2)

7. The ordering constraint — verified, the PR body has it the right way round

.github/workflows/deploy-aws-lambda.yml on.push.paths includes terraform/environments/aws/**, terraform/modules/compute/aws/lambda/**, terraform/modules/database/aws/**, terraform/modules/secrets/aws/** and terraform/modules/networking/aws/**. This PR touches files under all five. Merging does trigger the deploy, that deploy does call iam:PutRolePermissionsBoundary against the pre-existing boundary-less roles, and that grant exists only in the new cudly-deploy-iam. Apply the bootstrap root first. Getting this backwards reddens every AWS deploy with AccessDenied until the apply happens.

The transition-order argument inside the apply is also sound: every aws_iam_role_policy / aws_iam_role_policy_attachment references its role through aws_iam_role.X.name, so the graph orders PutRolePermissionsBoundary ahead of the writes that require it.


Findings

F1 — MEDIUM. The escape analysis is stated as complete and is not: lambda:*, ssm:* and ecs:* at Resource: "*" are the same shape of escape as sts:*

terraform/environments/aws/ci-cd-permissions/policy_boundary.tf:100-116 asserts:

organizations and sts are the two services that are NOT safe at service:* granularity, because at that width each of them is a complete escape from this boundary rather than a widening within it

The stated criterion is right, and the reasoning for sts — "the assumed session is a DIFFERENT principal, so this boundary does not follow it" — is exactly right. But it is applied to only two of the services in the list. At least three more meet the same criterion, because a boundary follows a principal, and each of these lets a boundaried role execute code under a different principal:

  • lambda:* on * → lambda:UpdateFunctionCode on any function in the account, then invoke. No iam:PassRole is required (the role is not being changed), so PassRoleCeiling's cudly-* scoping does not apply. Code then runs under that function's execution role.
  • ssm:* on * → ssm:SendCommand / ssm:StartSession to any SSM-managed instance. No PassRole. Code runs under that instance profile's role.
  • ecs:* on * → ecs:UpdateService onto an existing task-definition revision, or ecs:ExecuteCommand into a running task. Neither needs PassRole when the task role is already set.

Precondition in each case: a compute principal in the account that is not boundaried and holds more than the ceiling. Every Terraform-managed cudly-* role is boundaried after the transition apply, so this is account-state-dependent — the same class of precondition as the OrganizationAccountAccessRole reasoning that you (correctly) treated as decisive for sts.

The gap is wide relative to need. What the modules actually grant is:

lambda:InvokeFunction (×9), lambda:InvokeFunctionUrl, lambda:GetFunctionUrlConfig
ecs:RunTask (×4)
kms:Sign, kms:GetPublicKey, kms:DescribeKey
ec2:{Describe*,PurchaseReservedInstancesOffering,AcceptReservedInstancesExchangeQuote,
     GetReservedInstancesExchangeQuote,ModifyNetworkInterfaceAttribute}
s3: nothing at all (s3 is in the ceiling solely for AmazonSSMManagedInstanceCore's s3:GetObject)

I am not asking you to narrow the ceiling in this PR — your runtime-vs-apply-time argument for landing the escalation fix first and narrowing separately is correct, and I'd make the same call. Two things I do think belong here:

  1. Fix the comment. As written it tells a future maintainer the escape analysis is finished. It isn't. Something like "these are the two the modules force us to narrow today; lambda, ssm and ecs at * are escapes of the same shape and are left wide deliberately, tracked in #NNNN" costs three lines and stops the next person from trusting a closed door that is ajar.
  2. Amend "Left open, deliberately." It currently reads as data exposure ("read every secret, delete RDS instances, schedule KMS key deletion, commit RI/SP spend"). All true, and the RI/SP spend line is the money-path one. But it does not say that the ceiling still permits further privilege escalation out of the boundary when a non-boundaried compute principal exists. That is a materially different residual from "can read secrets", and sec(iac/aws): deploy role can create an unbounded role and attach AdministratorAccess to it #1705 should not be closed on the wider claim without it being written down.

If a follow-up narrows this, the minimum-blast-radius shape is a Deny inside the boundary on the code-execution verbs (lambda:UpdateFunctionCode, lambda:UpdateFunctionConfiguration, ssm:SendCommand, ssm:StartSession, ecs:UpdateService, ecs:ExecuteCommand, ecs:RegisterTaskDefinition) for resources outside cudly-* — a Deny cannot cause the silent runtime 403 a narrowed Allow can, because the deny surface is enumerable and none of those verbs appears in any module policy.

F2 — LOW. policy_iam.tf:14-16 overstates the condition-key claim for iam:CreatePolicy

The deploy role keeps iam:CreatePolicy and iam:CreatePolicyVersion, neither of which supports any condition key at all (verified against the AWS Service Authorization Reference)

CreatePolicy supports aws:RequestTag/${TagKey} and aws:TagKeys (service reference, quoted in §1). CreatePolicyVersion genuinely supports none. The operative conclusion is unaffected — neither key constrains the policy document, and the minted policy is inert because attaching it is denied — but the sentence is cited as verified fact and a maintainer could reasonably rely on it. Narrow it to "neither supports a condition key that constrains the policy document; CreatePolicyVersion supports none at all."

The parallel claim at policy_boundary.tf:26-29 about the four managed-policy mutation actions is correct; I verified all four return no condition keys.

F3 — LOW. The cited rendered sizes do not reproduce

PR body: "New sizes are 1127 and 1223 characters." Rendering both documents from the statement structures as compact JSON (which is what jsonencode emits, and the 6144 limit excludes whitespace) with a 12-digit account ID substituted for the boundary ARN, I get:

cudly-deploy-boundary: 966 characters
cudly-deploy-iam:     1083 characters

Both are ~13% below the quoted figures and both are far under 6144, so the conclusion — these fit comfortably and the split was the right call — is unaffected. Flagging only because the numbers are presented as measured evidence and don't reproduce; if they came from an earlier draft of the documents, worth re-measuring before the body becomes the record. The "five of ten attached policies" count I confirmed directly: role.tf now has attachments for networking, compute, compute_b, data, iam.

F4 — LOW / informational. Two residuals the "Left open" section doesn't name

  • iam:AddRoleToInstanceProfile supports no condition keys and stays unconditioned on role/cudly-* + instance-profile/cudly-*. Not exploitable today: launching with the profile needs iam:PassRole, which is scoped to cudly-* with the deploy role explicitly denied, and every Terraform-managed cudly-* role is boundaried. But its precondition is identical to the hand-made-cudly-*-role residual you do admit, so it belongs in the same bullet.
  • The rollback caveat is real but narrower than stated. rollback.yml takes cloud / environment / image_tag / reason and has no ref input; its actions/checkout steps carry no ref:, so they check out whatever ref the dispatch targeted. A rollback dispatched from main — the normal path, rolling the image back — applies current config and is completely unaffected. The DeleteRolePermissionsBoundary failure only occurs if someone selects an old branch or tag in the Actions ref picker. The README wording ("a ref that predates the boundary has no permissions_boundary in config") is accurate, but reads as though rollback is broken in general when the default path is fine. One clause would fix it.

Hypotheses tested and cleared

Stated explicitly so the negative results are on the record: PutRolePolicy unsupported-condition-key pipeline break; a missed aws_iam_role; a second environment root instantiating the modules unguarded; a role name or path = outside role/cudly-*; a fifth managed-policy attachment or a secret_read attachment breaking the apply; CreateServiceLinkedRole as a role-creation door; delete-then-recreate around the CreateRole condition; detaching cudly-deploy-iam from the deploy role; swapping the boundary for a weaker one; UpdateAssumeRolePolicy on an unboundaried role; a partition or case variant defeating ArnNotEquals; a Deny whose Resource never matches its target request; a service granted by a module but missing from the ceiling; a cross_account_role_name_prefix override capping the cross-account read; the trigger-path/ordering claim being inverted. All cleared, each by the check named in the section above.

What I could not verify, and why

  • No terraform plan and no live-state check. Out of scope by instruction and no credentials here, so nothing below the config layer is confirmed: whether the boundary policy already exists in the target account, what cudly-* roles exist today that Terraform does not manage (F1's and F4's precondition, and the residual you already admit), and whether any non-boundaried Lambda / SSM-managed instance / ECS service exists in the account. F1's severity is a function of that account state, and someone with console access should check it before sec(iac/aws): deploy role can create an unbounded role and attach AdministratorAccess to it #1705 is closed on the wider claim.
  • Runtime coverage is verified against declared grants only. An AWS call the application makes with no matching IAM grant is already broken on main; the ceiling neither fixes nor regresses it.
  • I did not attempt to render the documents through Terraform itself (that needs init + provider evaluation of aws_iam_policy.workload_boundary.arn); F3's figures come from reconstructing the statement structures, which is why I report the discrepancy rather than asserting the PR's numbers are wrong.

Recommendation: land it, in the stated order — bootstrap root applied by hand first, then merge. F1's comment and PR-body corrections are worth folding in before merge since they are prose-only and this is the durable record for a p0; F2–F4 are equally prose-only and can ride along. The mechanism itself is correct, the guard tests are the strongest part of the change, and I found no way to reconstruct the #1705 escalation against the committed configuration.

@cristim

cristim commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review, paced to one request per hour across this repo.

The CodeRabbit quota is per-developer per-organization and adaptive, tightening at the 95th percentile of recent review volume. Every open PR here draws on one budget, so bursting requests both consumes it faster and tightens the limit. Spacing them is what gets verdicts to land.

Full form rather than incremental: this head was pushed while the quota was exhausted, so its automatic review never ran and is not retried retroactively. An incremental request would skip exactly those commits and report clean on a diff it never read.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

@cristim Full review requested for pull request #1722.


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1722

Timestamp: 2026-08-06T08:28:24.958Z
Learning: In the LeanerCloud/CUDly repository, request CodeRabbit reviews at most once per hour across the repository because the adaptive quota is shared across open pull requests. When a PR head was pushed while automatic review was quota-exhausted and was not retried, use a full review rather than an incremental review so the missed commits are reviewed.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 42 minutes.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b86b745a-746e-4c7c-9c4c-712237d0989e

📥 Commits

Reviewing files that changed from the base of the PR and between 85a0b4b and 1c2ce2e.

📒 Files selected for processing (23)
  • terraform/environments/aws/ci-cd-permissions/README.md
  • terraform/environments/aws/ci-cd-permissions/policy_boundary.tf
  • terraform/environments/aws/ci-cd-permissions/policy_data.tf
  • terraform/environments/aws/ci-cd-permissions/policy_guard_test.go
  • terraform/environments/aws/ci-cd-permissions/policy_iam.tf
  • terraform/environments/aws/ci-cd-permissions/role.tf
  • terraform/environments/aws/compute.tf
  • terraform/environments/aws/database.tf
  • terraform/environments/aws/main.tf
  • terraform/environments/aws/networking.tf
  • terraform/environments/aws/secrets.tf
  • terraform/modules/compute/aws/cleanup-lambda/main.tf
  • terraform/modules/compute/aws/cleanup-lambda/variables.tf
  • terraform/modules/compute/aws/fargate/main.tf
  • terraform/modules/compute/aws/fargate/variables.tf
  • terraform/modules/compute/aws/lambda/main.tf
  • terraform/modules/compute/aws/lambda/variables.tf
  • terraform/modules/database/aws/main.tf
  • terraform/modules/database/aws/variables.tf
  • terraform/modules/networking/aws/main.tf
  • terraform/modules/networking/aws/variables.tf
  • terraform/modules/secrets/aws/main.tf
  • terraform/modules/secrets/aws/variables.tf

Comment @coderabbitai help to get the list of available commands.

… size

Fix three inaccuracies flagged by adversarial review, all in comments only:

- policy_boundary.tf: the organizations/sts comment read as a complete
  escape analysis. State the criterion, name organizations and sts as
  narrowed in this change, and note that lambda:*, ssm:* and ecs:* meet
  the same criterion and are tracked in #1723 instead.
- policy_iam.tf: iam:CreatePolicy does support aws:RequestTag/${TagKey}
  and aws:TagKeys condition keys (it accepts an optional Tags parameter).
  iam:CreatePolicyVersion supports none. The conclusion is unchanged
  since neither constrains the policy document.
- PR body: recompute the two new policies' compact-JSON sizes (966 and
  1083, not 1127 and 1223), add the privilege-escalation residual and
  the unconditioned iam:AddRoleToInstanceProfile action to "Left open,
  deliberately".

No policy logic, resources, or tests changed.
@cristim

cristim commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Addressed the four findings from the adversarial review at 5d86f152, prose-only per your read that the code and the escalation fix are correct:

  1. policy_boundary.tf:100-116 escape analysis. Rewrote the comment so it states the criterion (different principal, no iam:PassRole involved), names organizations and sts as narrowed in this change, and says explicitly that lambda:*, ssm:* and ecs:* meet the same criterion, stay at full width here, and are tracked in sec(iac/aws): deploy boundary still permits escaping to another principal via lambda/ssm/ecs at service:* #1723. No grants changed.
  2. PR body "Left open, deliberately" understated the residual. Added a bullet covering the further privilege-escalation-out-of-the-boundary path (same three services), distinct from the existing data-exposure bullet, linking sec(iac/aws): deploy boundary still permits escaping to another principal via lambda/ssm/ecs at service:* #1723.
  3. policy_iam.tf:14-16 factual error. iam:CreatePolicy does support aws:RequestTag/${TagKey} and aws:TagKeys (it takes an optional Tags parameter); iam:CreatePolicyVersion supports none. Fixed the claim in both the .tf comment and the matching PR-body bullet; the conclusion is unchanged since neither key constrains the policy document. Left the parallel claim at policy_boundary.tf:26-29 (about CreatePolicyVersion/SetDefaultPolicyVersion/DeletePolicy/DeletePolicyVersion) alone, it's correct.
  4. PR body character counts. Recomputed as compact JSON: 966 and 1083 (not 1127/1223), both still far under the 6144 limit.

Also added the iam:AddRoleToInstanceProfile bullet to "Left open" per your note, next to the hand-made-cudly-*-role residual it shares a precondition with.

Verification: terraform fmt -recursive -check, terraform validate in both ci-cd-permissions and environments/aws, and go test -count=1 -v ./terraform/environments/aws/ci-cd-permissions/... (9/9 guard tests pass) all clean. No policy logic, resources, or tests touched, every changed line in both .tf files is a comment.

Pushed at 1c2ce2ea5.

@cristim

cristim commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Bootstrap root APPLIED — merge this PR promptly

Applied terraform/environments/aws/ci-cd-permissions from this PR head (1c2ce2ea5) against account 909626172446, as arn:aws:iam::909626172446:user/cristi.

Apply complete! Resources: 3 added, 1 changed, 0 destroyed.

update  aws_iam_policy.data                 cudly-deploy-data
create  aws_iam_policy.workload_boundary    cudly-deploy-boundary
create  aws_iam_policy.iam                  cudly-deploy-iam
create  aws_iam_role_policy_attachment.iam  -> cudly-terraform-deploy

Plan reviewed before applying: 0 destroys, nothing outside this root.

Verified against live IAM, not the apply output

  • cudly-terraform-deploy now carries five policies: cudly-deploy-{compute,compute-b,networking,data,iam}.
  • cudly-deploy-data: the unconditioned Allow on iam:CreateRole / iam:AttachRolePolicy / iam:PutRolePolicy is gone. Those actions now appear in that policy only inside the IAMDenyModifyDeployRoleAndPolicies Deny.
  • cudly-deploy-iam re-grants the four actions under StringEquals iam:PermissionsBoundary = arn:aws:iam::909626172446:policy/cudly-deploy-boundary, with IAMDenyAttachUnapprovedManagedPolicy (four exact ARNs), IAMDenyStripRoleBoundary, IAMDenyBoundaryOnDeployRole.

⚠️ The window is open until this merges

cudly-deploy-boundary currently reports AttachmentCount: 0 — no role carries it yet, because the permissions_boundary arguments live in the module changes in this PR, which deploy from main.

Until then the deploy role can perform IAM writes only on roles that already carry the boundary, and none do. A deploy needing an IAM write on an existing role in this interval will fail with AccessDenied. Terraform only issues those writes on drift, so the practical risk is low, but it is non-zero and it shrinks to nothing the moment this merges.

The escalation itself is already closed: creating a role, or attaching/inlining a policy, now requires the boundary.

Nothing was applied to terraform/environments/aws — that root stays with CI on merge.

@cristim

cristim commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Merging without a CodeRabbit verdict, deliberately, with the reasoning recorded.

CodeRabbit has not reviewed this PR at any head — it has posted no comments here at all, having been throttled under Fair Usage when the branch was pushed, and a push that lands during the quota window is never auto-reviewed retroactively. A paced full-review request was made at 08:28Z against head 5d86f152, which the prose-correction push has since superseded.

Waiting increases risk rather than reducing it. The bootstrap root is already applied, so cudly-terraform-deploy can now perform IAM writes only on roles carrying cudly-deploy-boundary, and AttachmentCount is 0 because the permissions_boundary arguments are in this PR. Every hour unmerged is an hour in which a deploy needing an IAM write on an existing role fails with AccessDenied. Merging closes that window; waiting holds it open.

What this PR does carry:

  • An independent adversarial review posted above: 10 mutations designed and all 10 caught by the guard suite, 13 aws_iam_role resources independently enumerated with none missed, the iam:PermissionsBoundary support claim verified against the AWS Service Authorization Reference, and the apply-before-merge ordering verified against deploy-aws-lambda.yml. For IAM semantics that is a stronger check than a general-purpose review bot provides.
  • All CI green, including the two checks now required on main (CI Success, Run pre-commit hooks).
  • Zero unresolved review threads.
  • 9 guard tests passing, which convert boundary drift from a silent runtime 403 into a CI failure.
  • A verified live-IAM check that the unconditioned Allow is gone and the re-grant is boundary-conditioned.

Residuals are tracked, not hidden: #1723 (the ceiling still permits escaping to another principal via lambda:* / ssm:* / ecs:*, and still allows broad non-IAM actions) and #1692 (main now requires a PR and passing checks, but required_approving_review_count is 0, so there is still no second-human gate).

#1705 should not be closed on the wider claim until live account state is checked for non-boundaried Lambda functions, SSM-managed instances or ECS services, which determines #1723 severity and could not be verified without account access at review time.

@cristim
cristim merged commit ff808b2 into main Aug 6, 2026
20 checks passed
cristim added a commit that referenced this pull request Aug 15, 2026
cudly-deploy-boundary capped the deploy role's IAM writes (#1722) but still
granted ecs, lambda and ssm at `service:*` on Resource "*". Each of those three
lets a boundaried principal execute code as a DIFFERENT principal, which is not
a widening within the ceiling but a complete exit from it: a permissions
boundary constrains the principal it is attached to and does not follow the one
the code ends up running as.

  - lambda:UpdateFunctionCode on any function, then invoke, runs as that
    function's execution role.
  - ssm:SendCommand / ssm:StartSession to any SSM-managed instance runs as that
    instance's profile.
  - ecs:UpdateService onto an existing task-definition revision, or
    ecs:ExecuteCommand into a running task, runs as that task's role.

None of them needs iam:PassRole, so PassRoleCeiling's cudly-* scoping does not
constrain them either. This is the same criterion #1722 already applied to
organizations and sts; the enumeration was incomplete.

The three are now pinned to what the workload roles actually need, re-derived
from the tree rather than assumed:

  - ecs:RunTask, the only ecs action any module grants (the four EventBridge
    invoker roles in modules/compute/aws/fargate).
  - lambda:InvokeFunction and lambda:GetFunctionUrlConfig, the only two any
    module grants. The InvokeFunction / InvokeFunctionUrl in aws_lambda_permission
    blocks are resource policies granting a service principal, not grants to a
    workload role, so this ceiling never gated them.
  - the 15 ssm actions of AmazonSSMManagedInstanceCore v2 verbatim, which the
    fck-nat instance role carries for Session Manager. No module grants an ssm
    action of its own, and the operator-side verbs that reach a DIFFERENT
    instance are absent.

Narrowing is safe against the runtime-403 risk that argues for per-service
granularity everywhere else in this file: for these three the pinned lists are
the union of what terraform/modules grants and what the attached AWS managed
policies grant, i.e. a superset of the whole identity side. Effective
permissions are identity AND ceiling, so a ceiling that already covers the
identity side changes nothing for the legitimate path.

Guarded in CI in both directions:

  - TestBoundaryDeniesCrossPrincipalEscapes fails if any of the six escape
    actions becomes permitted again. It keys off a matcher, not off the literal
    tokens "ecs:*" / "lambda:*" / "ssm:*", because "*", "lambda:Update*" and a
    bare "ssm:SendCommand" entry all reopen the hole while leaving a check
    written against the wildcard spellings green.
  - TestBoundaryCoversSSMManagedInstanceCore closes the blind spot the existing
    coverage test cannot see: a role's identity policy is its inline policies
    plus the AWS managed policies attached to it, and a managed policy's actions
    appear nowhere in this tree.
  - TestActionPermittedMatchesWholeActionCaseInsensitively pins the matcher the
    other two delegate to. Its dangerous failure mode is silent: a matcher that
    is too strict leaves the escape test passing against a wide-open ceiling,
    which reads exactly like a ceiling that grants none. Matching is case
    -insensitive because IAM action matching is, so "ecs:updateservice" cannot
    slip past.
  - The coverage test now resolves grants through the same matcher instead of
    substring-searching the file, so an action moved from an Allow into a Deny
    no longer reads as covered, and it rejects a bare "*" or any iam grant wider
    than iam:PassRole in an Allow statement.

Mutation testing the above found a hole in the guard itself: actionStringPattern
only matched service:Action literals, so a bare "*" never entered any derived
action set. A ceiling of `Action = ["*"]` therefore passed every assertion in
this file, including the "no bare star" check written to catch exactly that and
the escape check, which found none of the six escapes in a set the star had
never entered. The pattern now matches a lone "*" as well.

Two residuals are documented rather than closed, both stated in the file:
ecs:RunTask against an already-registered task definition still runs as that
definition's task role, and cannot be resource-scoped here because the family is
"<stack_name>-fargate" with stack_name defaulting to a random suffix, so no
literal ARN pattern could be known to match; and iam:AddRoleToInstanceProfile
supports no condition key, though it needs iam:PassRole to matter and that is
scoped.

Closes #1723
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/security Security finding urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(iac/aws): deploy role can create an unbounded role and attach AdministratorAccess to it

1 participant