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
22 changes: 22 additions & 0 deletions pkg/workflow/apm_dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,28 @@ func buildAPMAppTokenMintStep(app *GitHubAppConfig, fallbackRepoExpr string) []s
return steps
}

// buildAPMAppTokenInvalidationStep generates the step to invalidate the GitHub App token
// that was minted for APM cross-org repository access. This step always runs (even on failure)
// to ensure the token is properly cleaned up after the APM pack step completes.
func buildAPMAppTokenInvalidationStep() []string {
var steps []string

steps = append(steps, " - name: Invalidate GitHub App token for APM\n")
steps = append(steps, fmt.Sprintf(" if: always() && steps.%s.outputs.token != ''\n", apmAppTokenStepID))
steps = append(steps, " env:\n")
steps = append(steps, fmt.Sprintf(" TOKEN: ${{ steps.%s.outputs.token }}\n", apmAppTokenStepID))
steps = append(steps, " run: |\n")
steps = append(steps, " echo \"Revoking GitHub App installation token for APM...\"\n")
steps = append(steps, " # GitHub CLI will auth with the token being revoked.\n")
steps = append(steps, " gh api \\\n")
steps = append(steps, " --method DELETE \\\n")
steps = append(steps, " -H \"Authorization: token $TOKEN\" \\\n")
steps = append(steps, " /installation/token || echo \"Token revocation failed (token may be expired or invalid).\"\n")
steps = append(steps, " echo \"Token invalidation step complete.\"\n")

return steps
}

// GenerateAPMPackStep generates the GitHub Actions step that installs APM packages and
// packs them into a bundle in the activation job. The step always uses isolated:true because
// the activation job has no repo context to preserve.
Expand Down
22 changes: 22 additions & 0 deletions pkg/workflow/apm_dependencies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package workflow

import (
"fmt"
"strings"
"testing"

Expand Down Expand Up @@ -537,3 +538,24 @@ func TestBuildAPMAppTokenMintStep(t *testing.T) {
assert.Contains(t, combined, "repositories: ${{ github.event.repository.name }}", "Should default to event repository name")
})
}

func TestBuildAPMAppTokenInvalidationStep(t *testing.T) {
t.Run("Invalidation step targets apm-app-token step ID", func(t *testing.T) {
steps := buildAPMAppTokenInvalidationStep()

combined := strings.Join(steps, "")
assert.Contains(t, combined, "Invalidate GitHub App token for APM", "Should have descriptive step name")
assert.Contains(t, combined, fmt.Sprintf("if: always() && steps.%s.outputs.token != ''", apmAppTokenStepID), "Should run always and check token exists")
assert.Contains(t, combined, fmt.Sprintf("TOKEN: ${{ steps.%s.outputs.token }}", apmAppTokenStepID), "Should reference apm-app-token step output")
assert.Contains(t, combined, "gh api", "Should call GitHub API to revoke token")
assert.Contains(t, combined, "--method DELETE", "Should use DELETE method to revoke token")
assert.Contains(t, combined, "/installation/token", "Should target installation token endpoint")
})

t.Run("Invalidation step uses always() condition for cleanup even on failure", func(t *testing.T) {
steps := buildAPMAppTokenInvalidationStep()

combined := strings.Join(steps, "")
assert.Contains(t, combined, "always()", "Must run even if prior steps fail to ensure token cleanup")
})
}
5 changes: 5 additions & 0 deletions pkg/workflow/compiler_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,11 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
steps = append(steps, fmt.Sprintf(" name: %s\n", apmArtifactName))
steps = append(steps, " path: ${{ steps.apm_pack.outputs.bundle-path }}\n")
steps = append(steps, " retention-days: 1\n")
// Invalidate the APM GitHub App token after use to enforce least-privilege token lifecycle.
if data.APMDependencies.GitHubApp != nil {
compilerActivationJobLog.Print("Adding APM GitHub App token invalidation step")
steps = append(steps, buildAPMAppTokenInvalidationStep()...)
}
}

// Upload aw_info.json and prompt.txt as the activation artifact for the agent job to download.
Expand Down
56 changes: 56 additions & 0 deletions pkg/workflow/compiler_activation_jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,59 @@ func TestBuildMainJob_EngineSpecific(t *testing.T) {
})
}
}

// TestBuildActivationJob_APMTokenInvalidation tests that the APM GitHub App token is invalidated after use
func TestBuildActivationJob_APMTokenInvalidation(t *testing.T) {
compiler := NewCompiler()

t.Run("Invalidation step added when APM github-app is configured", func(t *testing.T) {
workflowData := &WorkflowData{
Name: "Test Workflow",
Command: []string{"test"},
APMDependencies: &APMDependenciesInfo{
Packages: []string{"microsoft/apm-sample-package"},
GitHubApp: &GitHubAppConfig{
AppID: "${{ vars.APP_ID }}",
PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}",
},
},
}

job, err := compiler.buildActivationJob(workflowData, false, "", "test.lock.yml")
require.NoError(t, err, "buildActivationJob should succeed")
require.NotNil(t, job)

stepsStr := strings.Join(job.Steps, "")

// Token mint step should be present
assert.Contains(t, stepsStr, "id: apm-app-token", "Should mint APM GitHub App token")

// Invalidation step should be present and use always() condition
assert.Contains(t, stepsStr, "Invalidate GitHub App token for APM", "Should have APM token invalidation step")
assert.Contains(t, stepsStr, "always() && steps.apm-app-token.outputs.token != ''", "Invalidation step should run always")

// Invalidation step should appear after the APM bundle upload
uploadIdx := strings.Index(stepsStr, "Upload APM bundle artifact")
invalidateIdx := strings.Index(stepsStr, "Invalidate GitHub App token for APM")
require.NotEqual(t, -1, uploadIdx, "Upload APM bundle artifact step must be present")
require.NotEqual(t, -1, invalidateIdx, "Invalidate GitHub App token for APM step must be present")
assert.Greater(t, invalidateIdx, uploadIdx, "Invalidation step should appear after APM bundle upload")
})

t.Run("No invalidation step when APM has no github-app", func(t *testing.T) {
workflowData := &WorkflowData{
Name: "Test Workflow",
Command: []string{"test"},
APMDependencies: &APMDependenciesInfo{
Packages: []string{"microsoft/apm-sample-package"},
},
}

job, err := compiler.buildActivationJob(workflowData, false, "", "test.lock.yml")
require.NoError(t, err, "buildActivationJob should succeed")
require.NotNil(t, job)

stepsStr := strings.Join(job.Steps, "")
assert.NotContains(t, stepsStr, "Invalidate GitHub App token for APM", "Should not have invalidation step when no github-app configured")
})
}
Loading