From 8c472a454c172e1ac9b6cebf27bf8dff88869d23 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 22 Jun 2026 15:21:19 -0400 Subject: [PATCH] feat: emit least-privilege top-level workflow permissions Signed-off-by: Joshua Temple --- .github/workflows/orchestrate.yaml | 4 +- .github/workflows/promote.yaml | 6 +- .../callback-permissions-oidc.yaml | 14 +- .../least-privilege-permissions.yaml | 51 +++++ .../generate/callback_permissions_test.go | 20 +- internal/generate/generator.go | 25 ++- internal/generate/graph.go | 16 ++ internal/generate/hotfix.go | 23 +- .../least_privilege_permissions_test.go | 198 ++++++++++++++++++ internal/generate/promote.go | 31 +-- internal/generate/rollback.go | 13 +- 11 files changed, 349 insertions(+), 52 deletions(-) create mode 100644 e2e/scenarios/orchestrate/least-privilege-permissions.yaml create mode 100644 internal/generate/least_privilege_permissions_test.go diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index b6e7689c..61eba56a 100644 --- a/.github/workflows/orchestrate.yaml +++ b/.github/workflows/orchestrate.yaml @@ -24,7 +24,7 @@ concurrency: cancel-in-progress: true permissions: - contents: write + contents: read actions: read jobs: @@ -76,6 +76,8 @@ jobs: if: always() && needs.setup.result == 'success' runs-on: ubuntu-latest timeout-minutes: 30 + permissions: + contents: write outputs: cli_result: ${{ needs.build-cli.outputs.result }} validate_result: ${{ needs.validate.outputs.result }} diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml index 3c241f23..e9022306 100644 --- a/.github/workflows/promote.yaml +++ b/.github/workflows/promote.yaml @@ -54,8 +54,7 @@ on: default: false permissions: - contents: write - actions: write + contents: read concurrency: group: "${{ github.workflow }}" @@ -148,6 +147,9 @@ jobs: needs: [preflight, promote] if: always() && needs.preflight.result == 'success' runs-on: ubuntu-latest + permissions: + contents: write + actions: write steps: - uses: actions/checkout@v6 with: diff --git a/e2e/scenarios/orchestrate/callback-permissions-oidc.yaml b/e2e/scenarios/orchestrate/callback-permissions-oidc.yaml index 8fd73601..1d140e2c 100644 --- a/e2e/scenarios/orchestrate/callback-permissions-oidc.yaml +++ b/e2e/scenarios/orchestrate/callback-permissions-oidc.yaml @@ -11,12 +11,12 @@ description: | 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 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. + generated orchestrate.yaml must keep its top-level block at the least-privilege + base (contents: read, 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. @@ -53,7 +53,7 @@ steps: # 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" + - "permissions:\n contents: read\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, immediately before diff --git a/e2e/scenarios/orchestrate/least-privilege-permissions.yaml b/e2e/scenarios/orchestrate/least-privilege-permissions.yaml new file mode 100644 index 00000000..1d2449f9 --- /dev/null +++ b/e2e/scenarios/orchestrate/least-privilege-permissions.yaml @@ -0,0 +1,51 @@ +name: "Least-Privilege Permissions" +description: | + Verifies that the generated orchestrate.yaml defaults its top-level + permissions: block to reads only and pushes the write scope down to the single + job that needs it. The finalize job commits state, so it carries a job-level + contents: write block; no job other than the writers carries a write scope, and + the top-level block never grants a write. + + This is the OSSF Scorecard TokenPermissions posture: a workflow-wide default of + read-all with writes confined to individual jobs. A top-level write scope would + hand every job in the run a token broader than it needs; scoping the write to + the finalize job alone shrinks the blast radius of a compromised step. + + Generator-output verification scenario; the assertion runs on the staged repo + after StageRepoFromConfig generates workflows but before any orchestrate runs. + +config: + trunk_branch: main + environments: [] + builds: + - name: api + workflow: build-api.yaml + triggers: ["src/**"] + deploys: [] + +steps: + - name: "Initial commit; assert least-privilege permissions in orchestrate.yaml" + action: commit + commit: + message: "feat: add api build callback" + files: + src/main.go: | + package main + func main() {} + expect: + workflow_files: + - path: ".github/workflows/orchestrate.yaml" + contains: + # The top-level block is read-only: matching the whole block + # (permissions: through its trailing blank line) proves no write scope + # is present at the top level. + - "permissions:\n contents: read\n actions: read\n\n" + # The finalize job carries its own job-level permissions: block with + # the contents: write it needs to commit state, rendered immediately + # after its runs-on/timeout lines. + - " permissions:\n contents: write\n" + not_contains: + # No top-level write scope may appear. The job-level write above is + # indented (four spaces), so this unindented form only matches a + # top-level grant. + - "\npermissions:\n contents: write" diff --git a/internal/generate/callback_permissions_test.go b/internal/generate/callback_permissions_test.go index e2babd90..c0f1281c 100644 --- a/internal/generate/callback_permissions_test.go +++ b/internal/generate/callback_permissions_test.go @@ -76,8 +76,10 @@ func TestOrchestrate_TopLevelPermissions_ExcludesCallbackScopes(t *testing.T) { require.NoError(t, err) perms := topLevelPermissions(t, result) - assert.Contains(t, perms, "contents: write") + assert.Contains(t, perms, "contents: read") assert.Contains(t, perms, "actions: read") + assert.NotContains(t, perms, "contents: write", + "the top-level block must stay least privilege (reads only)") assert.NotContains(t, perms, "id-token: write", "callback-only scopes must not leak into the top-level block") } @@ -160,7 +162,7 @@ func TestOrchestrate_TopLevelPermissions_NoCallbackPermsByteIdentical(t *testing gen := NewGenerator(cfg, tmpDir) result, err := gen.Generate() require.NoError(t, err) - assert.Contains(t, result, "permissions:\n contents: write\n actions: read\n") + assert.Contains(t, result, "permissions:\n contents: read\n actions: read\n") } // TestPromote_TopLevelPermissions_ExcludesCallbackScopes asserts deploy callback @@ -184,8 +186,11 @@ func TestPromote_TopLevelPermissions_ExcludesCallbackScopes(t *testing.T) { require.NoError(t, err) perms := topLevelPermissions(t, result) - assert.Contains(t, perms, "contents: write") - assert.Contains(t, perms, "actions: write") + assert.Contains(t, perms, "contents: read") + assert.NotContains(t, perms, "contents: write", + "the top-level block must stay least privilege (reads only)") + assert.NotContains(t, perms, "actions: write", + "actions: write is scoped to the finalize job, not the top level") assert.NotContains(t, perms, "id-token: write", "callback-only scopes must not leak into the top-level block") } @@ -211,8 +216,11 @@ func TestRollback_TopLevelPermissions_ExcludesCallbackScopes(t *testing.T) { require.NoError(t, err) perms := topLevelPermissions(t, result) - assert.Contains(t, perms, "contents: write") - assert.Contains(t, perms, "actions: write") + assert.Contains(t, perms, "contents: read") + assert.NotContains(t, perms, "contents: write", + "the top-level block must stay least privilege (reads only)") + assert.NotContains(t, perms, "actions: write", + "rollback has no release dispatch, so actions: write is never granted") 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 a55bd55c..c8a1b40c 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -724,19 +724,15 @@ 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. 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. + // Default to a least-privilege top-level block: reads only. Write scopes are + // pushed down to the single job that needs them (the finalize job carries + // contents: write, plus deployments: write when the GitHub Deployments API is + // enabled). Callback scopes (e.g. id-token: write for OIDC) are scoped to + // their own caller job via writeCallbackPermissions. base := [][2]string{ - {"contents", "write"}, + {"contents", "read"}, {"actions", "read"}, } - // Opt-in GitHub Deployments API reporting needs deployments: write so the - // finalize job can create Deployments and post status updates. - if nativeDeploymentsEnabled(g.config) { - base = append(base, [2]string{"deployments", "write"}) - } writeTopLevelPermissions(sb, base) } @@ -1342,6 +1338,15 @@ func (g *Generator) writeFinalizeJob(sb *strings.Builder, sorted []string) { sb.WriteString(" runs-on: ubuntu-latest\n") g.writeOwnedTimeout(sb, " ") + // Push the write scopes down to the finalize job: it commits state (and the + // release) and, when the GitHub Deployments API is enabled, creates + // Deployments and posts status updates. The top-level block stays read-only. + finalizeScopes := [][2]string{{"contents", "write"}} + if nativeDeploymentsEnabled(g.config) { + finalizeScopes = append(finalizeScopes, [2]string{"deployments", "write"}) + } + writeJobPermissions(sb, " ", finalizeScopes) + // Output all callback outputs (sorted for deterministic output) // g.outputs is keyed by job ID (e.g., "build-app") var outputLines []string diff --git a/internal/generate/graph.go b/internal/generate/graph.go index c0d2de58..9e94b29e 100644 --- a/internal/generate/graph.go +++ b/internal/generate/graph.go @@ -316,6 +316,22 @@ func writeCallbackPermissions(sb *strings.Builder, indent string, perms map[stri } } +// writeJobPermissions emits a job-level permissions: block at the given indent +// for the supplied scopes in the order given (deterministic, no sorting), with +// no trailing blank line. It mirrors writeTopLevelPermissions but is indented +// for a single job, letting a generator push a write scope down to only the job +// that needs it while the top-level block stays least privilege. It emits +// nothing when scopes is empty. +func writeJobPermissions(sb *strings.Builder, indent string, scopes [][2]string) { + if len(scopes) == 0 { + return + } + fmt.Fprintf(sb, "%spermissions:\n", indent) + for _, kv := range scopes { + fmt.Fprintf(sb, "%s %s: %s\n", indent, kv[0], kv[1]) + } +} + // 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 70543709..dd2aa846 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -171,14 +171,13 @@ 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 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. + // Default to a least-privilege top-level block: reads only. The apply job + // carries contents/issues/pull-requests: write to push the cherry-pick branch, + // seed labels, and open the resolution PR; the finalize job carries contents: + // write to commit state. A callback's own scopes (e.g. id-token: write for + // OIDC) are scoped to its caller job via writeCallbackPermissions. base := [][2]string{ - {"contents", "write"}, - {"issues", "write"}, - {"pull-requests", "write"}, + {"contents", "read"}, {"actions", "read"}, } writeTopLevelPermissions(sb, base) @@ -284,6 +283,14 @@ func (g *HotfixGenerator) writeApplyJob(sb *strings.Builder) { // present for a given env) is handled inside the loop where COMMITS is empty. sb.WriteString(" if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true' && needs.plan.outputs.env_sequence != ''\n") sb.WriteString(" runs-on: ubuntu-latest\n") + // The apply job pushes the cherry-pick branch (contents: write), seeds labels + // via gh label create (issues: write), and opens the resolution PR + // (pull-requests: write). These writes live here, not at the top level. + writeJobPermissions(sb, " ", [][2]string{ + {"contents", "write"}, + {"issues", "write"}, + {"pull-requests", "write"}, + }) sb.WriteString(" env:\n") // Author every resolution PR with the configured state token so gh pr create // runs as a trigger-capable actor. A PR opened under the default GITHUB_TOKEN @@ -680,6 +687,8 @@ func (g *HotfixGenerator) writeFinalizeJob(sb *strings.Builder) { fmt.Fprintf(sb, " needs: %s\n", needsStr) fmt.Fprintf(sb, " if: success() && %s\n", mergedHotfixGuard()) sb.WriteString(" runs-on: ubuntu-latest\n") + // The finalize job commits the post-hotfix state, so it needs contents: write. + writeJobPermissions(sb, " ", [][2]string{{"contents", "write"}}) sb.WriteString(" env:\n") sb.WriteString(" TARGET_ENV: ${{ needs.context.outputs.target_env }}\n") // merge-sha is the tip of env/ after the resolution PR merged. diff --git a/internal/generate/least_privilege_permissions_test.go b/internal/generate/least_privilege_permissions_test.go new file mode 100644 index 00000000..f31f90bd --- /dev/null +++ b/internal/generate/least_privilege_permissions_test.go @@ -0,0 +1,198 @@ +package generate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// jobPermissionsBlock returns the rendered job-level permissions: block that +// immediately follows the target job's "runs-on: ubuntu-latest" line, up to the +// next line that is not part of the permissions block (a six-space entry). +func jobPermissionsBlock(t *testing.T, job string) string { + t.Helper() + lines := strings.Split(job, "\n") + var out []string + inBlock := false + for _, line := range lines { + if strings.TrimSpace(line) == "permissions:" && strings.HasPrefix(line, " ") { + inBlock = true + out = append(out, line) + continue + } + if inBlock { + if strings.HasPrefix(line, " ") { + out = append(out, line) + continue + } + break + } + } + require.True(t, inBlock, "no job-level permissions block found in job:\n%s", job) + return strings.Join(out, "\n") +} + +// TestOrchestrate_TopLevelLeastPrivilege asserts the orchestrate workflow's +// top-level permissions default to reads only, with no write scope, and that the +// finalize job carries the contents: write needed to commit state. +func TestOrchestrate_TopLevelLeastPrivilege(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0o755)) + writeCallWorkflow(t, tmpDir, ".github/workflows/build.yaml") + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev"}, + Builds: []config.BuildConfig{ + {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, + }, + } + + out, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + top := topLevelPermissions(t, out) + assert.Equal(t, "permissions:\n contents: read\n actions: read", top) + assert.NotContains(t, top, "contents: write") + assert.NotContains(t, top, "deployments: write") + + finalize := jobSection(out, "finalize:") + block := jobPermissionsBlock(t, finalize) + assert.Contains(t, block, "contents: write") +} + +// TestOrchestrate_NativeDeployments_JobLevelDeploymentsWrite asserts that with +// the GitHub Deployments API enabled, deployments: write lands on the finalize +// job, not the top-level block. +func TestOrchestrate_NativeDeployments_JobLevelDeploymentsWrite(t *testing.T) { + cfg, tmpDir := nativeDeploymentsConfig(t) + + out, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + top := topLevelPermissions(t, out) + assert.NotContains(t, top, "deployments: write", + "deployments: write must be scoped to the finalize job, not the top level") + assert.NotContains(t, top, "contents: write") + + finalize := jobSection(out, "finalize:") + block := jobPermissionsBlock(t, finalize) + assert.Contains(t, block, "contents: write") + assert.Contains(t, block, "deployments: write") +} + +// TestPromote_TopLevelLeastPrivilege asserts the promote workflow's top-level +// permissions default to contents: read only, and that the finalize job carries +// the contents: write and actions: write it needs to commit state and dispatch +// the Release workflow. +func TestPromote_TopLevelLeastPrivilege(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "services", Workflow: ".github/workflows/deploy.yaml"}, + }, + } + + out, err := NewPromoteGenerator(cfg, "").Generate() + require.NoError(t, err) + + top := topLevelPermissions(t, out) + assert.Equal(t, "permissions:\n contents: read", top) + assert.NotContains(t, top, "contents: write") + assert.NotContains(t, top, "actions: write") + + finalize := jobSection(out, "finalize:") + block := jobPermissionsBlock(t, finalize) + assert.Contains(t, block, "contents: write") + assert.Contains(t, block, "actions: write") +} + +// TestPromote_NativeDeployments_JobLevelDeploymentsWrite asserts deployments: +// write lands on the promote finalize job, not the top-level block. +func TestPromote_NativeDeployments_JobLevelDeploymentsWrite(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0o755)) + writeCallWorkflow(t, tmpDir, ".github/workflows/deploy.yaml") + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"production"}, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + }, + Deployments: &config.DeploymentsConfig{Enabled: true}, + EnvironmentConfig: map[string]config.EnvironmentConfig{ + "production": {EnvironmentURL: "https://app.example.com"}, + }, + } + + out, err := NewPromoteGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + top := topLevelPermissions(t, out) + assert.NotContains(t, top, "deployments: write") + + finalize := jobSection(out, "finalize:") + block := jobPermissionsBlock(t, finalize) + assert.Contains(t, block, "deployments: write") +} + +// TestHotfix_TopLevelLeastPrivilege asserts the hotfix workflow's top-level +// permissions default to reads only, with the write scopes pushed down to the +// apply and finalize jobs that need them. +func TestHotfix_TopLevelLeastPrivilege(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + } + + out, err := NewHotfixGenerator(cfg, "").Generate() + require.NoError(t, err) + + top := topLevelPermissions(t, out) + assert.Equal(t, "permissions:\n contents: read\n actions: read", top) + assert.NotContains(t, top, "contents: write") + assert.NotContains(t, top, "issues: write") + assert.NotContains(t, top, "pull-requests: write") + + apply := jobSection(out, "apply:") + applyBlock := jobPermissionsBlock(t, apply) + assert.Contains(t, applyBlock, "contents: write") + assert.Contains(t, applyBlock, "issues: write") + assert.Contains(t, applyBlock, "pull-requests: write") + + finalize := jobSection(out, "finalize:") + finalizeBlock := jobPermissionsBlock(t, finalize) + assert.Contains(t, finalizeBlock, "contents: write") +} + +// TestRollback_TopLevelLeastPrivilege asserts the rollback workflow's top-level +// permissions default to contents: read only, with contents: write pushed down +// to the finalize job. +func TestRollback_TopLevelLeastPrivilege(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "services", Workflow: ".github/workflows/deploy.yaml"}, + }, + } + + out, err := NewRollbackGenerator(cfg, "").Generate() + require.NoError(t, err) + + top := topLevelPermissions(t, out) + assert.Equal(t, "permissions:\n contents: read", top) + assert.NotContains(t, top, "contents: write") + assert.NotContains(t, top, "actions: write") + + finalize := jobSection(out, "finalize:") + block := jobPermissionsBlock(t, finalize) + assert.Contains(t, block, "contents: write") +} diff --git a/internal/generate/promote.go b/internal/generate/promote.go index a4dd7c56..fc60188d 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -596,20 +596,14 @@ func (g *PromoteGenerator) writeWorkflowTriggers(sb *strings.Builder) { } sb.WriteString("\n") - // 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 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. + // Default to a least-privilege top-level block: reads only. The finalize job + // carries the write scopes it needs (contents: write for state commits, + // actions: write to dispatch the Release workflow, and deployments: write + // when the GitHub Deployments API is enabled). A deploy callback's own scopes + // (e.g. id-token: write for OIDC) are scoped to its caller job via + // writeCallbackPermissions. base := [][2]string{ - {"contents", "write"}, - {"actions", "write"}, - } - // Opt-in GitHub Deployments API reporting needs deployments: write so the - // finalize job can create Deployments and post status updates. - if nativeDeploymentsEnabled(g.config) { - base = append(base, [2]string{"deployments", "write"}) + {"contents", "read"}, } writeTopLevelPermissions(sb, base) } @@ -1075,6 +1069,17 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { fmt.Fprintf(sb, " needs: [%s]\n", strings.Join(needs, ", ")) sb.WriteString(" if: always() && needs.preflight.result == 'success'\n") sb.WriteString(" runs-on: ubuntu-latest\n") + + // Push the write scopes down to the finalize job: contents: write to commit + // state, actions: write to dispatch the Release workflow, and deployments: + // write when the GitHub Deployments API is enabled. The top-level block stays + // read-only. + finalizeScopes := [][2]string{{"contents", "write"}, {"actions", "write"}} + if nativeDeploymentsEnabled(g.config) { + finalizeScopes = append(finalizeScopes, [2]string{"deployments", "write"}) + } + writeJobPermissions(sb, " ", finalizeScopes) + sb.WriteString(" steps:\n") writeMintSteps(sb, g.config, " ", seamRelease, seamState) writeActionStep(sb, g.config, " ", actionCheckout) diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go index 8f2784a8..660f6e8c 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -169,14 +169,13 @@ 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 deploy + // Default to a least-privilege top-level block: reads only. The finalize job + // carries contents: write to commit the rolled-back state. Rollback has no + // release dispatch, so no actions: write is granted anywhere. 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. + // caller job via writeCallbackPermissions. base := [][2]string{ - {"contents", "write"}, - {"actions", "write"}, + {"contents", "read"}, } writeTopLevelPermissions(sb, base) } @@ -355,6 +354,8 @@ func (g *RollbackGenerator) writeFinalizeJob(sb *strings.Builder) { fmt.Fprintf(sb, " needs: %s\n", needsStr) fmt.Fprintf(sb, " if: always() && needs.preflight.result == 'success' && %s != 'true'\n", g.paramReadExpr("dry_run")) sb.WriteString(" runs-on: ubuntu-latest\n") + // The finalize job commits the rolled-back state, so it needs contents: write. + writeJobPermissions(sb, " ", [][2]string{{"contents", "write"}}) sb.WriteString(" steps:\n") writeMintSteps(sb, g.config, " ", seamRelease, seamState) g.writeSetupCLI(sb)