Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3a10e8f
feat: Add configurable permissions for Actions automatic tokens
Excellencedev Dec 17, 2025
249794c
Merge branch 'main' into fix-24635
Excellencedev Dec 17, 2025
e20d12e
Merge branch 'main' into fix-24635
Excellencedev Dec 18, 2025
9a69f65
Adress all review comments
Excellencedev Dec 18, 2025
43e96d5
WIP
Excellencedev Dec 18, 2025
2a204e3
WIP
Excellencedev Dec 18, 2025
297ecef
Final core implementation changes
Excellencedev Dec 18, 2025
bd4420e
Merge branch 'main' into fix-24635
Excellencedev Dec 18, 2025
5317bb0
Fix lints
Excellencedev Dec 18, 2025
0682fd8
Fix test
Excellencedev Dec 18, 2025
fd1afc5
Fixing Test Failures for Token Permissions
Excellencedev Dec 18, 2025
a4aae82
Fix test
Excellencedev Dec 18, 2025
65051b1
Fix checks
Excellencedev Dec 18, 2025
a6b6e70
update tesr
Excellencedev Dec 18, 2025
b900c5c
Merge branch 'main' into fix-24635
Excellencedev Dec 19, 2025
5eb2f12
wip
Excellencedev Dec 19, 2025
92506da
Merge branch 'fix-24635' of https://github.com/Excellencedev/gitea in…
Excellencedev Dec 19, 2025
8daef63
Adress all reviewer feedback
Excellencedev Dec 19, 2025
5fd6d0e
Merge branch 'main' into fix-24635
Excellencedev Dec 19, 2025
af229dc
Merge branch 'main' into fix-24635
Excellencedev Dec 19, 2025
79a5d07
Completely redesign UI
Excellencedev Dec 20, 2025
d97712c
Merge branch 'fix-24635' of https://github.com/Excellencedev/gitea in…
Excellencedev Dec 20, 2025
058fc07
fix conflixt
Excellencedev Dec 20, 2025
04a6658
Merge remote-tracking branch 'origin/main' into fix-24635
Excellencedev Dec 20, 2025
64c2147
Adapt to JSON format
Excellencedev Dec 20, 2025
eca961e
Minor fixes
Excellencedev Dec 20, 2025
a5163fa
minor nitpick
Excellencedev Dec 20, 2025
9c5b278
Fix all bugs I found in the code
Excellencedev Dec 20, 2025
b0811fe
Formatting issues
Excellencedev Dec 20, 2025
b2f05ff
fix test
Excellencedev Dec 20, 2025
06b3db5
Improve test coverage
Excellencedev Dec 20, 2025
b0c2a95
Format
Excellencedev Dec 20, 2025
5628ab7
lint
Excellencedev Dec 20, 2025
38f384a
fmt
Excellencedev Dec 20, 2025
6cc6fd7
lint
Excellencedev Dec 20, 2025
463c670
issue fix
Excellencedev Dec 20, 2025
d25de6f
test fix
Excellencedev Dec 20, 2025
6d94723
regression
Excellencedev Dec 20, 2025
663d9b2
empty commit
Excellencedev Dec 20, 2025
34d13de
Merge branch 'main' into fix-24635
Excellencedev Dec 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions models/actions/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package actions

import (
"context"

repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
)

// GetOrgActionsConfig loads the ActionsConfig for an organization from user settings
// It returns a default config if no setting is found
func GetOrgActionsConfig(ctx context.Context, orgID int64) (*repo_model.ActionsConfig, error) {
val, err := user_model.GetUserSetting(ctx, orgID, "actions.config")
if err != nil {
return nil, err
}

cfg := &repo_model.ActionsConfig{}
if val == "" {
// Return defaults if no config exists
return cfg, nil
}

if err := json.Unmarshal([]byte(val), cfg); err != nil {
return nil, err
}

return cfg, nil
}

// SetOrgActionsConfig saves the ActionsConfig for an organization to user settings
func SetOrgActionsConfig(ctx context.Context, orgID int64, cfg *repo_model.ActionsConfig) error {
bs, err := json.Marshal(cfg)
if err != nil {
return err
}

return user_model.SetUserSetting(ctx, orgID, "actions.config", string(bs))
}
55 changes: 45 additions & 10 deletions models/perm/access/repo_permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,31 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito
return perm, err
}

var accessMode perm_model.AccessMode
if err := repo.LoadUnits(ctx); err != nil {
return perm, err
}

actionsUnit := repo.MustGetUnit(ctx, unit.TypeActions)
actionsCfg := actionsUnit.ActionsConfig()

if task.RepoID != repo.ID {
taskRepo, exist, err := db.GetByID[repo_model.Repository](ctx, task.RepoID)
if err != nil || !exist {
return perm, err
}
actionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()

// Check Organization Cross-Repo Access Policy
if repo.OwnerID == taskRepo.OwnerID && repo.Owner.IsOrganization() {
orgCfg, err := actions_model.GetOrgActionsConfig(ctx, repo.OwnerID)
if err != nil {
return perm, err
}
if !orgCfg.AllowCrossRepoAccess {
// Deny access if cross-repo is disabled in Org
return perm, nil
}
}

if !actionsCfg.IsCollaborativeOwner(taskRepo.OwnerID) || !taskRepo.IsPrivate {
// The task repo can access the current repo only if the task repo is private and
// the owner of the task repo is a collaborative owner of the current repo.
Expand All @@ -288,17 +306,34 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito
perm.AccessMode = min(perm.AccessMode, perm_model.AccessModeRead)
return perm, nil
}
accessMode = perm_model.AccessModeRead
} else if task.IsForkPullRequest {
accessMode = perm_model.AccessModeRead
} else {
accessMode = perm_model.AccessModeWrite
// Cross-repo access is always read-only
perm.SetUnitsWithDefaultAccessMode(repo.Units, perm_model.AccessModeRead)
return perm, nil
}

if err := repo.LoadUnits(ctx); err != nil {
return perm, err
// Get effective token permissions from 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
perm.unitsMode = make(map[unit.Type]perm_model.AccessMode)
perm.unitsMode[unit.TypeCode] = effectivePerms.Contents
perm.unitsMode[unit.TypeIssues] = effectivePerms.Issues
perm.unitsMode[unit.TypePullRequests] = effectivePerms.PullRequests
perm.unitsMode[unit.TypePackages] = effectivePerms.Packages
perm.unitsMode[unit.TypeActions] = effectivePerms.Actions
perm.unitsMode[unit.TypeWiki] = effectivePerms.Wiki

// Set base access mode to the maximum of all unit permissions
maxMode := perm_model.AccessModeNone
for _, mode := range perm.unitsMode {
if mode > maxMode {
maxMode = mode
}
}
perm.SetUnitsWithDefaultAccessMode(repo.Units, accessMode)
perm.AccessMode = maxMode

return perm, nil
}

Expand Down
164 changes: 164 additions & 0 deletions models/repo/repo_unit.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,122 @@ func (cfg *PullRequestsConfig) GetDefaultMergeStyle() MergeStyle {
return MergeStyleMerge
}

// ActionsTokenPermissionMode defines the default permission mode for Actions tokens
type ActionsTokenPermissionMode string

const (
// ActionsTokenPermissionModePermissive - write access by default (current behavior, backwards compatible)
ActionsTokenPermissionModePermissive ActionsTokenPermissionMode = "permissive"
// ActionsTokenPermissionModeRestricted - read access by default
ActionsTokenPermissionModeRestricted ActionsTokenPermissionMode = "restricted"
// ActionsTokenPermissionModeCustom - user-defined permissions
ActionsTokenPermissionModeCustom ActionsTokenPermissionMode = "custom"
)

// ActionsTokenPermissions defines the permissions for different repository units
type ActionsTokenPermissions struct {
// Contents (repository code) - read/write/none
Contents perm.AccessMode `json:"contents"`
// Issues - read/write/none
Issues perm.AccessMode `json:"issues"`
// PullRequests - read/write/none
PullRequests perm.AccessMode `json:"pull_requests"`
// Packages - read/write/none
Packages perm.AccessMode `json:"packages"`
// Actions - read/write/none
Actions perm.AccessMode `json:"actions"`
// Wiki - read/write/none
Wiki perm.AccessMode `json:"wiki"`
}

// HasRead checks if the permission has read access for the given scope
func (p ActionsTokenPermissions) HasRead(scope string) bool {
var mode perm.AccessMode
switch scope {
case "actions":
mode = p.Actions
case "contents":
mode = p.Contents
case "issues":
mode = p.Issues
case "packages":
mode = p.Packages
case "pull_requests":
mode = p.PullRequests
case "wiki":
mode = p.Wiki
}
return mode >= perm.AccessModeRead
}

// HasWrite checks if the permission has write access for the given scope
func (p ActionsTokenPermissions) HasWrite(scope string) bool {
var mode perm.AccessMode
switch scope {
case "actions":
mode = p.Actions
case "contents":
mode = p.Contents
case "issues":
mode = p.Issues
case "packages":
mode = p.Packages
case "pull_requests":
mode = p.PullRequests
case "wiki":
mode = p.Wiki
}
return mode >= perm.AccessModeWrite
}

// DefaultActionsTokenPermissions returns the default permissions for permissive mode
func DefaultActionsTokenPermissions(mode ActionsTokenPermissionMode) ActionsTokenPermissions {
if mode == ActionsTokenPermissionModeRestricted {
return ActionsTokenPermissions{
Contents: perm.AccessModeRead,
Issues: perm.AccessModeRead,
PullRequests: perm.AccessModeRead,
Packages: perm.AccessModeRead,
Actions: perm.AccessModeRead,
Wiki: perm.AccessModeRead,
}
}
// Permissive mode (default)
return ActionsTokenPermissions{
Contents: perm.AccessModeWrite,
Issues: perm.AccessModeWrite,
PullRequests: perm.AccessModeWrite,
Packages: perm.AccessModeRead, // Packages read by default for security
Actions: perm.AccessModeWrite,
Wiki: perm.AccessModeWrite,
}
}

// ForkPullRequestPermissions returns the restricted permissions for fork pull requests
func ForkPullRequestPermissions() ActionsTokenPermissions {
return ActionsTokenPermissions{
Contents: perm.AccessModeRead,
Issues: perm.AccessModeRead,
PullRequests: perm.AccessModeRead,
Packages: perm.AccessModeRead,
Actions: perm.AccessModeRead,
Wiki: perm.AccessModeRead,
}
}

type ActionsConfig struct {
DisabledWorkflows []string
// CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos.
// Only workflows from the private repos whose owners are in CollaborativeOwnerIDs can access the current repo's actions.
CollaborativeOwnerIDs []int64
// TokenPermissionMode defines the default permission mode (permissive or restricted)
TokenPermissionMode ActionsTokenPermissionMode `json:"token_permission_mode,omitempty"`
// DefaultTokenPermissions defines the default permissions for workflow tokens
DefaultTokenPermissions *ActionsTokenPermissions `json:"default_token_permissions,omitempty"`
// MaxTokenPermissions defines the maximum permissions (cannot be exceeded by workflow permissions keyword)
MaxTokenPermissions *ActionsTokenPermissions `json:"max_token_permissions,omitempty"`
// AllowCrossRepoAccess indicates if actions in this repo/org can access other repos in the same org
AllowCrossRepoAccess bool `json:"allow_cross_repo_access,omitempty"`
}

func (cfg *ActionsConfig) EnableWorkflow(file string) {
Expand Down Expand Up @@ -209,6 +320,59 @@ func (cfg *ActionsConfig) IsCollaborativeOwner(ownerID int64) bool {
return slices.Contains(cfg.CollaborativeOwnerIDs, ownerID)
}

// GetTokenPermissionMode returns the token permission mode (defaults to permissive for backwards compatibility)
func (cfg *ActionsConfig) GetTokenPermissionMode() ActionsTokenPermissionMode {
if cfg.TokenPermissionMode == "" {
return ActionsTokenPermissionModePermissive
}
return cfg.TokenPermissionMode
}

// GetEffectiveTokenPermissions returns the effective token permissions based on settings and context
func (cfg *ActionsConfig) GetEffectiveTokenPermissions(isForkPullRequest bool) ActionsTokenPermissions {
// Fork pull requests always get restricted read-only access for security
if isForkPullRequest {
return ForkPullRequestPermissions()
}

// Use custom default permissions if set
if cfg.DefaultTokenPermissions != nil {
return *cfg.DefaultTokenPermissions
}

// Otherwise use mode-based defaults
return DefaultActionsTokenPermissions(cfg.GetTokenPermissionMode())
}

// GetMaxTokenPermissions returns the maximum allowed permissions
func (cfg *ActionsConfig) GetMaxTokenPermissions() ActionsTokenPermissions {
if cfg.MaxTokenPermissions != nil {
return *cfg.MaxTokenPermissions
}
// Default max is write for everything except packages
return ActionsTokenPermissions{
Contents: perm.AccessModeWrite,
Issues: perm.AccessModeWrite,
PullRequests: perm.AccessModeWrite,
Packages: perm.AccessModeWrite,
Actions: perm.AccessModeWrite,
Wiki: perm.AccessModeWrite,
}
}

// ClampPermissions ensures that the given permissions don't exceed the maximum
func (cfg *ActionsConfig) ClampPermissions(perms ActionsTokenPermissions) ActionsTokenPermissions {
maxPerms := cfg.GetMaxTokenPermissions()
return ActionsTokenPermissions{
Contents: min(perms.Contents, maxPerms.Contents),
Issues: min(perms.Issues, maxPerms.Issues),
PullRequests: min(perms.PullRequests, maxPerms.PullRequests),
Packages: min(perms.Packages, maxPerms.Packages),
Actions: min(perms.Actions, maxPerms.Actions),
Wiki: min(perms.Wiki, maxPerms.Wiki),
}
}

// FromDB fills up a ActionsConfig from serialized format.
func (cfg *ActionsConfig) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
Expand Down
75 changes: 75 additions & 0 deletions models/repo/repo_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package repo
import (
"testing"

"code.gitea.io/gitea/models/perm"

"github.com/stretchr/testify/assert"
)

Expand All @@ -28,3 +30,76 @@ func TestActionsConfig(t *testing.T) {
cfg.DisableWorkflow("test3.yaml")
assert.Equal(t, "test1.yaml,test2.yaml,test3.yaml", cfg.ToString())
}

func TestActionsConfigTokenPermissions(t *testing.T) {
t.Run("Default Permission Mode", func(t *testing.T) {
cfg := &ActionsConfig{}
assert.Equal(t, ActionsTokenPermissionModePermissive, cfg.GetTokenPermissionMode())
})

t.Run("Explicit Permission Mode", func(t *testing.T) {
cfg := &ActionsConfig{
TokenPermissionMode: ActionsTokenPermissionModeRestricted,
}
assert.Equal(t, ActionsTokenPermissionModeRestricted, cfg.GetTokenPermissionMode())
})

t.Run("Effective Permissions - Permissive Mode", func(t *testing.T) {
cfg := &ActionsConfig{
TokenPermissionMode: ActionsTokenPermissionModePermissive,
}
perms := cfg.GetEffectiveTokenPermissions(false)
assert.Equal(t, perm.AccessModeWrite, perms.Contents)
assert.Equal(t, perm.AccessModeWrite, perms.Issues)
assert.Equal(t, perm.AccessModeRead, perms.Packages) // Packages read by default for security
})

t.Run("Effective Permissions - Restricted Mode", func(t *testing.T) {
cfg := &ActionsConfig{
TokenPermissionMode: ActionsTokenPermissionModeRestricted,
}
perms := cfg.GetEffectiveTokenPermissions(false)
assert.Equal(t, perm.AccessModeRead, perms.Contents)
assert.Equal(t, perm.AccessModeRead, perms.Issues)
assert.Equal(t, perm.AccessModeRead, perms.Packages)
})

t.Run("Fork Pull Request Always Read-Only", func(t *testing.T) {
cfg := &ActionsConfig{
TokenPermissionMode: ActionsTokenPermissionModePermissive,
}
// Even with permissive mode, fork PRs get read-only
perms := cfg.GetEffectiveTokenPermissions(true)
assert.Equal(t, perm.AccessModeRead, perms.Contents)
assert.Equal(t, perm.AccessModeRead, perms.Issues)
assert.Equal(t, perm.AccessModeRead, perms.Packages)
})

t.Run("Clamp Permissions", func(t *testing.T) {
cfg := &ActionsConfig{
MaxTokenPermissions: &ActionsTokenPermissions{
Contents: perm.AccessModeRead,
Issues: perm.AccessModeWrite,
PullRequests: perm.AccessModeRead,
Packages: perm.AccessModeRead,
Actions: perm.AccessModeNone,
Wiki: perm.AccessModeWrite,
},
}
input := ActionsTokenPermissions{
Contents: perm.AccessModeWrite, // Should be clamped to Read
Issues: perm.AccessModeWrite, // Should stay Write
PullRequests: perm.AccessModeWrite, // Should be clamped to Read
Packages: perm.AccessModeWrite, // Should be clamped to Read
Actions: perm.AccessModeRead, // Should be clamped to None
Wiki: perm.AccessModeRead, // Should stay Read
}
clamped := cfg.ClampPermissions(input)
assert.Equal(t, perm.AccessModeRead, clamped.Contents)
assert.Equal(t, perm.AccessModeWrite, clamped.Issues)
assert.Equal(t, perm.AccessModeRead, clamped.PullRequests)
assert.Equal(t, perm.AccessModeRead, clamped.Packages)
assert.Equal(t, perm.AccessModeNone, clamped.Actions)
assert.Equal(t, perm.AccessModeRead, clamped.Wiki)
})
}
Loading
Loading