From 1a36ccbbbda2cec5ad4bf4131ae40adbc9becbef Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 17 Jun 2026 09:45:21 -0400 Subject: [PATCH 1/2] fix: render permissions on reusable-workflow caller jobs 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..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 --- .../callback-permissions-oidc.yaml | 40 +++++++------ internal/generate/generator.go | 9 +++ internal/generate/graph.go | 43 +++++++++++--- internal/generate/hotfix.go | 1 + internal/generate/job_attributes_test.go | 58 ++++++++++++++++--- internal/generate/promote.go | 7 +++ internal/generate/rollback.go | 1 + 7 files changed, 125 insertions(+), 34 deletions(-) diff --git a/e2e/scenarios/orchestrate/callback-permissions-oidc.yaml b/e2e/scenarios/orchestrate/callback-permissions-oidc.yaml index 21833e6..5c7941f 100644 --- a/e2e/scenarios/orchestrate/callback-permissions-oidc.yaml +++ b/e2e/scenarios/orchestrate/callback-permissions-oidc.yaml @@ -1,20 +1,26 @@ -name: "Callback Permissions Union (OIDC)" +name: "Callback Permissions (OIDC)" description: | Verifies that per-callback permissions declared on a build callback are - unioned into the calling orchestrate.yaml's top-level permissions block. A - reusable-workflow caller job cannot carry job-level permissions, so any scope - a callback needs (notably id-token: write for OIDC) must be granted at the top - level of the calling workflow instead. + rendered as a job-level permissions: block on the reusable-workflow caller + job, and are also unioned into the calling orchestrate.yaml's top-level + permissions block. + + GitHub Actions allows a permissions: key on a job that calls a reusable + workflow (jobs..uses), so cascade scopes the caller job to least privilege + per callback. This is what enables id-token: write for cloud OIDC on exactly + the callback that needs it. The api build declares permissions: {id-token: write, packages: read}. The - generated orchestrate.yaml must keep its base scopes (contents: write, - actions: read) and append the callback scopes in deterministic alphabetical - order (id-token before packages). The caller job itself must NOT carry a - job-level permissions block, since GitHub Actions forbids permissions on a - reusable-workflow call. + generated orchestrate.yaml must keep its base top-level scopes (contents: + write, actions: read) and append the callback scopes in deterministic + alphabetical order (id-token before packages). The build-api caller job must + additionally carry its own job-level permissions: block with those scopes. Generator-output verification scenario; the assertion runs on the staged repo after StageRepoFromConfig generates workflows but before any orchestrate runs. + A real OIDC token exchange needs a cloud identity provider the local act/gitea + harness cannot provide, so this scenario verifies the rendered permissions and + does not attempt a live OIDC exchange. config: trunk_branch: main @@ -29,7 +35,7 @@ config: deploys: [] steps: - - name: "Initial commit; assert callback permissions union in orchestrate.yaml" + - name: "Initial commit; assert callback permissions in orchestrate.yaml" action: commit commit: message: "feat: add api callback requiring id-token for oidc" @@ -41,15 +47,13 @@ steps: workflow_files: - path: ".github/workflows/orchestrate.yaml" contains: - # Base scopes are preserved. + # Base top-level scopes are preserved. - "contents: write" - "actions: read" # Callback scopes are unioned in at the top level. - "id-token: write" - "packages: read" - not_contains: - # A reusable-workflow caller job cannot carry job-level permissions; - # the scopes live only in the top-level block, so the per-callback - # OIDC grant never appears as a job-scoped permissions line directly - # under the build-api caller's uses:. - - "uses: ./.github/workflows/build-api.yaml\n permissions:" + # The build-api caller job renders its own job-level permissions: + # block (GitHub allows permissions: on a uses: caller job), scoped to + # the callback's declared scopes in sorted order. + - " permissions:\n id-token: write\n packages: read\n uses: ./.github/workflows/build-api.yaml" diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 8352db4..eefeed5 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -929,6 +929,11 @@ func (g *Generator) writeCallbackJob(sb *strings.Builder, info CallbackInfo, wor sb.WriteString(" continue-on-error: true\n") } + // permissions: is allowed on a reusable-workflow caller job. Render the + // callback's configured scopes so the GITHUB_TOKEN is least-privilege per + // callback and OIDC (id-token: write) / provenance (attestations: write) work. + writeCallbackPermissions(sb, " ", info.Permissions) + // Every callback is emitted as a jobs..uses reusable-workflow call. fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(workflow)) @@ -1278,6 +1283,10 @@ func (g *Generator) writeRetryJob(sb *strings.Builder, info CallbackInfo, workfl g.writeStrategyBlock(sb, info.Matrix) } + // The retry shim re-invokes the same reusable workflow, so it needs the same + // least-privilege job-level permissions: as the original callback. + writeCallbackPermissions(sb, " ", info.Permissions) + fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(workflow)) g.writeWithInputs(sb, info) writeSecretsBlock(sb, info.Secrets) diff --git a/internal/generate/graph.go b/internal/generate/graph.go index f75369d..7b76378 100644 --- a/internal/generate/graph.go +++ b/internal/generate/graph.go @@ -42,14 +42,16 @@ type CallbackInfo struct { SupportsDryRun bool // When true, dry-run promotes invoke the callback with dry_run: true instead of skipping it // Per-callback job attributes carried from config. GHA forbids - // runs-on/permissions/concurrency/timeout-minutes on a reusable-workflow uses: - // callback, and schema validation rejects runs_on/permissions/concurrency/ - // timeout_minutes on reusable callbacks. Callbacks are reusable-workflow only, - // so these fields are populated from config but never emitted as job-level keys. + // runs-on/concurrency/timeout-minutes on a reusable-workflow uses: caller + // job, and schema validation rejects runs_on/concurrency/timeout_minutes on + // reusable callbacks, so those fields are populated from config but never + // emitted as job-level keys. permissions: is the exception: GitHub allows it + // on a uses: caller job, so Permissions IS rendered as a job-level + // permissions: block (see writeCallbackPermissions). TimeoutMinutes int // Per-callback timeout-minutes; validated-against, never emitted (belongs inside the called workflow) - RunsOn *config.RunsOn // Per-callback runner selection (#12) - Permissions map[string]string // Per-callback job permissions, incl. id-token: write OIDC (#35, #15) - Concurrency *config.ConcurrencyConfig // Per-callback concurrency override (#17) + RunsOn *config.RunsOn // Per-callback runner selection (#12); validated-against, never emitted + Permissions map[string]string // Per-callback job permissions, incl. id-token: write OIDC (#35, #15); emitted as job-level permissions: + Concurrency *config.ConcurrencyConfig // Per-callback concurrency override (#17); validated-against, never emitted // PassthroughArtifact declares GHA artifact upload/download steps to inject // around this job's callback invocation, enabling inter-job artifact passing @@ -334,6 +336,33 @@ func writeTopLevelPermissions(sb *strings.Builder, base [][2]string, callbackUni sb.WriteString("\n") } +// writeCallbackPermissions emits a job-level permissions: block for a +// reusable-workflow caller job (jobs..uses) from the callback's configured +// permissions map. GitHub Actions allows permissions: on a uses: caller job, so +// rendering it scopes the GITHUB_TOKEN to least privilege per callback and +// enables OIDC (id-token: write) and build provenance (attestations: write) for +// exactly the callbacks that need them. When perms is empty nothing is emitted, +// so callbacks without an explicit permissions: stanza are byte-identical to the +// prior output. Scopes are emitted in sorted order for deterministic output +// (cascade gates generation on determinism). +// +// indent is the two-space job-body indent for the job whose block this is (e.g. +// " " for orchestrate/promote jobs). +func writeCallbackPermissions(sb *strings.Builder, indent string, perms map[string]string) { + if len(perms) == 0 { + return + } + scopes := make([]string, 0, len(perms)) + for scope := range perms { + scopes = append(scopes, scope) + } + sort.Strings(scopes) + fmt.Fprintf(sb, "%spermissions:\n", indent) + for _, scope := range scopes { + fmt.Fprintf(sb, "%s %s: %s\n", indent, scope, perms[scope]) + } +} + // ensureValidateDependency adds "validate" to deps if not already present func ensureValidateDependency(deps []string) []string { for _, d := range deps { diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index 3f63b37..cf145a1 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -522,6 +522,7 @@ func (g *HotfixGenerator) writeBuildJobs(sb *strings.Builder) { sb.WriteString(" needs: context\n") fmt.Fprintf(sb, " if: %s\n", mergedHotfixGuard()) if b.Workflow != "" { + writeCallbackPermissions(sb, " ", b.Permissions) fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(b.Workflow)) sb.WriteString(" with:\n") sb.WriteString(" sha: ${{ github.event.pull_request.merge_commit_sha }}\n") diff --git a/internal/generate/job_attributes_test.go b/internal/generate/job_attributes_test.go index 1f40b9f..c3195e0 100644 --- a/internal/generate/job_attributes_test.go +++ b/internal/generate/job_attributes_test.go @@ -11,13 +11,14 @@ import ( "github.com/stretchr/testify/require" ) -// TestReusableWorkflowCallbackHasNoJobAttributes asserts that a reusable -// workflow: callback (jobs..uses) never carries runs-on / permissions / -// concurrency on the job. GHA forbids them there and schema validation rejects -// runs_on/concurrency on reusable callbacks. Only permissions is structurally -// accepted on the config, but the generator must not emit any of the three on a -// uses: job. -func TestReusableWorkflowCallbackHasNoJobAttributes(t *testing.T) { +// TestReusableWorkflowCallbackRendersPermissionsNotForbiddenAttributes asserts +// that a reusable workflow: callback (jobs..uses) renders its configured +// job-level permissions:, while still never carrying runs-on or concurrency. +// GitHub allows permissions: on a uses: caller job (it is least-privilege per +// callback), but forbids runs-on and the concurrency group key there. Schema +// validation rejects runs_on/concurrency on reusable callbacks, so those never +// reach the generator. +func TestReusableWorkflowCallbackRendersPermissionsNotForbiddenAttributes(t *testing.T) { tmpDir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github/workflows/build.yaml"), []byte("on:\n workflow_call:\n"), 0644)) @@ -41,12 +42,51 @@ func TestReusableWorkflowCallbackHasNoJobAttributes(t *testing.T) { job := jobSection(result, "build-app:") assert.Contains(t, job, "uses: ./.github/workflows/build.yaml") - // None of the cascade-owned job attributes appear on a reusable uses: job. + // permissions: IS rendered on the caller job, carrying the configured scope. + assert.Contains(t, job, "permissions:") + assert.Contains(t, job, "contents: read") + // runs-on and the concurrency group key remain forbidden on a uses: job. assert.NotContains(t, job, "runs-on:") - assert.NotContains(t, job, "permissions:") assert.NotContains(t, job, "concurrency:") } +// TestReusableWorkflowCallbackRendersOIDCPermissions asserts that a build +// callback declaring the OIDC scopes (id-token: write plus contents: read) +// renders both on the caller job's permissions: block, in deterministic sorted +// order. This is the motivating case: id-token: write enables cloud OIDC auth +// scoped to exactly the callback that needs it. +func TestReusableWorkflowCallbackRendersOIDCPermissions(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github/workflows/build.yaml"), []byte("on:\n workflow_call:\n"), 0644)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev"}, + Builds: []config.BuildConfig{ + { + Name: "app", + Workflow: ".github/workflows/build.yaml", + Triggers: []string{"src/**"}, + Permissions: map[string]string{"id-token": "write", "contents": "read"}, + }, + }, + } + + gen := NewGenerator(cfg, tmpDir) + result, err := gen.Generate() + require.NoError(t, err) + + job := jobSection(result, "build-app:") + assert.Contains(t, job, "permissions:") + assert.Contains(t, job, "contents: read") + assert.Contains(t, job, "id-token: write") + // Scopes render in sorted order so output is deterministic (contents before + // id-token). + assert.Less(t, strings.Index(job, "contents: read"), strings.Index(job, "id-token: write"), + "permission scopes must be emitted in sorted order") +} + // TestValidationRejectsJobAttributesOnReusableCallback confirms schema validation // still rejects runs_on / concurrency on a reusable-workflow callback, so the // generator never sees them there. diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 5f10f97..d14a966 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -779,6 +779,7 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { g.writeDeployStrategyOptions(sb, d.Rollout) sb.WriteString(" matrix:\n") fmt.Fprintf(sb, " include: ${{ fromJSON(needs.preflight.outputs.deploy_%s_matrix) }}\n", outputName) + writeCallbackPermissions(sb, " ", d.Permissions) fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(d.Workflow)) sb.WriteString(" with:\n") @@ -827,6 +828,7 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { // reusable-workflow caller job. The environment name is threaded via // the with: environment input below, and GitHub Environment protection // must be declared inside the reusable workflow's own job. + writeCallbackPermissions(sb, " ", d.Permissions) fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(d.Workflow)) sb.WriteString(" with:\n") sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") @@ -868,6 +870,7 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { // environment name is threaded via the with: environment input below and // GitHub Environment protection must be declared inside the reusable // workflow's own job. + writeCallbackPermissions(sb, " ", d.Permissions) fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(d.Workflow)) sb.WriteString(" with:\n") fmt.Fprintf(sb, " environment: %s\n", finalEnv) @@ -904,6 +907,7 @@ func (g *PromoteGenerator) writeExternalDeployJobs(sb *strings.Builder, finalEnv fmt.Fprintf(sb, " name: Deploy %s (external)\n", d.Name) sb.WriteString(" needs: [preflight, promote]\n") fmt.Fprintf(sb, " if: ${{ github.event.inputs.dry_run != 'true' && contains(fromJSON(needs.preflight.outputs.external_deploys_to_run), '%s') }}\n", d.Name) + writeCallbackPermissions(sb, " ", d.Permissions) fmt.Fprintf(sb, " uses: %s\n", g.resolveExternalWorkflow(ext, d.Workflow)) sb.WriteString(" with:\n") sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") @@ -917,6 +921,7 @@ func (g *PromoteGenerator) writeExternalDeployJobs(sb *strings.Builder, finalEnv fmt.Fprintf(sb, " name: Deploy %s (%s, external)\n", d.Name, finalEnv) sb.WriteString(" needs: [preflight, promote]\n") sb.WriteString(" if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.has_prod_deployment == 'true' }}\n") + writeCallbackPermissions(sb, " ", d.Permissions) fmt.Fprintf(sb, " uses: %s\n", g.resolveExternalWorkflow(ext, d.Workflow)) sb.WriteString(" with:\n") fmt.Fprintf(sb, " environment: %s\n", finalEnv) @@ -996,6 +1001,7 @@ func (g *PromoteGenerator) writeRollbackJobs(sb *strings.Builder) { sb.WriteString(" needs.preflight.outputs.rollback_sha != '' &&\n") fmt.Fprintf(sb, " needs.%s.result == 'success' &&\n", jobName) fmt.Fprintf(sb, " (%s)\n", anyFailure) + writeCallbackPermissions(sb, " ", d.Permissions) fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(d.Workflow)) sb.WriteString(" with:\n") sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") @@ -1021,6 +1027,7 @@ func (g *PromoteGenerator) writeRollbackJobs(sb *strings.Builder) { sb.WriteString(" needs.preflight.outputs.rollback_sha != '' &&\n") fmt.Fprintf(sb, " needs.%s.result == 'success' &&\n", jobName) fmt.Fprintf(sb, " (%s)\n", anyFailure) + writeCallbackPermissions(sb, " ", d.Permissions) fmt.Fprintf(sb, " uses: %s\n", g.resolveExternalWorkflow(ext, d.Workflow)) sb.WriteString(" with:\n") sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go index 70439d3..1ebbf9f 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -248,6 +248,7 @@ func (g *RollbackGenerator) writeDeployJobs(sb *strings.Builder) { // Reusable (uses:) deploy: thread the resolved env and SHA via with:. The // environment name is carried as an input; GitHub Environment protection // must be declared inside the reusable workflow's own job. + writeCallbackPermissions(sb, " ", d.Permissions) fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(d.Workflow)) sb.WriteString(" with:\n") sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") From 49fdd46e797b784fbe4d461cf2852fb7612736b3 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 17 Jun 2026 10:43:03 -0400 Subject: [PATCH 2/2] fix: scope per-callback permissions to the caller job (least-privilege) 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 --- docs/src/content/docs/configuration.md | 12 +++ .../callback-permissions-oidc.yaml | 38 ++++----- .../generate/callback_permissions_test.go | 66 +++++++++------- internal/generate/generator.go | 8 +- internal/generate/graph.go | 79 ++++--------------- internal/generate/hotfix.go | 9 ++- internal/generate/promote.go | 9 ++- internal/generate/rollback.go | 10 +-- 8 files changed, 106 insertions(+), 125 deletions(-) diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 1b0e150..8e465a0 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -215,9 +215,18 @@ ci: | `run_policy` | string | No | Execution policy | | `on_failure` | string | No | Failure handling | | `retries` | int | No | Retry attempts (0-3) | +| `permissions` | map | No | `GITHUB_TOKEN` scopes for this callback's caller job | The build's `artifact_id` output (if declared) is captured automatically into state. Any other declared outputs are forwarded to dependent deploys as inputs. +A `permissions` map is rendered as a job-level `permissions:` block on the caller job that invokes this callback, scoping the `GITHUB_TOKEN` to least privilege for that one job. GitHub Actions treats a job-level block as the **complete** permission set: it replaces the workflow default rather than merging with it. Declare the full set the callback needs, including `contents: read` if the callback checks out code and `id-token: write` for OIDC. cascade emits exactly the scopes you declare and never injects an implicit scope. + +```yaml +permissions: + contents: read + id-token: write +``` + ### deploys Section Deploys target environments: @@ -253,6 +262,9 @@ ci: | `run_policy` | string | No | Execution policy | | `on_failure` | string | No | Failure handling | | `retries` | int | No | Retry attempts (0-3) | +| `permissions` | map | No | `GITHUB_TOKEN` scopes for this callback's caller job | + +As with builds, a deploy's `permissions` map is the complete permission set for its caller job (it replaces the workflow default, not merges). Include every scope the deploy needs, such as `contents: read` for checkout and `id-token: write` for OIDC. ### Deploy Types diff --git a/e2e/scenarios/orchestrate/callback-permissions-oidc.yaml b/e2e/scenarios/orchestrate/callback-permissions-oidc.yaml index 5c7941f..8fd7360 100644 --- a/e2e/scenarios/orchestrate/callback-permissions-oidc.yaml +++ b/e2e/scenarios/orchestrate/callback-permissions-oidc.yaml @@ -1,20 +1,22 @@ name: "Callback Permissions (OIDC)" description: | - Verifies that per-callback permissions declared on a build callback are - rendered as a job-level permissions: block on the reusable-workflow caller - job, and are also unioned into the calling orchestrate.yaml's top-level - permissions block. + Verifies that per-callback permissions declared on a build callback are scoped + to the reusable-workflow caller job (least privilege) rather than widened into + the calling orchestrate.yaml's top-level permissions block. GitHub Actions allows a permissions: key on a job that calls a reusable - workflow (jobs..uses), so cascade scopes the caller job to least privilege - per callback. This is what enables id-token: write for cloud OIDC on exactly - the callback that needs it. + workflow (jobs..uses), so cascade renders the callback's declared scopes on + that one caller job. This confines the elevated scopes (notably id-token: write + for cloud OIDC) to the exact callback that needs them, shrinking the OIDC blast + radius instead of granting those scopes to every job in the workflow. The api build declares permissions: {id-token: write, packages: read}. The - generated orchestrate.yaml must keep its base top-level scopes (contents: - write, actions: read) and append the callback scopes in deterministic - alphabetical order (id-token before packages). The build-api caller job must - additionally carry its own job-level permissions: block with those scopes. + generated orchestrate.yaml must keep its top-level block at the base scopes only + (contents: write, actions: read) and render a separate job-level permissions: + block on the build-api caller job with the callback's scopes in deterministic + alphabetical order (id-token before packages). The declared map is the complete + permission set for that job; cascade emits exactly what is declared and does not + inject any implicit scope. Generator-output verification scenario; the assertion runs on the staged repo after StageRepoFromConfig generates workflows but before any orchestrate runs. @@ -47,13 +49,13 @@ steps: workflow_files: - path: ".github/workflows/orchestrate.yaml" contains: - # Base top-level scopes are preserved. - - "contents: write" - - "actions: read" - # Callback scopes are unioned in at the top level. - - "id-token: write" - - "packages: read" + # The top-level block is exactly the base scopes: matching the whole + # block (permissions: through its trailing blank line) proves the + # callback scopes are absent from the top level. A plain not_contains + # over the file cannot exclude the job-level occurrence below. + - "permissions:\n contents: write\n actions: read\n\n" # The build-api caller job renders its own job-level permissions: # block (GitHub allows permissions: on a uses: caller job), scoped to - # the callback's declared scopes in sorted order. + # the callback's declared scopes in sorted order, immediately before + # the uses: line. - " permissions:\n id-token: write\n packages: read\n uses: ./.github/workflows/build-api.yaml" diff --git a/internal/generate/callback_permissions_test.go b/internal/generate/callback_permissions_test.go index 282a994..e2babd9 100644 --- a/internal/generate/callback_permissions_test.go +++ b/internal/generate/callback_permissions_test.go @@ -48,12 +48,12 @@ func writeCallWorkflow(t *testing.T, dir, rel string) { require.NoError(t, os.WriteFile(full, []byte("on:\n workflow_call:\n"), 0644)) } -// TestOrchestrate_TopLevelPermissions_UnionIncludesCallbackScopes asserts that a -// build callback declaring id-token: write propagates into the calling -// workflow's top-level permissions union, alongside the base contents/actions -// scopes. A reusable-workflow caller job cannot set job-level permissions, so the -// top-level block must grant the union. -func TestOrchestrate_TopLevelPermissions_UnionIncludesCallbackScopes(t *testing.T) { +// TestOrchestrate_TopLevelPermissions_ExcludesCallbackScopes asserts that a build +// callback declaring id-token: write does NOT widen the calling workflow's +// top-level permissions block. The callback scope is scoped to the caller job +// (least privilege); the top-level block carries only cascade's own +// orchestration scopes. +func TestOrchestrate_TopLevelPermissions_ExcludesCallbackScopes(t *testing.T) { tmpDir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) writeCallWorkflow(t, tmpDir, ".github/workflows/build.yaml") @@ -78,13 +78,15 @@ func TestOrchestrate_TopLevelPermissions_UnionIncludesCallbackScopes(t *testing. perms := topLevelPermissions(t, result) assert.Contains(t, perms, "contents: write") assert.Contains(t, perms, "actions: read") - assert.Contains(t, perms, "id-token: write") + assert.NotContains(t, perms, "id-token: write", + "callback-only scopes must not leak into the top-level block") } -// TestOrchestrate_TopLevelPermissions_Deterministic asserts the appended -// callback-only scopes are emitted in stable sorted order and that repeated -// generation is byte-identical. -func TestOrchestrate_TopLevelPermissions_Deterministic(t *testing.T) { +// TestOrchestrate_CallbackJobPermissions_CarryDeclaredScopes asserts that the +// build callback's declared scopes are rendered on the caller job's own +// job-level permissions: block, in deterministic sorted order, and that +// repeated generation is byte-identical. +func TestOrchestrate_CallbackJobPermissions_CarryDeclaredScopes(t *testing.T) { tmpDir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) writeCallWorkflow(t, tmpDir, ".github/workflows/build.yaml") @@ -117,14 +119,22 @@ func TestOrchestrate_TopLevelPermissions_Deterministic(t *testing.T) { assert.Equal(t, first, result, "generation must be byte-identical across runs") } - perms := topLevelPermissions(t, first) - // Base scopes keep their existing order; callback-only scopes are appended - // sorted alphabetically: id-token before packages. - idIdx := strings.Index(perms, "id-token: write") - pkgIdx := strings.Index(perms, "packages: read") + job := jobSection(first, "build-app:") + assert.Contains(t, job, "permissions:") + assert.Contains(t, job, "id-token: write") + assert.Contains(t, job, "packages: read") + // Scopes render in sorted order so output is deterministic (id-token before + // packages). + idIdx := strings.Index(job, "id-token: write") + pkgIdx := strings.Index(job, "packages: read") require.NotEqual(t, -1, idIdx) require.NotEqual(t, -1, pkgIdx) - assert.Less(t, idIdx, pkgIdx, "callback-only scopes must be sorted alphabetically") + assert.Less(t, idIdx, pkgIdx, "callback scopes must be emitted in sorted order") + + // The top-level block must not carry the callback-only scopes. + perms := topLevelPermissions(t, first) + assert.NotContains(t, perms, "id-token: write") + assert.NotContains(t, perms, "packages: read") } // TestOrchestrate_TopLevelPermissions_NoCallbackPermsByteIdentical asserts that @@ -153,10 +163,10 @@ func TestOrchestrate_TopLevelPermissions_NoCallbackPermsByteIdentical(t *testing assert.Contains(t, result, "permissions:\n contents: write\n actions: read\n") } -// TestPromote_TopLevelPermissions_UnionIncludesCallbackScopes asserts deploy -// callback OIDC permissions propagate into the promote workflow's top-level -// union, alongside its base contents/actions scopes. -func TestPromote_TopLevelPermissions_UnionIncludesCallbackScopes(t *testing.T) { +// TestPromote_TopLevelPermissions_ExcludesCallbackScopes asserts deploy callback +// OIDC permissions do NOT widen the promote workflow's top-level block; they are +// scoped to the caller job instead. +func TestPromote_TopLevelPermissions_ExcludesCallbackScopes(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", Environments: []string{"dev", "prod"}, @@ -176,13 +186,14 @@ func TestPromote_TopLevelPermissions_UnionIncludesCallbackScopes(t *testing.T) { perms := topLevelPermissions(t, result) assert.Contains(t, perms, "contents: write") assert.Contains(t, perms, "actions: write") - assert.Contains(t, perms, "id-token: write") + assert.NotContains(t, perms, "id-token: write", + "callback-only scopes must not leak into the top-level block") } -// TestRollback_TopLevelPermissions_UnionIncludesCallbackScopes asserts deploy -// callback OIDC permissions propagate into the rollback workflow's top-level -// union, alongside its base contents/actions scopes. -func TestRollback_TopLevelPermissions_UnionIncludesCallbackScopes(t *testing.T) { +// TestRollback_TopLevelPermissions_ExcludesCallbackScopes asserts deploy callback +// OIDC permissions do NOT widen the rollback workflow's top-level block; they are +// scoped to the caller job instead. +func TestRollback_TopLevelPermissions_ExcludesCallbackScopes(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", Environments: []string{"dev", "prod"}, @@ -202,5 +213,6 @@ func TestRollback_TopLevelPermissions_UnionIncludesCallbackScopes(t *testing.T) perms := topLevelPermissions(t, result) assert.Contains(t, perms, "contents: write") assert.Contains(t, perms, "actions: write") - assert.Contains(t, perms, "id-token: write") + assert.NotContains(t, perms, "id-token: write", + "callback-only scopes must not leak into the top-level block") } diff --git a/internal/generate/generator.go b/internal/generate/generator.go index eefeed5..7484db9 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -711,14 +711,14 @@ func (g *Generator) writeConcurrency(sb *strings.Builder) { func (g *Generator) writePermissions(sb *strings.Builder) { // Base: permissions needed for release management (tags, releases) and state - // commits. A reusable callback cannot set its own job permissions, so any - // scope a callback declares (e.g. id-token: write for OIDC) is unioned in at - // the top level here. + // commits. Callback scopes (e.g. id-token: write for OIDC) are scoped to + // their own caller job via writeCallbackPermissions, not granted here, so the + // top-level block stays least privilege for cascade's own orchestration jobs. base := [][2]string{ {"contents", "write"}, {"actions", "read"}, } - writeTopLevelPermissions(sb, base, collectCallbackPermissions(g.config)) + writeTopLevelPermissions(sb, base) } func (g *Generator) writeJobs(sb *strings.Builder) { diff --git a/internal/generate/graph.go b/internal/generate/graph.go index 7b76378..c0d2de5 100644 --- a/internal/generate/graph.go +++ b/internal/generate/graph.go @@ -45,9 +45,12 @@ type CallbackInfo struct { // runs-on/concurrency/timeout-minutes on a reusable-workflow uses: caller // job, and schema validation rejects runs_on/concurrency/timeout_minutes on // reusable callbacks, so those fields are populated from config but never - // emitted as job-level keys. permissions: is the exception: GitHub allows it - // on a uses: caller job, so Permissions IS rendered as a job-level - // permissions: block (see writeCallbackPermissions). + // emitted as job-level keys. permissions: is different: GitHub allows it on a + // uses: caller job, so Permissions IS rendered as a job-level permissions: + // block scoped to exactly this callback (least privilege; see + // writeCallbackPermissions). These scopes are not merged into the workflow's + // top-level permissions block, keeping the OIDC blast radius confined to this + // one caller job. TimeoutMinutes int // Per-callback timeout-minutes; validated-against, never emitted (belongs inside the called workflow) RunsOn *config.RunsOn // Per-callback runner selection (#12); validated-against, never emitted Permissions map[string]string // Per-callback job permissions, incl. id-token: write OIDC (#35, #15); emitted as job-level permissions: @@ -270,69 +273,19 @@ func defaultString(s, def string) string { return s } -// collectCallbackPermissions unions the per-callback permissions of every -// callback in the manifest (validate, builds, deploys). A reusable-workflow -// caller job cannot carry job-level permissions, so any scope a callback needs -// (notably id-token: write for OIDC) must be granted by the calling workflow's -// top-level permissions block instead. The result is that union keyed by scope. -// -// Precedence when two callbacks set the same scope to different values: the -// lexicographically greatest value wins. This keeps "write" ahead of "read" -// ahead of "none", matching the monotonic GHA permission order (write subsumes -// read), and is independent of map iteration order so the union is fully -// deterministic. In practice callbacks agree on a scope's value, so this only -// matters for the read/write distinction. -func collectCallbackPermissions(cfg *config.TrunkConfig) map[string]string { - graph := BuildDependencyGraph(cfg) - union := make(map[string]string) - for _, node := range graph.Nodes { - for scope, value := range node.Permissions { - if existing, ok := union[scope]; ok && existing >= value { - continue - } - union[scope] = value - } - } - return union -} - -// writeTopLevelPermissions emits a top-level permissions: block. The base scopes -// are written first in their given order (preserving each generator's historical -// output), then any callback-union scope not already present in base is appended -// in alphabetical order for deterministic output. When a scope appears in both -// base and the callback union, the more-permissive value wins ("write" over a -// non-write value), so base scopes can be promoted to write by a callback that -// requires it. A trailing blank line is emitted to match the prior blocks. -// -// When the callback union is empty and contributes nothing, the output is -// byte-identical to the historical hardcoded base block. -func writeTopLevelPermissions(sb *strings.Builder, base [][2]string, callbackUnion map[string]string) { +// writeTopLevelPermissions emits a top-level permissions: block carrying only +// the given base scopes, in their given order (preserving each generator's +// historical output), followed by a trailing blank line to match the prior +// blocks. Callback scopes are deliberately NOT merged here: each callback's +// declared scopes are rendered on its own caller job by writeCallbackPermissions +// so the GITHUB_TOKEN is scoped to least privilege per callback (shrinking the +// OIDC blast radius). The top-level block grants only what cascade's own +// orchestration jobs need. +func writeTopLevelPermissions(sb *strings.Builder, base [][2]string) { sb.WriteString("permissions:\n") - - inBase := make(map[string]bool, len(base)) for _, kv := range base { - scope, value := kv[0], kv[1] - inBase[scope] = true - // Promote the base scope if a callback requires a more-permissive value - // (e.g. write over read), using the same lexicographic order as the union. - if cb, ok := callbackUnion[scope]; ok && cb > value { - value = cb - } - fmt.Fprintf(sb, " %s: %s\n", scope, value) + fmt.Fprintf(sb, " %s: %s\n", kv[0], kv[1]) } - - // Append callback-only scopes (not in base) in deterministic sorted order. - extra := make([]string, 0, len(callbackUnion)) - for scope := range callbackUnion { - if !inBase[scope] { - extra = append(extra, scope) - } - } - sort.Strings(extra) - for _, scope := range extra { - fmt.Fprintf(sb, " %s: %s\n", scope, callbackUnion[scope]) - } - sb.WriteString("\n") } diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index cf145a1..9ce4800 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -171,16 +171,17 @@ func (g *HotfixGenerator) writeTriggers(sb *strings.Builder) { // (required before gh pr create --label), pull-requests:write to open the // resolution PR, and actions:read for workflow introspection. func (g *HotfixGenerator) writePermissions(sb *strings.Builder) { - // Base scopes the hotfix workflow needs. A reusable callback cannot set its - // own job permissions, so any scope a callback declares (e.g. id-token: write - // for OIDC) is unioned in at the top level here. + // Base scopes the hotfix workflow needs. A callback's own scopes (e.g. + // id-token: write for OIDC) are scoped to its caller job via + // writeCallbackPermissions, not granted here, so the top-level block stays + // least privilege. base := [][2]string{ {"contents", "write"}, {"issues", "write"}, {"pull-requests", "write"}, {"actions", "read"}, } - writeTopLevelPermissions(sb, base, collectCallbackPermissions(g.config)) + writeTopLevelPermissions(sb, base) } // writeConcurrency keys the group per target environment. On dispatch the env is diff --git a/internal/generate/promote.go b/internal/generate/promote.go index d14a966..084720b 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -598,14 +598,15 @@ func (g *PromoteGenerator) writeWorkflowTriggers(sb *strings.Builder) { // Base: permissions needed for release management, state commits, and job // queries. actions:write is required to dispatch the Release workflow from - // the finalize job when a final release is published. A reusable callback - // cannot set its own job permissions, so any scope a deploy callback declares - // (e.g. id-token: write for OIDC) is unioned in at the top level here. + // the finalize job when a final release is published. A deploy callback's own + // scopes (e.g. id-token: write for OIDC) are scoped to its caller job via + // writeCallbackPermissions, not granted here, so the top-level block stays + // least privilege. base := [][2]string{ {"contents", "write"}, {"actions", "write"}, } - writeTopLevelPermissions(sb, base, collectCallbackPermissions(g.config)) + writeTopLevelPermissions(sb, base) } func (g *PromoteGenerator) writeJobs(sb *strings.Builder) { diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go index 1ebbf9f..af6234f 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -139,15 +139,15 @@ func (g *RollbackGenerator) writeTriggers(sb *strings.Builder) { sb.WriteString("\n") // Base: contents:write to commit the rolled-back state; actions:write for - // parity with the promote workflow's release/dispatch surface. A reusable - // callback cannot set its own job permissions, so any scope a deploy callback - // declares (e.g. id-token: write for OIDC) is unioned in at the top level - // here. + // parity with the promote workflow's release/dispatch surface. A deploy + // callback's own scopes (e.g. id-token: write for OIDC) are scoped to its + // caller job via writeCallbackPermissions, not granted here, so the top-level + // block stays least privilege. base := [][2]string{ {"contents", "write"}, {"actions", "write"}, } - writeTopLevelPermissions(sb, base, collectCallbackPermissions(g.config)) + writeTopLevelPermissions(sb, base) } // writeConcurrency serializes rollback runs so concurrent state writes cannot