diff --git a/cmd/hook.go b/cmd/hook.go index 1845ade625926..a57a875f03f32 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -187,6 +187,7 @@ Gitea or set your environment appropriately.`, "") prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64) deployKeyID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvDeployKeyID), 10, 64) actionPerm, _ := strconv.Atoi(os.Getenv(repo_module.EnvActionPerm)) + actionsTaskID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvActionsTaskID), 10, 64) hookOptions := private.HookOptions{ UserID: userID, @@ -197,6 +198,7 @@ Gitea or set your environment appropriately.`, "") PullRequestID: prID, DeployKeyID: deployKeyID, ActionPerm: actionPerm, + ActionsTaskID: actionsTaskID, } scanner := bufio.NewScanner(os.Stdin) diff --git a/models/actions/config.go b/models/actions/config.go new file mode 100644 index 0000000000000..7bd64b74d4ccc --- /dev/null +++ b/models/actions/config.go @@ -0,0 +1,43 @@ +// Copyright 2025 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)) +} diff --git a/models/actions/run_job.go b/models/actions/run_job.go index f72a7040e3359..d3653b5046c03 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -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"` diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index fa11acaee275f..e7206825054e3 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -399,6 +399,7 @@ func prepareMigrationTasks() []*migration { newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency), newMigration(324, "Fix closed milestone completeness for milestones with no issues", v1_26.FixClosedMilestoneCompleteness), + newMigration(325, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob), } return preparedMigrations } diff --git a/models/migrations/v1_26/v325.go b/models/migrations/v1_26/v325.go new file mode 100644 index 0000000000000..efac821c5dc81 --- /dev/null +++ b/models/migrations/v1_26/v325.go @@ -0,0 +1,15 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "xorm.io/xorm" +) + +func AddTokenPermissionsToActionRunJob(x *xorm.Engine) error { + type ActionRunJob struct { + TokenPermissions string `xorm:"TEXT"` + } + return x.Sync(new(ActionRunJob)) +} diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index 3235d83203cf2..4ab8244517dc0 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -104,6 +104,7 @@ func (p *Permission) UnitAccessMode(unitType unit.Type) perm_model.AccessMode { } func (p *Permission) SetUnitsWithDefaultAccessMode(units []*repo_model.RepoUnit, mode perm_model.AccessMode) { + p.AccessMode = mode p.units = units p.unitsMode = make(map[unit.Type]perm_model.AccessMode) for _, u := range p.units { @@ -268,14 +269,45 @@ 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, err := repo.GetUnit(ctx, unit.TypeActions) + if err != nil { + // If Actions unit doesn't exist, return empty permission + if repo_model.IsErrUnitTypeNotExist(err) { + return perm, nil + } + return perm, err + } + 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 { + + // Check Organization Cross-Repo Access Policy + if err := repo.LoadOwner(ctx); err != nil { + return perm, err + } + + isSameOrg := false + if repo.OwnerID == taskRepo.OwnerID && repo.Owner.IsOrganization() { + isSameOrg = true + 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 (!isSameOrg && !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. // FIXME should owner's visibility also be considered here? @@ -288,17 +320,59 @@ 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 + // Cross-repo access is always read-only + perm.SetUnitsWithDefaultAccessMode(repo.Units, perm_model.AccessModeRead) + return perm, nil + } + + // First check if job has explicit permissions stored from workflow YAML + var effectivePerms repo_model.ActionsTokenPermissions + var jobLoaded bool + + // Only attempt to load job if JobID is set (non-zero) + if task.JobID != 0 { + if err := task.LoadJob(ctx); err == nil { + jobLoaded = true + } else { + // If loading job fails (e.g. resource doesn't exist), log it but fall back to repo permissions + // This prevents 500 errors if the task has a broken job link + log.Warn("GetActionsUserRepoPermission: failed to load job %d for task %d: %v", task.JobID, task.ID, err) + } + } + + if jobLoaded && 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 { - accessMode = perm_model.AccessModeWrite + // No workflow permissions or job not found, use repository settings + effectivePerms = actionsCfg.GetEffectiveTokenPermissions(task.IsForkPullRequest) + effectivePerms = actionsCfg.ClampPermissions(effectivePerms) } - if err := repo.LoadUnits(ctx); err != nil { - return perm, err + // 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.Code + 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 } diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index ad0bb9d3f82ec..e5c1ec65bf862 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -168,11 +168,149 @@ 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 - custom permissions defined by MaxTokenPermissions + ActionsTokenPermissionModeCustom ActionsTokenPermissionMode = "custom" +) + +// ActionsTokenPermissions defines the permissions for different repository units +type ActionsTokenPermissions struct { + // Code (repository code) - read/write/none + Code 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"` + // Releases - read/write/none + Releases perm.AccessMode `json:"releases"` + // Projects - read/write/none + Projects perm.AccessMode `json:"projects"` +} + +// HasAccess checks if the permission meets the required access level for the given scope +func (p ActionsTokenPermissions) HasAccess(scope string, required perm.AccessMode) bool { + var mode perm.AccessMode + switch scope { + case "actions": + mode = p.Actions + case "contents": + mode = p.Code + case "issues": + mode = p.Issues + case "packages": + mode = p.Packages + case "pull_requests": + mode = p.PullRequests + case "wiki": + mode = p.Wiki + case "releases": + mode = p.Releases + case "projects": + mode = p.Projects + } + return mode >= required +} + +// HasRead checks if the permission has read access for the given scope (convenience wrapper for templates) +func (p ActionsTokenPermissions) HasRead(scope string) bool { + return p.HasAccess(scope, perm.AccessModeRead) +} + +// HasWrite checks if the permission has write access for the given scope (convenience wrapper for templates) +func (p ActionsTokenPermissions) HasWrite(scope string) bool { + return p.HasAccess(scope, perm.AccessModeWrite) +} + +// DefaultActionsTokenPermissions returns the default permissions for permissive mode +func DefaultActionsTokenPermissions(mode ActionsTokenPermissionMode) ActionsTokenPermissions { + if mode == ActionsTokenPermissionModeRestricted { + return ActionsTokenPermissions{ + Code: perm.AccessModeRead, + Issues: perm.AccessModeRead, + PullRequests: perm.AccessModeRead, + Packages: perm.AccessModeRead, + Actions: perm.AccessModeRead, + Wiki: perm.AccessModeRead, + Releases: perm.AccessModeRead, + Projects: perm.AccessModeRead, + } + } + // Permissive mode (default) + return ActionsTokenPermissions{ + Code: perm.AccessModeWrite, + Issues: perm.AccessModeWrite, + PullRequests: perm.AccessModeWrite, + Packages: perm.AccessModeRead, // Packages read by default for security + Actions: perm.AccessModeWrite, + Wiki: perm.AccessModeWrite, + Releases: perm.AccessModeWrite, + Projects: perm.AccessModeWrite, + } +} + +// ForkPullRequestPermissions returns the restricted permissions for fork pull requests +func ForkPullRequestPermissions() ActionsTokenPermissions { + return ActionsTokenPermissions{ + Code: perm.AccessModeRead, + Issues: perm.AccessModeRead, + PullRequests: perm.AccessModeRead, + Packages: perm.AccessModeRead, + Actions: perm.AccessModeRead, + Wiki: perm.AccessModeRead, + Releases: perm.AccessModeRead, + Projects: perm.AccessModeRead, + } +} + +// 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. // 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"` + // AllowedCrossRepoIDs is a list of specific repo IDs that can be accessed cross-repo (empty means all if AllowCrossRepoAccess is true) + AllowedCrossRepoIDs []int64 `json:"allowed_cross_repo_ids,omitempty"` + // FollowOrgConfig indicates if this repository should follow the organization-level configuration + FollowOrgConfig bool `json:"follow_org_config,omitempty"` } func (cfg *ActionsConfig) EnableWorkflow(file string) { @@ -209,6 +347,77 @@ 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{ + Code: perm.AccessModeWrite, + Issues: perm.AccessModeWrite, + PullRequests: perm.AccessModeWrite, + Packages: perm.AccessModeWrite, + Actions: perm.AccessModeWrite, + Wiki: perm.AccessModeWrite, + Releases: perm.AccessModeWrite, + Projects: 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{ + Code: min(perms.Code, maxPerms.Code), + 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), + Releases: min(perms.Releases, maxPerms.Releases), + Projects: min(perms.Projects, maxPerms.Projects), + } +} + +// IsRepoAllowedCrossAccess checks if a specific repo is allowed for cross-repo access +// Returns true if AllowCrossRepoAccess is enabled AND (AllowedCrossRepoIDs is empty OR repoID is in the list) +func (cfg *ActionsConfig) IsRepoAllowedCrossAccess(repoID int64) bool { + if !cfg.AllowCrossRepoAccess { + return false + } + // If no specific repos are configured, allow all + if len(cfg.AllowedCrossRepoIDs) == 0 { + return true + } + // Check if repo is in the allowed list + return slices.Contains(cfg.AllowedCrossRepoIDs, repoID) +} + // FromDB fills up a ActionsConfig from serialized format. func (cfg *ActionsConfig) FromDB(bs []byte) error { return json.UnmarshalHandleDoubleEncode(bs, &cfg) diff --git a/models/repo/repo_unit_test.go b/models/repo/repo_unit_test.go index 56dda5672d4ff..df91060a8926a 100644 --- a/models/repo/repo_unit_test.go +++ b/models/repo/repo_unit_test.go @@ -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.Code) + 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.Code) + 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.Code) + 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{ + Code: perm.AccessModeRead, + Issues: perm.AccessModeWrite, + PullRequests: perm.AccessModeRead, + Packages: perm.AccessModeRead, + Actions: perm.AccessModeNone, + Wiki: perm.AccessModeWrite, + }, + } + input := ActionsTokenPermissions{ + Code: 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.Code) + 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) + }) +} diff --git a/modules/private/hook.go b/modules/private/hook.go index 215996b9b9936..87eed7b18fdbc 100644 --- a/modules/private/hook.go +++ b/modules/private/hook.go @@ -38,6 +38,7 @@ type HookOptions struct { DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user. IsWiki bool ActionPerm int + ActionsTaskID int64 // if the pusher is an Actions user, the task ID } // SSHLogOption ssh log options diff --git a/modules/repository/env.go b/modules/repository/env.go index 55a81f006e2bf..b9328c6df2906 100644 --- a/modules/repository/env.go +++ b/modules/repository/env.go @@ -15,21 +15,22 @@ import ( // env keys for git hooks need const ( - EnvRepoName = "GITEA_REPO_NAME" - EnvRepoUsername = "GITEA_REPO_USER_NAME" - EnvRepoID = "GITEA_REPO_ID" - EnvRepoIsWiki = "GITEA_REPO_IS_WIKI" - EnvPusherName = "GITEA_PUSHER_NAME" - EnvPusherEmail = "GITEA_PUSHER_EMAIL" - EnvPusherID = "GITEA_PUSHER_ID" - EnvKeyID = "GITEA_KEY_ID" // public key ID - EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID" - EnvPRID = "GITEA_PR_ID" - EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks - EnvPushTrigger = "GITEA_PUSH_TRIGGER" - EnvIsInternal = "GITEA_INTERNAL_PUSH" - EnvAppURL = "GITEA_ROOT_URL" - EnvActionPerm = "GITEA_ACTION_PERM" + EnvRepoName = "GITEA_REPO_NAME" + EnvRepoUsername = "GITEA_REPO_USER_NAME" + EnvRepoID = "GITEA_REPO_ID" + EnvRepoIsWiki = "GITEA_REPO_IS_WIKI" + EnvPusherName = "GITEA_PUSHER_NAME" + EnvPusherEmail = "GITEA_PUSHER_EMAIL" + EnvPusherID = "GITEA_PUSHER_ID" + EnvKeyID = "GITEA_KEY_ID" // public key ID + EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID" + EnvPRID = "GITEA_PR_ID" + EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks + EnvPushTrigger = "GITEA_PUSH_TRIGGER" + EnvIsInternal = "GITEA_INTERNAL_PUSH" + EnvAppURL = "GITEA_ROOT_URL" + EnvActionPerm = "GITEA_ACTION_PERM" + EnvActionsTaskID = "GITEA_ACTIONS_TASK_ID" ) type PushTrigger string diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 480aafe87951c..98b2227a6a228 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -643,6 +643,7 @@ "user.block.note.edit": "Edit note", "user.block.list": "Blocked users", "user.block.list.none": "You have not blocked any users.", + "settings.general": "General", "settings.profile": "Profile", "settings.account": "Account", "settings.appearance": "Appearance", @@ -3727,5 +3728,52 @@ "git.filemode.normal_file": "Normal file", "git.filemode.executable_file": "Executable file", "git.filemode.symbolic_link": "Symbolic link", - "git.filemode.submodule": "Submodule" + "git.filemode.submodule": "Submodule", + "actions.general.token_permissions.title": "Action Token Permissions", + "actions.general.token_permissions.desc": "Configure the default permissions for the GITEA_TOKEN running in this repository.", + "actions.general.token_permissions.mode": "Default Token Permissions", + "actions.general.token_permissions.mode.permissive": "Permissive", + "actions.general.token_permissions.mode.permissive.desc": "Read and write permissions for all jobs.", + "actions.general.token_permissions.mode.restricted": "Restricted", + "actions.general.token_permissions.mode.restricted.desc": "Read-only permissions for contents and packages. No other permissions.", + "actions.general.token_permissions.mode.custom": "Custom", + "actions.general.token_permissions.mode.custom.desc": "Configure permissions for each category.", + "actions.general.token_permissions.access_none": "None", + "actions.general.token_permissions.access_read": "Read", + "actions.general.token_permissions.access_write": "Write", + "actions.general.token_permissions.contents": "Contents", + "actions.general.token_permissions.contents.description": "Repository contents, commits, branches, downloads, releases, and merges.", + "actions.general.token_permissions.issues": "Issues", + "actions.general.token_permissions.issues.description": "Issues and related comments, assignees, labels, and milestones.", + "actions.general.token_permissions.pull_requests": "Pull Requests", + "actions.general.token_permissions.pull_requests.description": "Pull requests and related comments, assignees, labels, and milestones.", + "actions.general.token_permissions.wiki": "Wiki", + "actions.general.token_permissions.wiki.description": "Wiki pages and files.", + "actions.general.token_permissions.releases": "Releases", + "actions.general.token_permissions.releases.description": "Repository releases and tags.", + "actions.general.token_permissions.projects": "Projects", + "actions.general.token_permissions.projects.description": "Repository projects and boards.", + "actions.general.token_permissions.packages": "Packages", + "actions.general.token_permissions.packages.description": "Packages and container images.", + "actions.general.token_permissions.actions_scope": "Actions", + "actions.general.token_permissions.actions_scope.description": "Interact with workflow runs.", + "actions.general.token_permissions.maximum": "Maximum Token Permissions", + "actions.general.token_permissions.maximum.description": "The maximum permissions tokens are allowed to have. Workflow-specified permissions cannot exceed these limits.", + "actions.general.token_permissions.fork_pr_note": "Note: Pull requests from forks always have read-only permissions.", + "actions.general.token_permissions.max_permissions": "Maximum Permissions", + "actions.general.token_permissions.max_permissions.desc": "Configure better restrictions for the GITEA_TOKEN running in this repository.", + "actions.general.token_permissions.read": "Read", + "actions.general.token_permissions.write": "Write", + "actions.general.token_permissions.none": "None", + "actions.general.token_permissions.cross_repo": "Allow Cross-Repository Access", + "actions.general.token_permissions.cross_repo_desc": "Control whether the token can access other repositories and packages within this organization.", + "actions.general.token_permissions.cross_repo_all": "All repositories in this organization", + "actions.general.token_permissions.cross_repo_selected": "Selected repositories only", + "actions.general.token_permissions.allowed_repos": "Allowed Repositories", + "actions.general.token_permissions.add_repo": "Add Repository", + "actions.general.token_permissions.follow_org": "Follow organization-level configuration", + "actions.general.token_permissions.follow_org_desc": "Use the Actions settings configured at the organization level instead of repository-specific settings.", + "all_repositories": "All Repositories", + "specific_repositories": "Specific Repositories" } + diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index f6ee5958b5bb9..e74857f5655f4 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -6,7 +6,9 @@ package packages import ( "net/http" + actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" + packages_model "code.gitea.io/gitea/models/packages" "code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -80,6 +82,63 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) { } } + isActionsToken, _ := ctx.Data["IsActionsToken"].(bool) + if isActionsToken && ctx.Package != nil && ctx.Package.Owner != nil && ctx.Package.Owner.IsOrganization() { + // Actions rules: + // 1. If the package key matches the task repo, allow. + // 2. If not, check cross-repo policy. + + taskID, ok := ctx.Data["ActionsTaskID"].(int64) + if ok && taskID > 0 { + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + log.Error("GetTaskByID: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "GetTaskByID", err.Error()) + return + } + if task == nil { + ctx.HTTPError(http.StatusInternalServerError, "GetTaskByID", "task not found") + return + } + + var packageRepoID int64 + if ctx.Package.Descriptor != nil && ctx.Package.Descriptor.Package != nil { + packageRepoID = ctx.Package.Descriptor.Package.RepoID + } + + // If package is not linked to any repo (org-level package), deny access from Actions + // Actions tokens should only access packages linked to repos + if packageRepoID == 0 { + if packageName := ctx.PathParam("packagename"); packageName != "" && ctx.Package.Owner != nil { + pkg, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeGeneric, packageName) + if err == nil && pkg != nil { + packageRepoID = pkg.RepoID + } + } + } + + if packageRepoID == 0 { + ctx.HTTPError(http.StatusForbidden, "reqPackageAccess", "Actions tokens cannot access packages not linked to a repository") + return + } + + if task.RepoID != packageRepoID { + // Cross-repository access - check org policy + cfg, err := actions_model.GetOrgActionsConfig(ctx, ctx.Package.Owner.ID) + if err != nil { + log.Error("GetOrgActionsConfig: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "GetOrgActionsConfig", err.Error()) + return + } + // Use selective cross-repo access check + if !cfg.IsRepoAllowedCrossAccess(packageRepoID) { + ctx.HTTPError(http.StatusForbidden, "reqPackageAccess", "cross-repository package access is not allowed for this repository") + return + } + } + } + } + if ctx.Package.AccessMode < accessMode && !ctx.IsUserSiteAdmin() { ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea Package API"`) ctx.HTTPError(http.StatusUnauthorized, "reqPackageAccess", "user should have specific permission or be a site admin") diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 27a0827a10f20..e5193d9c669f7 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -356,6 +356,12 @@ func ReqChangeRepoFileOptionsAndCheck(ctx *context.APIContext) { OldBranch: commonOpts.BranchName, NewBranch: commonOpts.NewBranchName, ForcePush: commonOpts.ForcePush, + ActionsTaskID: func() int64 { + if ctx.Data["ActionsTaskID"] != nil { + return ctx.Data["ActionsTaskID"].(int64) + } + return 0 + }(), Committer: &files_service.IdentityOptions{ GitUserName: commonOpts.Committer.Name, GitUserEmail: commonOpts.Committer.Email, diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index 88e8b466f1514..cb67afd0f9484 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -493,15 +493,29 @@ func (ctx *preReceiveContext) loadPusherAndPermission() bool { if ctx.opts.UserID == user_model.ActionsUserID { ctx.user = user_model.NewActionsUser() - ctx.userPerm.AccessMode = perm_model.AccessMode(ctx.opts.ActionPerm) - if err := ctx.Repo.Repository.LoadUnits(ctx); err != nil { - log.Error("Unable to get User id %d Error: %v", ctx.opts.UserID, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to get User id %d Error: %v", ctx.opts.UserID, err), - }) - return false + // Use the new GetActionsUserRepoPermission to respect token permission settings + if ctx.opts.ActionsTaskID > 0 { + userPerm, err := access_model.GetActionsUserRepoPermission(ctx, ctx.Repo.Repository, ctx.user, ctx.opts.ActionsTaskID) + if err != nil { + log.Error("Unable to get Actions user repo permission for task %d Error: %v", ctx.opts.ActionsTaskID, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get Actions user repo permission for task %d Error: %v", ctx.opts.ActionsTaskID, err), + }) + return false + } + ctx.userPerm = userPerm + } else { + // Fallback to old behavior if ActionsTaskID is not provided (for backwards compatibility) + ctx.userPerm.AccessMode = perm_model.AccessMode(ctx.opts.ActionPerm) + if err := ctx.Repo.Repository.LoadUnits(ctx); err != nil { + log.Error("Unable to get User id %d Error: %v", ctx.opts.UserID, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get User id %d Error: %v", ctx.opts.UserID, err), + }) + return false + } + ctx.userPerm.SetUnitsWithDefaultAccessMode(ctx.Repo.Repository.Units, ctx.userPerm.AccessMode) } - ctx.userPerm.SetUnitsWithDefaultAccessMode(ctx.Repo.Repository.Units, ctx.userPerm.AccessMode) } else { user, err := user_model.GetUserByID(ctx, ctx.opts.UserID) if err != nil { diff --git a/routers/web/org/setting/actions.go b/routers/web/org/setting/actions.go new file mode 100644 index 0000000000000..5e98bcbb6c173 --- /dev/null +++ b/routers/web/org/setting/actions.go @@ -0,0 +1,206 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "slices" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsActionsGeneral templates.TplName = "org/settings/actions_general" +) + +// ActionsGeneral renders the actions general settings page +func ActionsGeneral(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsOrgSettingsActionsGeneral"] = true + + // Load Org Actions Config + actionsCfg, err := actions_model.GetOrgActionsConfig(ctx, ctx.Org.Organization.AsUser().ID) + if err != nil { + ctx.ServerError("GetOrgActionsConfig", err) + return + } + + ctx.Data["TokenPermissionMode"] = actionsCfg.GetTokenPermissionMode() + ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive + ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted + ctx.Data["TokenPermissionModeCustom"] = repo_model.ActionsTokenPermissionModeCustom + ctx.Data["MaxTokenPermissions"] = actionsCfg.GetMaxTokenPermissions() + + ctx.Data["AllowCrossRepoAccess"] = actionsCfg.AllowCrossRepoAccess + ctx.Data["HasSelectedRepos"] = len(actionsCfg.AllowedCrossRepoIDs) > 0 + + // Load Allowed Repositories + var allowedRepos []*repo_model.Repository + if len(actionsCfg.AllowedCrossRepoIDs) > 0 { + // Since the list shouldn't be too long, we can loop. + // Ideally use GetRepositoriesByIDs but simple loop is fine for now. + for _, id := range actionsCfg.AllowedCrossRepoIDs { + repo, err := repo_model.GetRepositoryByID(ctx, id) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + continue + } + ctx.ServerError("GetRepositoryByID", err) + return + } + allowedRepos = append(allowedRepos, repo) + } + } + ctx.Data["AllowedRepos"] = allowedRepos + + ctx.HTML(http.StatusOK, tplSettingsActionsGeneral) +} + +// ActionsGeneralPost responses for actions general settings page +func ActionsGeneralPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsOrgSettingsActions"] = true + + actionsCfg, err := actions_model.GetOrgActionsConfig(ctx, ctx.Org.Organization.AsUser().ID) + if err != nil { + ctx.ServerError("GetOrgActionsConfig", err) + return + } + + // Update Token Permission Mode + permissionMode := repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode")) + if permissionMode == repo_model.ActionsTokenPermissionModeRestricted || + permissionMode == repo_model.ActionsTokenPermissionModePermissive || + permissionMode == repo_model.ActionsTokenPermissionModeCustom { + actionsCfg.TokenPermissionMode = permissionMode + } + + // Update Maximum Permissions (radio buttons: none/read/write) + parseMaxPerm := func(name string) perm.AccessMode { + value := ctx.FormString("max_" + name) + switch value { + case "write": + return perm.AccessModeWrite + case "read": + return perm.AccessModeRead + default: + return perm.AccessModeNone + } + } + + actionsCfg.MaxTokenPermissions = &repo_model.ActionsTokenPermissions{ + Code: parseMaxPerm("contents"), + Issues: parseMaxPerm("issues"), + Packages: parseMaxPerm("packages"), + PullRequests: parseMaxPerm("pull_requests"), + Wiki: parseMaxPerm("wiki"), + } + + // Update Cross-Repo Access Mode + crossRepoMode := ctx.FormString("cross_repo_mode") + switch crossRepoMode { + case "none": + actionsCfg.AllowCrossRepoAccess = false + actionsCfg.AllowedCrossRepoIDs = nil + case "all": + actionsCfg.AllowCrossRepoAccess = true + actionsCfg.AllowedCrossRepoIDs = nil + case "selected": + actionsCfg.AllowCrossRepoAccess = true + // Keep existing AllowedCrossRepoIDs, will be updated by separate API + } + + if err := actions_model.SetOrgActionsConfig(ctx, ctx.Org.Organization.AsUser().ID, actionsCfg); err != nil { + ctx.ServerError("SetOrgActionsConfig", err) + return + } + + ctx.Flash.Success(ctx.Tr("org.settings.update_setting_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/actions") +} + +// ActionsAllowedReposAdd adds a repository to the allowed list for cross-repo access +func ActionsAllowedReposAdd(ctx *context.Context) { + repoName := ctx.FormString("repo_name") + if repoName == "" { + ctx.Redirect(ctx.Org.OrgLink + "/settings/actions") + return + } + + repo, err := repo_model.GetRepositoryByName(ctx, ctx.Org.Organization.ID, repoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.Flash.Error(ctx.Tr("repo.not_exist")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/actions") + return + } + ctx.ServerError("GetRepositoryByName", err) + return + } + + actionsCfg, err := actions_model.GetOrgActionsConfig(ctx, ctx.Org.Organization.AsUser().ID) + if err != nil { + ctx.ServerError("GetOrgActionsConfig", err) + return + } + + // Check if already exists + // Check if already exists + if slices.Contains(actionsCfg.AllowedCrossRepoIDs, repo.ID) { + ctx.Redirect(ctx.Org.OrgLink + "/settings/actions") + return + } + + actionsCfg.AllowedCrossRepoIDs = append(actionsCfg.AllowedCrossRepoIDs, repo.ID) + // Ensure mode is set to selected if we are adding specific repos? + // Logic: If user adds a repo, they probably want it enabled. + // But let's respect the current mode toggle. If "all" or "none" is set, adding a repo updates the list but might not activate "selected" mode unless user explicitly chose "selected". + // However, if "selected" is active, this adds to it. + + if err := actions_model.SetOrgActionsConfig(ctx, ctx.Org.Organization.AsUser().ID, actionsCfg); err != nil { + ctx.ServerError("SetOrgActionsConfig", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/actions") +} + +// ActionsAllowedReposRemove removes a repository from the allowed list +func ActionsAllowedReposRemove(ctx *context.Context) { + repoID := ctx.FormInt64("repo_id") + if repoID == 0 { + ctx.Redirect(ctx.Org.OrgLink + "/settings/actions") + return + } + + actionsCfg, err := actions_model.GetOrgActionsConfig(ctx, ctx.Org.Organization.AsUser().ID) + if err != nil { + ctx.ServerError("GetOrgActionsConfig", err) + return + } + + // Filter out the ID + newIDs := make([]int64, 0, len(actionsCfg.AllowedCrossRepoIDs)) + for _, id := range actionsCfg.AllowedCrossRepoIDs { + if id != repoID { + newIDs = append(newIDs, id) + } + } + actionsCfg.AllowedCrossRepoIDs = newIDs + + if err := actions_model.SetOrgActionsConfig(ctx, ctx.Org.Organization.AsUser().ID, actionsCfg); err != nil { + ctx.ServerError("SetOrgActionsConfig", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/actions") +} diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index cc70cd4e06560..3395ff6ec8828 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -34,6 +34,7 @@ import ( context_module "code.gitea.io/gitea/services/context" notify_service "code.gitea.io/gitea/services/notify" + "github.com/nektos/act/pkg/jobparser" "github.com/nektos/act/pkg/model" "gopkg.in/yaml.v3" "xorm.io/builder" @@ -536,8 +537,37 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou } } + // Recalculate permissions on rerun to respect current repo settings + if len(job.WorkflowPayload) > 0 { + singleWorkflow, err := jobparser.Parse(job.WorkflowPayload) + if err != nil { + log.Warn("rerunJob: failed to parse workflow payload for job %d: %v", job.ID, err) + } else { + for _, flow := range singleWorkflow { + wfJobID, wfJob := flow.Job() + if wfJobID == job.JobID { + if job.Run.Repo == nil { + if err := job.Run.LoadRepo(ctx); err != nil { + return err + } + } + cfgUnit := job.Run.Repo.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + + defaultPerms := cfg.GetEffectiveTokenPermissions(job.Run.IsForkPullRequest) + workflowPerms := actions_service.ParseWorkflowPermissions(flow, defaultPerms) + jobPerms := actions_service.ParseJobPermissions(wfJob, workflowPerms) + finalPerms := cfg.ClampPermissions(jobPerms) + + job.TokenPermissions = repo_model.MarshalTokenPermissions(finalPerms) + break + } + } + } + } + if err := db.WithTx(ctx, func(ctx context.Context) error { - updateCols := []string{"task_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "is_concurrency_evaluated"} + updateCols := []string{"task_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "is_concurrency_evaluated", "token_permissions"} _, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, updateCols...) return err }); err != nil { diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index c7b53dcbfb1b5..8e1e13637ed0c 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -206,6 +206,7 @@ func httpBase(ctx *context.Context) *serviceHandler { return nil } environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, p.UnitAccessMode(unitType))) + environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionsTaskID, taskID)) } else { p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) if err != nil { diff --git a/routers/web/repo/setting/actions.go b/routers/web/repo/setting/actions.go index 9c2c9242d34b6..cd974c2371519 100644 --- a/routers/web/repo/setting/actions.go +++ b/routers/web/repo/setting/actions.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -34,8 +35,21 @@ 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["TokenPermissionModeCustom"] = repo_model.ActionsTokenPermissionModeCustom + ctx.Data["MaxTokenPermissions"] = actionsCfg.GetMaxTokenPermissions() + + // Follow org config (only for repos in orgs) + ctx.Data["IsInOrg"] = ctx.Repo.Repository.Owner.IsOrganization() + ctx.Data["FollowOrgConfig"] = actionsCfg.FollowOrgConfig + 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 +133,69 @@ 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 Follow Org Config (for repos in orgs) + actionsCfg.FollowOrgConfig = ctx.FormBool("follow_org_config") + + // Update permission mode (only if not following org config) + if !actionsCfg.FollowOrgConfig { + permissionMode := repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode")) + if permissionMode == repo_model.ActionsTokenPermissionModeRestricted || + permissionMode == repo_model.ActionsTokenPermissionModePermissive || + permissionMode == repo_model.ActionsTokenPermissionModeCustom { + actionsCfg.TokenPermissionMode = permissionMode + } else { + ctx.Flash.Error("Invalid token permission mode") + ctx.Redirect(redirectURL) + return + } + } + + // Update Maximum Permissions (radio buttons: none/read/write) + if actionsCfg.TokenPermissionMode == repo_model.ActionsTokenPermissionModeCustom { + parseMaxPerm := func(name string) perm.AccessMode { + value := ctx.FormString("max_" + name) + switch value { + case "write": + return perm.AccessModeWrite + case "read": + return perm.AccessModeRead + default: + return perm.AccessModeNone + } + } + + actionsCfg.MaxTokenPermissions = &repo_model.ActionsTokenPermissions{ + Code: parseMaxPerm("contents"), + Issues: parseMaxPerm("issues"), + Packages: parseMaxPerm("packages"), + PullRequests: parseMaxPerm("pull_requests"), + Wiki: parseMaxPerm("wiki"), + Actions: parseMaxPerm("actions"), + Releases: parseMaxPerm("releases"), + Projects: parseMaxPerm("projects"), + } + } else { + actionsCfg.MaxTokenPermissions = nil + } + + 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) +} diff --git a/routers/web/web.go b/routers/web/web.go index 64137876e02d5..9a704a16eab97 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -959,7 +959,12 @@ func registerWebRoutes(m *web.Router) { }) m.Group("/actions", func() { - m.Get("", org_setting.RedirectToDefaultSetting) + m.Get("", org_setting.ActionsGeneral) + m.Post("", org_setting.ActionsGeneralPost) + m.Group("/allowed_repos", func() { + m.Post("/add", org_setting.ActionsAllowedReposAdd) + m.Post("/remove", org_setting.ActionsAllowedReposRemove) + }) addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() @@ -1165,6 +1170,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 diff --git a/services/actions/permission_parser.go b/services/actions/permission_parser.go new file mode 100644 index 0000000000000..47edec689a5ba --- /dev/null +++ b/services/actions/permission_parser.go @@ -0,0 +1,152 @@ +// 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 + } + + // Unwrap DocumentNode if present (yaml.Unmarshal wraps content in DocumentNode) + node := rawPerms + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + node = node.Content[0] + } + + // Check for empty node after unwrapping + if node == nil || (node.Kind == yaml.ScalarNode && node.Value == "") { + return defaultPerms + } + + // Handle scalar values: "read-all" or "write-all" + if node.Kind == yaml.ScalarNode { + switch node.Value { + case "read-all": + return repo_model.ActionsTokenPermissions{ + Code: perm.AccessModeRead, + Issues: perm.AccessModeRead, + PullRequests: perm.AccessModeRead, + Packages: perm.AccessModeRead, + Actions: perm.AccessModeRead, + Wiki: perm.AccessModeRead, + Releases: perm.AccessModeRead, + Projects: perm.AccessModeRead, + } + case "write-all": + return repo_model.ActionsTokenPermissions{ + Code: perm.AccessModeWrite, + Issues: perm.AccessModeWrite, + PullRequests: perm.AccessModeWrite, + Packages: perm.AccessModeWrite, + Actions: perm.AccessModeWrite, + Wiki: perm.AccessModeWrite, + Releases: perm.AccessModeWrite, + Projects: perm.AccessModeWrite, + } + } + return defaultPerms + } + + // Handle mapping: individual permission scopes + if node.Kind == yaml.MappingNode { + result := defaultPerms // Start with defaults + + for i := 0; i < len(node.Content); i += 2 { + if i+1 >= len(node.Content) { + break + } + keyNode := node.Content[i] + valueNode := node.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.Code = 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 + case "releases": + result.Releases = accessMode + case "projects": + result.Projects = 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 + } +} diff --git a/services/actions/permission_parser_test.go b/services/actions/permission_parser_test.go new file mode 100644 index 0000000000000..919815d8f3a8b --- /dev/null +++ b/services/actions/permission_parser_test.go @@ -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.Code) + 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.Code) + 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{ + Code: 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.Code) + 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.Code) + 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.Code, result.Code) + 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.Code, result.Code) + 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{ + Code: 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.Code) + assert.Equal(t, perm.AccessModeNone, result.Issues) +} diff --git a/services/actions/run.go b/services/actions/run.go index 90413e9bc23e3..0780f27ce50dd 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -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 { diff --git a/services/repository/files/temp_repo.go b/services/repository/files/temp_repo.go index b605236c03abb..84e95983a09c3 100644 --- a/services/repository/files/temp_repo.go +++ b/services/repository/files/temp_repo.go @@ -11,6 +11,7 @@ import ( "io" "os" "regexp" + "strconv" "strings" "time" @@ -33,6 +34,8 @@ type TemporaryUploadRepository struct { gitRepo *git.Repository basePath string cleanup func() + + ActionsTaskID int64 } // NewTemporaryUploadRepository creates a new temporary upload repository @@ -362,6 +365,9 @@ func (t *TemporaryUploadRepository) CommitTree(ctx context.Context, opts *Commit func (t *TemporaryUploadRepository) Push(ctx context.Context, doer *user_model.User, commitHash, branch string, force bool) error { // Because calls hooks we need to pass in the environment env := repo_module.PushingEnvironment(doer, t.repo) + if t.ActionsTaskID > 0 { + env = append(env, repo_module.EnvActionsTaskID+"="+strconv.FormatInt(t.ActionsTaskID, 10)) + } if err := gitrepo.PushFromLocal(ctx, t.basePath, t.repo, git.PushOptions{ Branch: strings.TrimSpace(commitHash) + ":" + git.BranchPrefix + strings.TrimSpace(branch), Env: env, diff --git a/services/repository/files/update.go b/services/repository/files/update.go index bd992d06deee3..537808ee53762 100644 --- a/services/repository/files/update.go +++ b/services/repository/files/update.go @@ -54,16 +54,17 @@ type ChangeRepoFile struct { // ChangeRepoFilesOptions holds the repository files update options type ChangeRepoFilesOptions struct { - LastCommitID string - OldBranch string - NewBranch string - Message string - Files []*ChangeRepoFile - Author *IdentityOptions - Committer *IdentityOptions - Dates *CommitDateOptions - Signoff bool - ForcePush bool + LastCommitID string + OldBranch string + NewBranch string + Message string + Files []*ChangeRepoFile + Author *IdentityOptions + Committer *IdentityOptions + Dates *CommitDateOptions + Signoff bool + ForcePush bool + ActionsTaskID int64 } type RepoFileOptions struct { @@ -177,6 +178,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use if err != nil { log.Error("NewTemporaryUploadRepository failed: %v", err) } + t.ActionsTaskID = opts.ActionsTaskID defer t.Close() hasOldBranch := true if err := t.Clone(ctx, opts.OldBranch, true); err != nil { diff --git a/templates/org/settings/actions_general.tmpl b/templates/org/settings/actions_general.tmpl new file mode 100644 index 0000000000000..ef4bfff980cfe --- /dev/null +++ b/templates/org/settings/actions_general.tmpl @@ -0,0 +1,271 @@ +{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings actions")}} +
+

+ {{ctx.Locale.Tr "actions.actions"}} - {{ctx.Locale.Tr "settings.general"}} +

+
+
+ {{.CsrfTokenHtml}} + + +
+ {{ctx.Locale.Tr "actions.general.token_permissions.cross_repo"}} +
+

{{ctx.Locale.Tr "actions.general.token_permissions.cross_repo_desc"}}

+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+ {{ctx.Locale.Tr "actions.general.token_permissions.allowed_repos"}} +
+
+ {{range .AllowedRepos}} +
+
+ +
+
+ {{.Name}} +
+
+ {{else}} +
{{ctx.Locale.Tr "org.repos.none"}}
+ {{end}} +
+ +
+ {{ctx.Locale.Tr "actions.general.token_permissions.add_repo"}} +
+
+ + +
+
+ +
+ + +
+ {{ctx.Locale.Tr "actions.general.token_permissions.mode"}} +
+
+
+
+ + +
+

{{ctx.Locale.Tr "actions.general.token_permissions.mode.permissive.desc"}}

+
+
+
+ + +
+

{{ctx.Locale.Tr "actions.general.token_permissions.mode.restricted.desc"}}

+
+
+
+ + +
+

{{ctx.Locale.Tr "actions.general.token_permissions.mode.custom.desc"}}

+
+
+ +
+ + +
+
+ {{ctx.Locale.Tr "actions.general.token_permissions.max_permissions"}} +
+

{{ctx.Locale.Tr "actions.general.token_permissions.max_permissions.desc"}}

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ctx.Locale.Tr "units.unit"}}{{ctx.Locale.Tr "actions.general.token_permissions.access_none"}}{{ctx.Locale.Tr "actions.general.token_permissions.access_read"}}{{ctx.Locale.Tr "actions.general.token_permissions.access_write"}}
+ {{ctx.Locale.Tr "actions.general.token_permissions.contents"}} +

{{ctx.Locale.Tr "actions.general.token_permissions.contents.description"}}

+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ {{ctx.Locale.Tr "actions.general.token_permissions.issues"}} +

{{ctx.Locale.Tr "actions.general.token_permissions.issues.description"}}

+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ {{ctx.Locale.Tr "actions.general.token_permissions.pull_requests"}} +

{{ctx.Locale.Tr "actions.general.token_permissions.pull_requests.description"}}

+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ {{ctx.Locale.Tr "actions.general.token_permissions.wiki"}} +

{{ctx.Locale.Tr "actions.general.token_permissions.wiki.description"}}

+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ {{ctx.Locale.Tr "actions.general.token_permissions.packages"}} +

{{ctx.Locale.Tr "actions.general.token_permissions.packages.description"}}

+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ {{ctx.Locale.Tr "actions.general.token_permissions.actions_scope"}} +

{{ctx.Locale.Tr "actions.general.token_permissions.actions_scope.description"}}

+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ +
+ +
+ +
+
+
+
+{{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 58475de7e7a31..4c06b2cb1baf3 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -26,9 +26,12 @@ {{end}} {{if .EnableActions}} -
+
{{ctx.Locale.Tr "actions.actions"}}