Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
41 changes: 31 additions & 10 deletions models/perm/access/repo_permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,18 @@ 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()
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 +293,33 @@ 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)

// 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
120 changes: 120 additions & 0 deletions models/repo/repo_unit.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,78 @@ 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"
)

// 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"`
}

// 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"`
}

func (cfg *ActionsConfig) EnableWorkflow(file string) {
Expand Down Expand Up @@ -209,6 +276,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)
})
}
19 changes: 19 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3937,6 +3937,25 @@ general.collaborative_owner_not_exist = The collaborative owner does not exist.
general.remove_collaborative_owner = Remove Collaborative Owner
general.remove_collaborative_owner_desc = Removing a collaborative owner will prevent the repositories of the owner from accessing the actions in this repository. Continue?

general.token_permissions = Workflow Permissions
general.token_permissions.description = Configure the default permissions granted to the GITHUB_TOKEN when running workflows in this repository.
general.token_permissions.mode = Permission Mode
general.token_permissions.permissive = Read and write permissions
general.token_permissions.permissive.description = Workflows have read and write permissions in the repository for all scopes.
general.token_permissions.restricted = Read repository contents and packages permissions
general.token_permissions.restricted.description = Workflows have read permissions in the repository for the contents and packages scopes only.
general.token_permissions.fork_pr_note = Note: For workflows triggered by a pull request from a forked repository, the default GITHUB_TOKEN is always read-only.
general.token_permissions.contents = Contents
general.token_permissions.issues = Issues
general.token_permissions.pull_requests = Pull Requests
general.token_permissions.packages = Packages
general.token_permissions.actions_scope = Actions
general.token_permissions.wiki = Wiki
general.token_permissions.access_read = Read
general.token_permissions.access_write = Write
general.token_permissions.access_none = No access
general.token_permissions.update_success = Token permissions updated successfully.

[projects]
deleted.display_name = Deleted Project
type-1.display_name = Individual Project
Expand Down
40 changes: 39 additions & 1 deletion routers/web/repo/setting/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,17 @@ func ActionsGeneralSettings(ctx *context.Context) {
return
}

actionsCfg := actionsUnit.ActionsConfig()

// Token permission settings
ctx.Data["TokenPermissionMode"] = actionsCfg.GetTokenPermissionMode()
ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive
ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted
ctx.Data["EffectiveTokenPermissions"] = actionsCfg.GetEffectiveTokenPermissions(false)
ctx.Data["MaxTokenPermissions"] = actionsCfg.GetMaxTokenPermissions()

if ctx.Repo.Repository.IsPrivate {
collaborativeOwnerIDs := actionsUnit.ActionsConfig().CollaborativeOwnerIDs
collaborativeOwnerIDs := actionsCfg.CollaborativeOwnerIDs
collaborativeOwners, err := user_model.GetUsersByIDs(ctx, collaborativeOwnerIDs)
if err != nil {
ctx.ServerError("GetUsersByIDs", err)
Expand Down Expand Up @@ -119,3 +128,32 @@ func DeleteCollaborativeOwner(ctx *context.Context) {

ctx.JSONOK()
}

// UpdateTokenPermissions updates the token permission settings for the repository
func UpdateTokenPermissions(ctx *context.Context) {
redirectURL := ctx.Repo.RepoLink + "/settings/actions/general"

actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions)
if err != nil {
ctx.ServerError("GetUnit", err)
return
}

actionsCfg := actionsUnit.ActionsConfig()

// Update permission mode
permissionMode := ctx.FormString("token_permission_mode")
if permissionMode == string(repo_model.ActionsTokenPermissionModeRestricted) {
actionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModeRestricted
} else {
actionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModePermissive
}

if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil {
ctx.ServerError("UpdateRepoUnit", err)
return
}

ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
ctx.Redirect(redirectURL)
}
1 change: 1 addition & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,7 @@ func registerWebRoutes(m *web.Router) {
m.Post("/add", repo_setting.AddCollaborativeOwner)
m.Post("/delete", repo_setting.DeleteCollaborativeOwner)
})
m.Post("/token_permissions", repo_setting.UpdateTokenPermissions)
})
}, actions.MustEnableActions)
// the follow handler must be under "settings", otherwise this incomplete repo can't be accessed
Expand Down
Loading