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
7 changes: 7 additions & 0 deletions .changeset/patch-fix-otlp-oidc-id-token-permissions.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions docs/public/editor/autocomplete-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -2383,6 +2383,11 @@
"desc": "If true, skip token minting when client-id/private-key resolve to empty strings at runtime.",
"enum": [true, false],
"leaf": true
},
"audience": {
"type": "string",
"desc": "Optional OIDC audience passed to core.getIDToken(audience) when using credential-less OIDC mode (github-app present without app-id/private-key). Leave omitted to use the default audience.",
"leaf": true
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -9978,6 +9978,12 @@ observability:
# (optional)
ignore-if-missing: true

# Optional OIDC audience passed to core.getIDToken(audience) when using
# credential-less OIDC mode (github-app present without app-id/private-key). Leave
# omitted to use the default audience.
# (optional)
audience: "example-value"

# Rate limiting configuration to restrict how frequently users can trigger the
# workflow. Helps prevent abuse and resource exhaustion from programmatically
# triggered events.
Expand Down
15 changes: 5 additions & 10 deletions pkg/parser/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1375,9 +1375,9 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPResourceAttrib
}
}

func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppAudienceRejected(t *testing.T) {
func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppAudienceAccepted(t *testing.T) {
frontmatter := map[string]any{
"name": "OTLP github-app audience rejection",
"name": "OTLP github-app audience acceptance",
"on": map[string]any{
"issues": map[string]any{
"types": []any{"opened"},
Expand All @@ -1392,14 +1392,9 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppAudie
},
}

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/otlp-github-app-audience-reject-schema-test.md")
if err == nil {
t.Fatal("expected observability.otlp.github-app.audience to fail schema validation")
}
errText := err.Error()
if !strings.Contains(errText, "audience") ||
(!strings.Contains(errText, "github-app") && !strings.Contains(errText, "Unknown property")) {
t.Fatalf("expected schema validation error to reference unsupported github-app.audience syntax, got: %v", err)
err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/otlp-github-app-audience-accept-schema-test.md")
if err != nil {
t.Fatalf("expected observability.otlp.github-app.audience to pass schema validation (it is the credential-less OIDC audience field), got: %v", err)
}
}

Expand Down
4 changes: 4 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -11174,6 +11174,10 @@
"ignore-if-missing": {
"type": "boolean",
"description": "If true, skip token minting when client-id/private-key resolve to empty strings at runtime. Defaults to false."
},
"audience": {
"type": "string",
"description": "Optional OIDC audience passed to core.getIDToken(audience) when using credential-less OIDC mode (github-app present without app-id/private-key). Leave omitted to use the default audience."
Comment on lines +11178 to +11180
}
},
"additionalProperties": false
Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/compiler_activation_permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ func (c *Compiler) buildActivationBasePermissions(ctx *activationJobBuildContext
statusCommentIncludesPullRequests: ctx.statusCommentPRs,
statusCommentIncludesDiscussions: ctx.statusCommentDiscussions,
})
// When observability.otlp.github-app is configured without app-id/private-key
// credentials, id-token: write is needed so the activation job can mint the OTLP
// OIDC token via core.getIDToken(audience) (mirrors threat_detection_job.go).
if hasOTLPGitHubOIDCAuth(ctx.data.ParsedFrontmatter, ctx.data.RawFrontmatter) {
permsMap[PermissionIdToken] = PermissionWrite
}
return permsMap
}

Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/compiler_safe_outputs_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa

// Compute permissions and threat detection flag up front; both are used across phases.
permissions := ComputePermissionsForSafeOutputs(data.SafeOutputs)
// When observability.otlp.github-app is configured without app-id/private-key
// credentials, id-token: write is needed so the safe_outputs job can mint the OTLP
// OIDC token via core.getIDToken(audience) (mirrors threat_detection_job.go).
if hasOTLPGitHubOIDCAuth(data.ParsedFrontmatter, data.RawFrontmatter) {
permissions.Set(PermissionIdToken, PermissionWrite)
}
threatDetectionEnabled := IsDetectionJobEnabled(data.SafeOutputs)

// Compute artifact prefix once; it is referenced in all three phases.
Expand Down
9 changes: 8 additions & 1 deletion pkg/workflow/notify_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,19 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa
}
}
notifyCommentLog.Printf("Job built successfully: dependencies_count=%d", len(needs))
conclusionPerms := ComputePermissionsForSafeOutputs(data.SafeOutputs)
// When observability.otlp.github-app is configured without app-id/private-key
// credentials, id-token: write is needed so the conclusion job can mint the OTLP
// OIDC token via core.getIDToken(audience) (mirrors threat_detection_job.go).
if hasOTLPGitHubOIDCAuth(data.ParsedFrontmatter, data.RawFrontmatter) {
conclusionPerms.Set(PermissionIdToken, PermissionWrite)
}
return &Job{
Name: "conclusion",
If: RenderCondition(buildConclusionJobCondition(data, mainJobName, safeOutputJobNames)),
RunsOn: c.formatFrameworkJobRunsOn(data),
Environment: c.indentYAMLLines(resolveSafeOutputsEnvironment(data), " "),
Permissions: ComputePermissionsForSafeOutputs(data.SafeOutputs).RenderToYAML(),
Permissions: conclusionPerms.RenderToYAML(),
Concurrency: c.buildConclusionJobConcurrency(data),
Steps: steps,
Needs: needs,
Expand Down
82 changes: 82 additions & 0 deletions pkg/workflow/otlp_oidc_permissions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//go:build !integration

package workflow

import (
"os"
"path/filepath"
"testing"

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/stringutil"
"github.com/github/gh-aw/pkg/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestOTLPOIDCAudienceAllJobsHaveIDTokenWrite is a regression test for the bug where
// the activation, conclusion, and safe_outputs jobs were missing the id-token: write
// permission when observability.otlp.github-app was configured with audience-only
// (credential-less OIDC mode, no app-id/private-key). Each of these jobs receives the
// "Mint OTLP OIDC token" setup step (core.getIDToken) but previously lacked the
// permission required to call that API, causing:
//
// "Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable"
//
// The detection and evals jobs already had the fix; this test locks the invariant for
// the three that were missing it.
func TestOTLPOIDCAudienceAllJobsHaveIDTokenWrite(t *testing.T) {
tmpDir := testutil.TempDir(t, "otlp-oidc-perms-all-jobs")
testFile := filepath.Join(tmpDir, "otlp-oidc-audience.md")

// Audience-only (no app-id/private-key) triggers the credential-less OIDC path.
// safe-outputs is required to generate the conclusion and safe_outputs jobs.
testContent := `---
on:
issues:
types: [opened]
permissions:
contents: read
id-token: write
observability:
otlp:
endpoint: ${{ secrets.OTEL_ENDPOINT }}
github-app:
audience: https://otel.example.com
safe-outputs:
add-comment:
engine: copilot
---

# OTLP OIDC audience-only permissions regression test
`
require.NoError(t, os.WriteFile(testFile, []byte(testContent), 0644))

compiler := NewCompiler()
require.NoError(t, compiler.CompileWorkflow(testFile), "workflow should compile successfully")

lockBytes, err := os.ReadFile(stringutil.MarkdownToLockFile(testFile))
require.NoError(t, err, "failed to read generated lock file")
lockContent := string(lockBytes)

jobs := []string{
string(constants.ActivationJobName),
"conclusion",
"safe_outputs",
}
for _, jobName := range jobs {
t.Run(jobName, func(t *testing.T) {
section := extractJobSection(lockContent, jobName)
require.NotEmpty(t, section, "job %q should be present in the compiled output", jobName)

// Verify the OTLP OIDC mint step is present within this job's section so that
// the id-token: write permission is genuinely required for this specific job.
assert.Contains(t, section, "id: mint-otlp-oidc-token",
"job %q should contain the OTLP OIDC mint step (id: mint-otlp-oidc-token)", jobName)

// The core assertion: every job that receives the mint step must declare id-token: write.
assert.Contains(t, section, "id-token: write",
"job %q must have id-token: write when OTLP OIDC audience-only auth is configured", jobName)
})
}
}
Loading