0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-02-15 05:52:32 +01:00

Implement Workflow Level Permissions

This commit is contained in:
Excellencedev 2025-12-31 05:11:28 +01:00
parent f367039e78
commit 1ff75aa822
7 changed files with 463 additions and 3 deletions

View File

@ -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"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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")),
}))
}))
})
}