From 1ff75aa822b53435fb064326c0a39d6be859b075 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Wed, 31 Dec 2025 05:11:28 +0100 Subject: [PATCH] Implement Workflow Level Permissions --- models/actions/run_job.go | 4 + models/perm/access/repo_permission.go | 22 ++- models/repo/repo_unit.go | 19 +++ services/actions/permission_parser.go | 133 +++++++++++++++ services/actions/permission_parser_test.go | 169 ++++++++++++++++++++ services/actions/run.go | 23 +++ tests/integration/actions_job_token_test.go | 96 +++++++++++ 7 files changed, 463 insertions(+), 3 deletions(-) create mode 100644 services/actions/permission_parser.go create mode 100644 services/actions/permission_parser_test.go diff --git a/models/actions/run_job.go b/models/actions/run_job.go index f72a7040e3..d3653b5046 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -51,6 +51,10 @@ type ActionRunJob struct { ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` // evaluated concurrency.group ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` // evaluated concurrency.cancel-in-progress + // TokenPermissions stores the parsed permissions from the workflow YAML (workflow + job level, clamped by repo max settings) + // This is JSON-encoded repo_model.ActionsTokenPermissions + TokenPermissions string `xorm:"TEXT"` + Started timeutil.TimeStamp Stopped timeutil.TimeStamp Created timeutil.TimeStamp `xorm:"created"` diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index b21f716e8e..3fe2a514de 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -325,9 +325,25 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito return perm, nil } - // Get effective token permissions from repository settings - effectivePerms := actionsCfg.GetEffectiveTokenPermissions(task.IsForkPullRequest) - effectivePerms = actionsCfg.ClampPermissions(effectivePerms) + // Get effective token permissions + // First check if job has explicit permissions stored from workflow YAML + var effectivePerms repo_model.ActionsTokenPermissions + if err := task.LoadJob(ctx); err != nil { + return perm, err + } + if task.Job != nil && task.Job.TokenPermissions != "" { + // Use permissions parsed from workflow YAML (already clamped by repo max settings during insertion) + effectivePerms, err = repo_model.UnmarshalTokenPermissions(task.Job.TokenPermissions) + if err != nil { + // Fall back to repository settings if unmarshal fails + effectivePerms = actionsCfg.GetEffectiveTokenPermissions(task.IsForkPullRequest) + effectivePerms = actionsCfg.ClampPermissions(effectivePerms) + } + } else { + // No workflow permissions, use repository settings + effectivePerms = actionsCfg.GetEffectiveTokenPermissions(task.IsForkPullRequest) + effectivePerms = actionsCfg.ClampPermissions(effectivePerms) + } // Set up per-unit access modes based on configured permissions perm.units = repo.Units diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index 353ec5402a..a35ac65963 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -261,6 +261,25 @@ func ForkPullRequestPermissions() ActionsTokenPermissions { } } +// MarshalTokenPermissions serializes ActionsTokenPermissions to JSON +func MarshalTokenPermissions(perms ActionsTokenPermissions) string { + data, err := json.Marshal(perms) + if err != nil { + return "" + } + return string(data) +} + +// UnmarshalTokenPermissions deserializes JSON to ActionsTokenPermissions +func UnmarshalTokenPermissions(data string) (ActionsTokenPermissions, error) { + var perms ActionsTokenPermissions + if data == "" { + return perms, nil + } + err := json.Unmarshal([]byte(data), &perms) + return perms, err +} + type ActionsConfig struct { DisabledWorkflows []string // CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos. diff --git a/services/actions/permission_parser.go b/services/actions/permission_parser.go new file mode 100644 index 0000000000..88b29b16fc --- /dev/null +++ b/services/actions/permission_parser.go @@ -0,0 +1,133 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + + "github.com/nektos/act/pkg/jobparser" + "gopkg.in/yaml.v3" +) + +// ParseWorkflowPermissions extracts workflow-level permissions from a SingleWorkflow +// Returns the default permissions based on repository settings if no workflow permissions are specified +func ParseWorkflowPermissions(wf *jobparser.SingleWorkflow, defaultPerms repo_model.ActionsTokenPermissions) repo_model.ActionsTokenPermissions { + if wf == nil { + return defaultPerms + } + + // Check if workflow has RawPermissions + rawPerms := wf.RawPermissions + if rawPerms.Kind == yaml.ScalarNode && rawPerms.Value == "" { + return defaultPerms + } + + return parseRawPermissions(&rawPerms, defaultPerms) +} + +// ParseJobPermissions extracts job-level permissions, falling back to workflow defaults +func ParseJobPermissions(job *jobparser.Job, workflowPerms repo_model.ActionsTokenPermissions) repo_model.ActionsTokenPermissions { + if job == nil { + return workflowPerms + } + + // Check if job has RawPermissions + rawPerms := job.RawPermissions + if rawPerms.Kind == yaml.ScalarNode && rawPerms.Value == "" { + return workflowPerms + } + + return parseRawPermissions(&rawPerms, workflowPerms) +} + +// parseRawPermissions parses a YAML permissions node into ActionsTokenPermissions +func parseRawPermissions(rawPerms *yaml.Node, defaultPerms repo_model.ActionsTokenPermissions) repo_model.ActionsTokenPermissions { + if rawPerms == nil || (rawPerms.Kind == yaml.ScalarNode && rawPerms.Value == "") { + return defaultPerms + } + + // Handle scalar values: "read-all" or "write-all" + if rawPerms.Kind == yaml.ScalarNode { + switch rawPerms.Value { + case "read-all": + return repo_model.ActionsTokenPermissions{ + Contents: perm.AccessModeRead, + Issues: perm.AccessModeRead, + PullRequests: perm.AccessModeRead, + Packages: perm.AccessModeRead, + Actions: perm.AccessModeRead, + Wiki: perm.AccessModeRead, + } + case "write-all": + return repo_model.ActionsTokenPermissions{ + Contents: perm.AccessModeWrite, + Issues: perm.AccessModeWrite, + PullRequests: perm.AccessModeWrite, + Packages: perm.AccessModeWrite, + Actions: perm.AccessModeWrite, + Wiki: perm.AccessModeWrite, + } + } + return defaultPerms + } + + // Handle mapping: individual permission scopes + if rawPerms.Kind == yaml.MappingNode { + result := defaultPerms // Start with defaults + + for i := 0; i < len(rawPerms.Content); i += 2 { + if i+1 >= len(rawPerms.Content) { + break + } + keyNode := rawPerms.Content[i] + valueNode := rawPerms.Content[i+1] + + if keyNode.Kind != yaml.ScalarNode || valueNode.Kind != yaml.ScalarNode { + continue + } + + scope := keyNode.Value + accessStr := valueNode.Value + accessMode := parseAccessMode(accessStr) + + // Map GitHub Actions scopes to Gitea units + switch scope { + case "contents": + result.Contents = accessMode + case "issues": + result.Issues = accessMode + case "pull-requests": + result.PullRequests = accessMode + case "packages": + result.Packages = accessMode + case "actions": + result.Actions = accessMode + case "wiki": + result.Wiki = accessMode + // Additional GitHub scopes we don't explicitly handle yet: + // These fall through to defaults + // - deployments, environments, id-token, pages, repository-projects, security-events, statuses + } + } + + return result + } + + return defaultPerms +} + +// parseAccessMode converts a string access level to perm.AccessMode +func parseAccessMode(s string) perm.AccessMode { + switch s { + case "write": + return perm.AccessModeWrite + case "read": + return perm.AccessModeRead + case "none": + return perm.AccessModeNone + default: + return perm.AccessModeNone + } +} diff --git a/services/actions/permission_parser_test.go b/services/actions/permission_parser_test.go new file mode 100644 index 0000000000..f4dbb01705 --- /dev/null +++ b/services/actions/permission_parser_test.go @@ -0,0 +1,169 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +func TestParseRawPermissions_ReadAll(t *testing.T) { + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(`read-all`), &rawPerms) + assert.NoError(t, err) + + defaultPerms := repo_model.DefaultActionsTokenPermissions(repo_model.ActionsTokenPermissionModePermissive) + result := parseRawPermissions(&rawPerms, defaultPerms) + + assert.Equal(t, perm.AccessModeRead, result.Contents) + assert.Equal(t, perm.AccessModeRead, result.Issues) + assert.Equal(t, perm.AccessModeRead, result.PullRequests) + assert.Equal(t, perm.AccessModeRead, result.Packages) + assert.Equal(t, perm.AccessModeRead, result.Actions) + assert.Equal(t, perm.AccessModeRead, result.Wiki) +} + +func TestParseRawPermissions_WriteAll(t *testing.T) { + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(`write-all`), &rawPerms) + assert.NoError(t, err) + + defaultPerms := repo_model.DefaultActionsTokenPermissions(repo_model.ActionsTokenPermissionModeRestricted) + result := parseRawPermissions(&rawPerms, defaultPerms) + + assert.Equal(t, perm.AccessModeWrite, result.Contents) + assert.Equal(t, perm.AccessModeWrite, result.Issues) + assert.Equal(t, perm.AccessModeWrite, result.PullRequests) + assert.Equal(t, perm.AccessModeWrite, result.Packages) + assert.Equal(t, perm.AccessModeWrite, result.Actions) + assert.Equal(t, perm.AccessModeWrite, result.Wiki) +} + +func TestParseRawPermissions_IndividualScopes(t *testing.T) { + yamlContent := ` +contents: write +issues: read +pull-requests: none +packages: write +actions: read +wiki: write +` + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rawPerms) + assert.NoError(t, err) + + defaultPerms := repo_model.ActionsTokenPermissions{ + Contents: perm.AccessModeNone, + Issues: perm.AccessModeNone, + PullRequests: perm.AccessModeNone, + Packages: perm.AccessModeNone, + Actions: perm.AccessModeNone, + Wiki: perm.AccessModeNone, + } + result := parseRawPermissions(&rawPerms, defaultPerms) + + assert.Equal(t, perm.AccessModeWrite, result.Contents) + assert.Equal(t, perm.AccessModeRead, result.Issues) + assert.Equal(t, perm.AccessModeNone, result.PullRequests) + assert.Equal(t, perm.AccessModeWrite, result.Packages) + assert.Equal(t, perm.AccessModeRead, result.Actions) + assert.Equal(t, perm.AccessModeWrite, result.Wiki) +} + +func TestParseRawPermissions_PartialOverride(t *testing.T) { + yamlContent := ` +contents: read +issues: write +` + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rawPerms) + assert.NoError(t, err) + + // Defaults are write for everything + defaultPerms := repo_model.DefaultActionsTokenPermissions(repo_model.ActionsTokenPermissionModePermissive) + result := parseRawPermissions(&rawPerms, defaultPerms) + + // Overridden scopes + assert.Equal(t, perm.AccessModeRead, result.Contents) + assert.Equal(t, perm.AccessModeWrite, result.Issues) + // Non-overridden scopes keep defaults + assert.Equal(t, perm.AccessModeWrite, result.PullRequests) + assert.Equal(t, perm.AccessModeRead, result.Packages) // Packages default to read in permissive + assert.Equal(t, perm.AccessModeWrite, result.Actions) + assert.Equal(t, perm.AccessModeWrite, result.Wiki) +} + +func TestParseRawPermissions_EmptyNode(t *testing.T) { + var rawPerms yaml.Node + // Empty node + + defaultPerms := repo_model.DefaultActionsTokenPermissions(repo_model.ActionsTokenPermissionModePermissive) + result := parseRawPermissions(&rawPerms, defaultPerms) + + // Should return defaults + assert.Equal(t, defaultPerms.Contents, result.Contents) + assert.Equal(t, defaultPerms.Issues, result.Issues) +} + +func TestParseRawPermissions_NilNode(t *testing.T) { + defaultPerms := repo_model.DefaultActionsTokenPermissions(repo_model.ActionsTokenPermissionModePermissive) + result := parseRawPermissions(nil, defaultPerms) + + // Should return defaults + assert.Equal(t, defaultPerms.Contents, result.Contents) + assert.Equal(t, defaultPerms.Issues, result.Issues) +} + +func TestParseAccessMode(t *testing.T) { + tests := []struct { + input string + expected perm.AccessMode + }{ + {"write", perm.AccessModeWrite}, + {"read", perm.AccessModeRead}, + {"none", perm.AccessModeNone}, + {"", perm.AccessModeNone}, + {"invalid", perm.AccessModeNone}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := parseAccessMode(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMarshalUnmarshalTokenPermissions(t *testing.T) { + original := repo_model.ActionsTokenPermissions{ + Contents: perm.AccessModeWrite, + Issues: perm.AccessModeRead, + PullRequests: perm.AccessModeNone, + Packages: perm.AccessModeWrite, + Actions: perm.AccessModeRead, + Wiki: perm.AccessModeWrite, + } + + // Marshal + jsonStr := repo_model.MarshalTokenPermissions(original) + assert.NotEmpty(t, jsonStr) + + // Unmarshal + result, err := repo_model.UnmarshalTokenPermissions(jsonStr) + assert.NoError(t, err) + assert.Equal(t, original, result) +} + +func TestUnmarshalTokenPermissions_EmptyString(t *testing.T) { + result, err := repo_model.UnmarshalTokenPermissions("") + assert.NoError(t, err) + // Should return zero-value struct + assert.Equal(t, perm.AccessModeNone, result.Contents) + assert.Equal(t, perm.AccessModeNone, result.Issues) +} diff --git a/services/actions/run.go b/services/actions/run.go index 90413e9bc2..0780f27ce5 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -9,6 +9,8 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" @@ -103,6 +105,20 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs)) var hasWaitingJobs bool + + // Load Actions configuration to get default and max permissions + var actionsCfg *repo_model.ActionsConfig + actionsUnit, err := run.Repo.GetUnit(ctx, unit_model.TypeActions) + if err == nil { + actionsCfg = actionsUnit.ActionsConfig() + } else { + // Default config if Actions unit doesn't exist + actionsCfg = &repo_model.ActionsConfig{} + } + + // Get default permissions based on repository settings + defaultPerms := actionsCfg.GetEffectiveTokenPermissions(run.IsForkPullRequest) + for _, v := range jobs { id, job := v.Job() needs := job.Needs() @@ -113,6 +129,12 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar shouldBlockJob := len(needs) > 0 || run.NeedApproval || run.Status == actions_model.StatusBlocked + // Parse workflow-level and job-level permissions + workflowPerms := ParseWorkflowPermissions(v, defaultPerms) + jobPerms := ParseJobPermissions(job, workflowPerms) + // Clamp by repository max settings + finalPerms := actionsCfg.ClampPermissions(jobPerms) + job.Name = util.EllipsisDisplayString(job.Name, 255) runJob := &actions_model.ActionRunJob{ RunID: run.ID, @@ -126,6 +148,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar Needs: needs, RunsOn: job.RunsOn(), Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting), + TokenPermissions: repo_model.MarshalTokenPermissions(finalPerms), } // check job concurrency if job.RawConcurrency != nil { diff --git a/tests/integration/actions_job_token_test.go b/tests/integration/actions_job_token_test.go index 92b3e16ffc..12133b3261 100644 --- a/tests/integration/actions_job_token_test.go +++ b/tests/integration/actions_job_token_test.go @@ -514,3 +514,99 @@ func TestActionsTokenPermissionsWorkflowScenario(t *testing.T) { })) }) } + +// TestActionsWorkflowPermissionsKeyword tests that the `permissions:` keyword in a workflow YAML +// restricts the token even when the repository is in permissive mode. +// This is exactly what the reviewer reported: `permissions: read-all` should restrict write operations. +func TestActionsWorkflowPermissionsKeyword(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + httpContext := NewAPITestContext(t, "user2", "repo-workflow-perms-kw", auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository) + t.Run("Workflow Permissions Keyword", doAPICreateRepository(httpContext, false, func(t *testing.T, repository structs.Repository) { + // Enable Actions unit with PERMISSIVE mode (default write access) + err := db.Insert(t.Context(), &repo_model.RepoUnit{ + RepoID: repository.ID, + Type: unit_model.TypeActions, + Config: &repo_model.ActionsConfig{ + TokenPermissionMode: repo_model.ActionsTokenPermissionModePermissive, + }, + }) + require.NoError(t, err) + + // Create an Actions run job with TokenPermissions set (simulating a workflow with permissions: read-all) + // This is what the permission parser does when parsing the workflow YAML + readOnlyPerms := repo_model.ActionsTokenPermissions{ + Contents: perm.AccessModeRead, + Issues: perm.AccessModeRead, + PullRequests: perm.AccessModeRead, + Packages: perm.AccessModeRead, + Actions: perm.AccessModeRead, + Wiki: perm.AccessModeRead, + } + permsJSON := repo_model.MarshalTokenPermissions(readOnlyPerms) + + // Create a run and job with explicit permissions + run := &actions_model.ActionRun{ + RepoID: repository.ID, + OwnerID: repository.Owner.ID, + Title: "Test workflow with read-all permissions", + Status: actions_model.StatusRunning, + Ref: "refs/heads/master", + CommitSHA: "abc123", + } + require.NoError(t, db.Insert(t.Context(), run)) + + job := &actions_model.ActionRunJob{ + RunID: run.ID, + RepoID: repository.ID, + OwnerID: repository.Owner.ID, + CommitSHA: "abc123", + Name: "test-job", + JobID: "test-job", + Status: actions_model.StatusRunning, + TokenPermissions: permsJSON, // This is the key - workflow-declared permissions + } + require.NoError(t, db.Insert(t.Context(), job)) + + // Create task linked to the job + task := &actions_model.ActionTask{ + JobID: job.ID, + RepoID: repository.ID, + Status: actions_model.StatusRunning, + IsForkPullRequest: false, + } + require.NoError(t, task.GenerateToken()) + require.NoError(t, db.Insert(t.Context(), task)) + + // Update job with task ID + job.TaskID = task.ID + _, err = db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job) + require.NoError(t, err) + + // Test: Even though repo is in PERMISSIVE mode, the workflow has permissions: read-all + // So write operations should FAIL + session := emptyTestSession(t) + testCtx := APITestContext{ + Session: session, + Token: task.Token, + Username: "user2", + Reponame: "repo-workflow-perms-kw", + } + + // Read should work + testCtx.ExpectedCode = http.StatusOK + t.Run("GITEA_TOKEN Get Repository (Read OK)", doAPIGetRepository(testCtx, func(t *testing.T, r structs.Repository) { + assert.Equal(t, "repo-workflow-perms-kw", r.Name) + })) + + // Write should FAIL due to workflow permissions: read-all + testCtx.ExpectedCode = http.StatusForbidden + t.Run("GITEA_TOKEN Create File (Write BLOCKED by workflow permissions)", doAPICreateFile(testCtx, "should-fail-due-to-workflow-perms.txt", &structs.CreateFileOptions{ + FileOptions: structs.FileOptions{ + BranchName: "master", + Message: "this should fail due to workflow permissions", + }, + ContentBase64: base64.StdEncoding.EncodeToString([]byte("Should Not Be Created")), + })) + })) + }) +}