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
12 changes: 12 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
54 changes: 30 additions & 24 deletions e2e/scenarios/orchestrate/callback-permissions-oidc.yaml
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
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.
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.<id>.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 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 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.
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
Expand All @@ -29,7 +37,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"
Expand All @@ -41,15 +49,13 @@ steps:
workflow_files:
- path: ".github/workflows/orchestrate.yaml"
contains:
# Base 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 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, immediately before
# the uses: line.
- " permissions:\n id-token: write\n packages: read\n uses: ./.github/workflows/build-api.yaml"
66 changes: 39 additions & 27 deletions internal/generate/callback_permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"},
Expand All @@ -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"},
Expand All @@ -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")
}
17 changes: 13 additions & 4 deletions internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.<id>.uses reusable-workflow call.
fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(workflow))

Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading