diff --git a/docs/src/content/docs/reference/permissions.md b/docs/src/content/docs/reference/permissions.md index b38bc95b0ee..e25fc38540f 100644 --- a/docs/src/content/docs/reference/permissions.md +++ b/docs/src/content/docs/reference/permissions.md @@ -43,6 +43,35 @@ permissions: This permission is safe to use and does not require safe-outputs, even in strict mode. +### GitHub App-Only Permissions + +Certain permission scopes cannot be granted to `GITHUB_TOKEN` and are forwarded instead as inputs to [`actions/create-github-app-token`](https://github.com/actions/create-github-app-token) when a GitHub App is configured. These scopes are omitted from the compiled workflow's `permissions:` block. + +**Repository-level:** `administration`, `environments`, `git-signing`, `vulnerability-alerts`, `workflows`, `repository-hooks`, `single-file`, `codespaces`, `repository-custom-properties` + +**Organization-level:** `organization-projects`, `members`, `organization-administration`, `team-discussions`, `organization-hooks`, `organization-members`, `organization-packages`, `organization-self-hosted-runners`, `organization-custom-org-roles`, `organization-custom-properties`, `organization-custom-repository-roles`, `organization-announcement-banners`, `organization-events`, `organization-plan`, `organization-user-blocking`, `organization-personal-access-token-requests`, `organization-personal-access-tokens`, `organization-copilot`, `organization-codespaces` + +**User-level:** `email-addresses`, `codespaces-lifecycle-admin`, `codespaces-metadata` + +These scopes must always be declared as `read`. Declaring `write` is a compile error; write operations through a GitHub App must go through [safe outputs](/gh-aw/reference/safe-outputs/), which provide a separate sanitized job for write operations. + +Declaring any of these scopes without a configured `github-app` causes a compile error. The GitHub App can be configured in `tools.github.github-app`, `safe-outputs.github-app`, or the top-level `github-app:` field — see [Tools](/gh-aw/reference/tools/) for configuration details. + +```aw wrap +permissions: + contents: read + workflows: read # GitHub App-only scope + members: read # GitHub App-only scope +tools: + github: + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +``` + +> [!NOTE] +> Shorthand permissions (`read-all`, `write-all`, `all: read`) do not trigger the "GitHub App required" validation. + ## Configuration Specify individual permission levels: @@ -116,7 +145,9 @@ permissions: contents: read ``` -**Exception:** The `id-token: write` permission is explicitly allowed as it is used for OIDC authentication with cloud providers and does not grant repository write access. +**Exceptions:** +- `id-token: write` is allowed for OIDC authentication with cloud providers and does not grant repository write access. +- GitHub App-only scopes (see above) always refuse `write` at compile time regardless of this policy; use [safe outputs](/gh-aw/reference/safe-outputs/) for write operations that require a GitHub App. #### Migrating Existing Workflows diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 7e3723dc35d..4d6b44d2b2b 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -131,6 +131,12 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath return formatCompilerError(markdownPath, "error", err.Error(), err) } + // Validate GitHub App-only permissions require a GitHub App to be configured + log.Printf("Validating GitHub App-only permissions") + if err := validateGitHubAppOnlyPermissions(workflowData); err != nil { + return formatCompilerError(markdownPath, "error", err.Error(), err) + } + // Validate agent file exists if specified in engine config log.Printf("Validating agent file if specified") if err := c.validateAgentFile(workflowData, markdownPath); err != nil { diff --git a/pkg/workflow/dangerous_permissions_validation_test.go b/pkg/workflow/dangerous_permissions_validation_test.go index 2fb31ce6f02..e627e0d5b45 100644 --- a/pkg/workflow/dangerous_permissions_validation_test.go +++ b/pkg/workflow/dangerous_permissions_validation_test.go @@ -153,7 +153,7 @@ func TestFindWritePermissions(t *testing.T) { { name: "write-all shorthand", permissions: NewPermissionsWriteAll(), - expectedWriteCount: 15, // All permission scopes except id-token (which is excluded) + expectedWriteCount: 14, // All GitHub Actions permission scopes except id-token and metadata (which are excluded) expectedScopes: nil, // Don't check specific scopes for shorthand }, { diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 66518fa8f3c..56592bcd72a 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -35,28 +35,78 @@ type RuntimesConfig struct { Ruby *RuntimeConfig `json:"ruby,omitempty"` // Ruby runtime } -// PermissionsConfig represents GitHub Actions permissions configuration -// Supports both shorthand (read-all, write-all) and detailed scope-based permissions +// GitHubActionsPermissionsConfig holds permission scopes supported by the GitHub Actions GITHUB_TOKEN. +// These scopes can be declared in the workflow's top-level permissions block and are enforced +// natively by GitHub Actions. +type GitHubActionsPermissionsConfig struct { + Actions string `json:"actions,omitempty"` + Checks string `json:"checks,omitempty"` + Contents string `json:"contents,omitempty"` + Deployments string `json:"deployments,omitempty"` + IDToken string `json:"id-token,omitempty"` + Issues string `json:"issues,omitempty"` + Discussions string `json:"discussions,omitempty"` + Packages string `json:"packages,omitempty"` + Pages string `json:"pages,omitempty"` + PullRequests string `json:"pull-requests,omitempty"` + RepositoryProjects string `json:"repository-projects,omitempty"` + SecurityEvents string `json:"security-events,omitempty"` + Statuses string `json:"statuses,omitempty"` +} + +// GitHubAppPermissionsConfig holds permission scopes that are exclusive to GitHub App +// installation access tokens (not supported by GITHUB_TOKEN). When any of these are +// specified, a GitHub App must be configured in the workflow. +type GitHubAppPermissionsConfig struct { + // Organization-level permissions (the common use-case placed first) + OrganizationProjects string `json:"organization-projects,omitempty"` + Members string `json:"members,omitempty"` + OrganizationAdministration string `json:"organization-administration,omitempty"` + TeamDiscussions string `json:"team-discussions,omitempty"` + OrganizationHooks string `json:"organization-hooks,omitempty"` + OrganizationMembers string `json:"organization-members,omitempty"` + OrganizationPackages string `json:"organization-packages,omitempty"` + OrganizationSelfHostedRunners string `json:"organization-self-hosted-runners,omitempty"` + OrganizationCustomOrgRoles string `json:"organization-custom-org-roles,omitempty"` + OrganizationCustomProperties string `json:"organization-custom-properties,omitempty"` + OrganizationCustomRepositoryRoles string `json:"organization-custom-repository-roles,omitempty"` + OrganizationAnnouncementBanners string `json:"organization-announcement-banners,omitempty"` + OrganizationEvents string `json:"organization-events,omitempty"` + OrganizationPlan string `json:"organization-plan,omitempty"` + OrganizationUserBlocking string `json:"organization-user-blocking,omitempty"` + OrganizationPersonalAccessTokenReqs string `json:"organization-personal-access-token-requests,omitempty"` + OrganizationPersonalAccessTokens string `json:"organization-personal-access-tokens,omitempty"` + OrganizationCopilot string `json:"organization-copilot,omitempty"` + OrganizationCodespaces string `json:"organization-codespaces,omitempty"` + // Repository-level permissions + Administration string `json:"administration,omitempty"` + Environments string `json:"environments,omitempty"` + GitSigning string `json:"git-signing,omitempty"` + VulnerabilityAlerts string `json:"vulnerability-alerts,omitempty"` + Workflows string `json:"workflows,omitempty"` + RepositoryHooks string `json:"repository-hooks,omitempty"` + SingleFile string `json:"single-file,omitempty"` + Codespaces string `json:"codespaces,omitempty"` + RepositoryCustomProperties string `json:"repository-custom-properties,omitempty"` + // User-level permissions + EmailAddresses string `json:"email-addresses,omitempty"` + CodespacesLifecycleAdmin string `json:"codespaces-lifecycle-admin,omitempty"` + CodespacesMetadata string `json:"codespaces-metadata,omitempty"` +} + +// PermissionsConfig represents GitHub Actions permissions configuration. +// Supports both shorthand (read-all, write-all) and detailed scope-based permissions. +// Embeds GitHubActionsPermissionsConfig for standard GITHUB_TOKEN scopes and +// GitHubAppPermissionsConfig for GitHub App-only scopes. type PermissionsConfig struct { // Shorthand permission (read-all, write-all, read, write, none) Shorthand string `json:"-"` // Not in JSON, set when parsing shorthand format - // Detailed permissions by scope - Actions string `json:"actions,omitempty"` - Checks string `json:"checks,omitempty"` - Contents string `json:"contents,omitempty"` - Deployments string `json:"deployments,omitempty"` - IDToken string `json:"id-token,omitempty"` - Issues string `json:"issues,omitempty"` - Discussions string `json:"discussions,omitempty"` - Packages string `json:"packages,omitempty"` - Pages string `json:"pages,omitempty"` - PullRequests string `json:"pull-requests,omitempty"` - RepositoryProjects string `json:"repository-projects,omitempty"` - SecurityEvents string `json:"security-events,omitempty"` - Statuses string `json:"statuses,omitempty"` - OrganizationProjects string `json:"organization-projects,omitempty"` - OrganizationPackages string `json:"organization-packages,omitempty"` + // GitHub Actions GITHUB_TOKEN permission scopes + GitHubActionsPermissionsConfig + + // GitHub App-only permission scopes (require a GitHub App to be configured) + GitHubAppPermissionsConfig } // PluginMCPConfig represents MCP configuration for a plugin @@ -356,6 +406,7 @@ func parsePermissionsConfig(permissions map[string]any) (*PermissionsConfig, err for scope, level := range permissions { if levelStr, ok := level.(string); ok { switch scope { + // GitHub Actions permission scopes case "actions": config.Actions = levelStr case "checks": @@ -384,8 +435,67 @@ func parsePermissionsConfig(permissions map[string]any) (*PermissionsConfig, err config.Statuses = levelStr case "organization-projects": config.OrganizationProjects = levelStr + // GitHub App-only permission scopes + case "administration": + config.Administration = levelStr + case "environments": + config.Environments = levelStr + case "git-signing": + config.GitSigning = levelStr + case "vulnerability-alerts": + config.VulnerabilityAlerts = levelStr + case "workflows": + config.Workflows = levelStr + case "repository-hooks": + config.RepositoryHooks = levelStr + case "single-file": + config.SingleFile = levelStr + case "codespaces": + config.Codespaces = levelStr + case "repository-custom-properties": + config.RepositoryCustomProperties = levelStr + case "members": + config.Members = levelStr + case "organization-administration": + config.OrganizationAdministration = levelStr + case "team-discussions": + config.TeamDiscussions = levelStr + case "organization-hooks": + config.OrganizationHooks = levelStr + case "organization-members": + config.OrganizationMembers = levelStr case "organization-packages": config.OrganizationPackages = levelStr + case "organization-self-hosted-runners": + config.OrganizationSelfHostedRunners = levelStr + case "organization-custom-org-roles": + config.OrganizationCustomOrgRoles = levelStr + case "organization-custom-properties": + config.OrganizationCustomProperties = levelStr + case "organization-custom-repository-roles": + config.OrganizationCustomRepositoryRoles = levelStr + case "organization-announcement-banners": + config.OrganizationAnnouncementBanners = levelStr + case "organization-events": + config.OrganizationEvents = levelStr + case "organization-plan": + config.OrganizationPlan = levelStr + case "organization-user-blocking": + config.OrganizationUserBlocking = levelStr + case "organization-personal-access-token-requests": + config.OrganizationPersonalAccessTokenReqs = levelStr + case "organization-personal-access-tokens": + config.OrganizationPersonalAccessTokens = levelStr + case "organization-copilot": + config.OrganizationCopilot = levelStr + case "organization-codespaces": + config.OrganizationCodespaces = levelStr + case "email-addresses": + config.EmailAddresses = levelStr + case "codespaces-lifecycle-admin": + config.CodespacesLifecycleAdmin = levelStr + case "codespaces-metadata": + config.CodespacesMetadata = levelStr } } } @@ -767,6 +877,7 @@ func permissionsConfigToMap(config *PermissionsConfig) map[string]any { result := make(map[string]any) + // GitHub Actions permission scopes if config.Actions != "" { result["actions"] = config.Actions } @@ -809,9 +920,102 @@ func permissionsConfigToMap(config *PermissionsConfig) map[string]any { if config.OrganizationProjects != "" { result["organization-projects"] = config.OrganizationProjects } + + // GitHub App-only permission scopes - repository-level + if config.Administration != "" { + result["administration"] = config.Administration + } + if config.Environments != "" { + result["environments"] = config.Environments + } + if config.GitSigning != "" { + result["git-signing"] = config.GitSigning + } + if config.VulnerabilityAlerts != "" { + result["vulnerability-alerts"] = config.VulnerabilityAlerts + } + if config.Workflows != "" { + result["workflows"] = config.Workflows + } + if config.RepositoryHooks != "" { + result["repository-hooks"] = config.RepositoryHooks + } + if config.SingleFile != "" { + result["single-file"] = config.SingleFile + } + if config.Codespaces != "" { + result["codespaces"] = config.Codespaces + } + if config.RepositoryCustomProperties != "" { + result["repository-custom-properties"] = config.RepositoryCustomProperties + } + + // GitHub App-only permission scopes - organization-level + if config.Members != "" { + result["members"] = config.Members + } + if config.OrganizationAdministration != "" { + result["organization-administration"] = config.OrganizationAdministration + } + if config.TeamDiscussions != "" { + result["team-discussions"] = config.TeamDiscussions + } + if config.OrganizationHooks != "" { + result["organization-hooks"] = config.OrganizationHooks + } + if config.OrganizationMembers != "" { + result["organization-members"] = config.OrganizationMembers + } if config.OrganizationPackages != "" { result["organization-packages"] = config.OrganizationPackages } + if config.OrganizationSelfHostedRunners != "" { + result["organization-self-hosted-runners"] = config.OrganizationSelfHostedRunners + } + if config.OrganizationCustomOrgRoles != "" { + result["organization-custom-org-roles"] = config.OrganizationCustomOrgRoles + } + if config.OrganizationCustomProperties != "" { + result["organization-custom-properties"] = config.OrganizationCustomProperties + } + if config.OrganizationCustomRepositoryRoles != "" { + result["organization-custom-repository-roles"] = config.OrganizationCustomRepositoryRoles + } + if config.OrganizationAnnouncementBanners != "" { + result["organization-announcement-banners"] = config.OrganizationAnnouncementBanners + } + if config.OrganizationEvents != "" { + result["organization-events"] = config.OrganizationEvents + } + if config.OrganizationPlan != "" { + result["organization-plan"] = config.OrganizationPlan + } + if config.OrganizationUserBlocking != "" { + result["organization-user-blocking"] = config.OrganizationUserBlocking + } + if config.OrganizationPersonalAccessTokenReqs != "" { + result["organization-personal-access-token-requests"] = config.OrganizationPersonalAccessTokenReqs + } + if config.OrganizationPersonalAccessTokens != "" { + result["organization-personal-access-tokens"] = config.OrganizationPersonalAccessTokens + } + if config.OrganizationCopilot != "" { + result["organization-copilot"] = config.OrganizationCopilot + } + if config.OrganizationCodespaces != "" { + result["organization-codespaces"] = config.OrganizationCodespaces + } + + // GitHub App-only permission scopes - user-level + if config.EmailAddresses != "" { + result["email-addresses"] = config.EmailAddresses + } + if config.CodespacesLifecycleAdmin != "" { + result["codespaces-lifecycle-admin"] = config.CodespacesLifecycleAdmin + } + if config.CodespacesMetadata != "" { + result["codespaces-metadata"] = config.CodespacesMetadata + } if len(result) == 0 { return nil diff --git a/pkg/workflow/github_app_permissions_validation.go b/pkg/workflow/github_app_permissions_validation.go new file mode 100644 index 00000000000..2158f7bceb4 --- /dev/null +++ b/pkg/workflow/github_app_permissions_validation.go @@ -0,0 +1,169 @@ +package workflow + +import ( + "errors" + "sort" + "strings" +) + +var githubAppPermissionsLog = newValidationLogger("github_app_permissions") + +// validateGitHubAppOnlyPermissions validates that when GitHub App-only permissions +// are specified in the workflow, a GitHub App is configured somewhere in the workflow, +// and that no GitHub App-only permission is declared with "write" access (write operations +// must be performed via safe-outputs, not through declared permissions). +// +// GitHub App-only permissions (e.g., members, administration) cannot be exercised +// through the GITHUB_TOKEN — they require a GitHub App installation access token. When such +// permissions are declared, a GitHub App must be configured via one of: +// - tools.github.github-app +// - safe-outputs.github-app +// - the top-level github-app field (for the activation/pre-activation jobs) +// +// Returns an error if GitHub App-only permissions are used without any GitHub App configured, +// or if "write" level is requested for any GitHub App-only scope. +func validateGitHubAppOnlyPermissions(workflowData *WorkflowData) error { + githubAppPermissionsLog.Print("Starting GitHub App-only permissions validation") + + if workflowData.Permissions == "" { + githubAppPermissionsLog.Print("No permissions defined, validation passed") + return nil + } + + permissions := NewPermissionsParser(workflowData.Permissions).ToPermissions() + if permissions == nil { + githubAppPermissionsLog.Print("Could not parse permissions, validation passed") + return nil + } + + // Find any GitHub App-only permission scopes that are *explicitly* declared. + // We must not use Get() here because shorthand permissions (read-all / write-all) and + // "all: read" would cause Get() to return a value for every scope, incorrectly + // requiring a GitHub App even when no App-only scopes were explicitly declared. + var appOnlyScopes []PermissionScope + for _, scope := range GetAllGitHubAppOnlyScopes() { + if _, exists := permissions.GetExplicit(scope); exists { + appOnlyScopes = append(appOnlyScopes, scope) + } + } + + if len(appOnlyScopes) == 0 { + githubAppPermissionsLog.Print("No GitHub App-only permissions found, validation passed") + return nil + } + + githubAppPermissionsLog.Printf("Found %d GitHub App-only permissions, checking for GitHub App configuration", len(appOnlyScopes)) + + // Check if "write" is requested for any read-only GitHub App-only scopes. + if err := validateGitHubAppOnlyPermissionsWrite(permissions, appOnlyScopes); err != nil { + return err + } + + // Check if any GitHub App is configured + if hasGitHubAppConfigured(workflowData) { + githubAppPermissionsLog.Print("GitHub App is configured, validation passed") + return nil + } + + // Format the error message + return formatGitHubAppRequiredError(appOnlyScopes) +} + +// validateGitHubAppOnlyPermissionsWrite checks that no GitHub App-only scope has been +// requested with "write" access. Write operations on GitHub App tokens must be performed +// via safe-outputs, not through declared permissions. +func validateGitHubAppOnlyPermissionsWrite(permissions *Permissions, appOnlyScopes []PermissionScope) error { + var writtenScopes []PermissionScope + for _, scope := range appOnlyScopes { + if level, exists := permissions.GetExplicit(scope); exists && level == PermissionWrite { + writtenScopes = append(writtenScopes, scope) + } + } + if len(writtenScopes) == 0 { + return nil + } + return formatWriteOnAppScopesError(writtenScopes) +} + +// formatWriteOnAppScopesError formats the error when "write" is requested for +// any GitHub App-only scope. All App-only scopes must be declared read-only; +// write operations are performed via safe-outputs. +func formatWriteOnAppScopesError(scopes []PermissionScope) error { + scopeStrs := make([]string, len(scopes)) + for i, s := range scopes { + scopeStrs[i] = string(s) + } + sort.Strings(scopeStrs) + + var lines []string + lines = append(lines, "GitHub App permissions must be declared as \"read\" only.") + lines = append(lines, "Write operations are performed via safe-outputs, not through declared permissions.") + lines = append(lines, "The following GitHub App-only permissions were declared with \"write\" access:") + lines = append(lines, "") + for _, s := range scopeStrs { + lines = append(lines, " - "+s) + } + lines = append(lines, "") + lines = append(lines, "Change the permission level to \"read\" or use safe-outputs for write operations.") + + return errors.New(strings.Join(lines, "\n")) +} + +func hasGitHubAppConfigured(workflowData *WorkflowData) bool { + // Check tools.github.github-app + if workflowData.ParsedTools != nil && + workflowData.ParsedTools.GitHub != nil && + workflowData.ParsedTools.GitHub.GitHubApp != nil { + githubAppPermissionsLog.Print("Found GitHub App in tools.github") + return true + } + + // Check safe-outputs.github-app + if workflowData.SafeOutputs != nil && workflowData.SafeOutputs.GitHubApp != nil { + githubAppPermissionsLog.Print("Found GitHub App in safe-outputs") + return true + } + + // Check the activation job github-app + if workflowData.ActivationGitHubApp != nil { + githubAppPermissionsLog.Print("Found GitHub App in activation config") + return true + } + + return false +} + +// formatGitHubAppRequiredError formats an error message when GitHub App-only permissions +// are used without a GitHub App configured. +func formatGitHubAppRequiredError(appOnlyScopes []PermissionScope) error { + // Sort for deterministic output + scopeStrs := make([]string, len(appOnlyScopes)) + for i, s := range appOnlyScopes { + scopeStrs[i] = string(s) + } + sort.Strings(scopeStrs) + + var lines []string + lines = append(lines, "GitHub App-only permissions require a GitHub App to be configured.") + lines = append(lines, "The following permissions are not supported by the GITHUB_TOKEN and") + lines = append(lines, "can only be exercised through a GitHub App installation access token:") + lines = append(lines, "") + for _, s := range scopeStrs { + lines = append(lines, " - "+s) + } + lines = append(lines, "") + lines = append(lines, "To fix this, configure a GitHub App in your workflow. For example:") + lines = append(lines, ""+"tools:") + lines = append(lines, " github:") + lines = append(lines, " github-app:") + lines = append(lines, " app-id: ${{ vars.APP_ID }}") + lines = append(lines, " private-key: ${{ secrets.APP_PRIVATE_KEY }}") + lines = append(lines, "") + lines = append(lines, "Or in the safe-outputs section:") + lines = append(lines, "safe-outputs:") + lines = append(lines, " github-app:") + lines = append(lines, " app-id: ${{ vars.APP_ID }}") + lines = append(lines, " private-key: ${{ secrets.APP_PRIVATE_KEY }}") + + return errors.New(strings.Join(lines, "\n")) +} diff --git a/pkg/workflow/github_app_permissions_validation_test.go b/pkg/workflow/github_app_permissions_validation_test.go new file mode 100644 index 00000000000..017b0a994cc --- /dev/null +++ b/pkg/workflow/github_app_permissions_validation_test.go @@ -0,0 +1,509 @@ +//go:build !integration + +package workflow + +import ( + "strings" + "testing" +) + +func TestValidateGitHubAppOnlyPermissions(t *testing.T) { + tests := []struct { + name string + permissions string + parsedTools *ToolsConfig + safeOutputs *SafeOutputsConfig + activationApp *GitHubAppConfig + shouldError bool + errorContains string + }{ + { + name: "no permissions - should pass", + permissions: "", + shouldError: false, + }, + { + name: "GitHub Actions-only permissions - should pass without github-app", + permissions: "permissions:\n contents: read\n issues: write", + shouldError: false, + }, + { + name: "organization-projects (App-only) without github-app - should error", + permissions: "permissions:\n organization-projects: read", + shouldError: true, + errorContains: "GitHub App-only permissions require a GitHub App", + }, + { + name: "members permission without github-app - should error", + permissions: "permissions:\n members: read", + shouldError: true, + errorContains: "GitHub App-only permissions require a GitHub App", + }, + { + name: "administration permission without github-app - should error", + permissions: "permissions:\n administration: read", + shouldError: true, + errorContains: "GitHub App-only permissions require a GitHub App", + }, + { + name: "members permission with tools.github.github-app - should pass", + permissions: "permissions:\n members: read", + parsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + shouldError: false, + }, + { + name: "members permission with safe-outputs.github-app - should pass", + permissions: "permissions:\n members: read", + safeOutputs: &SafeOutputsConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + shouldError: false, + }, + { + name: "members permission with activation github-app - should pass", + permissions: "permissions:\n members: read", + activationApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + shouldError: false, + }, + { + name: "workflows permission without github-app - should error", + permissions: "permissions:\n workflows: write", + shouldError: true, + errorContains: "workflows", + }, + { + name: "vulnerability-alerts permission without github-app - should error", + permissions: "permissions:\n vulnerability-alerts: read", + shouldError: true, + errorContains: "vulnerability-alerts", + }, + { + name: "mixed Actions and App-only permissions with github-app - should pass", + permissions: "permissions:\n contents: read\n members: read\n administration: read", + parsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + shouldError: false, + }, + { + name: "mixed Actions and App-only permissions without github-app - should error", + permissions: "permissions:\n contents: read\n members: read", + shouldError: true, + errorContains: "GitHub App-only permissions require a GitHub App", + }, + { + name: "read-all shorthand should NOT require a GitHub App", + permissions: "read-all", + shouldError: false, + }, + { + name: "write-all shorthand should NOT require a GitHub App", + permissions: "write-all", + shouldError: false, + }, + { + name: "all:read should NOT require a GitHub App", + permissions: "permissions:\n all: read", + shouldError: false, + }, + { + name: "organization-events with write - should error (no write on App-only scopes)", + permissions: "permissions:\n organization-events: write", + shouldError: true, + errorContains: "safe-outputs", + }, + { + name: "organization-plan with write - should error (no write on App-only scopes)", + permissions: "permissions:\n organization-plan: write", + shouldError: true, + errorContains: "safe-outputs", + }, + { + name: "email-addresses with write - should error (no write on App-only scopes)", + permissions: "permissions:\n email-addresses: write", + shouldError: true, + errorContains: "safe-outputs", + }, + { + name: "codespaces-metadata with write - should error (no write on App-only scopes)", + permissions: "permissions:\n codespaces-metadata: write", + shouldError: true, + errorContains: "safe-outputs", + }, + { + name: "organization-events with read and github-app - should pass", + permissions: "permissions:\n organization-events: read", + parsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + shouldError: false, + }, + { + name: "administration with write - should error (no write on App-only scopes)", + permissions: "permissions:\n administration: write", + shouldError: true, + errorContains: "safe-outputs", + }, + { + name: "members with write - should error even with github-app (no write on App-only scopes)", + permissions: "permissions:\n members: write", + parsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + shouldError: true, + errorContains: "safe-outputs", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workflowData := &WorkflowData{ + Permissions: tt.permissions, + ParsedTools: tt.parsedTools, + SafeOutputs: tt.safeOutputs, + ActivationGitHubApp: tt.activationApp, + } + + err := validateGitHubAppOnlyPermissions(workflowData) + + if tt.shouldError { + if err == nil { + t.Errorf("Expected error but got none") + return + } + if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error to contain %q, but got: %v", tt.errorContains, err) + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + } + } + }) + } +} + +func TestIsGitHubAppOnlyScope(t *testing.T) { + tests := []struct { + scope PermissionScope + expected bool + }{ + // GitHub Actions scopes - should NOT be GitHub App-only + {PermissionActions, false}, + {PermissionChecks, false}, + {PermissionContents, false}, + {PermissionDeployments, false}, + {PermissionIssues, false}, + {PermissionPackages, false}, + {PermissionPages, false}, + {PermissionPullRequests, false}, + {PermissionSecurityEvents, false}, + {PermissionStatuses, false}, + {PermissionDiscussions, false}, + // organization-projects is a GitHub App-only scope (not in GitHub Actions GITHUB_TOKEN) + {PermissionOrganizationProj, true}, + // GitHub App-only scopes - should return true + {PermissionAdministration, true}, + {PermissionMembers, true}, + {PermissionOrganizationAdministration, true}, + {PermissionEnvironments, true}, + {PermissionGitSigning, true}, + {PermissionTeamDiscussions, true}, + {PermissionVulnerabilityAlerts, true}, + {PermissionWorkflows, true}, + {PermissionRepositoryHooks, true}, + {PermissionOrganizationHooks, true}, + {PermissionOrganizationMembers, true}, + {PermissionOrganizationPackages, true}, + {PermissionOrganizationSelfHostedRunners, true}, + {PermissionSingleFile, true}, + {PermissionCodespaces, true}, + {PermissionEmailAddresses, true}, + } + + for _, tt := range tests { + t.Run(string(tt.scope), func(t *testing.T) { + result := IsGitHubAppOnlyScope(tt.scope) + if result != tt.expected { + t.Errorf("IsGitHubAppOnlyScope(%q) = %v, want %v", tt.scope, result, tt.expected) + } + }) + } +} + +func TestGetAllGitHubAppOnlyScopes(t *testing.T) { + scopes := GetAllGitHubAppOnlyScopes() + if len(scopes) == 0 { + t.Error("GetAllGitHubAppOnlyScopes should return at least one scope") + } + + // Verify some key scopes are included + keyScopes := []PermissionScope{ + PermissionAdministration, + PermissionMembers, + PermissionOrganizationAdministration, + PermissionEnvironments, + PermissionWorkflows, + PermissionVulnerabilityAlerts, + PermissionOrganizationPackages, + } + + scopeSet := make(map[PermissionScope]bool) + for _, s := range scopes { + scopeSet[s] = true + } + + for _, expected := range keyScopes { + if !scopeSet[expected] { + t.Errorf("Expected scope %q to be in GetAllGitHubAppOnlyScopes()", expected) + } + } + + // Verify that GitHub Actions scopes are NOT included + actionScopes := []PermissionScope{ + PermissionContents, + PermissionIssues, + PermissionPullRequests, + PermissionChecks, + PermissionIdToken, + } + for _, notExpected := range actionScopes { + if scopeSet[notExpected] { + t.Errorf("Expected scope %q to NOT be in GetAllGitHubAppOnlyScopes()", notExpected) + } + } +} + +func TestGitHubAppOnlyPermissionsRenderToYAML(t *testing.T) { + tests := []struct { + name string + permissions *Permissions + expectEmpty bool + shouldContain []string + shouldSkip []string + }{ + { + name: "members permission is skipped in GitHub Actions YAML", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionMembers, PermissionRead) + return p + }(), + shouldSkip: []string{"members: read"}, + }, + { + name: "administration permission is skipped in GitHub Actions YAML", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionAdministration, PermissionRead) + return p + }(), + shouldSkip: []string{"administration: read"}, + }, + { + name: "only App-only scopes returns empty string (not bare 'permissions:')", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionMembers, PermissionRead) + p.Set(PermissionAdministration, PermissionRead) + return p + }(), + expectEmpty: true, + shouldSkip: []string{"members: read", "administration: read", "permissions:"}, + }, + { + name: "contents permission IS included in GitHub Actions YAML", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionContents, PermissionRead) + return p + }(), + shouldContain: []string{"contents: read"}, + }, + { + name: "mixed permissions - only Actions scopes rendered", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionContents, PermissionRead) + p.Set(PermissionIssues, PermissionRead) + p.Set(PermissionMembers, PermissionRead) + p.Set(PermissionAdministration, PermissionRead) + return p + }(), + shouldContain: []string{"contents: read", "issues: read"}, + shouldSkip: []string{"members: read", "administration: read"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + yaml := tt.permissions.RenderToYAML() + + if tt.expectEmpty && yaml != "" { + t.Errorf("Expected empty YAML output but got:\n%s", yaml) + } + + for _, expected := range tt.shouldContain { + if !strings.Contains(yaml, expected) { + t.Errorf("Expected YAML to contain %q, but got:\n%s", expected, yaml) + } + } + + for _, notExpected := range tt.shouldSkip { + if strings.Contains(yaml, notExpected) { + t.Errorf("Expected YAML to NOT contain %q, but got:\n%s", notExpected, yaml) + } + } + }) + } +} + +func TestConvertPermissionsToAppTokenFields_GitHubAppOnly(t *testing.T) { + tests := []struct { + name string + permissions *Permissions + expectedFields map[string]string + absentFields []string + }{ + { + name: "members permission maps to permission-members", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionMembers, PermissionRead) + return p + }(), + expectedFields: map[string]string{ + "permission-members": "read", + }, + }, + { + name: "administration permission maps to permission-administration", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionAdministration, PermissionRead) + return p + }(), + expectedFields: map[string]string{ + "permission-administration": "read", + }, + }, + { + name: "workflows permission maps to permission-workflows", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionWorkflows, PermissionWrite) + return p + }(), + expectedFields: map[string]string{ + "permission-workflows": "write", + }, + }, + { + name: "vulnerability-alerts maps correctly", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionVulnerabilityAlerts, PermissionRead) + return p + }(), + expectedFields: map[string]string{ + "permission-vulnerability-alerts": "read", + }, + }, + { + name: "models permission is NOT mapped (no GitHub App equivalent)", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionModels, PermissionRead) + return p + }(), + absentFields: []string{"permission-models"}, + }, + { + name: "id-token permission is NOT mapped (not applicable to GitHub Apps)", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionIdToken, PermissionWrite) + return p + }(), + absentFields: []string{"permission-id-token"}, + }, + { + name: "organization-packages permission maps correctly", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionOrganizationPackages, PermissionRead) + return p + }(), + expectedFields: map[string]string{ + "permission-organization-packages": "read", + }, + }, + { + name: "read-all shorthand does NOT produce App-only permission fields", + permissions: NewPermissionsReadAll(), + // App-only scopes must not appear when using shorthand + absentFields: []string{ + "permission-members", + "permission-administration", + "permission-workflows", + "permission-organization-projects", + }, + }, + { + name: "write-all shorthand does NOT produce App-only permission fields", + permissions: NewPermissionsWriteAll(), + absentFields: []string{ + "permission-members", + "permission-administration", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := convertPermissionsToAppTokenFields(tt.permissions) + + for key, expectedValue := range tt.expectedFields { + if actualValue, exists := fields[key]; !exists { + t.Errorf("Expected field %q to be present, but it was not. Got fields: %v", key, fields) + } else if actualValue != expectedValue { + t.Errorf("Expected field %q = %q, got %q", key, expectedValue, actualValue) + } + } + + for _, absentKey := range tt.absentFields { + if _, exists := fields[absentKey]; exists { + t.Errorf("Expected field %q to be absent, but it was present with value %q", absentKey, fields[absentKey]) + } + } + }) + } +} diff --git a/pkg/workflow/permissions.go b/pkg/workflow/permissions.go index 78dc40674f5..baa36c363d5 100644 --- a/pkg/workflow/permissions.go +++ b/pkg/workflow/permissions.go @@ -1,6 +1,8 @@ package workflow import ( + "slices" + "github.com/github/gh-aw/pkg/logger" ) @@ -9,6 +11,7 @@ var permissionsLog = logger.New("workflow:permissions") // convertStringToPermissionScope converts a string key to a PermissionScope func convertStringToPermissionScope(key string) PermissionScope { switch key { + // GitHub Actions permission scopes (supported by GITHUB_TOKEN) case "actions": return PermissionActions case "attestations": @@ -37,14 +40,77 @@ func convertStringToPermissionScope(key string) PermissionScope { return PermissionPullRequests case "repository-projects": return PermissionRepositoryProj - case "organization-projects": - return PermissionOrganizationProj case "security-events": return PermissionSecurityEvents case "statuses": return PermissionStatuses case "copilot-requests": return PermissionCopilotRequests + // GitHub App-only permission scopes (not supported by GITHUB_TOKEN, require a GitHub App) + // organization-projects is included here because it is a GitHub App-only scope + // (it is excluded from GetAllPermissionScopes() and skipped in YAML rendering). + case "organization-projects": + return PermissionOrganizationProj + case "administration": + return PermissionAdministration + case "members": + return PermissionMembers + case "organization-administration": + return PermissionOrganizationAdministration + case "environments": + return PermissionEnvironments + case "git-signing": + return PermissionGitSigning + case "team-discussions": + return PermissionTeamDiscussions + case "vulnerability-alerts": + return PermissionVulnerabilityAlerts + case "workflows": + return PermissionWorkflows + case "repository-hooks": + return PermissionRepositoryHooks + case "organization-hooks": + return PermissionOrganizationHooks + case "organization-members": + return PermissionOrganizationMembers + case "organization-packages": + return PermissionOrganizationPackages + case "organization-self-hosted-runners": + return PermissionOrganizationSelfHostedRunners + case "single-file": + return PermissionSingleFile + case "codespaces": + return PermissionCodespaces + case "email-addresses": + return PermissionEmailAddresses + case "repository-custom-properties": + return PermissionRepositoryCustomProperties + case "organization-custom-org-roles": + return PermissionOrganizationCustomOrgRoles + case "organization-custom-properties": + return PermissionOrganizationCustomProperties + case "organization-custom-repository-roles": + return PermissionOrganizationCustomRepositoryRoles + case "organization-announcement-banners": + return PermissionOrganizationAnnouncementBanners + case "organization-events": + return PermissionOrganizationEvents + case "organization-plan": + return PermissionOrganizationPlan + case "organization-user-blocking": + return PermissionOrganizationUserBlocking + case "organization-personal-access-token-requests": + return PermissionOrganizationPersonalAccessTokenReqs + case "organization-personal-access-tokens": + return PermissionOrganizationPersonalAccessTokens + case "organization-copilot": + return PermissionOrganizationCopilot + case "organization-codespaces": + return PermissionOrganizationCodespaces + case "codespaces-lifecycle-admin": + return PermissionCodespacesLifecycleAdmin + case "codespaces-metadata": + return PermissionCodespacesMetadata case "all": // "all" is a meta-key handled at the parser level; it is not a real scope return "" @@ -67,6 +133,7 @@ const ( type PermissionScope string const ( + // GitHub Actions permission scopes (supported by GITHUB_TOKEN) PermissionActions PermissionScope = "actions" PermissionAttestations PermissionScope = "attestations" PermissionChecks PermissionScope = "checks" @@ -87,9 +154,52 @@ const ( // PermissionCopilotRequests is a GitHub Actions permission scope used with the copilot-requests feature. // It enables use of the GitHub Actions token as the Copilot authentication token. PermissionCopilotRequests PermissionScope = "copilot-requests" + + // GitHub App-only permission scopes (not supported by GITHUB_TOKEN, require a GitHub App token). + // When any of these are specified in the workflow permissions, a GitHub App must be configured. + // These permissions are skipped when rendering GitHub Actions workflow YAML, but are passed + // as permission-* inputs when minting GitHub App installation access tokens. + + // Repository-level GitHub App permissions + PermissionAdministration PermissionScope = "administration" + PermissionEnvironments PermissionScope = "environments" + PermissionGitSigning PermissionScope = "git-signing" + PermissionVulnerabilityAlerts PermissionScope = "vulnerability-alerts" + PermissionWorkflows PermissionScope = "workflows" + PermissionRepositoryHooks PermissionScope = "repository-hooks" + PermissionSingleFile PermissionScope = "single-file" + PermissionCodespaces PermissionScope = "codespaces" + PermissionRepositoryCustomProperties PermissionScope = "repository-custom-properties" + + // Organization-level GitHub App permissions + PermissionMembers PermissionScope = "members" + PermissionOrganizationAdministration PermissionScope = "organization-administration" + PermissionTeamDiscussions PermissionScope = "team-discussions" + PermissionOrganizationHooks PermissionScope = "organization-hooks" + PermissionOrganizationMembers PermissionScope = "organization-members" + PermissionOrganizationPackages PermissionScope = "organization-packages" + PermissionOrganizationSelfHostedRunners PermissionScope = "organization-self-hosted-runners" + PermissionOrganizationCustomOrgRoles PermissionScope = "organization-custom-org-roles" + PermissionOrganizationCustomProperties PermissionScope = "organization-custom-properties" + PermissionOrganizationCustomRepositoryRoles PermissionScope = "organization-custom-repository-roles" + PermissionOrganizationAnnouncementBanners PermissionScope = "organization-announcement-banners" + PermissionOrganizationEvents PermissionScope = "organization-events" + PermissionOrganizationPlan PermissionScope = "organization-plan" + PermissionOrganizationUserBlocking PermissionScope = "organization-user-blocking" + PermissionOrganizationPersonalAccessTokenReqs PermissionScope = "organization-personal-access-token-requests" + PermissionOrganizationPersonalAccessTokens PermissionScope = "organization-personal-access-tokens" + PermissionOrganizationCopilot PermissionScope = "organization-copilot" + PermissionOrganizationCodespaces PermissionScope = "organization-codespaces" + + // User-level GitHub App permissions + PermissionEmailAddresses PermissionScope = "email-addresses" + PermissionCodespacesLifecycleAdmin PermissionScope = "codespaces-lifecycle-admin" + PermissionCodespacesMetadata PermissionScope = "codespaces-metadata" ) -// GetAllPermissionScopes returns all available permission scopes +// GetAllPermissionScopes returns all GitHub Actions permission scopes (supported by GITHUB_TOKEN). +// These are the scopes that can be set on the workflow's GITHUB_TOKEN. +// For GitHub App-only scopes, see GetAllGitHubAppOnlyScopes. func GetAllPermissionScopes() []PermissionScope { return []PermissionScope{ PermissionActions, @@ -106,12 +216,59 @@ func GetAllPermissionScopes() []PermissionScope { PermissionPages, PermissionPullRequests, PermissionRepositoryProj, - PermissionOrganizationProj, PermissionSecurityEvents, PermissionStatuses, } } +// GetAllGitHubAppOnlyScopes returns all GitHub App-only permission scopes. +// These scopes are not supported by GITHUB_TOKEN and require a GitHub App installation token. +// When any of these scopes are used in a workflow, a GitHub App must be configured. +func GetAllGitHubAppOnlyScopes() []PermissionScope { + return []PermissionScope{ + // Repository-level GitHub App permissions + PermissionAdministration, + PermissionEnvironments, + PermissionGitSigning, + PermissionVulnerabilityAlerts, + PermissionWorkflows, + PermissionRepositoryHooks, + PermissionSingleFile, + PermissionCodespaces, + PermissionRepositoryCustomProperties, + // Organization-level GitHub App permissions + PermissionOrganizationProj, + PermissionMembers, + PermissionOrganizationAdministration, + PermissionTeamDiscussions, + PermissionOrganizationHooks, + PermissionOrganizationMembers, + PermissionOrganizationPackages, + PermissionOrganizationSelfHostedRunners, + PermissionOrganizationCustomOrgRoles, + PermissionOrganizationCustomProperties, + PermissionOrganizationCustomRepositoryRoles, + PermissionOrganizationAnnouncementBanners, + PermissionOrganizationEvents, + PermissionOrganizationPlan, + PermissionOrganizationUserBlocking, + PermissionOrganizationPersonalAccessTokenReqs, + PermissionOrganizationPersonalAccessTokens, + PermissionOrganizationCopilot, + PermissionOrganizationCodespaces, + // User-level GitHub App permissions + PermissionEmailAddresses, + PermissionCodespacesLifecycleAdmin, + PermissionCodespacesMetadata, + } +} + +// IsGitHubAppOnlyScope returns true if the scope is a GitHub App-only permission +// (not supported by GITHUB_TOKEN). These scopes require a GitHub App to exercise. +func IsGitHubAppOnlyScope(scope PermissionScope) bool { + return slices.Contains(GetAllGitHubAppOnlyScopes(), scope) +} + // Permissions represents GitHub Actions permissions // It can be a shorthand (read-all, write-all, read, write, none) or a map of scopes to levels // It can also have an "all" permission that expands to all scopes diff --git a/pkg/workflow/permissions_operations.go b/pkg/workflow/permissions_operations.go index dfc54df035b..3e043b498a8 100644 --- a/pkg/workflow/permissions_operations.go +++ b/pkg/workflow/permissions_operations.go @@ -47,6 +47,19 @@ func (p *Permissions) Set(scope PermissionScope, level PermissionLevel) { p.permissions[scope] = level } +// GetExplicit returns the permission level only if the scope was explicitly declared in the +// permissions map. Unlike Get, it never returns a level derived from shorthand (read-all / +// write-all) or "all: read" defaults. Use this when you need to know what the user explicitly +// specified — for example, when deciding which GitHub App-only scopes to forward to +// actions/create-github-app-token, or when validating that App-only scopes are present. +func (p *Permissions) GetExplicit(scope PermissionScope) (PermissionLevel, bool) { + if p == nil { + return "", false + } + level, exists := p.permissions[scope] + return level, exists +} + // Get gets the permission level for a specific scope func (p *Permissions) Get(scope PermissionScope) (PermissionLevel, bool) { if p.shorthand != "" { @@ -253,12 +266,15 @@ func (p *Permissions) RenderToYAML() string { var lines []string lines = append(lines, "permissions:") + hasRenderable := false for _, scopeStr := range scopes { scope := PermissionScope(scopeStr) level := allPerms[scope] - // Skip organization-projects - it's only valid for GitHub App tokens, not workflow permissions - if scope == PermissionOrganizationProj { + // Skip GitHub App-only permissions - they are not valid GitHub Actions workflow permissions + // and cannot be set on the GITHUB_TOKEN. They are handled separately when minting + // GitHub App installation access tokens. + if IsGitHubAppOnlyScope(scope) { continue } @@ -267,6 +283,7 @@ func (p *Permissions) RenderToYAML() string { continue } + hasRenderable = true // Add 2 spaces for proper indentation under permissions: // When rendered in a job, the job renderer adds 4 spaces to the first line only, // so we need to pre-indent continuation lines with 4 additional spaces @@ -274,5 +291,13 @@ func (p *Permissions) RenderToYAML() string { lines = append(lines, fmt.Sprintf(" %s: %s", scope, level)) } + // If everything was skipped (all App-only or metadata), return as if empty + if !hasRenderable { + if p.explicitEmpty { + return "permissions: {}" + } + return "" + } + return strings.Join(lines, "\n") } diff --git a/pkg/workflow/permissions_operations_test.go b/pkg/workflow/permissions_operations_test.go index 9874d1bad20..05518baac88 100644 --- a/pkg/workflow/permissions_operations_test.go +++ b/pkg/workflow/permissions_operations_test.go @@ -350,23 +350,23 @@ func TestPermissionsMerge(t *testing.T) { base: NewPermissionsFromMap(map[PermissionScope]PermissionLevel{PermissionContents: PermissionWrite}), merge: NewPermissionsReadAll(), want: map[PermissionScope]PermissionLevel{ - PermissionContents: PermissionWrite, // preserved - PermissionActions: PermissionRead, // added - PermissionAttestations: PermissionRead, - PermissionChecks: PermissionRead, - PermissionDeployments: PermissionRead, - PermissionDiscussions: PermissionRead, - PermissionIssues: PermissionRead, - PermissionMetadata: PermissionRead, - PermissionPackages: PermissionRead, - PermissionPages: PermissionRead, - PermissionPullRequests: PermissionRead, - PermissionRepositoryProj: PermissionRead, - PermissionOrganizationProj: PermissionRead, - PermissionSecurityEvents: PermissionRead, - PermissionStatuses: PermissionRead, - PermissionModels: PermissionRead, + PermissionContents: PermissionWrite, // preserved + PermissionActions: PermissionRead, // added + PermissionAttestations: PermissionRead, + PermissionChecks: PermissionRead, + PermissionDeployments: PermissionRead, + PermissionDiscussions: PermissionRead, + PermissionIssues: PermissionRead, + PermissionMetadata: PermissionRead, + PermissionPackages: PermissionRead, + PermissionPages: PermissionRead, + PermissionPullRequests: PermissionRead, + PermissionRepositoryProj: PermissionRead, + PermissionSecurityEvents: PermissionRead, + PermissionStatuses: PermissionRead, + PermissionModels: PermissionRead, // Note: id-token is NOT included because it doesn't support read level + // Note: organization-projects is NOT included because it's a GitHub App-only scope }, }, { @@ -374,23 +374,23 @@ func TestPermissionsMerge(t *testing.T) { base: NewPermissionsFromMap(map[PermissionScope]PermissionLevel{PermissionContents: PermissionRead}), merge: NewPermissionsWriteAll(), want: map[PermissionScope]PermissionLevel{ - PermissionContents: PermissionRead, // preserved (not overwritten) - PermissionActions: PermissionWrite, - PermissionAttestations: PermissionWrite, - PermissionChecks: PermissionWrite, - PermissionDeployments: PermissionWrite, - PermissionDiscussions: PermissionWrite, - PermissionIdToken: PermissionWrite, // id-token supports write - PermissionIssues: PermissionWrite, - PermissionMetadata: PermissionWrite, - PermissionPackages: PermissionWrite, - PermissionPages: PermissionWrite, - PermissionPullRequests: PermissionWrite, - PermissionRepositoryProj: PermissionWrite, - PermissionOrganizationProj: PermissionWrite, - PermissionSecurityEvents: PermissionWrite, - PermissionStatuses: PermissionWrite, - PermissionModels: PermissionWrite, + PermissionContents: PermissionRead, // preserved (not overwritten) + PermissionActions: PermissionWrite, + PermissionAttestations: PermissionWrite, + PermissionChecks: PermissionWrite, + PermissionDeployments: PermissionWrite, + PermissionDiscussions: PermissionWrite, + PermissionIdToken: PermissionWrite, // id-token supports write + PermissionIssues: PermissionWrite, + PermissionMetadata: PermissionWrite, + PermissionPackages: PermissionWrite, + PermissionPages: PermissionWrite, + PermissionPullRequests: PermissionWrite, + PermissionRepositoryProj: PermissionWrite, + PermissionSecurityEvents: PermissionWrite, + PermissionStatuses: PermissionWrite, + PermissionModels: PermissionWrite, + // Note: organization-projects is NOT included because it's a GitHub App-only scope }, }, { @@ -398,23 +398,23 @@ func TestPermissionsMerge(t *testing.T) { base: NewPermissionsFromMap(map[PermissionScope]PermissionLevel{PermissionContents: PermissionWrite}), merge: NewPermissionsReadAll(), want: map[PermissionScope]PermissionLevel{ - PermissionContents: PermissionWrite, - PermissionActions: PermissionRead, - PermissionAttestations: PermissionRead, - PermissionChecks: PermissionRead, - PermissionDeployments: PermissionRead, - PermissionDiscussions: PermissionRead, - PermissionIssues: PermissionRead, - PermissionMetadata: PermissionRead, - PermissionPackages: PermissionRead, - PermissionPages: PermissionRead, - PermissionPullRequests: PermissionRead, - PermissionRepositoryProj: PermissionRead, - PermissionOrganizationProj: PermissionRead, - PermissionSecurityEvents: PermissionRead, - PermissionStatuses: PermissionRead, - PermissionModels: PermissionRead, + PermissionContents: PermissionWrite, + PermissionActions: PermissionRead, + PermissionAttestations: PermissionRead, + PermissionChecks: PermissionRead, + PermissionDeployments: PermissionRead, + PermissionDiscussions: PermissionRead, + PermissionIssues: PermissionRead, + PermissionMetadata: PermissionRead, + PermissionPackages: PermissionRead, + PermissionPages: PermissionRead, + PermissionPullRequests: PermissionRead, + PermissionRepositoryProj: PermissionRead, + PermissionSecurityEvents: PermissionRead, + PermissionStatuses: PermissionRead, + PermissionModels: PermissionRead, // Note: id-token is NOT included because it doesn't support read level + // Note: organization-projects is NOT included because it's a GitHub App-only scope }, }, { @@ -422,23 +422,22 @@ func TestPermissionsMerge(t *testing.T) { base: NewPermissionsFromMap(map[PermissionScope]PermissionLevel{PermissionIssues: PermissionRead}), merge: NewPermissionsWriteAll(), want: map[PermissionScope]PermissionLevel{ - PermissionIssues: PermissionRead, - PermissionActions: PermissionWrite, - PermissionAttestations: PermissionWrite, - PermissionChecks: PermissionWrite, - PermissionContents: PermissionWrite, - PermissionDeployments: PermissionWrite, - PermissionDiscussions: PermissionWrite, - PermissionIdToken: PermissionWrite, // id-token supports write - PermissionMetadata: PermissionWrite, - PermissionPackages: PermissionWrite, - PermissionPages: PermissionWrite, - PermissionPullRequests: PermissionWrite, - PermissionRepositoryProj: PermissionWrite, - PermissionOrganizationProj: PermissionWrite, - PermissionSecurityEvents: PermissionWrite, - PermissionStatuses: PermissionWrite, - PermissionModels: PermissionWrite, + PermissionIssues: PermissionRead, + PermissionActions: PermissionWrite, + PermissionAttestations: PermissionWrite, + PermissionChecks: PermissionWrite, + PermissionContents: PermissionWrite, + PermissionDeployments: PermissionWrite, + PermissionDiscussions: PermissionWrite, + PermissionIdToken: PermissionWrite, // id-token supports write + PermissionMetadata: PermissionWrite, + PermissionPackages: PermissionWrite, + PermissionPages: PermissionWrite, + PermissionPullRequests: PermissionWrite, + PermissionRepositoryProj: PermissionWrite, + PermissionSecurityEvents: PermissionWrite, + PermissionStatuses: PermissionWrite, + PermissionModels: PermissionWrite, }, }, { diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index 828b32d1ff9..92b7bbddf7f 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -197,17 +197,23 @@ func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions // convertPermissionsToAppTokenFields converts job Permissions to permission-* action inputs // This follows GitHub's recommendation for explicit permission control -// Note: This only includes permissions that are valid for GitHub App tokens. -// Some GitHub Actions permissions (like 'discussions', 'models') don't have -// corresponding GitHub App permissions and are skipped. +// Note: This maps all permissions (both GitHub Actions and GitHub App-only) to their +// corresponding permission-* fields in actions/create-github-app-token. +// Some GitHub Actions permissions (like 'models', 'id-token', 'attestations', 'copilot-requests') +// don't have corresponding GitHub App permissions and are skipped. +// +// For GitHub Actions permissions (actions, checks, contents, …) we use Get() so that shorthand +// permissions like "read-all" are correctly expanded. +// For GitHub App-only permissions (administration, members, organization-secrets, …) we use +// GetExplicit() so that only scopes the user actually declared are forwarded — a "read-all" +// shorthand must never accidentally grant broad GitHub App-only permissions. func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]string { fields := make(map[string]string) // Map GitHub Actions permissions to GitHub App permissions - // Only include permissions that exist in the actions/create-github-app-token action // See: https://github.com/actions/create-github-app-token#permissions - // Repository permissions that map directly + // GitHub Actions permissions that also exist in GitHub App if level, ok := permissions.Get(PermissionActions); ok { fields["permission-actions"] = string(level) } @@ -238,18 +244,117 @@ func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]str if level, ok := permissions.Get(PermissionStatuses); ok { fields["permission-statuses"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationProj); ok { - fields["permission-organization-projects"] = string(level) - } if level, ok := permissions.Get(PermissionDiscussions); ok { fields["permission-discussions"] = string(level) } - // Note: The following GitHub Actions permissions do NOT have GitHub App equivalents: - // - models (no GitHub App permission for this) - // - id-token (not applicable to GitHub Apps) - // - attestations (no GitHub App permission for this) - // - repository-projects (removed - classic projects are sunset; use organization-projects for Projects v2 via PAT/GitHub App) + // GitHub App-only permissions (not available in GitHub Actions GITHUB_TOKEN). + // Use GetExplicit() so that shorthand permissions like "read-all" do not accidentally + // expand into broad GitHub App-only grants that the user never declared. + // Repository-level + if level, ok := permissions.GetExplicit(PermissionAdministration); ok { + fields["permission-administration"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionEnvironments); ok { + fields["permission-environments"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionGitSigning); ok { + fields["permission-git-signing"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionVulnerabilityAlerts); ok { + fields["permission-vulnerability-alerts"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionWorkflows); ok { + fields["permission-workflows"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionRepositoryHooks); ok { + fields["permission-repository-hooks"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionSingleFile); ok { + fields["permission-single-file"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionCodespaces); ok { + fields["permission-codespaces"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionRepositoryCustomProperties); ok { + fields["permission-repository-custom-properties"] = string(level) + } + // Organization-level + if level, ok := permissions.GetExplicit(PermissionOrganizationProj); ok { + fields["permission-organization-projects"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionMembers); ok { + fields["permission-members"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationAdministration); ok { + fields["permission-organization-administration"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionTeamDiscussions); ok { + fields["permission-team-discussions"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationHooks); ok { + fields["permission-organization-hooks"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationMembers); ok { + fields["permission-organization-members"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationPackages); ok { + fields["permission-organization-packages"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationSelfHostedRunners); ok { + fields["permission-organization-self-hosted-runners"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationCustomOrgRoles); ok { + fields["permission-organization-custom-org-roles"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationCustomProperties); ok { + fields["permission-organization-custom-properties"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationCustomRepositoryRoles); ok { + fields["permission-organization-custom-repository-roles"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationAnnouncementBanners); ok { + fields["permission-organization-announcement-banners"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationEvents); ok { + fields["permission-organization-events"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationPlan); ok { + fields["permission-organization-plan"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationUserBlocking); ok { + fields["permission-organization-user-blocking"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationPersonalAccessTokenReqs); ok { + fields["permission-organization-personal-access-token-requests"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationPersonalAccessTokens); ok { + fields["permission-organization-personal-access-tokens"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationCopilot); ok { + fields["permission-organization-copilot"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionOrganizationCodespaces); ok { + fields["permission-organization-codespaces"] = string(level) + } + // User-level + if level, ok := permissions.GetExplicit(PermissionEmailAddresses); ok { + fields["permission-email-addresses"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionCodespacesLifecycleAdmin); ok { + fields["permission-codespaces-lifecycle-admin"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionCodespacesMetadata); ok { + fields["permission-codespaces-metadata"] = string(level) + } + + // Note: The following GitHub Actions permissions do NOT have GitHub App equivalents + // and are therefore not mapped to permission-* fields: + // - models: no GitHub App permission for AI model access + // - id-token: not applicable to GitHub Apps (OIDC-specific) + // - attestations: no GitHub App permission for this + // - copilot-requests: GitHub Actions-specific Copilot authentication token + // - metadata: GitHub App metadata permission is automatically included (read-only) return fields }