Implement Workflow Level Permissions

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

No files matched your search

+133
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
}
}
+169
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)
}
+23
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 {