Skip to content

chore(iac): derive the CFN issuer host, create the GCP signing key first, read the dev Function URL from state - #1312

Draft
IAmJSD wants to merge 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-324
Draft

IAmJSD wants to merge 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-324

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Fixes the three low IaC findings from the 2026-09-02 audit that #324 groups together. I re-checked each one against current main (a14dc8f2) before changing anything.

Finding Still live on main? Fix Which deploy applies it
A13b-014: CFN OIDCIssuerHost parameter Yes. template.yaml still declared it. handler_federation.go and aws-cfn-deploy.sh.tmpl still passed it Parameter removed. The trust policy derives the host from OIDCIssuerURL, and the server stops passing it No infra deploy. It ships in the CUDly server binary that serves GET /api/federation/iac, so the AWS Lambda app deploy picks it up. CUDly instances hosted on GCP or Azure only pick it up once the deploy pause (in place since 2026-10-20) lifts. Customer stacks change only when a customer re-downloads the bundle and runs it
A13c-024: GCP OIDC signing key with no create-first Yes. signing-key.tf still had prevent_destroy = false, a hard-coded 86400s and no create_before_destroy create_before_destroy = true. destroy_scheduled_duration becomes an input whose default leaves deployed keys unchanged deploy-gcp.yml (Cloud Run). That workflow is paused since 2026-10-20, so this only takes effect after the pause lifts. Expected plan diff: none (a lifecycle change, and the default equals today's value)
A13c-017: dev Function URL host hand-pasted in github-dev.tfvars Yes. 33pz…lambda-url.us-east-1.on.aws was still there. #16 (the CloudFront cut-over) is parked: the maintainer deferred it and the issue is unclaimed, so the issue's "fix here only if #16 stays parked" condition holds The host is read back from the previous apply's lambda_function_url output and passed as TF_VAR_lambda_deployed_function_url deploy-aws-lambda.yml, which runs on its own when this merges (it touches terraform/environments/aws/** and the workflow). rollback.yml does the same for Lambda rollbacks. Expected CORS diff on dev: none, as long as the URL in state is still the one that was pasted

Root cause and fix

A13b-014: the CloudFormation issuer host

iac/federation/aws-target/cloudformation/template.yaml took the trust policy's condition-key host as a second free-text parameter. Its own description said the operator had to keep it equal to OIDCIssuerURL minus https://. If the two differ (a trailing slash, or a typo in the tenant ID), IAM never fills in <host>:aud/<host>:sub. The stack still reports success, and every AssumeRoleWithWebIdentity is then denied.

  • The parameter is gone. Both condition keys are now !Sub ["${Host}:aud|sub", {Host: !Select [1, !Split ["https://", !Ref OIDCIssuerURL]]}]. OIDCIssuerURL's existing AllowedPattern (^https://[^/]+(/[^/]+)*$) guarantees exactly one leading https:// and no trailing slash. So element 1 is the whole host and path, the same value as local.oidc_condition_host in the Terraform sibling.
  • CloudFormation rejects a parameter the template doesn't declare. So in the same change I removed OIDCIssuerHost from buildCFParamsJSON, from aws-cfn-deploy.sh.tmpl's --parameter-overrides, and from federationIaCData, where nothing else used it.

A13c-024: the GCP OIDC signing key

terraform/modules/compute/gcp/cloud-run/signing-key.tf had neither create-first ordering nor a working protection.

  • create_before_destroy = true, matching modules/compute/aws/lambda/signing-key.tf (cli#1480). Destroying a Cloud KMS key schedules all of its versions for destruction, which stops AsymmetricSign at once. So a change that forces a new key used to break client-assertion minting immediately. With create-first, one of two things happens:

    • A region move builds the new key ring and key before the old key goes.
    • Any other replacement fails on the key-name collision before the old key is touched, because Cloud KMS never frees a key name.

    Either way the live key keeps signing.

  • destroy_scheduled_duration is now an input (signing_key_destroy_scheduled_duration, passed through from terraform/environments/gcp). It is validated to the 1–120 day range Cloud KMS accepts. I checked the provider source: in google provider v5.45.2 (the version in the lock files) this attribute is ForceNew. A new default would therefore plan a replacement of every deployed key, so the default stays 86400s. A longer window only applies to a project whose key doesn't exist yet. I did not change any tfvars.

  • prevent_destroy cannot be an input. Terraform only accepts a literal for lifecycle meta-arguments. Setting it to true would break cleanup-staging.yml, which runs terraform destroy on staging. The line that restated Terraform's default (prevent_destroy = false) is replaced with a comment explaining this. The deletion_policy = "PREVENT" field that would allow per-environment protection only exists from provider v7. This deviates from the issue's suggested fix, which asked for both values as module inputs.

A13c-017: the dev Function URL CORS origin

The Function URL ID is assigned by AWS. So lambda_allowed_origins in github-dev.tfvars carried a pasted copy that went stale whenever the URL was reissued, with no Terraform diff.

  • I added a new variable, lambda_deployed_function_url, with validation ^https://[a-z0-9]+\.lambda-url\.[a-z0-9-]+\.on\.aws/?$. compute.tf appends its origin (trailing slash removed, de-duplicated) to lambda_allowed_origins for the Lambda module only. It is a separate variable rather than TF_VAR_lambda_allowed_origins as the issue suggested, because a -var-file value overrides a TF_VAR_*. Since github-dev.tfvars keeps its static localhost:3000 entry, the issue's version would have been silently ignored.
  • scripts/read-lambda-function-url.sh reads terraform output -raw lambda_function_url from the already-initialized directory:
    • Prints nothing when the output is missing (first deploy) or null.
    • Removes the trailing slash.
    • Exits 1 on anything that isn't a single Function URL, so a bad value never reaches CORS or $GITHUB_OUTPUT.
  • deploy-aws-lambda.yml runs the script before the plan and passes the value to terraform plan. A new step after the apply emits a ::warning:: when the apply created or reissued the URL: the allowlist only gains the new origin on the next deploy.
  • rollback.yml's Lambda job exports the same variable before its direct terraform apply. Without that, a rollback would drop the origin.
  • github-dev.tfvars now lists only http://localhost:3000. Staging and prod keep their *.invalid placeholders. Once deployed they will also get their own Function URL origin, which is the origin the SPA is served from.

Closes #324

Verification

All of the evidence below is unit, mock or fixture evidence. Nothing ran against a real cloud account: no plan, apply or deploy, and no real credentials. Go runs used AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true, and Terraform used TF_CLI_CONFIG_FILE=/dev/null.

Fail-before / pass-after. I put main's versions of the implementation files back into the tree, kept the new tests, ran everything below, then restored the fix.

Test On main's implementation On this branch
go test ./iac/ -run 'TestAWSTargetCFN_|TestCFNEval_' FAIL. OIDCIssuerHost is still a parameter, and the condition keys can't be evaluated from OIDCIssuerURL alone ok
go test ./internal/api/ -run TestCFNBundle_ FAIL. The gcp and azure bundles still declare and pass OIDCIssuerHost ok
go test ./terraform/modules/compute/gcp/ -run SigningKey FAIL. No create_before_destroy, a restated prevent_destroy = false, a hard-coded duration ok
go test ./terraform/environments/aws/ -run 'FunctionURL|CORS' FAIL. github-dev.tfvars pins 33pz…lambda-url.us-east-1.on.aws, nothing is wired into the module, deploy or rollback ok
terraform test in terraform/modules/compute/gcp/cloud-run (mocked google) 2 passed, 2 failed, 2 skipped 6 passed
terraform test in terraform/environments/aws (all providers mocked, target module.compute_lambda) 0 passed, 1 failed, 4 skipped 5 passed
bash scripts/test-read-lambda-function-url.sh (stubbed terraform) 0 passed, 10 failed (script missing) 10 passed

What the new tests cover:

  • iac/aws_target_cfn_test.go parses the shipped template with yaml.v3. It evaluates each StringEquals key with a small !Ref/!Select/!Split/!Sub evaluator for a Google and an Azure issuer URL, and compares the result with Terraform's trimsuffix(trimprefix(url, "https://"), "/"). TestCFNEval_Positive is the control, so the test cannot pass by evaluating nothing.
  • TestCFNBundle_OverridesMatchTemplateParameters checks every key in the downloaded bundle's params JSON and deploy script against the template the same bundle ships. Every passed key must be declared, and every parameter without a default must be passed.

Other checks, all on this branch:

  • go build ./..., go vet ./..., go vet -tags integration ./...: clean.
  • go test -count=1 with no integration tag, one package at a time: ./iac/, ./internal/api/, ./internal/iacfiles/, ./scripts/, ./terraform/environments/aws/, ./terraform/environments/gcp/, ./terraform/modules/compute/gcp/, ./terraform/modules/compute/aws/, ./internal/mocks/. All ok.
  • golangci-lint v2.10.1 run --allow-parallel-runners on the touched Go packages: 0 issues. gocyclo -over 10 on internal/api/handler_federation.go: clean (that file only has removals).
  • shellcheck on both new scripts and actionlint on ci.yml, deploy-aws-lambda.yml and rollback.yml: clean.
  • terraform fmt -check -recursive terraform/: clean.
    • terraform/modules/compute/gcp/cloud-run: init -backend=false -lockfile=readonly and validate.
    • terraform/environments/aws and terraform/environments/gcp: init -backend=false and validate. Their lock files were restored afterwards, unchanged.
  • CloudFormation: bash scripts/check-aws-iam-parity.sh (ok) and bash scripts/test-aws-iam-parity.sh (16 passed). cfn-lint is not installed on this machine, so it was not run. bash scripts/test-aws-tfstate-platform-key.sh: 26 passed.

CI wiring added:

  • A "Test GCP compute modules" step (matrix gcp) that runs terraform/modules/compute/gcp/*/tests. For it to work with -lockfile=readonly on Ubuntu runners, cloud-run/.terraform.lock.hcl gains the linux_amd64 h1: hash for the same google 5.45.2 package; without it, readonly init fails the checksum check. That hash was added with terraform providers lock -platform=linux_amd64.
  • A "Run Function URL reader self-tests" step in the aws-tfstate-platform-key job.
  • terraform/environments/aws/tests/ has no CI runner on main yet. The open fix(iac): require PostgreSQL 15+ in every environment's version input #1233 branch adds "Test environment inputs", which runs terraform test in terraform/environments/<cloud> whenever a tests dir exists, and that picks this file up. The Go tests in terraform/environments/aws run under go test ./... in the meantime.

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ b56afb2b = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-204 76f85a4
fix/issue-438 78fe011
fix/issue-838 120b07f
fix/issue-143 aef1256
fix/issue-349 cfbb551
fix/issue-80 63405f9
fix/issue-199 36b9f8f
fix/issue-41 96fc237
fix/issue-299 6af14ed
fix/issue-324 3ab6729

fix/issue-324 and fix/issue-204 both edit .github/workflows/ci.yml next to the azure-kv-access-policy job. The tree kept #324's new "Run Function URL reader self-tests" step and #204's updated job comment; whichever PR merges second takes that same resolution.

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers. The full suite ran on ed0c7174, which differs from b56afb2b only by one .gitallowed line on fix/issue-324:

  • Passed: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package that has them, one package at a time (-tags integration -count=1, 60-minute local timeout): internal/api, internal/purchase, internal/scheduler and internal/server all pass.
  • Secrets: the first run caught a bare placeholder account ID in fix/issue-324's new tftest, now allowlisted with an anchored .gitallowed line. Re-run at b56afb2b: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Out of scope / for the owner

  • Possible existing CloudFormation problem, not verified. The trust policy has always used an intrinsic function as a map key (!Sub "${OIDCIssuerHost}:aud": on main). This PR keeps that shape and puts the derivation inside it. As far as I know, CloudFormation rejects map keys that aren't plain strings ("map keys must be strings; received a map instead"). The common workaround is to build AssumeRolePolicyDocument as a whole !Sub JSON string. I could not check this offline: there is no cfn-lint here, and I made no AWS calls. If it holds, the aws-target CFN bundle has never created a stack, and this PR neither makes that worse nor fixes it. Suggest running aws cloudformation validate-template --template-body file://iac/federation/aws-target/cloudformation/template.yaml with real credentials, and opening a separate issue if it fails. That fix would need OIDCAudience, OIDCSubjectClaim and OIDCIssuerURL to forbid " and \, so those values can't break out of the JSON string.
  • Existing customer stacks. Updating a stack with the new template but an old deploy-cfn.sh or params JSON fails with "Parameters: [OIDCIssuerHost] do not exist in the template". Re-downloading the bundle gives a matching pair. The condition-key strings are unchanged, so the role itself doesn't change.
  • GCP staging redeploys. After cleanup-staging.yml's terraform destroy, the KMS key ring and key names stay taken. So a fresh staging apply against the same project can't recreate cudly-oidc-signing. This predates this PR and is not touched.
  • The #153 branch (fix/issue-153). It adds OIDCIssuerHost to scripts/generate-federation-iac.go's iacData, plus drift tests that assume the field and the deploy-script argument exist. Once this lands, that branch needs to drop the field again, assert OIDCIssuerHost is absent from the rendered script, and swap OIDCIssuerHost out of its known-fields control.

Maintainer: please mirror the issue's triage labels on this PR: impact/internal, type/chore, triaged, priority/p3, severity/low, urgency/eventually, effort/s.

🤖 Generated with Claude Code

IAmJSD and others added 2 commits October 23, 2026 08:45
…rst, read the dev Function URL from state

Three low findings from the 2026-09-02 audit.

A13b-014: the aws-target CloudFormation template took the trust policy's
condition-key host as a second parameter, OIDCIssuerHost, that the operator
had to keep equal to OIDCIssuerURL minus https://. The host is now derived
with !Select [1, !Split ["https://", !Ref OIDCIssuerURL]], the same value as
the Terraform module's local.oidc_condition_host, and the server no longer
passes the parameter in the bundle's params JSON or deploy script (CFN would
reject an undeclared parameter).

A13c-024: the Cloud Run module's OIDC signing key now has
create_before_destroy, matching its AWS twin, so a change that forces a new
key cannot destroy the live one first. destroy_scheduled_duration becomes an
input whose default keeps every deployed key unchanged (the provider marks it
ForceNew). prevent_destroy cannot be an input (Terraform requires a literal)
and staging cleanup runs terraform destroy, so the redundant
`prevent_destroy = false` line is replaced by a comment saying so.

A13c-017: github-dev.tfvars no longer pins the dev Lambda Function URL host.
The deploy and rollback workflows read the previous apply's
lambda_function_url output (scripts/read-lambda-function-url.sh) and pass it
as TF_VAR_lambda_deployed_function_url; compute.tf appends its origin to the
CORS allowlist. A post-apply step warns when the apply changed the URL.

Closes LeanerCloud#324

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…S tftest

The combined-tree git-secrets run flagged
terraform/environments/aws/tests/lambda_cors_origins.tftest.hcl:10, a bare
account_id = "123456789012" in the mocked aws_caller_identity. Add an
anchored whole-line .gitallowed entry for that one line (bracketed digits so
the entry does not match itself), placed before the other terraform/ entries
so it does not collide with LeanerCloud#1255's entry for its own tftest.

Refs LeanerCloud#324

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added impact/internal Team-internal only type/chore Maintenance / non-user-visible triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline effort/s Hours labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(iac): low findings from the 2026-09-02 audit: CFN host param, GCP KMS key, dev CORS

1 participant