Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
68 changes: 68 additions & 0 deletions pkg/workflow/builtin_job_permissions_integration_test.go
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))

Copy link
Copy Markdown
Contributor

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: write being 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: write but id-token is absent, then assert that after augmentation id-token: write appears. For example:

// workflow only declares id-token at job level, not at top level
// compiler should NOT compute id-token — verify it is added by augmentation
assert.Equal(t, "write", perms["id-token"], "id-token should be injected by augmentation, not compiler")

This is the actual regression the PR is fixing; the current test doesn't isolate it.

@copilot please address this.


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
}
}
42 changes: 39 additions & 3 deletions pkg/workflow/compiler_custom_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] The error-field reporting when needs is set has ambiguous logic. When len(augmentedNeeds) > 0 and hasPermissions is true but augmentedIf == "", the field is reported as just configuredJobName (no sub-field), which hides which specific field caused the augmentation attempt.

💡 Details

The original code reported the exact field (needs, if, or the combined job name). The new || hasPermissions in else if augmentedIf != "" || hasPermissions means that when only needs and permissions are set (no if), the error message points to the bare job name rather than naming the offending field. Consider listing all present fields explicitly, e.g. configuredJobName + ".needs+permissions", or enumerating them for clarity in the error.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] userPermissions == nil is unreachable — ToPermissions() always returns a non-nil *Permissions. This guard silently swallows a misconfigured value (e.g. permissions: true) as a no-op instead of surfacing an error.

💡 Suggested fix

Either 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-nil

If you want to guard against a scalar value being passed, detect that before calling ToPermissions().

@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
}

Expand Down
Loading