Repository navigation
fix(iac/federation): make the AWS-target CloudFormation template loadable - #1507
Conversation
…able The trust policy in iac/federation/aws-target/cloudformation/template.yaml used !Sub expressions as the keys of its StringEquals map. CloudFormation does not accept an intrinsic function as a map key, so the template could not be loaded at all (cfn-lint E0000 "Unhashable type") and deploy-cfn.sh from a downloaded AWS-target bundle created no stack. AssumeRolePolicyDocument is now a single Fn::Sub JSON string producing the same policy: one Allow statement for the OIDC provider, sts:AssumeRoleWithWebIdentity, StringEquals on <host>:aud and <host>:sub, the host still derived from OIDCIssuerURL. Fn::Sub inserts raw text, so OIDCIssuerURL, OIDCAudience and OIDCSubjectClaim now also reject '"', '\' and control characters, and no admitted value can leave its JSON string. Tests render the document with the in-repo evaluator (now with !If and the template's Conditions), decode it with a duplicate-key-rejecting decoder, and compare it to the exact intended policy, including for every ASCII character in each parameter. A bundle test checks the download ships the committed template byte for byte and runs cfn-lint on it when available, and a pinned cfn-lint pre-commit hook now lints every CloudFormation template. Closes LeanerCloud#1489 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale: none found. The cfn-lint output, the W3005 notes and the four-template coverage all match what I reproduced. Reviewer: rF1. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rF3. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
Root cause
iac/federation/aws-target/cloudformation/template.yamlbuilt the role's trust policy as a YAML mapping whose twoStringEqualskeys were!Subexpressions (${Host}:audand${Host}:sub, with the host taken fromOIDCIssuerURLsince #1312). CloudFormation does not accept an intrinsic function as a map key, so it cannot load the template at all. cfn-lint 1.58.0 onmainreports:So a customer who downloads the AWS-target bundle (
format=cfn, or thecloudformation/part offormat=bundle) and runsdeploy-cfn.shgets a template-format error and no stack. The repo's own Go evaluator iniac/aws_target_cfn_test.goaccepted the template, so CI never noticed.Fix
Template.
AssumeRolePolicyDocumentis now a singleFn::SubJSON string. It produces the same policy as before:Allowstatement, withPrincipal.Federatedset to the OIDC provider ARNAction: sts:AssumeRoleWithWebIdentityStringEqualson exactly<host>:audand<host>:sub. The host is still!Select [1, !Split ["https://", OIDCIssuerURL]], the audience still falls back tosts.amazonaws.comwhen it is empty, and the subject is still the requiredOIDCSubjectClaim.There is no wildcard, no new operator and no new key.
Stricter
AllowedPatterns.Fn::Subinserts text as-is, with no JSON escaping. SoOIDCIssuerURL(both its host and path segments),OIDCAudienceandOIDCSubjectClaimnow also reject",\and control characters (\x00-\x1F). Every rule they already enforced is unchanged. With those characters excluded, no value can end its JSON string early to add, replace or widen a condition. TheConstraintDescriptions explain why. No real issuer URL, audience or subject contains these characters, and the API and generator already restrict subjects more tightly than this.CI. A new local pre-commit hook pins
cfn-lint==1.58.0with--non-zero-exit-code errorand lints all four CloudFormation templates:cloudformation/stacks/*andiac/federation/*/cloudformation. It follows the same pattern as thezizmorhook.check-yamlskips these directories, so until now nothing checked that they load.Comments only. In
iac/federation/aws-target/terraform/variables.tfandscripts/generate-federation-iac.go, comments claimed the Terraform validation and the CloudFormation pattern were byte-for-byte equivalent. They are updated to say the template now also rejects",\and control characters. Terraform does not need those rejections becausejsonencodeescapes them. No Terraform behavior changes.Closes #1489
Tests
All new and changed tests are unit or fixture tests. Nothing calls AWS.
iac/aws_target_cfn_trust_policy_test.go(new):TestAWSTargetCFN_MappingKeysArePlainStringswalks every mapping in the template and fails on any key that is not a plain string. This is the load-failure regression itself.TestAWSTargetCFN_ConditionKeysDeriveFromIssuerURLrenders the document for the Google and Azure issuers, with and without an audience. It decodes the result with a strict decoder (duplicate keys and trailing data are rejected) and checks it equals the exact intended policy.TestAWSTargetCFN_TrustPolicyValuesCannotEscapeJSONtests the injection property. Each of the three parameters gets values carrying every ASCII character plus U+00A0, U+00E9, U+2028 and U+2029 (in both the host and path positions for the URL). Each value the parameter'sAllowedPattern/MinLengthadmits must render to valid JSON that equals the intended policy with that value in place.TestAWSTargetCFN_RejectsJSONBreakoutchecks that concrete breakout payloads are rejected, for example1"}},{"Effect":"Allow","Principal":{"AWS":....TestDecodeStrictJSON_Controlis a control test for the strict decoder.iac/aws_target_cfn_test.go: the evaluator now also handles!If,!Equalsand!Not, and evaluates the template's realConditionssection. The old structural test is replaced by the rendering tests above.TestCFNEval_Positiveadds controls for!If(both branches, plus an undefined condition),!Not/!Equals, and single-pass!Sub.internal/api/handler_federation_cfn_params_test.go:TestCFNBundle_TemplatePassesCFNLintbuilds the realformat=cfndownload for gcp and azure sources. It asserts thatcloudformation/template.yamlis byte-identical to the committed file, so the pre-commit lint covers what customers download. Whencfn-lintis on PATH it also runs it on the extracted template and requires exit 0 (no findings, warnings included). Without cfn-lint it skips, the same way the Terraform bundle test skips withoutterraform.Verification
All commands were run locally on Linux, Go 1.26.9, with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true. cfn-lint 1.58.0 was installed in a throwaway venv.cfn-lint (static analysis):
Pre-commit hook:
Fail-before / pass-after, unit/fixture evidence. With
main's template in this tree:With the fix, all of these pass, and the bundle test passes both with and without cfn-lint on PATH.
Mutation probes (template edited, then
go test ./iac/). Each one fails at least one test:"allowed inOIDCSubjectClaim\allowed inOIDCAudience"allowed in the URL hostStringEqualschanged toStringLike"*":subcondition droppedsts:TagSessionaction added!Select [0, ...]Other checks:
go build ./...,go vet ./...andgo vet -tags integration ./...: clean.go test -count=1on./iac/,./scripts/(generator bundle tests),./internal/api/,./internal/iacfiles/and./internal/mocks/: all ok.golangci-lintv2.10.1run --allow-parallel-runners ./iac/... ./internal/api/... ./scripts/...: 0 issues.gocyclo -over 10 -ignore "_test\.go" scripts/generate-federation-iac.go: clean. Only a comment changed in that file.bash scripts/check-aws-iam-parity.shandbash scripts/test-aws-iam-parity.sh: pass (the permissions policy is unchanged).terraform fmt -checkandterraform validate(init -backend=false) iniac/federation/aws-target/terraform: clean.trailing-whitespace,end-of-file-fixer,check-yaml,go-fmtandcfn-linton the changed files: pass.Not run here:
trivy config(not installed on this machine), the full pre-commit job, the integration suites and the race tests. A realaws cloudformation validate-template/deploy was deliberately not run (no AWS calls). One limitation: cfn-lint does not parse the JSON inside aFn::Substring (I checked by adding a stray comma: cfn-lint still exits 0). That is why the Go tests decode and compare the rendered JSON themselves.Combined-tree verification at local queue commit
89fe5df6= origin/main3bfc2fe5+fix/issue-1484,fix/issue-1498,fix/issue-1489andfix/issue-1494merged in that order. Passed:go build ./...;go vetwith and without-tags integration;go test -race -short -count=1 ./...; golangci-lint v2.10.1 with and without the integration tag; gocyclo -over 10;go test -race -tags integrationoninternal/api,internal/configandinternal/database/postgres/migrations; the git-secrets scan andscripts/test-git-secrets-allowlist.sh.Out of scope / notes
DependsOn) on each ofcloudformation/stacks/CUDly/template.yamlandiac/federation/aws-cross-account/cloudformation/template.yaml. These are not load failures, so the hook gates on errors only, and I left those two templates untouched. Fixing the warnings would mean deleting two redundantDependsOnlines; after that the hook could drop--non-zero-exit-code error.oidc_audienceandoidc_subject_claimvalidations still accept"and\. That is safe there becausejsonencodeescapes them, but values containing them are now accepted by Terraform and rejected by CloudFormation. Tightening Terraform to match is a possible follow-up for parity. It is not needed for correctness.!Subkeys (${OIDCIssuerHost}:aud), and chore(iac): derive the CFN issuer host, create the GCP signing key first, read the dev Function URL from state #1312 recorded the same E0000 for it. So, as far as this history shows, no customer stack can have been created from this template, and there is no in-place upgrade to worry about. I did not check every older revision.Maintainers: please mirror the issue's triage labels onto this PR:
priority/p2,type/bug. The issue has notriagedlabel.🤖 Generated with Claude Code