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/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index 3235d83203cf2..b21f716e8ee44 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -95,7 +95,7 @@ func (p *Permission) UnitAccessMode(unitType unit.Type) perm_model.AccessMode { if m, ok := p.unitsMode[unitType]; ok { return util.Iif(p.AccessMode >= perm_model.AccessModeAdmin, p.AccessMode, m) } - // if the units map does not contain the access mode, return the default access mode if the unit exists + // If the units map does not contain the access mode, return the default access mode if the unit exists unitDefaultAccessMode := p.AccessMode unitDefaultAccessMode = max(unitDefaultAccessMode, p.anonymousAccessMode[unitType]) unitDefaultAccessMode = max(unitDefaultAccessMode, p.everyoneAccessMode[unitType]) @@ -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,34 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito perm.AccessMode = min(perm.AccessMode, perm_model.AccessModeRead) return perm, nil } - accessMode = perm_model.AccessModeRead - } else if task.IsForkPullRequest { - accessMode = perm_model.AccessModeRead - } else { - accessMode = perm_model.AccessModeWrite + // Cross-repo access is always read-only + perm.SetUnitsWithDefaultAccessMode(repo.Units, perm_model.AccessModeRead) + return perm, nil } - if err := repo.LoadUnits(ctx); err != nil { - return perm, err + // Get effective token permissions from repository settings + effectivePerms := actionsCfg.GetEffectiveTokenPermissions(task.IsForkPullRequest) + effectivePerms = actionsCfg.ClampPermissions(effectivePerms) + + // Set up per-unit access modes based on configured permissions + perm.units = repo.Units + perm.unitsMode = make(map[unit.Type]perm_model.AccessMode) + perm.unitsMode[unit.TypeCode] = effectivePerms.Contents + perm.unitsMode[unit.TypeIssues] = effectivePerms.Issues + perm.unitsMode[unit.TypePullRequests] = effectivePerms.PullRequests + perm.unitsMode[unit.TypePackages] = effectivePerms.Packages + perm.unitsMode[unit.TypeActions] = effectivePerms.Actions + perm.unitsMode[unit.TypeWiki] = effectivePerms.Wiki + + // Set base access mode to the maximum of all unit permissions + maxMode := perm_model.AccessModeNone + for _, mode := range perm.unitsMode { + if mode > maxMode { + maxMode = mode + } } - perm.SetUnitsWithDefaultAccessMode(repo.Units, accessMode) + perm.AccessMode = maxMode + return perm, nil } diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index ad0bb9d3f82ec..e84a52439c5b0 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -168,11 +168,112 @@ func (cfg *PullRequestsConfig) GetDefaultMergeStyle() MergeStyle { return MergeStyleMerge } +// ActionsTokenPermissionMode defines the default permission mode for Actions tokens +type ActionsTokenPermissionMode string + +const ( + // ActionsTokenPermissionModePermissive - write access by default (current behavior, backwards compatible) + ActionsTokenPermissionModePermissive ActionsTokenPermissionMode = "permissive" + // ActionsTokenPermissionModeRestricted - read access by default + ActionsTokenPermissionModeRestricted ActionsTokenPermissionMode = "restricted" + // ActionsTokenPermissionModeCustom - user-defined permissions + ActionsTokenPermissionModeCustom ActionsTokenPermissionMode = "custom" +) + +// ActionsTokenPermissions defines the permissions for different repository units +type ActionsTokenPermissions struct { + // Contents (repository code) - read/write/none + Contents perm.AccessMode `json:"contents"` + // Issues - read/write/none + Issues perm.AccessMode `json:"issues"` + // PullRequests - read/write/none + PullRequests perm.AccessMode `json:"pull_requests"` + // Packages - read/write/none + Packages perm.AccessMode `json:"packages"` + // Actions - read/write/none + Actions perm.AccessMode `json:"actions"` + // Wiki - read/write/none + Wiki perm.AccessMode `json:"wiki"` +} + +// 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.Contents + case "issues": + mode = p.Issues + case "packages": + mode = p.Packages + case "pull_requests": + mode = p.PullRequests + case "wiki": + mode = p.Wiki + } + 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{ + Contents: perm.AccessModeRead, + Issues: perm.AccessModeRead, + PullRequests: perm.AccessModeRead, + Packages: perm.AccessModeRead, + Actions: perm.AccessModeRead, + Wiki: perm.AccessModeRead, + } + } + // Permissive mode (default) + return ActionsTokenPermissions{ + Contents: perm.AccessModeWrite, + Issues: perm.AccessModeWrite, + PullRequests: perm.AccessModeWrite, + Packages: perm.AccessModeRead, // Packages read by default for security + Actions: perm.AccessModeWrite, + Wiki: perm.AccessModeWrite, + } +} + +// ForkPullRequestPermissions returns the restricted permissions for fork pull requests +func ForkPullRequestPermissions() ActionsTokenPermissions { + return ActionsTokenPermissions{ + Contents: perm.AccessModeRead, + Issues: perm.AccessModeRead, + PullRequests: perm.AccessModeRead, + Packages: perm.AccessModeRead, + Actions: perm.AccessModeRead, + Wiki: perm.AccessModeRead, + } +} + type ActionsConfig struct { DisabledWorkflows []string // CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos. // Only workflows from the private repos whose owners are in CollaborativeOwnerIDs can access the current repo's actions. CollaborativeOwnerIDs []int64 + // TokenPermissionMode defines the default permission mode (permissive or restricted) + TokenPermissionMode ActionsTokenPermissionMode `json:"token_permission_mode,omitempty"` + // DefaultTokenPermissions defines the default permissions for workflow tokens + DefaultTokenPermissions *ActionsTokenPermissions `json:"default_token_permissions,omitempty"` + // MaxTokenPermissions defines the maximum permissions (cannot be exceeded by workflow permissions keyword) + MaxTokenPermissions *ActionsTokenPermissions `json:"max_token_permissions,omitempty"` + // AllowCrossRepoAccess indicates if actions in this repo/org can access other repos in the same org + AllowCrossRepoAccess bool `json:"allow_cross_repo_access,omitempty"` } func (cfg *ActionsConfig) EnableWorkflow(file string) { @@ -209,6 +310,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) diff --git a/models/repo/repo_unit_test.go b/models/repo/repo_unit_test.go index 56dda5672d4ff..11b430e485fc7 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.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) + }) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 307cccf8bd6bf..8f420b9581e9b 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3769,7 +3769,38 @@ "general.add_collaborative_owner": "Add Collaborative Owner", "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.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.contents.description": "Access source code, files, commits and branches.", + "general.token_permissions.issues": "Issues", + "general.token_permissions.issues.description": "Organize bug reports, tasks and milestones.", + "general.token_permissions.pull_requests": "Pull Requests", + "general.token_permissions.pull_requests.description": "Enable pull requests and code reviews.", + "general.token_permissions.packages": "Packages", + "general.token_permissions.packages.description": "Manage repository packages.", + "general.token_permissions.actions_scope": "Actions", + "general.token_permissions.actions_scope.description": "Manage actions.", + "general.token_permissions.wiki": "Wiki", + "general.token_permissions.wiki.description": "Write and share documentation with collaborators.", + "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.", + "general.token_permissions.cross_repo": "Cross-Repository Access", + "general.token_permissions.cross_repo_desc": "Allow workflows in this organization to access other repositories within the same organization.", + "general.token_permissions.custom": "Custom permissions", + "general.token_permissions.custom.description": "Configure permissions for each scope individually.", + "general.token_permissions.individual": "Individual Permissions", + "general.token_permissions.maximum": "Maximum Permissions", + "general.token_permissions.maximum.description": "Configure the maximum permissions that can be requested by a workflow." }, "projects": { "deleted.display_name": "Deleted Project", diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index f6ee5958b5bb9..1d1ae4291ea8d 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -6,6 +6,7 @@ package packages import ( "net/http" + actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/modules/log" @@ -80,6 +81,58 @@ 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.Visibility.IsPrivate() { + // 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 task.RepoID != packageRepoID { + // 1. Private packages MUST be linked to a repository + if packageRepoID == 0 { + ctx.HTTPError(http.StatusForbidden, "reqPackageAccess", "private package must be linked to a repository to be accessed by Actions") + return + } + + // 2. Check Org Cross-Repo Access Policy + if ctx.Package.Owner.IsOrganization() { + 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 + } + if !cfg.AllowCrossRepoAccess { + ctx.HTTPError(http.StatusForbidden, "reqPackageAccess", "cross-repository package access is disabled") + return + } + } + + // 3. Fallthrough to GetActionsUserRepoPermission + // We rely on the backend permission check below to handle other Cross-Repository restrictions + // (e.g., User collaborative owners, token scopes). + } + } + } + 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/web/org/setting/actions.go b/routers/web/org/setting/actions.go new file mode 100644 index 0000000000000..949ceb1a2a213 --- /dev/null +++ b/routers/web/org/setting/actions.go @@ -0,0 +1,123 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + 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["DefaultTokenPermissions"] = actionsCfg.GetEffectiveTokenPermissions(false) + ctx.Data["MaxTokenPermissions"] = actionsCfg.GetMaxTokenPermissions() + + ctx.Data["AllowCrossRepoAccess"] = actionsCfg.AllowCrossRepoAccess + + 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 + } + + if actionsCfg.TokenPermissionMode == repo_model.ActionsTokenPermissionModeCustom { + // Custom mode uses radio buttons for each permission scope + parsePerm := func(name string) perm.AccessMode { + value := ctx.FormString(name) + switch value { + case "write": + return perm.AccessModeWrite + case "read": + return perm.AccessModeRead + default: + return perm.AccessModeNone + } + } + + actionsCfg.DefaultTokenPermissions = &repo_model.ActionsTokenPermissions{ + Actions: parsePerm("perm_actions"), + Contents: parsePerm("perm_contents"), + Issues: parsePerm("perm_issues"), + Packages: parsePerm("perm_packages"), + PullRequests: parsePerm("perm_pull_requests"), + Wiki: parsePerm("perm_wiki"), + } + } else { + actionsCfg.DefaultTokenPermissions = nil + } + + // 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{ + Actions: parseMaxPerm("actions"), + Contents: parseMaxPerm("contents"), + Issues: parseMaxPerm("issues"), + Packages: parseMaxPerm("packages"), + PullRequests: parseMaxPerm("pull_requests"), + Wiki: parseMaxPerm("wiki"), + } + + // Update Cross-Repo Access + actionsCfg.AllowCrossRepoAccess = ctx.FormBool("allow_cross_repo_access") + + 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") +} diff --git a/routers/web/repo/setting/actions.go b/routers/web/repo/setting/actions.go index 9c2c9242d34b6..cd3b0ae7bf796 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,18 @@ 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["DefaultTokenPermissions"] = 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 +130,84 @@ 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 := 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 + } + + if actionsCfg.TokenPermissionMode == repo_model.ActionsTokenPermissionModeCustom { + // Custom mode uses radio buttons for each permission scope + parsePerm := func(name string) perm.AccessMode { + value := ctx.FormString(name) + switch value { + case "write": + return perm.AccessModeWrite + case "read": + return perm.AccessModeRead + default: + return perm.AccessModeNone + } + } + + actionsCfg.DefaultTokenPermissions = &repo_model.ActionsTokenPermissions{ + Actions: parsePerm("perm_actions"), + Contents: parsePerm("perm_contents"), + Issues: parsePerm("perm_issues"), + Packages: parsePerm("perm_packages"), + PullRequests: parsePerm("perm_pull_requests"), + Wiki: parsePerm("perm_wiki"), + } + } else { + actionsCfg.DefaultTokenPermissions = nil + } + + // 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{ + Actions: parseMaxPerm("actions"), + Contents: parseMaxPerm("contents"), + Issues: parseMaxPerm("issues"), + Packages: parseMaxPerm("packages"), + PullRequests: parseMaxPerm("pull_requests"), + Wiki: parseMaxPerm("wiki"), + } + + 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 86e51d607e2fd..73b9a5dd0ba0b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -961,7 +961,8 @@ func registerWebRoutes(m *web.Router) { }) m.Group("/actions", func() { - m.Get("", org_setting.RedirectToDefaultSetting) + m.Get("", org_setting.ActionsGeneral) + m.Post("", org_setting.ActionsGeneralPost) addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() @@ -1167,6 +1168,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/templates/org/settings/actions_general.tmpl b/templates/org/settings/actions_general.tmpl new file mode 100644 index 0000000000000..374e0cf46d770 --- /dev/null +++ b/templates/org/settings/actions_general.tmpl @@ -0,0 +1,215 @@ +{{template "org/settings/layout_head" .}} +