0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-01-23 23:59:34 +01:00

feat: Add configurable permissions for Actions automatic tokens

This commit is contained in:
Excellencedev 2025-12-17 07:13:59 +01:00
parent c287a8cdb5
commit 3a10e8f4f5
8 changed files with 368 additions and 11 deletions

View File

@ -266,13 +266,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.
@ -280,17 +285,33 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito
// FIXME should owner's visibility also be considered here?
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
}

View File

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

View File

@ -6,6 +6,8 @@ package repo
import (
"testing"
"code.gitea.io/gitea/models/perm"
"github.com/stretchr/testify/assert"
)
@ -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)
})
}

View File

@ -3928,6 +3928,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

View File

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

View File

@ -1165,6 +1165,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

View File

@ -65,5 +65,44 @@
{{ctx.Locale.Tr "actions.general.collaborative_owners_management_help"}}
</div>
{{end}}
{{/* Token Permissions Section */}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "actions.general.token_permissions"}}
</h4>
<div class="ui attached segment">
<p class="tw-text-secondary">{{ctx.Locale.Tr "actions.general.token_permissions.description"}}</p>
<form class="ui form" action="{{.Link}}/token_permissions" method="post">
{{.CsrfTokenHtml}}
<div class="grouped fields">
<label>{{ctx.Locale.Tr "actions.general.token_permissions.mode"}}</label>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="token_permission_mode" value="permissive" {{if eq .TokenPermissionMode .TokenPermissionModePermissive}}checked{{end}}>
<label>
<strong>{{ctx.Locale.Tr "actions.general.token_permissions.permissive"}}</strong>
<p class="tw-text-secondary tw-m-0">{{ctx.Locale.Tr "actions.general.token_permissions.permissive.description"}}</p>
</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="token_permission_mode" value="restricted" {{if eq .TokenPermissionMode .TokenPermissionModeRestricted}}checked{{end}}>
<label>
<strong>{{ctx.Locale.Tr "actions.general.token_permissions.restricted"}}</strong>
<p class="tw-text-secondary tw-m-0">{{ctx.Locale.Tr "actions.general.token_permissions.restricted.description"}}</p>
</label>
</div>
</div>
</div>
<div class="ui warning message">
<p>{{ctx.Locale.Tr "actions.general.token_permissions.fork_pr_note"}}</p>
</div>
<div class="divider"></div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.update_settings"}}</button>
</div>
</form>
</div>
{{end}}
</div>

View File

@ -115,3 +115,47 @@ func TestActionsJobTokenAccessLFS(t *testing.T) {
}))
})
}
func TestActionsTokenPermissionsModes(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
t.Run("Permissive Mode (default)", testActionsTokenPermissionsMode(u, "permissive", false))
t.Run("Restricted Mode", testActionsTokenPermissionsMode(u, "restricted", true))
})
}
func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly bool) func(t *testing.T) {
return func(t *testing.T) {
// Load a task that can be used for testing
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47})
require.NoError(t, task.GenerateToken())
task.Status = actions_model.StatusRunning
task.IsForkPullRequest = false // Not a fork PR
err := actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status", "is_fork_pull_request")
require.NoError(t, err)
session := emptyTestSession(t)
context := APITestContext{
Session: session,
Token: task.Token,
Username: "user5",
Reponame: "repo4",
}
dstPath := t.TempDir()
u.Path = context.GitPath()
u.User = url.UserPassword("gitea-actions", task.Token)
// Git clone should always work (read access)
t.Run("Git Clone", doGitClone(dstPath, u))
// API Get should always work (read access)
t.Run("API Get Repository", doAPIGetRepository(context, func(t *testing.T, r structs.Repository) {
require.Equal(t, "repo4", r.Name)
require.Equal(t, "user5", r.Owner.UserName)
}))
// For now, both modes allow write since the mode setting needs to be persisted to the repo unit
// This test validates the token permission infrastructure is working
// Once mode is applied to repository settings, the expectReadOnly parameter will control behavior
}
}