-
Notifications
You must be signed in to change notification settings - Fork 482
Retain jobs.*.permissions for built-in safe_outputs and conclusion jobs #50642
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a2b7712
6d95509
813ab29
d71ebac
48f5242
8a96c01
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| # ADR-50642: Additive Permissions Merge for Built-in Jobs | ||
|
|
||
| **Date**: 2026-08-05 | ||
| **Status**: Draft | ||
| **Deciders**: Unknown | ||
|
|
||
| --- | ||
|
|
||
| ### Context | ||
|
|
||
| The workflow compiler generates built-in jobs (`safe_outputs`, `conclusion`) with a least-privilege permission set computed automatically. Authors sometimes need scopes beyond this baseline — most notably `id-token: write` for OIDC token minting — and express these by declaring a `permissions` block under `jobs.<built-in>` in their workflow file. Prior to this change, `applyBuiltinJobAugmentations` only consulted `needs` and `if` fields from a built-in job's config entry; a `permissions` block was silently skipped, causing the declared scopes to be absent from the compiled lock file. This caused OIDC token minting to fail at runtime with "Missing id-token permission" errors despite the author having explicitly declared `id-token: write`. | ||
|
|
||
| ### Decision | ||
|
|
||
| We will support user-declared `permissions` blocks under `jobs.<built-in>` entries in `applyBuiltinJobAugmentations`, merging them **additively** with the compiler-computed least-privilege permissions. The merge applies "write overrides read" semantics and preserves all compiler-required scopes. A new helper `applyBuiltinJobPermissionsAugmentation` encapsulates the merge logic and is invoked before the existing `needs`/`if` augmentation path. | ||
|
|
||
| ### Alternatives Considered | ||
|
|
||
| #### Alternative 1: Reject permissions blocks on built-in jobs (error on detection) | ||
|
|
||
| Treat any `permissions:` key under a built-in job config entry as a validation error. Authors would be required to rely solely on the compiler's least-privilege computation and could not extend it. This is the safest option for minimizing permission surface, but it makes OIDC-dependent workflows impossible to author without the compiler growing built-in knowledge of every OIDC-related scope. | ||
|
|
||
| #### Alternative 2: Full replacement of compiler-computed permissions | ||
|
|
||
| Allow the user's declared permissions to entirely replace the compiler-computed set, giving authors complete control. This is simpler to implement but risks silently dropping required scopes (e.g., `issues: write` that `safe_outputs` needs to post comments), which would cause different runtime failures. The additive approach avoids this regression. | ||
|
|
||
| ### Consequences | ||
|
|
||
| #### Positive | ||
| - OIDC-dependent workflows that declare `id-token: write` under `jobs.safe_outputs.permissions` or `jobs.conclusion.permissions` now compile correctly and retain the scope in the lock file. | ||
| - Additive merge preserves all compiler-required scopes; no existing workflow is affected by this change (the new path is only entered when a `permissions` block is present). | ||
|
|
||
| #### Negative | ||
| - Authors can widen the permission surface of compiler-generated built-in jobs beyond the least-privilege baseline, potentially granting scopes that are not strictly needed at runtime. | ||
| - The compiler gains a new augmentation code path for permissions that must be kept in sync with future changes to built-in job generation and the `Permissions.Merge` / `RenderToYAML` helpers. | ||
|
|
||
| #### Neutral | ||
| - The error message for invalid built-in job augmentation now also covers the `.permissions` field, improving diagnostics when a user references a built-in job that the workflow does not generate. | ||
| - The integration test (`builtin_job_permissions_integration_test.go`) compiles a real workflow fixture and asserts lock-file contents, establishing a regression guard for this behavior. | ||
|
|
||
| --- | ||
|
|
||
| *ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package workflow | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/goccy/go-yaml" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/github/gh-aw/pkg/testutil" | ||
| ) | ||
|
|
||
| // TestBuiltinJobPermissionsAugmentation verifies that user-declared permissions under | ||
| // jobs.<built-in>.permissions (e.g. safe_outputs, conclusion) are merged additively into the | ||
| // compiled built-in jobs, so scopes such as id-token: write are retained in the lock file. | ||
| func TestBuiltinJobPermissionsAugmentation(t *testing.T) { | ||
| tmpDir := testutil.TempDir(t, "builtin-job-permissions-augmentation") | ||
| compiler := NewCompiler() | ||
|
|
||
| workflowContent := `--- | ||
| on: | ||
| issue_comment: | ||
| types: [created] | ||
| engine: copilot | ||
| strict: false | ||
| permissions: | ||
| contents: read | ||
| id-token: write | ||
| safe-outputs: | ||
| add-comment: | ||
| jobs: | ||
| safe_outputs: | ||
| permissions: | ||
| id-token: write | ||
| contents: read | ||
| issues: write | ||
| conclusion: | ||
| permissions: | ||
| id-token: write | ||
| contents: read | ||
| issues: write | ||
| --- | ||
| Builtin job permissions augmentation | ||
| ` | ||
|
|
||
| workflowFile := filepath.Join(tmpDir, "builtin-job-permissions-augmentation.md") | ||
| require.NoError(t, os.WriteFile(workflowFile, []byte(workflowContent), 0644)) | ||
| require.NoError(t, compiler.CompileWorkflow(workflowFile)) | ||
|
|
||
| lockFile := filepath.Join(tmpDir, "builtin-job-permissions-augmentation.lock.yml") | ||
| lockBytes, err := os.ReadFile(lockFile) | ||
| require.NoError(t, err) | ||
|
|
||
| var lock map[string]any | ||
| require.NoError(t, yaml.Unmarshal(lockBytes, &lock)) | ||
| jobs, ok := lock["jobs"].(map[string]any) | ||
| require.True(t, ok) | ||
|
|
||
| for _, jobName := range []string{"safe_outputs", "conclusion"} { | ||
| job, ok := jobs[jobName].(map[string]any) | ||
| require.True(t, ok, "expected %s job in compiled workflow", jobName) | ||
| perms, ok := job["permissions"].(map[string]any) | ||
| require.True(t, ok, "expected %s permissions to be a map", jobName) | ||
| assert.Equal(t, "write", perms["id-token"], "%s should retain id-token: write from jobs.%s.permissions", jobName, jobName) | ||
|
Comment on lines
+64
to
+66
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -775,7 +775,8 @@ func (c *Compiler) applyBuiltinJobAugmentations(data *WorkflowData) error { | |
| if err != nil { | ||
| return err | ||
| } | ||
| if len(augmentedNeeds) == 0 && augmentedIf == "" { | ||
| _, hasPermissions := configMap["permissions"] | ||
| if len(augmentedNeeds) == 0 && augmentedIf == "" && !hasPermissions { | ||
|
Comment on lines
+778
to
+779
|
||
| continue | ||
| } | ||
|
|
||
|
|
@@ -784,13 +785,23 @@ func (c *Compiler) applyBuiltinJobAugmentations(data *WorkflowData) error { | |
| // Report the actual field(s) the author configured so they can identify the problem. | ||
| augmentedField := configuredJobName + ".needs" | ||
| if len(augmentedNeeds) == 0 { | ||
| augmentedField = configuredJobName + ".if" | ||
| } else if augmentedIf != "" { | ||
| if augmentedIf != "" { | ||
| augmentedField = configuredJobName + ".if" | ||
| } else { | ||
| augmentedField = configuredJobName + ".permissions" | ||
| } | ||
| } else if augmentedIf != "" || hasPermissions { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnosing-bugs] The error-field reporting when 💡 DetailsThe original code reported the exact field ( @copilot please address this. |
||
| augmentedField = configuredJobName | ||
| } | ||
| return fmt.Errorf("jobs.%s: cannot augment %q because this workflow does not generate that job", augmentedField, targetJobName) | ||
| } | ||
|
|
||
| if hasPermissions { | ||
| if err := applyBuiltinJobPermissionsAugmentation(configuredJobName, targetJobName, configMap, targetJob); err != nil { | ||
| return err | ||
| } | ||
| } | ||
|
|
||
| normalizedNeeds := make([]string, 0, len(augmentedNeeds)) | ||
| for _, rawNeed := range augmentedNeeds { | ||
| need := normalizeBuiltinJobAlias(rawNeed) | ||
|
|
@@ -840,7 +851,32 @@ func (c *Compiler) applyBuiltinJobAugmentations(data *WorkflowData) error { | |
| compilerJobsLog.Printf("Applied jobs.%s.needs augmentation to %q: %v", configuredJobName, targetJobName, normalizedNeeds) | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // applyBuiltinJobPermissionsAugmentation merges user-declared jobs.<built-in>.permissions | ||
| // into a compiler-generated built-in job (e.g. safe_outputs, conclusion). The merge is | ||
| // additive: the compiler-computed permissions are preserved and the user's declared scopes | ||
| // are added on top, with write overriding read. This ensures scopes such as id-token: write | ||
| // that authors declare under jobs.*.permissions are retained in the compiled lock file rather | ||
| // than being dropped by the minimal least-privilege permission computation. | ||
| func applyBuiltinJobPermissionsAugmentation(configuredJobName, targetJobName string, configMap map[string]any, targetJob *Job) error { | ||
| permissionsValue, exists := configMap["permissions"] | ||
| if !exists || permissionsValue == nil { | ||
| return nil | ||
| } | ||
|
|
||
| userPermissions := NewPermissionsParserFromValue(permissionsValue).ToPermissions() | ||
| if userPermissions == nil { | ||
| return nil | ||
| } | ||
|
Comment on lines
+869
to
+872
|
||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] 💡 Suggested fixEither remove the nil check, or validate with an explicit empty/invalid check and return an error: userPermissions := NewPermissionsParserFromValue(permissionsValue).ToPermissions()
// Remove the nil guard; ToPermissions() always returns non-nilIf you want to guard against a scalar value being passed, detect that before calling @copilot please address this. |
||
| // Start from the compiler-computed permissions already rendered on the job, then merge | ||
| // the user-declared permissions additively so no compiler-required scope is lost. | ||
| merged := NewPermissionsParser(targetJob.Permissions).ToPermissions() | ||
| merged.Merge(userPermissions) | ||
| targetJob.Permissions = merged.RenderToYAML() | ||
| compilerJobsLog.Printf("Applied jobs.%s.permissions augmentation to %q", configuredJobName, targetJobName) | ||
| return nil | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd] The test only asserts the happy path where the user-declared permissions match the compiler-computed ones. It doesn't cover the additive-merge case where the user declares a scope not already present in the compiler-computed permissions (the core bug scenario:
id-token: writebeing dropped because the compiler doesn't compute it).💡 Suggested additional assertion
Add a separate sub-test (or extend the existing one) where the compiler-computed job would have
contents: read, issues: writebutid-tokenis absent, then assert that after augmentationid-token: writeappears. For example:This is the actual regression the PR is fixing; the current test doesn't isolate it.
@copilot please address this.