Skip to content

fix: scope per-callback permissions to the caller job (least-privilege)#204

Merged
joshua-temple merged 2 commits into
mainfrom
fix/render-callback-permissions
Jun 17, 2026
Merged

fix: scope per-callback permissions to the caller job (least-privilege)#204
joshua-temple merged 2 commits into
mainfrom
fix/render-callback-permissions

Conversation

@joshua-temple

@joshua-temple joshua-temple commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Problem

main already grants per-callback permissions: (e.g. id-token: write for OIDC) by unioning every callback's permission map into the top-level permissions: block of the generated workflow. That works, but it over-grants: every job in the workflow receives the elevated scopes, not just the one caller job that needs them. For OIDC this widens the blast radius well beyond the callback that requested id-token: write.

Fix

Scope each callback's declared permissions to its own reusable-workflow caller job instead of the top level:

  • The top-level permissions: block now carries only cascade's own orchestration scopes (the historical base). Callback scopes are no longer merged in, so the collectCallbackPermissions union helper and the callbackUnion parameter of writeTopLevelPermissions are removed (no dead code).
  • Each caller job whose callback declares a non-empty permissions: map gets a job-level permissions: block with exactly the declared scopes, sorted for deterministic output. A callback that declares nothing gets no job-level block and inherits the top-level base, so existing manifests are byte-identical.
  • GitHub Actions allows permissions: on a reusable-workflow uses: caller job, and actionlint accepts it. The earlier wording claiming GitHub forbids it has been corrected.

Semantics

Job-level permissions replace the workflow default rather than merging. A callback's permissions: map is therefore the complete permission set for that job. cascade emits exactly what is declared and never injects an implicit scope (no auto contents: read). The configuration docs note this so users include contents: read for checkout and id-token: write for OIDC.

Verification

  • go build ./..., go test ./... (1402 passing), golangci-lint run ./... all green.
  • e2e module builds and vets clean; callback-permissions-oidc.yaml scenario rewritten to assert the job-level block carries the declared scopes and the top-level block excludes them.
  • Regenerating this repo's own workflows produces a byte-identical permissions block (the manifest declares no callback permissions).
  • actionlint accepts the generated job-level permissions: block.

Closes #182

Per-callback permissions: maps were accepted, scope-validated, and carried
onto the generator graph node, but never rendered on the uses: caller job, so
they were a validated no-op. GitHub Actions allows a permissions: key on a job
that calls a reusable workflow (jobs.<id>.uses); only runs-on, the concurrency
group key, environment, timeout-minutes, steps, env, container, services, and
defaults are forbidden there.

Render the callback Permissions map as a sorted job-level permissions: block at
every caller-job emission site (orchestrate jobs and their retry shims, promote
deploy/prod/external/rollback jobs, rollback deploys, hotfix builds). runs-on
and concurrency stay omitted on callbacks, matching existing behavior. Rendering
the map enables least-privilege per callback, id-token: write for cloud OIDC,
and attestations: write for build provenance.

Closes #182

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
Stop unioning callback permissions into the workflow top-level block.
Each callback's declared scopes are now rendered only on its own
reusable-workflow caller job, so the GITHUB_TOKEN is scoped to least
privilege per callback and the OIDC blast radius stays confined to the
one job that needs id-token: write.

The top-level permissions block now carries only cascade's own
orchestration scopes. Job-level permissions replace the workflow
default rather than merging, so a callback's map is the complete set
for that job; cascade emits exactly what is declared.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@joshua-temple joshua-temple changed the title fix: render permissions on reusable-workflow caller jobs fix: scope per-callback permissions to the caller job (least-privilege) Jun 17, 2026
@joshua-temple joshua-temple merged commit 4ce5709 into main Jun 17, 2026
9 checks passed
@joshua-temple joshua-temple deleted the fix/render-callback-permissions branch June 17, 2026 18:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: scope per-callback permissions to the caller job (least-privilege)

1 participant