Skip to content

feat: trust GitHub immutable OIDC subjects in the deploy role - #19

Closed
dannysteenman wants to merge 4 commits into
mainfrom
feat/immutable-github-oidc
Closed

feat: trust GitHub immutable OIDC subjects in the deploy role#19
dannysteenman wants to merge 4 commits into
mainfrom
feat/immutable-github-oidc

Conversation

@dannysteenman

@dannysteenman dannysteenman commented Aug 1, 2026

Copy link
Copy Markdown
Member

Summary

The deploy role trusted repo:OWNER/REPOSITORY:*, a subject built from names alone. Rename a repository, or delete and recreate one under the same name, and the trust follows the name instead of the repository. This moves the trust policy to GitHub's immutable subject claim:

repo:OWNER@OWNER-ID/REPOSITORY@REPOSITORY-ID:*

Matches the change in the TypeScript starter kit.

aws-cdk-github-oidc goes away with it. GithubActionsRole builds the legacy name-only subject and gives you no way to opt out, so the stack now uses aws-cdk-lib IAM constructs directly. It still imports the account's existing OIDC provider rather than creating one, so the synthesized template holds the same resources as before, with only the subject changed. Diffed the two templates to confirm.

src/bin/git_helper.py resolves the repository identity: in GitHub Actions from the default GITHUB_REPOSITORY, GITHUB_REPOSITORY_ID, and GITHUB_REPOSITORY_OWNER_ID variables, and locally from the origin remote plus gh api. No workflow needs a GitHub token.

The subject lands in an IAM StringLike condition, so build_github_actions_oidc_subject raises on a malformed field rather than rendering it. An id of * would otherwise widen the policy to every repository sharing that name.

GitHubOIDCStack picks up two optional arguments:

  • additional_repositories: other repositories under the same owner, each with its numeric GitHub ID (gh api repos/OWNER/NAME --jq .id). Checked in rather than looked up, because a lookup would hand every synthesizing CI job a token able to read the other repository.
  • subject_context: defaults to *, matching what the stack trusted before. Narrow it to environment:production once the deploy workflows declare a GitHub environment.

Drive-by fix

pytest couldn't import anything under src, so all 3 test modules failed to collect, including the pre-existing test_example.py. The CDK app entrypoint lives in src, so Python puts that directory on sys.path at runtime, but pytest collects from the repository root. Setting tool.pytest.ini_options.pythonpath through .projenrc.py gives it the same import root.

There's no build workflow in this repo, so nothing was running pytest to catch it.

Re-synth also revealed the pytest pin had drifted: pyproject.toml said 9.0.3 while .projenrc.py used projen's 7.4.3 default. Pinned it in .projenrc.py so the generated file matches.

Cutover

A repository keeps emitting the legacy subject until it's opted in, and there's no fallback. Deploy the stack first, then:

gh api -X PUT repos/OWNER/REPOSITORY/actions/oidc/customization/sub -F use_default=true -F use_immutable_subject=true

Reverse that order and GitHub issues a subject the deployed role rejects.

Diagram

flowchart LR
  Actions["GitHub Actions default variables"] --> Resolver["get_git_repository_identity()"]
  Git["origin remote + gh api"] --> Resolver
  Resolver --> Subject["build_github_actions_oidc_subject()"]
  Extra["additional_repositories: name + numeric id"] --> Subject
  Subject --> Role["GitHubDeployRole trust policy"]
Loading

Validation

  • uv run projen build, 27 tests green (1 before this branch, and it was failing to collect)
  • uv run ty check src/ clean
  • uv run cdk synth against this checkout renders repo:towardsthecloud@200249707/aws-cdk-python-starter-kit@775652363:*, resolved through gh api
  • Stack tests pin a fake repository identity through the environment variables, so they don't shell out to git or read whichever checkout they run in

The deploy role's trust policy now matches
repo:OWNER@OWNER-ID/REPOSITORY@REPOSITORY-ID:*, so a renamed, recreated, or
transferred repository can no longer inherit a trust written for a different
one.

src/bin/git_helper.py resolves the repository's immutable identity from the
GitHub Actions default variables, falling back to the origin remote plus gh api
for local synth. No workflow needs a GitHub token.

aws-cdk-github-oidc goes away with it: GithubActionsRole builds the legacy
name-only subject and offers no way to opt out. The stack now uses aws-cdk-lib
IAM constructs directly and still imports the account's existing OIDC provider,
so the synthesized resources are unchanged apart from the subject.

pytest could not import src, so every test module failed to collect. Setting
tool.pytest.ini_options.pythonpath through .projenrc.py fixes that and revives
the existing test_example.py.
@dannysteenman dannysteenman added the enhancement New feature or request label Aug 1, 2026
@dannysteenman
dannysteenman requested a review from axonstone August 1, 2026 20:29
dannysteenman and others added 2 commits August 2, 2026 11:56
* feat: align the python starter kit with the typescript one

Brings the Python kit up to the feature set and structure of
aws-cdk-starter-kit, which has been the one getting the new features.

Structure:
- src/aspects: permission boundary, S3 encryption, S3 public access and
  RFC 1918 VPC CIDR aspects, with a README.
- src/stacks: FoundationStack (deploy role + CDK toolkit cleaner) and
  StarterStack replace GitHubOIDCStack and BaseStack.
- src/custom_constructs: the OIDC role moves out of the stack into
  GitHubActionsOidcConstruct. The folder keeps its name because a
  src/constructs package would shadow the constructs pip package.

Deployments:
- EnvironmentConfig replaces the target_accounts dict. Environments are
  test (branch deploys on) and production, deployed in list order.
- Branch deployments: GIT_BRANCH_REF names stacks after the branch,
  express mode on ephemeral test stacks, and a destroy workflow on
  branch deletion. FoundationStack is skipped for branch deploys.
- Tasks gain :all/:stack variants plus ls and branch-only hotswap.

CI:
- build.yml runs ruff, ty, pytest and synth on every PR, with projen's
  self-mutation push. Nothing ran tests in CI before.
- cdk-diff-pr-comment posts a diff on pull requests.
- release.yml tags and publishes when the project version changes.
- The CDK CLI comes from the pinned aws-cdk-cli PyPI package instead of
  npm install -g aws-cdk, so the cdk_cli_version pin is real.

Two places where a literal port would have broken:
- BucketPublicAccessAspect catches the ValueError jsii raises when
  reading a struct set from the TypeScript side, which every bucket
  built through the Bucket L2 construct hits.
- The destroy workflow filters branches in the job condition, because
  GitHub ignores a branches filter on delete events.

BREAKING CHANGE: the deploy role is now named GitHubActionsServiceRole
rather than GitHubDeployRole, and the dev:* tasks and cdk-deploy-dev
workflow are replaced by test:* and production:*.

* refactor: apply simplify review findings

Reuse and altitude reviewers both flagged the deploy role name being
retyped in .projenrc.py while the construct kept its own default. Two
sides must agree on it: FoundationStack creates the role and the
generated workflows put its ARN in role-to-assume. env_helper now owns
DEFAULT_GITHUB_DEPLOY_ROLE_NAME and both import it. DEFAULT_ENVIRONMENT
gets the same treatment.

That required env_helper to stop importing projen at runtime, since the
CDK app imports it for create_env_resource_name. The annotation moves
under TYPE_CHECKING, so synthesis no longer drags the build tool in.

Also from the reviews:
- .projenrc.py builds the task env dict once instead of repeating four
  of five keys for the branch variant.
- TRAILING_HYPHENS and TRAILING_NON_ALPHANUMERIC collapse into one
  TRAILING_SEPARATORS pattern; every string they trim is drawn from the
  same alphabet, so they did the same job.
- _create_cdk_deployment_workflow drops chained_on_previous_environment,
  which only duplicated whether triggers carries workflow_run, and the
  unreachable -1 fallback on the environment index.
- BucketPublicAccessAspect flattens its nested if/else into guard
  clauses matching the rest of the function.
- app.py tags branch deploys with extract_cleaned_branch_name directly
  rather than calling create_env_resource_name("b") for its suffix
  logic. The tag is now "add-api" rather than "b-add-api".

Generated workflows are byte-identical, confirming the cicd_helper
changes are behavior-preserving.

* chore: drop dependency auto-approval and gate releases by age

Nothing merges a dependency bump on its own any more. auto_approve_options
and the auto-merge override are gone, which removes .github/workflows/
auto-approve.yml, and the "auto-approve" label goes with them since it no
longer triggers anything. Dependabot still opens grouped weekly PRs; they
now wait for a human.

Adds the uv equivalent of the TypeScript kit's pnpm minimumReleaseAge:
tool.uv.exclude-newer = "7 days" makes resolution skip anything published
in the last week, so a compromised or broken release has time to be caught
and yanked. uv takes a rolling duration, so there is no timestamp to
maintain. Only `uv lock` resolves; the deploy workflows run
`uv sync --frozen` and are unaffected.

The versions this file pins needed exempting. exclude-newer applies to
explicit pins too, so pinning a CDK release on its publication day made
resolution fail outright rather than just holding back an upgrade
(aws-cdk-lib==2.263.0 hit exactly this). tool.uv.exclude-newer-package
sets "0 days" for each, and PINNED_PACKAGES now feeds both that and the
Dependabot ignore list, which previously named aws-cdk rather than the
aws-cdk-cli package this project actually depends on and missed pytest.

* chore: move projen and pytest to the newest gate-eligible releases

projen 0.99.62 -> 0.101.20 and pytest 9.0.3 -> 9.1.1. Both are the newest
releases at least MINIMUM_RELEASE_AGE_DAYS old, so `uv lock` resolves them
without any exclude-newer-package exemption. aws-cdk-lib stays on 2.254.0.

  aws-cdk-lib  2.254.0    80 days
  aws-cdk-cli  2.1130.0   23 days
  projen       0.101.20    8 days
  pytest       9.1.1      43 days

pytest never needed reverting: 9.1.1 is 43 days old and always cleared the
gate. Only aws-cdk-lib 2.263.0 ever tripped it.

Regenerating under 0.101.20 drops the auto-generated step `id:` fields that
0.101.23 emits. Cosmetic only; every id referenced by an output or a step
condition is set explicitly and is unchanged.

* docs: rewrite the readme prose

The feature list sold the kit instead of describing it ("Production-ready",
"Seamless Security", "enterprise-ready", "Ship-ready"). Every bullet now
names what the thing actually does, so a reader can tell whether they want
it. The Project Structure section opened with four paragraphs of throat
clearing before the tree; it's two sentences now, and they say why a single
stack stops working rather than that modularity is good.

Fixes three things that were wrong rather than just wordy:

- Step 3 told you to `npm install -g aws-cdk`. The CDK CLI comes from the
  aws-cdk-cli PyPI package in the lockfile now, so `uv sync` covers it.
  Removing that step renumbered the rest, and step 6 pointed at the wrong
  one for account setup.
- `cdk bootstrap` in step 7 assumed a global install that no longer exists,
  so it's `uv run cdk bootstrap`.
- Step 7 ended in a stray double bracket.

Leaves the TIP-LIST block alone, since that's synced across repos.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant