Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/orchestrate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ concurrency:
cancel-in-progress: true

permissions:
contents: write
contents: read
actions: read

jobs:
Expand Down Expand Up @@ -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 }}
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/promote.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ on:
default: false

permissions:
contents: write
actions: write
contents: read

concurrency:
group: "${{ github.workflow }}"
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 7 additions & 7 deletions e2e/scenarios/orchestrate/callback-permissions-oidc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions e2e/scenarios/orchestrate/least-privilege-permissions.yaml
Original file line number Diff line number Diff line change
@@ -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"
20 changes: 14 additions & 6 deletions internal/generate/callback_permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
Expand All @@ -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")
}
Expand All @@ -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")
}
25 changes: 15 additions & 10 deletions internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions internal/generate/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 16 additions & 7 deletions internal/generate/hotfix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/<target> after the resolution PR merged.
Expand Down
Loading
Loading