diff --git a/cmd/hook.go b/cmd/hook.go index 4f6492b0f0..a0280e283f 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -194,7 +194,7 @@ Gitea or set your environment appropriately.`, "") userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) 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, @@ -204,7 +204,7 @@ Gitea or set your environment appropriately.`, "") GitPushOptions: pushOptions(), PullRequestID: prID, DeployKeyID: deployKeyID, - ActionPerm: actionPerm, + ActionsTaskID: actionsTaskID, IsWiki: isWiki, } diff --git a/models/actions/config.go b/models/actions/config.go new file mode 100644 index 0000000000..4f5357c560 --- /dev/null +++ b/models/actions/config.go @@ -0,0 +1,74 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/util" + + "xorm.io/xorm/convert" +) + +// OwnerActionsConfig defines the Actions configuration for a user or organization +type OwnerActionsConfig struct { + // TokenPermissionMode defines the default permission mode (permissive, restricted) + TokenPermissionMode repo_model.ActionsTokenPermissionMode `json:"token_permission_mode,omitempty"` + + // MaxTokenPermissions defines the absolute maximum permissions any token can have in this context. + MaxTokenPermissions *repo_model.ActionsTokenPermissions `json:"max_token_permissions,omitempty"` + + // AllowedCrossRepoIDs is a list of specific repo IDs that can be accessed cross-repo + AllowedCrossRepoIDs []int64 `json:"allowed_cross_repo_ids,omitempty"` +} + +var _ convert.ConversionFrom = (*OwnerActionsConfig)(nil) + +func (cfg *OwnerActionsConfig) FromDB(bytes []byte) error { + _ = json.Unmarshal(bytes, cfg) + cfg.TokenPermissionMode, _ = util.EnumValue(cfg.TokenPermissionMode) + return nil +} + +// GetOwnerActionsConfig loads the OwnerActionsConfig for a user or organization from user settings +// It returns a default config if no setting is found +func GetOwnerActionsConfig(ctx context.Context, userID int64) (ret OwnerActionsConfig, err error) { + return user_model.GetUserSettingJSON(ctx, userID, user_model.SettingsKeyActionsConfig, ret) +} + +// SetOwnerActionsConfig saves the OwnerActionsConfig for a user or organization to user settings +func SetOwnerActionsConfig(ctx context.Context, userID int64, cfg OwnerActionsConfig) error { + return user_model.SetUserSettingJSON(ctx, userID, user_model.SettingsKeyActionsConfig, cfg) +} + +// GetDefaultTokenPermissions returns the default token permissions by its TokenPermissionMode. +func (cfg *OwnerActionsConfig) GetDefaultTokenPermissions() repo_model.ActionsTokenPermissions { + switch cfg.TokenPermissionMode { + case repo_model.ActionsTokenPermissionModeRestricted: + return repo_model.MakeRestrictedPermissions() + case repo_model.ActionsTokenPermissionModePermissive: + return repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite) + default: + return repo_model.MakeActionsTokenPermissions(perm.AccessModeNone) + } +} + +// GetMaxTokenPermissions returns the maximum allowed permissions +func (cfg *OwnerActionsConfig) GetMaxTokenPermissions() repo_model.ActionsTokenPermissions { + if cfg.MaxTokenPermissions != nil { + return *cfg.MaxTokenPermissions + } + // Default max is write for everything + return repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite) +} + +// ClampPermissions ensures that the given permissions don't exceed the maximum +func (cfg *OwnerActionsConfig) ClampPermissions(perms repo_model.ActionsTokenPermissions) repo_model.ActionsTokenPermissions { + maxPerms := cfg.GetMaxTokenPermissions() + return repo_model.ClampActionsTokenPermissions(perms, maxPerms) +} diff --git a/models/actions/run_job.go b/models/actions/run_job.go index c752e61b7d..616e298dc9 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -51,6 +51,11 @@ 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 explicit permissions from workflow/job YAML (no org/repo clamps applied). + // Org/repo clamps are enforced when the token is used at runtime. + // It is JSON-encoded repo_model.ActionsTokenPermissions and may be empty if not specified. + TokenPermissions *repo_model.ActionsTokenPermissions `xorm:"JSON TEXT"` + Started timeutil.TimeStamp Stopped timeutil.TimeStamp Created timeutil.TimeStamp `xorm:"created"` diff --git a/models/actions/token_permissions.go b/models/actions/token_permissions.go new file mode 100644 index 0000000000..985f6cc97b --- /dev/null +++ b/models/actions/token_permissions.go @@ -0,0 +1,60 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" +) + +// ComputeTaskTokenPermissions computes the effective permissions for a job token against the target repository. +// It uses the job's stored permissions (if any), then applies org/repo clamps and fork/cross-repo restrictions. +// Note: target repository access policy checks are enforced in GetActionsUserRepoPermission; this function only computes the job token's effective permission ceiling. +func ComputeTaskTokenPermissions(ctx context.Context, task *ActionTask, targetRepo *repo_model.Repository) (ret repo_model.ActionsTokenPermissions, err error) { + if err := task.LoadJob(ctx); err != nil { + return ret, err + } + if err := task.Job.LoadRepo(ctx); err != nil { + return ret, err + } + runRepo := task.Job.Repo + + if err := runRepo.LoadOwner(ctx); err != nil { + return ret, err + } + + repoActionsCfg := runRepo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + ownerActionsCfg, err := GetOwnerActionsConfig(ctx, runRepo.OwnerID) + if err != nil { + return ret, err + } + + var jobDeclaredPerms repo_model.ActionsTokenPermissions + if task.Job.TokenPermissions != nil { + jobDeclaredPerms = *task.Job.TokenPermissions + } else if repoActionsCfg.OverrideOwnerConfig { + jobDeclaredPerms = repoActionsCfg.GetDefaultTokenPermissions() + } else { + jobDeclaredPerms = ownerActionsCfg.GetDefaultTokenPermissions() + } + + var effectivePerms repo_model.ActionsTokenPermissions + if repoActionsCfg.OverrideOwnerConfig { + effectivePerms = repoActionsCfg.ClampPermissions(jobDeclaredPerms) + } else { + effectivePerms = ownerActionsCfg.ClampPermissions(jobDeclaredPerms) + } + + // Cross-repository access and fork pull requests are strictly read-only for security. + // This ensures a "task repo" cannot gain write access to other repositories via CrossRepoAccess settings. + isSameRepo := task.Job.RepoID == targetRepo.ID + restrictCrossRepoAccess := task.IsForkPullRequest || !isSameRepo + if restrictCrossRepoAccess { + effectivePerms = repo_model.ClampActionsTokenPermissions(effectivePerms, repo_model.MakeRestrictedPermissions()) + } + + return effectivePerms, nil +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c1d448577c..dc5dc9f330 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -402,6 +402,7 @@ func prepareMigrationTasks() []*migration { newMigration(325, "Fix missed repo_id when migrate attachments", v1_26.FixMissedRepoIDWhenMigrateAttachments), newMigration(326, "Migrate commit status target URL to use run ID and job ID", v1_26.FixCommitStatusTargetURLToUseRunAndJobID), newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner), + newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob), } return preparedMigrations } diff --git a/models/migrations/v1_26/v328.go b/models/migrations/v1_26/v328.go new file mode 100644 index 0000000000..8104730528 --- /dev/null +++ b/models/migrations/v1_26/v328.go @@ -0,0 +1,16 @@ +// Copyright 2026 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:"JSON TEXT"` + } + _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(ActionRunJob)) + return err +} diff --git a/models/perm/access/actions_repo_permission_test.go b/models/perm/access/actions_repo_permission_test.go new file mode 100644 index 0000000000..442f6cf2fc --- /dev/null +++ b/models/perm/access/actions_repo_permission_test.go @@ -0,0 +1,155 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package access + +import ( + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + perm_model "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetActionsUserRepoPermission(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + // Use fixtures for repos and users + repo4 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) // Public, Owner 5, has Actions unit + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) // Private, Owner 2, no Actions unit in fixtures + repo15 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 15}) // Private, Owner 2, no Actions unit in fixtures + owner2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + actionsUser := user_model.NewActionsUser() + + // Ensure repo2 and repo15 have Actions units for testing configuration + for _, r := range []*repo_model.Repository{repo2, repo15} { + require.NoError(t, db.Insert(ctx, &repo_model.RepoUnit{ + RepoID: r.ID, + Type: unit.TypeActions, + Config: &repo_model.ActionsConfig{}, + })) + } + + t.Run("SameRepo_Public", func(t *testing.T) { + task47 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47}) + require.Equal(t, repo4.ID, task47.RepoID) + + perm, err := GetActionsUserRepoPermission(ctx, repo4, actionsUser, task47.ID) + require.NoError(t, err) + + // Public repo, bot should have Read access even if not collaborator + assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode) + assert.True(t, perm.CanRead(unit.TypeCode)) + }) + + t.Run("SameRepo_Private", func(t *testing.T) { + // Use Task 53 which is already in Repo 2 (Private) + task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53}) + require.Equal(t, repo2.ID, task53.RepoID) + + perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID) + require.NoError(t, err) + + // Private repo, bot has no base access, but gets Write from effective tokens perms (Permissive by default) + assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode) + assert.True(t, perm.CanWrite(unit.TypeCode)) + }) + + t.Run("CrossRepo_Denied_None", func(t *testing.T) { + task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53}) + + // Set owner policy to nil allowed repos (None) + cfg := actions_model.OwnerActionsConfig{} + require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, cfg)) + + perm, err := GetActionsUserRepoPermission(ctx, repo15, actionsUser, task53.ID) + require.NoError(t, err) + + // Should NOT have access to the private repo. + assert.False(t, perm.CanRead(unit.TypeCode)) + }) + + t.Run("ForkPR_NoCrossRepo", func(t *testing.T) { + task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53}) + task53.IsForkPullRequest = true + require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request")) + + // Policy contains repo15 + cfg := actions_model.OwnerActionsConfig{ + AllowedCrossRepoIDs: []int64{repo15.ID}, + } + require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, cfg)) + + perm, err := GetActionsUserRepoPermission(ctx, repo15, actionsUser, task53.ID) + require.NoError(t, err) + + // Fork PR never gets cross-repo access to other private repos + assert.False(t, perm.CanRead(unit.TypeCode)) + }) + + t.Run("Inheritance_And_Clamping", func(t *testing.T) { + task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53}) + task53.IsForkPullRequest = false + require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request")) + + // Owner policy: Restricted mode (Read-only Code) + ownerCfg := actions_model.OwnerActionsConfig{ + TokenPermissionMode: repo_model.ActionsTokenPermissionModeRestricted, + MaxTokenPermissions: &repo_model.ActionsTokenPermissions{ + UnitAccessModes: map[unit.Type]perm_model.AccessMode{ + unit.TypeCode: perm_model.AccessModeRead, + }, + }, + } + require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, ownerCfg)) + + // Repo policy: OverrideOwnerConfig = false (should inherit owner's restricted mode) + repo2ActionsUnit := repo2.MustGetUnit(ctx, unit.TypeActions) + repo2ActionsCfg := repo2ActionsUnit.ActionsConfig() + repo2ActionsCfg.OverrideOwnerConfig = false + require.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, repo2ActionsUnit)) + + perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID) + require.NoError(t, err) + + // Should be clamped to Read-only + assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeCode)) + assert.False(t, perm.CanWrite(unit.TypeCode)) + }) + + t.Run("RepoOverride_Clamping", func(t *testing.T) { + task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53}) + + // Owner policy: Permissive (Write access) + ownerCfg := actions_model.OwnerActionsConfig{ + TokenPermissionMode: repo_model.ActionsTokenPermissionModePermissive, + } + require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, ownerCfg)) + + // Repo policy: OverrideOwnerConfig = true, MaxTokenPermissions = Read + repo2ActionsUnit := repo2.MustGetUnit(ctx, unit.TypeActions) + repo2ActionsCfg := repo2ActionsUnit.ActionsConfig() + repo2ActionsCfg.OverrideOwnerConfig = true + repo2ActionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModeRestricted + repo2ActionsCfg.MaxTokenPermissions = &repo_model.ActionsTokenPermissions{ + UnitAccessModes: map[unit.Type]perm_model.AccessMode{ + unit.TypeCode: perm_model.AccessModeRead, + }, + } + require.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, repo2ActionsUnit)) + + perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID) + require.NoError(t, err) + + // Should be clamped to Read-only + assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeCode)) + }) +} diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index 3235d83203..622fa5d99a 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "maps" "slices" "strings" @@ -258,6 +259,23 @@ func finalProcessRepoUnitPermission(user *user_model.User, perm *Permission) { } } +func checkSameOwnerCrossRepoAccess(ctx context.Context, taskRepo, targetRepo *repo_model.Repository, isForkPR bool) bool { + if isForkPR { + // Fork PRs are never allowed cross-repo access to other private repositories of the owner. + return false + } + if taskRepo.OwnerID != targetRepo.OwnerID { + return false + } + ownerCfg, err := actions_model.GetOwnerActionsConfig(ctx, targetRepo.OwnerID) + if err != nil { + log.Error("GetOwnerActionsConfig: %v", err) + return false + } + + return slices.Contains(ownerCfg.AllowedCrossRepoIDs, targetRepo.ID) +} + // GetActionsUserRepoPermission returns the actions user permissions to the repository func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Repository, actionsUser *user_model.User, taskID int64) (perm Permission, err error) { if actionsUser.ID != user_model.ActionsUserID { @@ -268,37 +286,96 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito return perm, err } - var accessMode perm_model.AccessMode + if err := task.LoadJob(ctx); err != nil { + return perm, err + } + + var taskRepo *repo_model.Repository if task.RepoID != repo.ID { - taskRepo, exist, err := db.GetByID[repo_model.Repository](ctx, task.RepoID) - if err != nil || !exist { + if err := task.Job.LoadRepo(ctx); err != nil { return perm, err } - actionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() - if !actionsCfg.IsCollaborativeOwner(taskRepo.OwnerID) || !taskRepo.IsPrivate { - // The task repo can access the current repo only if the task repo is private and - // the owner of the task repo is a collaborative owner of the current repo. - // FIXME should owner's visibility also be considered here? + taskRepo = task.Job.Repo + } else { + taskRepo = repo + } - // check permission like simple user but limit to read-only - perm, err = GetUserRepoPermission(ctx, repo, user_model.NewActionsUser()) + // Compute effective permissions for this task against the target repo + effectivePerms, err := actions_model.ComputeTaskTokenPermissions(ctx, task, repo) + if err != nil { + return perm, err + } + if task.RepoID != repo.ID { + // Cross-repo access must also respect the target repo's permission ceiling. + targetRepoActionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + if targetRepoActionsCfg.OverrideOwnerConfig { + effectivePerms = targetRepoActionsCfg.ClampPermissions(effectivePerms) + } else { + targetRepoOwnerActionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, repo.OwnerID) if err != nil { return perm, err } - perm.AccessMode = min(perm.AccessMode, perm_model.AccessModeRead) - return perm, nil + effectivePerms = targetRepoOwnerActionsCfg.ClampPermissions(effectivePerms) } - accessMode = perm_model.AccessModeRead - } else if task.IsForkPullRequest { - accessMode = perm_model.AccessModeRead - } else { - accessMode = perm_model.AccessModeWrite } if err := repo.LoadUnits(ctx); err != nil { return perm, err } - perm.SetUnitsWithDefaultAccessMode(repo.Units, accessMode) + + var maxPerm Permission + + // Set up per-unit access modes based on configured permissions + maxPerm.units = repo.Units + maxPerm.unitsMode = maps.Clone(effectivePerms.UnitAccessModes) + + // Check permission like simple user but limit to read-only (PR #36095) + // Enhanced to also grant read-only access if isSameRepo is true and target repository is public + botPerm, err := GetUserRepoPermission(ctx, repo, user_model.NewActionsUser()) + if err != nil { + return perm, err + } + if botPerm.AccessMode >= perm_model.AccessModeRead { + // Public repo allows read access, increase permissions to at least read + // Otherwise you cannot access your own repository if your permissions are set to none but the repository is public + for _, u := range repo.Units { + if botPerm.CanRead(u.Type) { + maxPerm.unitsMode[u.Type] = max(maxPerm.unitsMode[u.Type], perm_model.AccessModeRead) + } + } + } + + if task.RepoID == repo.ID { + return maxPerm, nil + } + + if checkSameOwnerCrossRepoAccess(ctx, taskRepo, repo, task.IsForkPullRequest) { + // Access allowed by owner policy (grants access to private repos). + // Note: maxPerm has already been restricted to Read-Only in ComputeTaskTokenPermissions + // because isSameRepo is false. + return maxPerm, nil + } + + // Fall through to allow public repository read access via botPerm check below + + // Check if the repo is public or the Bot has explicit access + if botPerm.AccessMode >= perm_model.AccessModeRead { + return maxPerm, nil + } + + // Check Collaborative Owner and explicit Bot permissions + // We allow access if: + // 1. It's a collaborative owner relationship + // 2. The Actions Bot user has been explicitly granted access and repository is private + // 3. The repository is public (handled by botPerm above) + + if taskRepo.IsPrivate { + actionsUnit := repo.MustGetUnit(ctx, unit.TypeActions) + if actionsUnit.ActionsConfig().IsCollaborativeOwner(taskRepo.OwnerID) { + return maxPerm, nil + } + } + return perm, nil } diff --git a/models/repo/pull_request_default_test.go b/models/repo/pull_request_default_test.go index 1c4f585ed9..b1653f2f1a 100644 --- a/models/repo/pull_request_default_test.go +++ b/models/repo/pull_request_default_test.go @@ -26,7 +26,7 @@ func TestDefaultTargetBranchSelection(t *testing.T) { prConfig := prUnit.PullRequestsConfig() prConfig.DefaultTargetBranch = "branch2" prUnit.Config = prConfig - assert.NoError(t, UpdateRepoUnit(ctx, prUnit)) + assert.NoError(t, UpdateRepoUnitConfig(ctx, prUnit)) repo.Units = nil assert.Equal(t, "branch2", repo.GetPullRequestTargetBranch(ctx)) } diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index 811f83c999..e927174a55 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -778,3 +778,11 @@ func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (Repositor repos := make(RepositoryList, 0, opts.PageSize) return repos, count, db.SetSessionPagination(sess, &opts).Find(&repos) } + +func GetOwnerRepositoriesByIDs(ctx context.Context, ownerID int64, repoIDs []int64) (RepositoryList, error) { + if len(repoIDs) == 0 { + return RepositoryList{}, nil + } + repos := make(RepositoryList, 0, len(repoIDs)) + return repos, db.GetEngine(ctx).Where(builder.Eq{"owner_id": ownerID}).In("id", repoIDs).Find(&repos) +} diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index 491e96770c..797b34de69 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -5,8 +5,6 @@ package repo import ( "context" - "slices" - "strings" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" @@ -175,57 +173,6 @@ func DefaultPullRequestsUnit(repoID int64) RepoUnit { return RepoUnit{RepoID: repoID, Type: unit.TypePullRequests, Config: DefaultPullRequestsConfig()} } -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 -} - -func (cfg *ActionsConfig) EnableWorkflow(file string) { - cfg.DisabledWorkflows = util.SliceRemoveAll(cfg.DisabledWorkflows, file) -} - -func (cfg *ActionsConfig) ToString() string { - return strings.Join(cfg.DisabledWorkflows, ",") -} - -func (cfg *ActionsConfig) IsWorkflowDisabled(file string) bool { - return slices.Contains(cfg.DisabledWorkflows, file) -} - -func (cfg *ActionsConfig) DisableWorkflow(file string) { - if slices.Contains(cfg.DisabledWorkflows, file) { - return - } - - cfg.DisabledWorkflows = append(cfg.DisabledWorkflows, file) -} - -func (cfg *ActionsConfig) AddCollaborativeOwner(ownerID int64) { - if !slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) { - cfg.CollaborativeOwnerIDs = append(cfg.CollaborativeOwnerIDs, ownerID) - } -} - -func (cfg *ActionsConfig) RemoveCollaborativeOwner(ownerID int64) { - cfg.CollaborativeOwnerIDs = util.SliceRemoveAll(cfg.CollaborativeOwnerIDs, ownerID) -} - -func (cfg *ActionsConfig) IsCollaborativeOwner(ownerID int64) bool { - return slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) -} - -// FromDB fills up a ActionsConfig from serialized format. -func (cfg *ActionsConfig) FromDB(bs []byte) error { - return json.UnmarshalHandleDoubleEncode(bs, &cfg) -} - -// ToDB exports a ActionsConfig to a serialized format. -func (cfg *ActionsConfig) ToDB() ([]byte, error) { - return json.Marshal(cfg) -} - // ProjectsMode represents the projects enabled for a repository type ProjectsMode string @@ -279,7 +226,8 @@ func (cfg *ProjectsConfig) IsProjectsAllowed(m ProjectsMode) bool { func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) { switch colName { case "type": - switch unit.Type(db.Cell2Int64(val)) { + r.Type = unit.Type(db.Cell2Int64(val)) + switch r.Type { case unit.TypeExternalWiki: r.Config = new(ExternalWikiConfig) case unit.TypeExternalTracker: @@ -297,6 +245,11 @@ func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) { default: r.Config = new(UnitConfig) } + case "config": + if *val == nil { + // XROM doesn't call FromDB if the value is nil, but we need to set default values for the config fields + _ = r.Config.FromDB(nil) + } } } @@ -360,9 +313,9 @@ func getUnitsByRepoID(ctx context.Context, repoID int64) (units []*RepoUnit, err return units, nil } -// UpdateRepoUnit updates the provided repo unit -func UpdateRepoUnit(ctx context.Context, unit *RepoUnit) error { - _, err := db.GetEngine(ctx).ID(unit.ID).Update(unit) +// UpdateRepoUnitConfig updates the config of the provided repo unit +func UpdateRepoUnitConfig(ctx context.Context, unit *RepoUnit) error { + _, err := db.GetEngine(ctx).ID(unit.ID).Cols("config").Update(unit) return err } diff --git a/models/repo/repo_unit_actions.go b/models/repo/repo_unit_actions.go new file mode 100644 index 0000000000..50e2925792 --- /dev/null +++ b/models/repo/repo_unit_actions.go @@ -0,0 +1,153 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "slices" + + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/util" +) + +// 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" +) + +func (ActionsTokenPermissionMode) EnumValues() []ActionsTokenPermissionMode { + return []ActionsTokenPermissionMode{ActionsTokenPermissionModePermissive /* default */, ActionsTokenPermissionModeRestricted} +} + +// ActionsTokenPermissions defines the permissions for different repository units +type ActionsTokenPermissions struct { + UnitAccessModes map[unit.Type]perm.AccessMode `json:"unit_access_modes,omitempty"` +} + +var ActionsTokenUnitTypes = []unit.Type{ + unit.TypeCode, + unit.TypeIssues, + unit.TypePullRequests, + unit.TypePackages, + unit.TypeActions, + unit.TypeWiki, + unit.TypeReleases, + unit.TypeProjects, +} + +func MakeActionsTokenPermissions(unitAccessMode perm.AccessMode) (ret ActionsTokenPermissions) { + ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode) + for _, u := range ActionsTokenUnitTypes { + ret.UnitAccessModes[u] = unitAccessMode + } + return ret +} + +// ClampActionsTokenPermissions ensures that the given permissions don't exceed the maximum +func ClampActionsTokenPermissions(p1, p2 ActionsTokenPermissions) (ret ActionsTokenPermissions) { + ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode) + for _, ut := range ActionsTokenUnitTypes { + ret.UnitAccessModes[ut] = min(p1.UnitAccessModes[ut], p2.UnitAccessModes[ut]) + } + return ret +} + +// MakeRestrictedPermissions returns the restricted permissions +func MakeRestrictedPermissions() ActionsTokenPermissions { + ret := MakeActionsTokenPermissions(perm.AccessModeNone) + ret.UnitAccessModes[unit.TypeCode] = perm.AccessModeRead + ret.UnitAccessModes[unit.TypePackages] = perm.AccessModeRead + ret.UnitAccessModes[unit.TypeReleases] = perm.AccessModeRead + return ret +} + +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, restricted, or custom) + TokenPermissionMode ActionsTokenPermissionMode `json:"token_permission_mode,omitempty"` + // MaxTokenPermissions defines the absolute maximum permissions any token can have in this context. + // Workflow YAML "permissions" keywords can reduce permissions but never exceed this ceiling. + MaxTokenPermissions *ActionsTokenPermissions `json:"max_token_permissions,omitempty"` + // OverrideOwnerConfig indicates if this repository should override the owner-level configuration (User or Org) + OverrideOwnerConfig bool `json:"override_owner_config,omitempty"` +} + +func (cfg *ActionsConfig) EnableWorkflow(file string) { + cfg.DisabledWorkflows = util.SliceRemoveAll(cfg.DisabledWorkflows, file) +} + +func (cfg *ActionsConfig) IsWorkflowDisabled(file string) bool { + return slices.Contains(cfg.DisabledWorkflows, file) +} + +func (cfg *ActionsConfig) DisableWorkflow(file string) { + if slices.Contains(cfg.DisabledWorkflows, file) { + return + } + + cfg.DisabledWorkflows = append(cfg.DisabledWorkflows, file) +} + +func (cfg *ActionsConfig) AddCollaborativeOwner(ownerID int64) { + if !slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) { + cfg.CollaborativeOwnerIDs = append(cfg.CollaborativeOwnerIDs, ownerID) + } +} + +func (cfg *ActionsConfig) RemoveCollaborativeOwner(ownerID int64) { + cfg.CollaborativeOwnerIDs = util.SliceRemoveAll(cfg.CollaborativeOwnerIDs, ownerID) +} + +func (cfg *ActionsConfig) IsCollaborativeOwner(ownerID int64) bool { + return slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) +} + +// GetDefaultTokenPermissions returns the default token permissions by its TokenPermissionMode. +// It does not apply MaxTokenPermissions; callers must clamp if needed. +func (cfg *ActionsConfig) GetDefaultTokenPermissions() ActionsTokenPermissions { + switch cfg.TokenPermissionMode { + case ActionsTokenPermissionModeRestricted: + return MakeRestrictedPermissions() + case ActionsTokenPermissionModePermissive: + return MakeActionsTokenPermissions(perm.AccessModeWrite) + default: + return ActionsTokenPermissions{} + } +} + +// GetMaxTokenPermissions returns the maximum allowed permissions +func (cfg *ActionsConfig) GetMaxTokenPermissions() ActionsTokenPermissions { + if cfg.MaxTokenPermissions != nil { + return *cfg.MaxTokenPermissions + } + // Default max is write for everything + return MakeActionsTokenPermissions(perm.AccessModeWrite) +} + +// ClampPermissions ensures that the given permissions don't exceed the maximum +func (cfg *ActionsConfig) ClampPermissions(perms ActionsTokenPermissions) ActionsTokenPermissions { + maxPerms := cfg.GetMaxTokenPermissions() + return ClampActionsTokenPermissions(perms, maxPerms) +} + +// FromDB fills up a ActionsConfig from serialized format. +func (cfg *ActionsConfig) FromDB(bs []byte) error { + _ = json.UnmarshalHandleDoubleEncode(bs, &cfg) + cfg.TokenPermissionMode, _ = util.EnumValue(cfg.TokenPermissionMode) + return nil +} + +// ToDB exports a ActionsConfig to a serialized format. +func (cfg *ActionsConfig) ToDB() ([]byte, error) { + return json.Marshal(cfg) +} diff --git a/models/repo/repo_unit_test.go b/models/repo/repo_unit_test.go index 56dda5672d..08f9ac2cd4 100644 --- a/models/repo/repo_unit_test.go +++ b/models/repo/repo_unit_test.go @@ -4,8 +4,12 @@ package repo import ( + "strings" "testing" + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/unit" + "github.com/stretchr/testify/assert" ) @@ -26,5 +30,75 @@ func TestActionsConfig(t *testing.T) { cfg.DisableWorkflow("test1.yaml") cfg.DisableWorkflow("test2.yaml") cfg.DisableWorkflow("test3.yaml") - assert.Equal(t, "test1.yaml,test2.yaml,test3.yaml", cfg.ToString()) + assert.Equal(t, "test1.yaml,test2.yaml,test3.yaml", strings.Join(cfg.DisabledWorkflows, ",")) +} + +func TestActionsConfigTokenPermissions(t *testing.T) { + t.Run("Default Permission Mode", func(t *testing.T) { + cfg := &ActionsConfig{TokenPermissionMode: "invalid-value"} + _ = cfg.FromDB(nil) + assert.Equal(t, ActionsTokenPermissionModePermissive, cfg.TokenPermissionMode) + assert.Equal(t, perm.AccessModeWrite, cfg.GetDefaultTokenPermissions().UnitAccessModes[unit.TypeCode]) + }) + + t.Run("Explicit Permission Mode", func(t *testing.T) { + cfg := &ActionsConfig{ + TokenPermissionMode: ActionsTokenPermissionModeRestricted, + } + assert.Equal(t, ActionsTokenPermissionModeRestricted, cfg.TokenPermissionMode) + }) + + t.Run("Effective Permissions - Permissive Mode", func(t *testing.T) { + cfg := &ActionsConfig{ + TokenPermissionMode: ActionsTokenPermissionModePermissive, + } + defaultPerms := cfg.GetDefaultTokenPermissions() + perms := cfg.ClampPermissions(defaultPerms) + assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeIssues]) + assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypePackages]) + }) + + t.Run("Effective Permissions - Restricted Mode", func(t *testing.T) { + cfg := &ActionsConfig{ + TokenPermissionMode: ActionsTokenPermissionModeRestricted, + } + defaultPerms := cfg.GetDefaultTokenPermissions() + perms := cfg.ClampPermissions(defaultPerms) + assert.Equal(t, perm.AccessModeRead, perms.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeNone, perms.UnitAccessModes[unit.TypeIssues]) + assert.Equal(t, perm.AccessModeRead, perms.UnitAccessModes[unit.TypePackages]) + }) + + t.Run("Clamp Permissions", func(t *testing.T) { + cfg := &ActionsConfig{ + MaxTokenPermissions: &ActionsTokenPermissions{ + UnitAccessModes: map[unit.Type]perm.AccessMode{ + unit.TypeCode: perm.AccessModeRead, + unit.TypeIssues: perm.AccessModeWrite, + unit.TypePullRequests: perm.AccessModeRead, + unit.TypePackages: perm.AccessModeRead, + unit.TypeActions: perm.AccessModeNone, + unit.TypeWiki: perm.AccessModeWrite, + }, + }, + } + input := ActionsTokenPermissions{ + UnitAccessModes: map[unit.Type]perm.AccessMode{ + unit.TypeCode: perm.AccessModeWrite, // Should be clamped to Read + unit.TypeIssues: perm.AccessModeWrite, // Should stay Write + unit.TypePullRequests: perm.AccessModeWrite, // Should be clamped to Read + unit.TypePackages: perm.AccessModeWrite, // Should be clamped to Read + unit.TypeActions: perm.AccessModeRead, // Should be clamped to None + unit.TypeWiki: perm.AccessModeRead, // Should stay Read + }, + } + clamped := cfg.ClampPermissions(input) + assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeWrite, clamped.UnitAccessModes[unit.TypeIssues]) + assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePullRequests]) + assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePackages]) + assert.Equal(t, perm.AccessModeNone, clamped.UnitAccessModes[unit.TypeActions]) + assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeWiki]) + }) } diff --git a/models/user/setting.go b/models/user/setting.go index c65afae76c..a16fc86e55 100644 --- a/models/user/setting.go +++ b/models/user/setting.go @@ -11,10 +11,12 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/json" setting_module "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "xorm.io/builder" + "xorm.io/xorm/convert" ) // Setting is a key value store of user settings @@ -211,3 +213,44 @@ func upsertUserSettingValue(ctx context.Context, userID int64, key, value string return err }) } + +func GetUserSettingJSON[T any](ctx context.Context, userID int64, key string, def T) (ret T, _ error) { + ret = def + str, err := GetUserSetting(ctx, userID, key) + if err != nil { + return ret, err + } + + conv, ok := any(&ret).(convert.ConversionFrom) + if !ok { + conv, ok = any(ret).(convert.ConversionFrom) + } + if ok { + if err := conv.FromDB(util.UnsafeStringToBytes(str)); err != nil { + return ret, err + } + } else { + if str == "" { + return ret, nil + } + err = json.Unmarshal(util.UnsafeStringToBytes(str), &ret) + } + return ret, err +} + +func SetUserSettingJSON[T any](ctx context.Context, userID int64, key string, val T) (err error) { + conv, ok := any(&val).(convert.ConversionTo) + if !ok { + conv, ok = any(val).(convert.ConversionTo) + } + var bs []byte + if ok { + bs, err = conv.ToDB() + } else { + bs, err = json.Marshal(val) + } + if err != nil { + return err + } + return SetUserSetting(ctx, userID, key, util.UnsafeBytesToString(bs)) +} diff --git a/models/user/setting_options.go b/models/user/setting_options.go index 587a46e8de..5867b908d1 100644 --- a/models/user/setting_options.go +++ b/models/user/setting_options.go @@ -22,4 +22,6 @@ const ( SettingEmailNotificationGiteaActionsAll = "all" SettingEmailNotificationGiteaActionsFailureOnly = "failure-only" // Default for actions email preference SettingEmailNotificationGiteaActionsDisabled = "disabled" + + SettingsKeyActionsConfig = "actions.config" ) diff --git a/modules/private/hook.go b/modules/private/hook.go index 215996b9b9..ce87ccd801 100644 --- a/modules/private/hook.go +++ b/modules/private/hook.go @@ -37,7 +37,7 @@ type HookOptions struct { PushTrigger repository.PushTrigger 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 55a81f006e..ed2c6fef81 100644 --- a/modules/repository/env.go +++ b/modules/repository/env.go @@ -11,25 +11,26 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" ) // 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" + EnvActionsTaskID = "GITEA_ACTIONS_TASK_ID" ) type PushTrigger string @@ -54,36 +55,39 @@ func PushingEnvironment(doer *user_model.User, repo *repo_model.Repository) []st return FullPushingEnvironment(doer, doer, repo, repo.Name, 0, 0) } +func DoerPushingEnvironment(doer *user_model.User, repo *repo_model.Repository, isWiki bool) []string { + env := []string{ + EnvAppURL + "=" + setting.AppURL, + EnvRepoName + "=" + repo.Name + util.Iif(isWiki, ".wiki", ""), + EnvRepoUsername + "=" + repo.OwnerName, + EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10), + EnvRepoIsWiki + "=" + strconv.FormatBool(isWiki), + EnvPusherName + "=" + doer.Name, + EnvPusherID + "=" + strconv.FormatInt(doer.ID, 10), + } + if !doer.KeepEmailPrivate { + env = append(env, EnvPusherEmail+"="+doer.Email) + } + if taskID, isActionsUser := user_model.GetActionsUserTaskID(doer); isActionsUser { + env = append(env, EnvActionsTaskID+"="+strconv.FormatInt(taskID, 10)) + } + return env +} + // FullPushingEnvironment returns an os environment to allow hooks to work on push func FullPushingEnvironment(author, committer *user_model.User, repo *repo_model.Repository, repoName string, prID, prIndex int64) []string { - isWiki := "false" - if strings.HasSuffix(repoName, ".wiki") { - isWiki = "true" - } - + isWiki := strings.HasSuffix(repoName, ".wiki") authorSig := author.NewGitSig() committerSig := committer.NewGitSig() - environ := append(os.Environ(), "GIT_AUTHOR_NAME="+authorSig.Name, "GIT_AUTHOR_EMAIL="+authorSig.Email, "GIT_COMMITTER_NAME="+committerSig.Name, "GIT_COMMITTER_EMAIL="+committerSig.Email, - EnvRepoName+"="+repoName, - EnvRepoUsername+"="+repo.OwnerName, - EnvRepoIsWiki+"="+isWiki, - EnvPusherName+"="+committer.Name, - EnvPusherID+"="+strconv.FormatInt(committer.ID, 10), - EnvRepoID+"="+strconv.FormatInt(repo.ID, 10), EnvPRID+"="+strconv.FormatInt(prID, 10), EnvPRIndex+"="+strconv.FormatInt(prIndex, 10), - EnvAppURL+"="+setting.AppURL, "SSH_ORIGINAL_COMMAND=gitea-internal", ) - - if !committer.KeepEmailPrivate { - environ = append(environ, EnvPusherEmail+"="+committer.Email) - } - + environ = append(environ, DoerPushingEnvironment(committer, repo, isWiki)...) return environ } diff --git a/modules/util/util.go b/modules/util/util.go index d7702439d6..7d1343f20d 100644 --- a/modules/util/util.go +++ b/modules/util/util.go @@ -8,6 +8,7 @@ import ( "crypto/rand" "fmt" "math/big" + "slices" "strconv" "strings" @@ -240,6 +241,20 @@ func OptionalArg[T any](optArg []T, defaultValue ...T) (ret T) { return ret } +type EnumConst[T comparable] interface { + EnumValues() []T +} + +// EnumValue returns the value if it's in the enum const's values, +// otherwise returns the first item of enums as default value. +func EnumValue[T comparable](val EnumConst[T]) (ret T, valid bool) { + enums := val.EnumValues() + if slices.Contains(enums, val.(T)) { + return val.(T), true + } + return enums[0], false +} + func ReserveLineBreakForTextarea(input string) string { // Since the content is from a form which is a textarea, the line endings are \r\n. // It's a standard behavior of HTML. diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 1eafb3c9d1..c9a13f8e21 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -646,6 +646,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", @@ -3757,5 +3758,24 @@ "git.filemode.normal_file": "Regular", "git.filemode.executable_file": "Executable", "git.filemode.symbolic_link": "Symlink", - "git.filemode.submodule": "Submodule" + "git.filemode.submodule": "Submodule", + "org.repos.none": "No repositories.", + "actions.general.permissions": "Actions Token Permissions", + "actions.general.token_permissions.mode": "Default Token Permissions", + "actions.general.token_permissions.mode.desc": "An Actions job will use the default permissions if it doesn't declare its permissions in the workflow file.", + "actions.general.token_permissions.mode.permissive": "Permissive", + "actions.general.token_permissions.mode.permissive.desc": "Read and write permissions for the job's repository.", + "actions.general.token_permissions.mode.restricted": "Restricted", + "actions.general.token_permissions.mode.restricted.desc": "Read-only permissions for contents units (code, releases) of the job's repository.", + "actions.general.token_permissions.override_owner": "Override owner-level configuration", + "actions.general.token_permissions.override_owner_desc": "If enabled, this repository will use its own Actions configuration instead of following the owner-level (user or organization) configuration.", + "actions.general.token_permissions.maximum": "Maximum Token Permissions", + "actions.general.token_permissions.maximum.description": "Actions job's effective permissions will be limited by the maximum permissions.", + "actions.general.token_permissions.fork_pr_note": "If a job is started by a pull request from a fork, its effective permissions won't exceed the read-only permissions.", + "actions.general.token_permissions.customize_max_permissions": "Customize maximum permissions", + "actions.general.cross_repo": "Cross-Repository Access", + "actions.general.cross_repo_desc": "Allow the selected repositories to be accessed (read-only) by all the repositories in this owner with GITEA_TOKEN when running Actions jobs.", + "actions.general.cross_repo_selected": "Selected repositories", + "actions.general.cross_repo_target_repos": "Target Repositories", + "actions.general.cross_repo_add": "Add Target Repository" } diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index 704b777dbf..2dbf072f3a 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -496,16 +496,25 @@ 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) + taskID := ctx.opts.ActionsTaskID + ctx.user = user_model.NewActionsUserWithTaskID(taskID) + if taskID == 0 { + log.Error("HookPreReceive: ActionsUser with task ID 0") ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to get User id %d Error: %v", ctx.opts.UserID, err), + Err: "ActionsUser with task ID 0", }) return false } - ctx.userPerm.SetUnitsWithDefaultAccessMode(ctx.Repo.Repository.Units, ctx.userPerm.AccessMode) + + userPerm, err := access_model.GetActionsUserRepoPermission(ctx, ctx.Repo.Repository, ctx.user, taskID) + if err != nil { + log.Error("Unable to get Actions user repo permission for task %d Error: %v", taskID, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get Actions user repo permission for task %d Error: %v", taskID, err), + }) + return false + } + ctx.userPerm = userPerm } else { user, err := user_model.GetUserByID(ctx, ctx.opts.UserID) if err != nil { diff --git a/routers/web/admin/runners.go b/routers/web/admin/runners.go deleted file mode 100644 index 4b89237364..0000000000 --- a/routers/web/admin/runners.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package admin - -import ( - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/services/context" -) - -func RedirectToDefaultSetting(ctx *context.Context) { - ctx.Redirect(setting.AppSubURL + "/-/admin/actions/runners") -} diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go index 3d2f624263..a50d9130ac 100644 --- a/routers/web/misc/misc.go +++ b/routers/web/misc/misc.go @@ -51,6 +51,12 @@ func StaticRedirect(target string) func(w http.ResponseWriter, req *http.Request } } +func LocationRedirect(target string) func(w http.ResponseWriter, req *http.Request) { + return func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, target, http.StatusSeeOther) + } +} + func WebBannerDismiss(ctx *context.Context) { _, rev, _ := setting.Config().Instance.WebBanner.ValueRevision(ctx) middleware.SetSiteCookie(ctx.Resp, middleware.CookieWebBannerDismissed, strconv.Itoa(rev), 48*3600) diff --git a/routers/web/org/setting/runners.go b/routers/web/org/setting/runners.go deleted file mode 100644 index fe05709237..0000000000 --- a/routers/web/org/setting/runners.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package setting - -import ( - "code.gitea.io/gitea/services/context" -) - -func RedirectToDefaultSetting(ctx *context.Context) { - ctx.Redirect(ctx.Org.OrgLink + "/settings/actions/runners") -} diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 6a48a2daa3..5c010dfbed 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -832,7 +832,7 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) { cfg.DisableWorkflow(workflow) } - if err := repo_model.UpdateRepoUnit(ctx, cfgUnit); err != nil { + if err := repo_model.UpdateRepoUnitConfig(ctx, cfgUnit); err != nil { ctx.ServerError("UpdateRepoUnit", err) return } diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index e922ed99fc..fb9445aed0 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -59,7 +59,6 @@ func CorsHandler() func(next http.Handler) http.Handler { // httpBase does the common work for git http services, // including early response, authentication, repository lookup and permission check. func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { - username := ctx.PathParam("username") reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git") if ctx.FormString("go-get") == "1" { @@ -131,10 +130,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { // Only public pull don't need auth. isPublicPull := repoExist && !repo.IsPrivate && isPull - var ( - askAuth = !isPublicPull || setting.Service.RequireSignInViewStrict - environ []string - ) + askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict // don't allow anonymous pulls if organization is not public if isPublicPull { @@ -184,21 +180,14 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { return nil } - environ = []string{ - repo_module.EnvRepoUsername + "=" + username, - repo_module.EnvRepoName + "=" + reponame, - repo_module.EnvPusherName + "=" + ctx.Doer.Name, - repo_module.EnvPusherID + fmt.Sprintf("=%d", ctx.Doer.ID), - repo_module.EnvAppURL + "=" + setting.AppURL, - } - if repoExist { // Because of special ref "refs/for" (agit) , need delay write permission check if git.DefaultFeatures().SupportProcReceive { accessMode = perm.AccessModeRead } - if taskID, ok := user_model.GetActionsUserTaskID(ctx.Doer); ok { + taskID, isActionsUser := user_model.GetActionsUserTaskID(ctx.Doer) + if isActionsUser { p, err := access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID) if err != nil { ctx.ServerError("GetActionsUserRepoPermission", err) @@ -209,7 +198,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { ctx.PlainText(http.StatusNotFound, "Repository not found") return nil } - environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, p.UnitAccessMode(unitType))) } else { p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) if err != nil { @@ -228,16 +216,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { return nil } } - - if !ctx.Doer.KeepEmailPrivate { - environ = append(environ, repo_module.EnvPusherEmail+"="+ctx.Doer.Email) - } - - if isWiki { - environ = append(environ, repo_module.EnvRepoIsWiki+"=true") - } else { - environ = append(environ, repo_module.EnvRepoIsWiki+"=false") - } } if !repoExist { @@ -286,7 +264,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { } } - environ = append(environ, repo_module.EnvRepoID+fmt.Sprintf("=%d", repo.ID)) + var environ []string + if !isPull { + // if not "pull", then must be "push", and doer must exist + environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki) + } return &serviceHandler{serviceType, repo, isWiki, environ} } diff --git a/routers/web/repo/setting/actions.go b/routers/web/repo/setting/actions.go index 9c2c9242d3..2237828d61 100644 --- a/routers/web/repo/setting/actions.go +++ b/routers/web/repo/setting/actions.go @@ -8,11 +8,13 @@ import ( "net/http" "strings" + "code.gitea.io/gitea/models/actions" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" + shared_actions "code.gitea.io/gitea/routers/web/shared/actions" "code.gitea.io/gitea/services/context" repo_service "code.gitea.io/gitea/services/repository" ) @@ -34,8 +36,31 @@ func ActionsGeneralSettings(ctx *context.Context) { return } + actionsCfg := actionsUnit.ActionsConfig() + + // Token permission settings + ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive + ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted + + // Follow owner config (only for repos in orgs) + ctx.Data["OverrideOwnerConfig"] = actionsCfg.OverrideOwnerConfig + if actionsCfg.OverrideOwnerConfig { + ctx.Data["MaxTokenPermissions"] = actionsCfg.GetMaxTokenPermissions() + ctx.Data["TokenPermissionMode"] = actionsCfg.TokenPermissionMode + ctx.Data["EnableMaxTokenPermissions"] = actionsCfg.MaxTokenPermissions != nil + } else { + ownerActionsConfig, err := actions.GetOwnerActionsConfig(ctx, ctx.Repo.Repository.OwnerID) + if err != nil { + ctx.ServerError("GetOwnerActionsConfig", err) + return + } + ctx.Data["MaxTokenPermissions"] = ownerActionsConfig.GetMaxTokenPermissions() + ctx.Data["TokenPermissionMode"] = ownerActionsConfig.TokenPermissionMode + ctx.Data["EnableMaxTokenPermissions"] = ownerActionsConfig.MaxTokenPermissions != nil + } + 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) @@ -89,8 +114,8 @@ func AddCollaborativeOwner(ctx *context.Context) { } actionsCfg := actionsUnit.ActionsConfig() actionsCfg.AddCollaborativeOwner(ownerID) - if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil { - ctx.ServerError("UpdateRepoUnit", err) + if err := repo_model.UpdateRepoUnitConfig(ctx, actionsUnit); err != nil { + ctx.ServerError("UpdateRepoUnitConfig", err) return } @@ -112,10 +137,59 @@ func DeleteCollaborativeOwner(ctx *context.Context) { return } actionsCfg.RemoveCollaborativeOwner(ownerID) - if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil { - ctx.ServerError("UpdateRepoUnit", err) + if err := repo_model.UpdateRepoUnitConfig(ctx, actionsUnit); err != nil { + ctx.ServerError("UpdateRepoUnitConfig", err) return } 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 Override Owner Config (for repos in orgs) + // If checked, it means we WANT to override (opt-out of following) + actionsCfg.OverrideOwnerConfig = ctx.FormBool("override_owner_config") + + // Update permission mode (only if overriding owner config) + shouldUpdate := actionsCfg.OverrideOwnerConfig + + if shouldUpdate { + permissionMode, permissionModeValid := util.EnumValue(repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode"))) + if !permissionModeValid { + ctx.Flash.Error("Invalid token permission mode") + ctx.Redirect(redirectURL) + return + } + actionsCfg.TokenPermissionMode = permissionMode + } + + // Update Maximum Permissions (radio buttons: none/read/write) + enableMaxPermissions := ctx.FormBool("enable_max_permissions") + if shouldUpdate { + if enableMaxPermissions { + actionsCfg.MaxTokenPermissions = shared_actions.ParseMaxTokenPermissions(ctx) + } else { + // If not enabled, ensure any sent permissions are ignored and set to nil + actionsCfg.MaxTokenPermissions = nil + } + } + + if err := repo_model.UpdateRepoUnitConfig(ctx, actionsUnit); err != nil { + ctx.ServerError("UpdateRepoUnitConfig", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(redirectURL) +} diff --git a/routers/web/shared/actions/general.go b/routers/web/shared/actions/general.go new file mode 100644 index 0000000000..8a924f6e1f --- /dev/null +++ b/routers/web/shared/actions/general.go @@ -0,0 +1,160 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "net/http" + "slices" + "strconv" + + 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/models/unit" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +const ( + tplOrgSettingsActionsGeneral templates.TplName = "org/settings/actions_general" + tplUserSettingsActionsGeneral templates.TplName = "user/settings/actions_general" +) + +// ParseMaxTokenPermissions parses the maximum token permissions from form values +func ParseMaxTokenPermissions(ctx *context.Context) *repo_model.ActionsTokenPermissions { + parseMaxPerm := func(unitType unit.Type) perm.AccessMode { + value := ctx.FormString("max_unit_access_mode_" + strconv.Itoa(int(unitType))) + switch value { + case "write": + return perm.AccessModeWrite + case "read": + return perm.AccessModeRead + default: + return perm.AccessModeNone + } + } + ret := new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)) + for _, ut := range repo_model.ActionsTokenUnitTypes { + ret.UnitAccessModes[ut] = parseMaxPerm(ut) + } + return ret +} + +// GeneralSettings renders the actions general settings page +func GeneralSettings(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.actions") + + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + if rCtx.IsOrg { + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsOrgSettingsActionsGeneral"] = true + } else if rCtx.IsUser { + ctx.Data["PageIsUserSettings"] = true + ctx.Data["PageIsUserSettingsActionsGeneral"] = true + } else { + ctx.NotFound(nil) + return + } + + // Load User/Org Actions Config + actionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, rCtx.OwnerID) + if err != nil { + ctx.ServerError("GetOwnerActionsConfig", err) + return + } + + ctx.Data["TokenPermissionMode"] = actionsCfg.TokenPermissionMode + ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive + ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted + ctx.Data["MaxTokenPermissions"] = actionsCfg.MaxTokenPermissions + if actionsCfg.MaxTokenPermissions == nil { + ctx.Data["MaxTokenPermissions"] = (&repo_model.ActionsConfig{}).GetMaxTokenPermissions() + } + ctx.Data["EnableMaxTokenPermissions"] = actionsCfg.MaxTokenPermissions != nil + + // Load Allowed Repositories + allowedRepos, err := repo_model.GetOwnerRepositoriesByIDs(ctx, rCtx.OwnerID, actionsCfg.AllowedCrossRepoIDs) + if err != nil { + ctx.ServerError("GetOwnerRepositoriesByIDs", err) + return + } + + ctx.Data["AllowedRepos"] = allowedRepos + ctx.Data["OwnerID"] = rCtx.OwnerID + + if rCtx.IsOrg { + ctx.HTML(http.StatusOK, tplOrgSettingsActionsGeneral) + } else { + ctx.HTML(http.StatusOK, tplUserSettingsActionsGeneral) + } +} + +// UpdateGeneralSettings responses for actions general settings page +func UpdateGeneralSettings(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + if !rCtx.IsOrg && !rCtx.IsUser { + ctx.NotFound(nil) + return + } + + actionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, rCtx.OwnerID) + if err != nil { + ctx.ServerError("GetOwnerActionsConfig", err) + return + } + + if ctx.FormBool("cross_repo_add_target") { + targetRepoName := ctx.FormString("cross_repo_add_target_name") + if targetRepoName != "" { + targetRepo, err := repo_model.GetRepositoryByName(ctx, rCtx.OwnerID, targetRepoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.JSONError("Repository doesn't exist") + return + } + ctx.ServerError("GetRepositoryByName", err) + return + } + if !slices.Contains(actionsCfg.AllowedCrossRepoIDs, targetRepo.ID) { + actionsCfg.AllowedCrossRepoIDs = append(actionsCfg.AllowedCrossRepoIDs, targetRepo.ID) + } + } + } + + if crossRepoRemoveTargetID := ctx.FormInt64("cross_repo_remove_target_id"); crossRepoRemoveTargetID != 0 { + actionsCfg.AllowedCrossRepoIDs = util.SliceRemoveAll(actionsCfg.AllowedCrossRepoIDs, crossRepoRemoveTargetID) + } + + // Update Token Permission Mode + tokenPermissionMode, tokenPermissionModeValid := util.EnumValue(repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode"))) + if tokenPermissionModeValid { + actionsCfg.TokenPermissionMode = tokenPermissionMode + enableMaxPermissions := ctx.FormBool("enable_max_permissions") + // Update Maximum Permissions (radio buttons: none/read/write) + if enableMaxPermissions { + actionsCfg.MaxTokenPermissions = ParseMaxTokenPermissions(ctx) + } else { + actionsCfg.MaxTokenPermissions = nil + } + } + + if err := actions_model.SetOwnerActionsConfig(ctx, rCtx.OwnerID, actionsCfg); err != nil { + ctx.ServerError("SetOwnerActionsConfig", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.saved_successfully")) + ctx.JSONRedirect("") // use JSONRedirect because frontend uses form-fetch-action +} diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go index 8a4e93fe82..3609258440 100644 --- a/routers/web/shared/actions/runners.go +++ b/routers/web/shared/actions/runners.go @@ -5,6 +5,7 @@ package actions import ( "errors" + "fmt" "net/http" "net/url" @@ -58,8 +59,7 @@ func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) { if ctx.Data["PageIsOrgSettings"] == true { if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { - ctx.ServerError("RenderUserOrgHeader", err) - return nil, nil //nolint:nilnil // error is already handled by ctx.ServerError + return nil, fmt.Errorf("RenderUserOrgHeader: %w", err) } return &runnersCtx{ RepoID: 0, diff --git a/routers/web/user/setting/runner.go b/routers/web/user/setting/runner.go deleted file mode 100644 index 2bb10cceb9..0000000000 --- a/routers/web/user/setting/runner.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package setting - -import ( - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/services/context" -) - -func RedirectToDefaultSetting(ctx *context.Context) { - ctx.Redirect(setting.AppSubURL + "/user/settings/actions/runners") -} diff --git a/routers/web/web.go b/routers/web/web.go index fd5276e4e0..f09f1bde8f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -33,7 +33,6 @@ import ( "code.gitea.io/gitea/routers/web/healthcheck" "code.gitea.io/gitea/routers/web/misc" "code.gitea.io/gitea/routers/web/org" - org_setting "code.gitea.io/gitea/routers/web/org/setting" "code.gitea.io/gitea/routers/web/repo" "code.gitea.io/gitea/routers/web/repo/actions" repo_setting "code.gitea.io/gitea/routers/web/repo/setting" @@ -693,7 +692,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, packagesEnabled) m.Group("/actions", func() { - m.Get("", user_setting.RedirectToDefaultSetting) + m.Get("", misc.LocationRedirect("./actions/general")) + m.Group("/general", func() { + m.Get("", shared_actions.GeneralSettings) + m.Post("", shared_actions.UpdateGeneralSettings) + }) addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() @@ -846,7 +849,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, oauth2Enabled) m.Group("/actions", func() { - m.Get("", admin.RedirectToDefaultSetting) + m.Get("", misc.LocationRedirect("./actions/runners")) addSettingsRunnersRoutes() addSettingsVariablesRoutes() }) @@ -998,7 +1001,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }) m.Group("/actions", func() { - m.Get("", org_setting.RedirectToDefaultSetting) + m.Get("", misc.LocationRedirect("./actions/general")) + m.Group("/general", func() { + m.Get("", shared_actions.GeneralSettings) + m.Post("", shared_actions.UpdateGeneralSettings) + }) addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() @@ -1202,9 +1209,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/actions/general", func() { m.Get("", repo_setting.ActionsGeneralSettings) m.Post("/actions_unit", repo_setting.ActionsUnitPost) - }) + }) // doesn't require actions enabled m.Group("/actions", func() { - m.Get("", shared_actions.RedirectToDefaultSetting) + m.Get("", misc.LocationRedirect("./actions/general")) addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() @@ -1213,6 +1220,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { 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 0000000000..9ff6134a7a --- /dev/null +++ b/services/actions/permission_parser.go @@ -0,0 +1,141 @@ +// Copyright 2026 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" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/actions/jobparser" + "code.gitea.io/gitea/modules/setting" + + "go.yaml.in/yaml/v4" +) + +// ExtractJobPermissionsFromWorkflow extracts permissions from an already parsed workflow/job. +// It returns nil if neither workflow nor job explicitly specifies permissions. +func ExtractJobPermissionsFromWorkflow(flow *jobparser.SingleWorkflow, job *jobparser.Job) *repo_model.ActionsTokenPermissions { + if flow == nil || job == nil { + return nil + } + + jobPerms := parseRawPermissionsExplicit(&job.RawPermissions) + if jobPerms != nil { + return jobPerms + } + + workflowPerms := parseRawPermissionsExplicit(&flow.RawPermissions) + if workflowPerms != nil { + return workflowPerms + } + + return nil +} + +// parseRawPermissionsExplicit parses a YAML permissions node and returns only explicit scopes. +// It returns nil if the node does not explicitly specify permissions. +func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPermissions { + if rawPerms == nil || (rawPerms.Kind == yaml.ScalarNode && rawPerms.Value == "") { + return nil + } + + // Unwrap DocumentNode and resolve AliasNode + node := rawPerms + for node.Kind == yaml.DocumentNode || node.Kind == yaml.AliasNode { + if node.Kind == yaml.DocumentNode { + if len(node.Content) == 0 { + return nil + } + node = node.Content[0] + } else { + node = node.Alias + } + } + + if node.Kind == yaml.ScalarNode && node.Value == "" { + return nil + } + + // Handle scalar values: "read-all" or "write-all" + if node.Kind == yaml.ScalarNode { + switch node.Value { + case "read-all": + return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead)) + case "write-all": + return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite)) + default: + // Explicit but unrecognized scalar: return all-none permissions. + return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)) + } + } + + // Handle mapping: individual permission scopes + if node.Kind == yaml.MappingNode { + result := repo_model.MakeActionsTokenPermissions(perm.AccessModeNone) + + // Collect all scopes into a map first to handle priority + scopes := make(map[string]perm.AccessMode) + 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 + } + + scopes[keyNode.Value] = parseAccessMode(valueNode.Value) + } + + // 1. Apply 'contents' first (lower priority) + if mode, ok := scopes["contents"]; ok { + result.UnitAccessModes[unit.TypeCode] = mode + result.UnitAccessModes[unit.TypeReleases] = mode + } + + // 2. Apply all other scopes (overwrites contents if specified) + for scope, mode := range scopes { + switch scope { + case "contents": + // already handled + case "code": + result.UnitAccessModes[unit.TypeCode] = mode + case "issues": + result.UnitAccessModes[unit.TypeIssues] = mode + case "pull-requests": + result.UnitAccessModes[unit.TypePullRequests] = mode + case "packages": + result.UnitAccessModes[unit.TypePackages] = mode + case "actions": + result.UnitAccessModes[unit.TypeActions] = mode + case "wiki": + result.UnitAccessModes[unit.TypeWiki] = mode + case "releases": + result.UnitAccessModes[unit.TypeReleases] = mode + case "projects": + result.UnitAccessModes[unit.TypeProjects] = mode + default: + setting.PanicInDevOrTesting("Unrecognized permission scope: %s", scope) + } + } + + return &result + } + + return nil +} + +// 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 + 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 0000000000..06352516fd --- /dev/null +++ b/services/actions/permission_parser_test.go @@ -0,0 +1,196 @@ +// Copyright 2026 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" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/actions/jobparser" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestParseRawPermissions_ReadAll(t *testing.T) { + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(`read-all`), &rawPerms) + assert.NoError(t, err) + + result := parseRawPermissionsExplicit(&rawPerms) + require.NotNil(t, result) + + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeIssues]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypePullRequests]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypePackages]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeWiki]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeProjects]) +} + +func TestParseRawPermissions_WriteAll(t *testing.T) { + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(`write-all`), &rawPerms) + assert.NoError(t, err) + + result := parseRawPermissionsExplicit(&rawPerms) + require.NotNil(t, result) + + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeIssues]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePullRequests]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePackages]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeActions]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeProjects]) +} + +func TestParseRawPermissions_IndividualScopes(t *testing.T) { + yamlContent := ` +contents: write +issues: read +pull-requests: none +packages: write +actions: read +wiki: write +projects: none +` + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rawPerms) + assert.NoError(t, err) + + result := parseRawPermissionsExplicit(&rawPerms) + require.NotNil(t, result) + + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeIssues]) + assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypePullRequests]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePackages]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki]) + assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeProjects]) +} + +func TestParseRawPermissions_Priority(t *testing.T) { + t.Run("granular-wins-over-contents", func(t *testing.T) { + yamlContent := ` +contents: read +code: write +releases: none +` + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rawPerms) + assert.NoError(t, err) + + result := parseRawPermissionsExplicit(&rawPerms) + require.NotNil(t, result) + + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeReleases]) + }) + + t.Run("contents-applied-first", func(t *testing.T) { + yamlContent := ` +code: none +releases: write +contents: read +` + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rawPerms) + assert.NoError(t, err) + + result := parseRawPermissionsExplicit(&rawPerms) + require.NotNil(t, result) + + // code: none should win over contents: read + assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeCode]) + // releases: write should win over contents: read + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeReleases]) + }) +} + +func TestParseRawPermissions_EmptyNode(t *testing.T) { + var rawPerms yaml.Node + // Empty node + + result := parseRawPermissionsExplicit(&rawPerms) + + // Should return nil for non-explicit + assert.Nil(t, result) +} + +func TestParseRawPermissions_NilNode(t *testing.T) { + result := parseRawPermissionsExplicit(nil) + + // Should return nil + assert.Nil(t, result) +} + +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 TestExtractJobPermissionsFromWorkflow(t *testing.T) { + workflowYAML := ` +name: Test Permissions +on: workflow_dispatch +permissions: read-all + +jobs: + job-read-only: + runs-on: ubuntu-latest + steps: + - run: echo "Full read-only" + + job-none-perms: + permissions: none + runs-on: ubuntu-latest + steps: + - run: echo "Full read-only" + + job-override: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - run: echo "Override to write" +` + + expectedPerms := map[string]*repo_model.ActionsTokenPermissions{} + expectedPerms["job-read-only"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead)) + expectedPerms["job-none-perms"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)) + expectedPerms["job-override"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)) + expectedPerms["job-override"].UnitAccessModes[unit.TypeCode] = perm.AccessModeWrite + expectedPerms["job-override"].UnitAccessModes[unit.TypeReleases] = perm.AccessModeWrite + + singleWorkflows, err := jobparser.Parse([]byte(workflowYAML)) + require.NoError(t, err) + for _, flow := range singleWorkflows { + jobID, jobDef := flow.Job() + require.NotNil(t, jobDef) + t.Run(jobID, func(t *testing.T) { + assert.Equal(t, expectedPerms[jobID], ExtractJobPermissionsFromWorkflow(flow, jobDef)) + }) + } +} diff --git a/services/actions/run.go b/services/actions/run.go index c9eadc48d1..e9fcdcaf43 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -103,6 +103,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs)) var hasWaitingJobs bool + for _, v := range jobs { id, job := v.Job() needs := job.Needs() @@ -127,6 +128,11 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar RunsOn: job.RunsOn(), Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting), } + // Parse workflow/job permissions (no clamping here) + if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil { + runJob.TokenPermissions = perms + } + // check job concurrency if job.RawConcurrency != nil { rawConcurrency, err := yaml.Marshal(job.RawConcurrency) diff --git a/services/actions/token_permission_design.md b/services/actions/token_permission_design.md new file mode 100644 index 0000000000..d5318606d0 --- /dev/null +++ b/services/actions/token_permission_design.md @@ -0,0 +1,123 @@ +# Actions Token Permission System Design + +This document details the design of the Actions Token Permission system within Gitea, originally proposed in [#24635](https://github.com/go-gitea/gitea/issues/24635). + +## Design Philosophy & GitHub Differences + +Gitea Actions uses a **strict clamping mechanism** for token permissions. +While workflows can request explicit permissions that exceed the repository's default baseline +(e.g., requesting `write` when the default mode is `Restricted`), +these requests are always bounded by a hard ceiling. + +The maximum allowable permissions (`MaxTokenPermissions`) are set at the Repository or Organization level. +**Any permissions requested by a workflow are strictly clamped by this ceiling policy.** +This ensures that workflows cannot bypass organizational or repository-level security restrictions. + +## Terminology + +### 1. `GITEA_TOKEN` +- The automatic token generated for each Actions job. +- Its permissions (read/write/none) are scoped to the repository and specific features (Code, Issues, etc.). + +### 2. Token Permission Mode +- The default access level granted to a token when no explicit `permissions:` block is present in a workflow. +- **Permissive**: Grants `write` access to most repository scopes by default. +- **Restricted**: Grants `read` access (or none) to repository scopes by default. + +### 3. Actions Token Permissions +- A structure representing the granular permission scopes available to a token. +- Includes scopes like: Code, Releases (both grouped under `contents` in workflow syntax), + Issues, PullRequests, Actions, Wiki, and Projects. +- **Note**: The `Packages` scope is supported in workflow/job `permissions:` blocks + but is currently hidden from the settings UI. + +### 4. Cross-Repository Access +- By default, a token can access the repository where the workflow is running, + as well as any **public repositories (read-only)** on the instance. +- Users and organizations can configure an `AllowedCrossRepoIDs` list in their owner-level settings + to grant the token **read-only** access to other private/internal repositories they own. +- If the `AllowedCrossRepoIDs` list is empty, there is no cross-repository access + to other private repositories (default for enhanced security). +- In any configuration, individual jobs can disable or limit cross-repo access + by explicitly restricting their permissions (e.g., `permissions: none`). +- **Note on Forks**: Cross-repository access to private repositories is fundamentally denied + for workflows triggered by fork pull requests (see [Special Cases](#2-fork-pull-requests)). + +## Token Lifecycle & Permission Evaluation + +When a job starts, Gitea evaluates the requested permissions for the `GITEA_TOKEN` through a multistep clamping process: + +### Step 1: Determine Base Permissions From Workflow +- If the job explicitly specifies a valid `permissions:` block, Gitea parses it. +- If the job inherits a top-level `permissions:` block, Gitea parses that. +- If an invalid or unparseable `permissions:` block is specified, or no explicit permissions are defined at all, + Gitea falls back to using the repository's default `TokenPermissionMode` (Permissive or Restricted) + to generate base permissions. + +### Step 2: Apply Repository Clamping +- Repositories can define `MaxTokenPermissions` in their Actions settings. +- The base permissions from Step 1 are clamped against these maximum allowed permissions. +- If the repository says `Issues: read` and the workflow requests `Issues: write`, the final token gets `Issues: read`. + +### Step 3: Apply Organization/User Clamping (Hierarchical Override) +- The organization (or user) has an owner-level configuration (`UserActionsConfig`) containing `MaxTokenPermissions`, + and these restrictions cascade down. +- The repository's clamping limits cannot exceed the owner's limits + UNLESS the repository explicitly enables `OverrideOwnerConfig`. +- If `OverrideOwnerConfig` is false, and the owner sets `MaxTokenPermissions` to `read` for all scopes, + no repository under that owner can grant `write` access, regardless of their own settings or the workflow's request. + +## Parsing Priority for "contents" Scope + +In GitHub Actions compatibility, the `contents` scope maps to multiple granular scopes in Gitea. +- `contents: write` maps to `Code: write` and `Releases: write`. +- When a workflow specifies both `contents` and a more granular scope (e.g., `code`), + the granular scope takes absolute priority. + +**Example YAML**: +```yaml +permissions: + contents: write + code: read +``` +**Result**: The token gets `Code: read` (from granular) and `Releases: write` (from contents). + +## Special Cases & Edge Scenarios + +### 1. Empty Permissions Mapping (`permissions: {}`) +- Explicitly setting an empty mapping means "revoke all permissions". +- The token gets `none` for all scopes. + +### 2. Fork Pull Requests +- Workflows triggered by Pull Requests from forks inherently operate in `Restricted` mode for security reasons. +- The base permissions for the current repository are automatically downgraded to `read` (or `none`), + preventing untrusted code from modifying the repository. +- **Cross-Repo Access in Forks**: For workflows triggered by fork pull requests, cross-repository access + to other private repositories is strictly denied, regardless of the `AllowedCrossRepoIDs` configuration. + Fork PRs can only read the target repository and truly public repositories. + +### 3. Public Repositories in Cross-Repo Access +- As mentioned in Cross-Repository Access, truly public repositories can always be read by the token, + regardless of the `AllowedCrossRepoIDs` setting. The allowed list only governs access + to private/internal repositories owned by the same user or organization. + +## Packages Registry + +"Packages" belong to "owner" but not "repository". Although there is a function "linking a package to a repository", +in most cases it doesn't really work. When accessing a package, usually there is no information about a repository. +So the "packages" permission should be designed separately from other permissions. + +A possible approach is like this: let owner set packages permissions, and make the repositories follow. + +- On owner-level: + - Add a "Packages" permission section + - "Default permissions for all repositories" can be set to none/read/write + - Set different permissions for selected repositories (if needed), like the "Collaborators" permission setting + +- On repository-level: + - Now a repository can have "Packages" permission + - The repository-level "Packages" permission is clamped by the owner-level "Packages" permission + - If the owner-level "Packages" permission for this repository is read, + then the repository cannot set its "Packages" permission to write + +Maybe reusing the "org teams" permission system is a good choice: bind a repository's Actions token to a team. diff --git a/services/actions/workflow.go b/services/actions/workflow.go index faa540421f..b41741403f 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -41,7 +41,7 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl cfg.DisableWorkflow(workflow.ID) } - return repo_model.UpdateRepoUnit(ctx, cfgUnit) + return repo_model.UpdateRepoUnitConfig(ctx, cfgUnit) } func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) { diff --git a/services/doctor/fix16961.go b/services/doctor/fix16961.go index 50d9ac6621..63e33350ad 100644 --- a/services/doctor/fix16961.go +++ b/services/doctor/fix16961.go @@ -296,7 +296,7 @@ func fixBrokenRepoUnits16961(ctx context.Context, logger log.Logger, autofix boo return nil } - return repo_model.UpdateRepoUnit(ctx, repoUnit) + return repo_model.UpdateRepoUnitConfig(ctx, repoUnit) }, ) if err != nil { diff --git a/services/pull/update_test.go b/services/pull/update_test.go index 4b5772e35d..0bb6754445 100644 --- a/services/pull/update_test.go +++ b/services/pull/update_test.go @@ -26,7 +26,7 @@ func TestIsUserAllowedToUpdate(t *testing.T) { setRepoAllowRebaseUpdate := func(t *testing.T, repoID int64, allow bool) { repoUnit := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoUnit{RepoID: repoID, Type: unit.TypePullRequests}) repoUnit.PullRequestsConfig().AllowRebaseUpdate = allow - require.NoError(t, repo_model.UpdateRepoUnit(t.Context(), repoUnit)) + require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoUnit)) } user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) diff --git a/services/repository/transfer.go b/services/repository/transfer.go index a601ee6f16..fbf357c366 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" @@ -246,6 +247,19 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName return fmt.Errorf("recalculateAccesses: %w", err) } + // Remove repository from old owner's Actions AllowedCrossRepoIDs if present + if oldActionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, oldOwner.ID); err == nil { + newAllowedCrossRepoIDs := util.SliceRemoveAll(oldActionsCfg.AllowedCrossRepoIDs, repo.ID) + if len(newAllowedCrossRepoIDs) != len(oldActionsCfg.AllowedCrossRepoIDs) { + oldActionsCfg.AllowedCrossRepoIDs = newAllowedCrossRepoIDs + if err := actions_model.SetOwnerActionsConfig(ctx, oldOwner.ID, oldActionsCfg); err != nil { + return fmt.Errorf("SetOwnerActionsConfig: %w", err) + } + } + } else { + return fmt.Errorf("GetOwnerActionsConfig: %w", err) + } + // Update repository count. if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil { return fmt.Errorf("increase new owner repository count: %w", err) diff --git a/templates/org/settings/actions_general.tmpl b/templates/org/settings/actions_general.tmpl new file mode 100644 index 0000000000..ebf9482f61 --- /dev/null +++ b/templates/org/settings/actions_general.tmpl @@ -0,0 +1,5 @@ +{{template "org/settings/layout_head" (dict "ctxData" .)}} +