From 3a10e8f4f5f716e3a8956b005dfd9be3b47ea4c4 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Wed, 17 Dec 2025 07:13:59 +0100 Subject: [PATCH 01/30] feat: Add configurable permissions for Actions automatic tokens --- models/perm/access/repo_permission.go | 41 +++++-- models/repo/repo_unit.go | 120 +++++++++++++++++++ models/repo/repo_unit_test.go | 75 ++++++++++++ options/locale/locale_en-US.ini | 19 +++ routers/web/repo/setting/actions.go | 40 ++++++- routers/web/web.go | 1 + templates/repo/settings/actions_general.tmpl | 39 ++++++ tests/integration/actions_job_token_test.go | 44 +++++++ 8 files changed, 368 insertions(+), 11 deletions(-) diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index 15526cb1e6f1f..f5b7c9ef33589 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -266,13 +266,18 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito return perm, err } - var accessMode perm_model.AccessMode + if err := repo.LoadUnits(ctx); err != nil { + return perm, err + } + + actionsUnit := repo.MustGetUnit(ctx, unit.TypeActions) + actionsCfg := actionsUnit.ActionsConfig() + if task.RepoID != repo.ID { taskRepo, exist, err := db.GetByID[repo_model.Repository](ctx, task.RepoID) if err != nil || !exist { return perm, err } - actionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() if !actionsCfg.IsCollaborativeOwner(taskRepo.OwnerID) || !taskRepo.IsPrivate { // The task repo can access the current repo only if the task repo is private and // the owner of the task repo is a collaborative owner of the current repo. @@ -280,17 +285,33 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito // FIXME should owner's visibility also be considered here? return perm, nil } - accessMode = perm_model.AccessModeRead - } else if task.IsForkPullRequest { - accessMode = perm_model.AccessModeRead - } else { - accessMode = perm_model.AccessModeWrite + // Cross-repo access is always read-only + perm.SetUnitsWithDefaultAccessMode(repo.Units, perm_model.AccessModeRead) + return perm, nil } - if err := repo.LoadUnits(ctx); err != nil { - return perm, err + // Get effective token permissions from repository settings + effectivePerms := actionsCfg.GetEffectiveTokenPermissions(task.IsForkPullRequest) + + // Set up per-unit access modes based on configured permissions + perm.units = repo.Units + perm.unitsMode = make(map[unit.Type]perm_model.AccessMode) + perm.unitsMode[unit.TypeCode] = effectivePerms.Contents + perm.unitsMode[unit.TypeIssues] = effectivePerms.Issues + perm.unitsMode[unit.TypePullRequests] = effectivePerms.PullRequests + perm.unitsMode[unit.TypePackages] = effectivePerms.Packages + perm.unitsMode[unit.TypeActions] = effectivePerms.Actions + perm.unitsMode[unit.TypeWiki] = effectivePerms.Wiki + + // Set base access mode to the maximum of all unit permissions + maxMode := perm_model.AccessModeNone + for _, mode := range perm.unitsMode { + if mode > maxMode { + maxMode = mode + } } - perm.SetUnitsWithDefaultAccessMode(repo.Units, accessMode) + perm.AccessMode = maxMode + return perm, nil } diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index ad0bb9d3f82ec..6e420373daa3d 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -168,11 +168,78 @@ func (cfg *PullRequestsConfig) GetDefaultMergeStyle() MergeStyle { return MergeStyleMerge } +// ActionsTokenPermissionMode defines the default permission mode for Actions tokens +type ActionsTokenPermissionMode string + +const ( + // ActionsTokenPermissionModePermissive - write access by default (current behavior, backwards compatible) + ActionsTokenPermissionModePermissive ActionsTokenPermissionMode = "permissive" + // ActionsTokenPermissionModeRestricted - read access by default + ActionsTokenPermissionModeRestricted ActionsTokenPermissionMode = "restricted" +) + +// ActionsTokenPermissions defines the permissions for different repository units +type ActionsTokenPermissions struct { + // Contents (repository code) - read/write/none + Contents perm.AccessMode `json:"contents"` + // Issues - read/write/none + Issues perm.AccessMode `json:"issues"` + // PullRequests - read/write/none + PullRequests perm.AccessMode `json:"pull_requests"` + // Packages - read/write/none + Packages perm.AccessMode `json:"packages"` + // Actions - read/write/none + Actions perm.AccessMode `json:"actions"` + // Wiki - read/write/none + Wiki perm.AccessMode `json:"wiki"` +} + +// DefaultActionsTokenPermissions returns the default permissions for permissive mode +func DefaultActionsTokenPermissions(mode ActionsTokenPermissionMode) ActionsTokenPermissions { + if mode == ActionsTokenPermissionModeRestricted { + return ActionsTokenPermissions{ + Contents: perm.AccessModeRead, + Issues: perm.AccessModeRead, + PullRequests: perm.AccessModeRead, + Packages: perm.AccessModeRead, + Actions: perm.AccessModeRead, + Wiki: perm.AccessModeRead, + } + } + // Permissive mode (default) + return ActionsTokenPermissions{ + Contents: perm.AccessModeWrite, + Issues: perm.AccessModeWrite, + PullRequests: perm.AccessModeWrite, + Packages: perm.AccessModeRead, // Packages read by default for security + Actions: perm.AccessModeWrite, + Wiki: perm.AccessModeWrite, + } +} + +// ForkPullRequestPermissions returns the restricted permissions for fork pull requests +func ForkPullRequestPermissions() ActionsTokenPermissions { + return ActionsTokenPermissions{ + Contents: perm.AccessModeRead, + Issues: perm.AccessModeRead, + PullRequests: perm.AccessModeRead, + Packages: perm.AccessModeRead, + Actions: perm.AccessModeRead, + Wiki: perm.AccessModeRead, + } +} + type ActionsConfig struct { DisabledWorkflows []string // CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos. // Only workflows from the private repos whose owners are in CollaborativeOwnerIDs can access the current repo's actions. CollaborativeOwnerIDs []int64 + // TokenPermissionMode defines the default permission mode (permissive or restricted) + TokenPermissionMode ActionsTokenPermissionMode `json:"token_permission_mode,omitempty"` + // DefaultTokenPermissions defines the default permissions for workflow tokens + DefaultTokenPermissions *ActionsTokenPermissions `json:"default_token_permissions,omitempty"` + // MaxTokenPermissions defines the maximum permissions (cannot be exceeded by workflow permissions keyword) + MaxTokenPermissions *ActionsTokenPermissions `json:"max_token_permissions,omitempty"` } func (cfg *ActionsConfig) EnableWorkflow(file string) { @@ -209,6 +276,59 @@ func (cfg *ActionsConfig) IsCollaborativeOwner(ownerID int64) bool { return slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) } +// GetTokenPermissionMode returns the token permission mode (defaults to permissive for backwards compatibility) +func (cfg *ActionsConfig) GetTokenPermissionMode() ActionsTokenPermissionMode { + if cfg.TokenPermissionMode == "" { + return ActionsTokenPermissionModePermissive + } + return cfg.TokenPermissionMode +} + +// GetEffectiveTokenPermissions returns the effective token permissions based on settings and context +func (cfg *ActionsConfig) GetEffectiveTokenPermissions(isForkPullRequest bool) ActionsTokenPermissions { + // Fork pull requests always get restricted read-only access for security + if isForkPullRequest { + return ForkPullRequestPermissions() + } + + // Use custom default permissions if set + if cfg.DefaultTokenPermissions != nil { + return *cfg.DefaultTokenPermissions + } + + // Otherwise use mode-based defaults + return DefaultActionsTokenPermissions(cfg.GetTokenPermissionMode()) +} + +// GetMaxTokenPermissions returns the maximum allowed permissions +func (cfg *ActionsConfig) GetMaxTokenPermissions() ActionsTokenPermissions { + if cfg.MaxTokenPermissions != nil { + return *cfg.MaxTokenPermissions + } + // Default max is write for everything except packages + return ActionsTokenPermissions{ + Contents: perm.AccessModeWrite, + Issues: perm.AccessModeWrite, + PullRequests: perm.AccessModeWrite, + Packages: perm.AccessModeWrite, + Actions: perm.AccessModeWrite, + Wiki: perm.AccessModeWrite, + } +} + +// ClampPermissions ensures that the given permissions don't exceed the maximum +func (cfg *ActionsConfig) ClampPermissions(perms ActionsTokenPermissions) ActionsTokenPermissions { + maxPerms := cfg.GetMaxTokenPermissions() + return ActionsTokenPermissions{ + Contents: min(perms.Contents, maxPerms.Contents), + Issues: min(perms.Issues, maxPerms.Issues), + PullRequests: min(perms.PullRequests, maxPerms.PullRequests), + Packages: min(perms.Packages, maxPerms.Packages), + Actions: min(perms.Actions, maxPerms.Actions), + Wiki: min(perms.Wiki, maxPerms.Wiki), + } +} + // FromDB fills up a ActionsConfig from serialized format. func (cfg *ActionsConfig) FromDB(bs []byte) error { return json.UnmarshalHandleDoubleEncode(bs, &cfg) 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.ini b/options/locale/locale_en-US.ini index 981d9de2f8623..9990d30c2c32e 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3928,6 +3928,25 @@ general.collaborative_owner_not_exist = The collaborative owner does not exist. general.remove_collaborative_owner = Remove Collaborative Owner general.remove_collaborative_owner_desc = Removing a collaborative owner will prevent the repositories of the owner from accessing the actions in this repository. Continue? +general.token_permissions = Workflow Permissions +general.token_permissions.description = Configure the default permissions granted to the GITHUB_TOKEN when running workflows in this repository. +general.token_permissions.mode = Permission Mode +general.token_permissions.permissive = Read and write permissions +general.token_permissions.permissive.description = Workflows have read and write permissions in the repository for all scopes. +general.token_permissions.restricted = Read repository contents and packages permissions +general.token_permissions.restricted.description = Workflows have read permissions in the repository for the contents and packages scopes only. +general.token_permissions.fork_pr_note = Note: For workflows triggered by a pull request from a forked repository, the default GITHUB_TOKEN is always read-only. +general.token_permissions.contents = Contents +general.token_permissions.issues = Issues +general.token_permissions.pull_requests = Pull Requests +general.token_permissions.packages = Packages +general.token_permissions.actions_scope = Actions +general.token_permissions.wiki = Wiki +general.token_permissions.access_read = Read +general.token_permissions.access_write = Write +general.token_permissions.access_none = No access +general.token_permissions.update_success = Token permissions updated successfully. + [projects] deleted.display_name = Deleted Project type-1.display_name = Individual Project diff --git a/routers/web/repo/setting/actions.go b/routers/web/repo/setting/actions.go index 9c2c9242d34b6..724233a1532ae 100644 --- a/routers/web/repo/setting/actions.go +++ b/routers/web/repo/setting/actions.go @@ -34,8 +34,17 @@ func ActionsGeneralSettings(ctx *context.Context) { return } + actionsCfg := actionsUnit.ActionsConfig() + + // Token permission settings + ctx.Data["TokenPermissionMode"] = actionsCfg.GetTokenPermissionMode() + ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive + ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted + ctx.Data["EffectiveTokenPermissions"] = actionsCfg.GetEffectiveTokenPermissions(false) + ctx.Data["MaxTokenPermissions"] = actionsCfg.GetMaxTokenPermissions() + if ctx.Repo.Repository.IsPrivate { - collaborativeOwnerIDs := actionsUnit.ActionsConfig().CollaborativeOwnerIDs + collaborativeOwnerIDs := actionsCfg.CollaborativeOwnerIDs collaborativeOwners, err := user_model.GetUsersByIDs(ctx, collaborativeOwnerIDs) if err != nil { ctx.ServerError("GetUsersByIDs", err) @@ -119,3 +128,32 @@ func DeleteCollaborativeOwner(ctx *context.Context) { ctx.JSONOK() } + +// UpdateTokenPermissions updates the token permission settings for the repository +func UpdateTokenPermissions(ctx *context.Context) { + redirectURL := ctx.Repo.RepoLink + "/settings/actions/general" + + actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions) + if err != nil { + ctx.ServerError("GetUnit", err) + return + } + + actionsCfg := actionsUnit.ActionsConfig() + + // Update permission mode + permissionMode := ctx.FormString("token_permission_mode") + if permissionMode == string(repo_model.ActionsTokenPermissionModeRestricted) { + actionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModeRestricted + } else { + actionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModePermissive + } + + if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil { + ctx.ServerError("UpdateRepoUnit", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(redirectURL) +} diff --git a/routers/web/web.go b/routers/web/web.go index 89a570dce0773..1c01eb63a1bb7 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1165,6 +1165,7 @@ func registerWebRoutes(m *web.Router) { m.Post("/add", repo_setting.AddCollaborativeOwner) m.Post("/delete", repo_setting.DeleteCollaborativeOwner) }) + m.Post("/token_permissions", repo_setting.UpdateTokenPermissions) }) }, actions.MustEnableActions) // the follow handler must be under "settings", otherwise this incomplete repo can't be accessed diff --git a/templates/repo/settings/actions_general.tmpl b/templates/repo/settings/actions_general.tmpl index 06b7c8bad5025..4a9d2512bacb3 100644 --- a/templates/repo/settings/actions_general.tmpl +++ b/templates/repo/settings/actions_general.tmpl @@ -65,5 +65,44 @@ {{ctx.Locale.Tr "actions.general.collaborative_owners_management_help"}} {{end}} + + {{/* Token Permissions Section */}} +

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

+
+

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

+
+ {{.CsrfTokenHtml}} +
+ +
+
+ + +
+
+
+
+ + +
+
+
+
+

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

+
+
+
+ +
+
+
{{end}} diff --git a/tests/integration/actions_job_token_test.go b/tests/integration/actions_job_token_test.go index c4e8e880eb824..f2559d618c79f 100644 --- a/tests/integration/actions_job_token_test.go +++ b/tests/integration/actions_job_token_test.go @@ -115,3 +115,47 @@ func TestActionsJobTokenAccessLFS(t *testing.T) { })) }) } + +func TestActionsTokenPermissionsModes(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + t.Run("Permissive Mode (default)", testActionsTokenPermissionsMode(u, "permissive", false)) + t.Run("Restricted Mode", testActionsTokenPermissionsMode(u, "restricted", true)) + }) +} + +func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly bool) func(t *testing.T) { + return func(t *testing.T) { + // Load a task that can be used for testing + task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47}) + require.NoError(t, task.GenerateToken()) + task.Status = actions_model.StatusRunning + task.IsForkPullRequest = false // Not a fork PR + err := actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status", "is_fork_pull_request") + require.NoError(t, err) + + session := emptyTestSession(t) + context := APITestContext{ + Session: session, + Token: task.Token, + Username: "user5", + Reponame: "repo4", + } + dstPath := t.TempDir() + + u.Path = context.GitPath() + u.User = url.UserPassword("gitea-actions", task.Token) + + // Git clone should always work (read access) + t.Run("Git Clone", doGitClone(dstPath, u)) + + // API Get should always work (read access) + t.Run("API Get Repository", doAPIGetRepository(context, func(t *testing.T, r structs.Repository) { + require.Equal(t, "repo4", r.Name) + require.Equal(t, "user5", r.Owner.UserName) + })) + + // For now, both modes allow write since the mode setting needs to be persisted to the repo unit + // This test validates the token permission infrastructure is working + // Once mode is applied to repository settings, the expectReadOnly parameter will control behavior + } +} From 9a69f65ee43694ca2f194b85979482bb9528dd75 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Thu, 18 Dec 2025 04:01:54 +0100 Subject: [PATCH 02/30] Adress all review comments --- routers/web/repo/setting/actions.go | 12 +++++----- templates/repo/settings/actions_general.tmpl | 2 +- tests/integration/actions_job_token_test.go | 24 ++++++++++++++++--- .../api_helper_for_declarative_test.go | 18 ++++++++++++++ 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/routers/web/repo/setting/actions.go b/routers/web/repo/setting/actions.go index 724233a1532ae..29e525665b1fa 100644 --- a/routers/web/repo/setting/actions.go +++ b/routers/web/repo/setting/actions.go @@ -40,8 +40,6 @@ func ActionsGeneralSettings(ctx *context.Context) { ctx.Data["TokenPermissionMode"] = actionsCfg.GetTokenPermissionMode() ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted - ctx.Data["EffectiveTokenPermissions"] = actionsCfg.GetEffectiveTokenPermissions(false) - ctx.Data["MaxTokenPermissions"] = actionsCfg.GetMaxTokenPermissions() if ctx.Repo.Repository.IsPrivate { collaborativeOwnerIDs := actionsCfg.CollaborativeOwnerIDs @@ -142,11 +140,13 @@ func UpdateTokenPermissions(ctx *context.Context) { actionsCfg := actionsUnit.ActionsConfig() // Update permission mode - permissionMode := ctx.FormString("token_permission_mode") - if permissionMode == string(repo_model.ActionsTokenPermissionModeRestricted) { - actionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModeRestricted + permissionMode := repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode")) + if permissionMode == repo_model.ActionsTokenPermissionModeRestricted || permissionMode == repo_model.ActionsTokenPermissionModePermissive { + actionsCfg.TokenPermissionMode = permissionMode } else { - actionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModePermissive + ctx.Flash.Error("Invalid token permission mode") + ctx.Redirect(redirectURL) + return } if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil { diff --git a/templates/repo/settings/actions_general.tmpl b/templates/repo/settings/actions_general.tmpl index 4a9d2512bacb3..3f2c429a4b824 100644 --- a/templates/repo/settings/actions_general.tmpl +++ b/templates/repo/settings/actions_general.tmpl @@ -95,7 +95,7 @@ -
+

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

diff --git a/tests/integration/actions_job_token_test.go b/tests/integration/actions_job_token_test.go index f2559d618c79f..3b061895b3fd1 100644 --- a/tests/integration/actions_job_token_test.go +++ b/tests/integration/actions_job_token_test.go @@ -154,8 +154,26 @@ func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly boo require.Equal(t, "user5", r.Owner.UserName) })) - // For now, both modes allow write since the mode setting needs to be persisted to the repo unit - // This test validates the token permission infrastructure is working - // Once mode is applied to repository settings, the expectReadOnly parameter will control behavior + // Test Write Access + context.ExpectedCode = util.Iif(expectReadOnly, http.StatusForbidden, http.StatusCreated) + t.Run("API Create File", doAPICreateFile(context, "test-permissions.txt", &structs.CreateFileOptions{ + FileOptions: structs.FileOptions{ + NewBranchName: "new-branch-permissions", + Message: "Create File", + }, + ContentBase64: base64.StdEncoding.EncodeToString([]byte(`This is a test file for permissions.`)), + })) + + // Test Delete Access + context.ExpectedCode = util.Iif(expectReadOnly, http.StatusForbidden, http.StatusNoContent) + if !expectReadOnly { + // Clean up created file if we had write access + t.Run("API Delete File", doAPIDeleteFile(context, "test-permissions.txt", &structs.DeleteFileOptions{ + FileOptions: structs.FileOptions{ + BranchName: "new-branch-permissions", + Message: "Delete File", + }, + })) + } } } diff --git a/tests/integration/api_helper_for_declarative_test.go b/tests/integration/api_helper_for_declarative_test.go index b30cdfd0fc3b1..d7dcad953ca1c 100644 --- a/tests/integration/api_helper_for_declarative_test.go +++ b/tests/integration/api_helper_for_declarative_test.go @@ -374,6 +374,24 @@ func doAPICreateFile(ctx APITestContext, treepath string, options *api.CreateFil } } +func doAPIDeleteFile(ctx APITestContext, treepath string, options *api.DeleteFileOptions, callback ...func(*testing.T, api.FileDeleteResponse)) func(*testing.T) { + return func(t *testing.T) { + req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", ctx.Username, ctx.Reponame, treepath), &options). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + + var contents api.FileDeleteResponse + DecodeJSON(t, resp, &contents) + if len(callback) > 0 { + callback[0](t, contents) + } + } +} + func doAPICreateOrganization(ctx APITestContext, options *api.CreateOrgOption, callback ...func(*testing.T, api.Organization)) func(t *testing.T) { return func(t *testing.T) { req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &options). From 43e96d5ead1856eef337d6a7f9badb803e3c6c11 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Thu, 18 Dec 2025 09:17:07 +0100 Subject: [PATCH 03/30] WIP --- models/actions/config.go | 43 +++++++++++++ models/perm/access/repo_permission.go | 13 ++++ models/repo/repo_unit.go | 2 + models/user/setting.go | 1 + options/locale/locale_en-US.ini | 2 + routers/api/packages/api.go | 51 +++++++++++++++ routers/web/org/setting/actions.go | 69 +++++++++++++++++++++ routers/web/web.go | 3 +- templates/org/settings/actions_general.tmpl | 47 ++++++++++++++ 9 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 models/actions/config.go create mode 100644 routers/web/org/setting/actions.go create mode 100644 templates/org/settings/actions_general.tmpl diff --git a/models/actions/config.go b/models/actions/config.go new file mode 100644 index 0000000000000..bb04f8a3ca8d1 --- /dev/null +++ b/models/actions/config.go @@ -0,0 +1,43 @@ +// Copyright 2024 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 2ecdef6a48463..3e655d2672c10 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -280,6 +280,19 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito if err != nil || !exist { return perm, err } + + // Check Organization Cross-Repo Access Policy + if repo.OwnerID == taskRepo.OwnerID && repo.Owner.IsOrganization() { + 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 !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. diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index 6e420373daa3d..7ae9ad287c702 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -240,6 +240,8 @@ type ActionsConfig struct { 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) { diff --git a/models/user/setting.go b/models/user/setting.go index c65afae76c84f..7b60e4ee83895 100644 --- a/models/user/setting.go +++ b/models/user/setting.go @@ -10,6 +10,7 @@ import ( "strings" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/cache" setting_module "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 756aa35b1f63f..50609582ae096 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3955,6 +3955,8 @@ 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. [projects] deleted.display_name = Deleted Project diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index f6ee5958b5bb9..822510dce75cb 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -6,7 +6,9 @@ package packages import ( "net/http" + actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -80,6 +82,55 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) { } } + if ctx.Data["IsActionsToken"] == true { + if ctx.Package != 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 { + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + log.Error("GetTaskByID: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "GetTaskByID", err.Error()) + return + } + + var packageRepoID int64 + if ctx.Package.Descriptor != nil && ctx.Package.Descriptor.Package != nil { + packageRepoID = ctx.Package.Descriptor.Package.RepoID + } + + if task.RepoID != packageRepoID { + // Not linked to the running repo. + // Check Org 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 + } + } else { + // For user-owned packages, maybe stricter? Or same? + // Issue says "only when they have been linked". + // If Owner is User, Cross-Repo setting is not available (it's Org setting). + // Default to Strict for Users? + if task.RepoID != ctx.Package.RepoID { + ctx.HTTPError(http.StatusForbidden, "reqPackageAccess", "package must be linked to the repository") + return + } + } + } + } + } + } + if ctx.Package.AccessMode < accessMode && !ctx.IsUserSiteAdmin() { ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea Package API"`) ctx.HTTPError(http.StatusUnauthorized, "reqPackageAccess", "user should have specific permission or be a site admin") diff --git a/routers/web/org/setting/actions.go b/routers/web/org/setting/actions.go new file mode 100644 index 0000000000000..44d014b20c357 --- /dev/null +++ b/routers/web/org/setting/actions.go @@ -0,0 +1,69 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsActionsGeneral base.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["PageIsOrgSettingsActions"] = true + + // Load Org Actions Config + actionsCfg, err := actions_model.GetOrgActionsConfig(ctx, ctx.Org.Organization.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["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.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 { + actionsCfg.TokenPermissionMode = permissionMode + } + + // Update Cross-Repo Access + actionsCfg.AllowCrossRepoAccess = ctx.FormBool("allow_cross_repo_access") + + if err := actions_model.SetOrgActionsConfig(ctx, ctx.Org.Organization.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/web.go b/routers/web/web.go index ea70d8bb62f1d..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() diff --git a/templates/org/settings/actions_general.tmpl b/templates/org/settings/actions_general.tmpl new file mode 100644 index 0000000000000..1057d8d8e165e --- /dev/null +++ b/templates/org/settings/actions_general.tmpl @@ -0,0 +1,47 @@ +{{template "org/settings/layout_head" .}} +
+

+ {{.locale.Tr "actions.actions"}} +

+
+
+
+
+ {{.CsrfTokenHtml}} +
+ +
+
+
+ + +

{{.locale.Tr "actions.general.token_permissions.permissive.description"}}

+
+
+
+
+ + +

{{.locale.Tr "actions.general.token_permissions.restricted.description"}}

+
+
+
+
+ +

{{.locale.Tr "actions.general.token_permissions.cross_repo"}}

+
+
+ + +
+
+ +
+ +
+
+
+
+
+
+{{template "org/settings/layout_footer" .}} From 2a204e36a7718f856f6e0279581048ffe7c06571 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Thu, 18 Dec 2025 12:00:47 +0100 Subject: [PATCH 04/30] WIP --- models/repo/repo_unit.go | 42 +++++++ options/locale/locale_en-US.ini | 3 + routers/web/org/setting/actions.go | 30 ++++- routers/web/repo/setting/actions.go | 30 ++++- templates/org/settings/actions_general.tmpl | 116 ++++++++++++++++++ templates/repo/settings/actions_general.tmpl | 119 +++++++++++++++++++ 6 files changed, 338 insertions(+), 2 deletions(-) diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index 7ae9ad287c702..4c3b212f7b920 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -176,6 +176,8 @@ const ( 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 @@ -194,6 +196,46 @@ type ActionsTokenPermissions struct { Wiki perm.AccessMode `json:"wiki"` } +// HasRead checks if the permission has read access for the given scope +func (p ActionsTokenPermissions) HasRead(scope string) 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 >= perm.AccessModeRead +} + +// HasWrite checks if the permission has write access for the given scope +func (p ActionsTokenPermissions) HasWrite(scope string) 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 >= perm.AccessModeWrite +} + // DefaultActionsTokenPermissions returns the default permissions for permissive mode func DefaultActionsTokenPermissions(mode ActionsTokenPermissionMode) ActionsTokenPermissions { if mode == ActionsTokenPermissionModeRestricted { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 50609582ae096..1780f19e5236c 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3957,6 +3957,9 @@ 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 [projects] deleted.display_name = Deleted Project diff --git a/routers/web/org/setting/actions.go b/routers/web/org/setting/actions.go index 44d014b20c357..1b3568188953c 100644 --- a/routers/web/org/setting/actions.go +++ b/routers/web/org/setting/actions.go @@ -7,6 +7,7 @@ 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/base" "code.gitea.io/gitea/services/context" @@ -32,6 +33,8 @@ func ActionsGeneral(ctx *context.Context) { 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["AllowCrossRepoAccess"] = actionsCfg.AllowCrossRepoAccess @@ -52,10 +55,35 @@ func ActionsGeneralPost(ctx *context.Context) { // Update Token Permission Mode permissionMode := repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode")) - if permissionMode == repo_model.ActionsTokenPermissionModeRestricted || permissionMode == repo_model.ActionsTokenPermissionModePermissive { + if permissionMode == repo_model.ActionsTokenPermissionModeRestricted || + permissionMode == repo_model.ActionsTokenPermissionModePermissive || + permissionMode == repo_model.ActionsTokenPermissionModeCustom { actionsCfg.TokenPermissionMode = permissionMode } + if actionsCfg.TokenPermissionMode == repo_model.ActionsTokenPermissionModeCustom { + parsePerm := func(name string) perm.AccessMode { + if ctx.FormBool(name + "_write") { + return perm.AccessModeWrite + } + if ctx.FormBool(name + "_read") { + return perm.AccessModeRead + } + return perm.AccessModeNone + } + + actionsCfg.DefaultTokenPermissions = &repo_model.ActionsTokenPermissions{ + Actions: parsePerm("actions"), + Contents: parsePerm("contents"), + Issues: parsePerm("issues"), + Packages: parsePerm("packages"), + PullRequests: parsePerm("pull_requests"), + Wiki: parsePerm("wiki"), + } + } else { + actionsCfg.DefaultTokenPermissions = nil + } + // Update Cross-Repo Access actionsCfg.AllowCrossRepoAccess = ctx.FormBool("allow_cross_repo_access") diff --git a/routers/web/repo/setting/actions.go b/routers/web/repo/setting/actions.go index 29e525665b1fa..79875a00eb304 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" @@ -40,6 +41,8 @@ func ActionsGeneralSettings(ctx *context.Context) { 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) if ctx.Repo.Repository.IsPrivate { collaborativeOwnerIDs := actionsCfg.CollaborativeOwnerIDs @@ -141,7 +144,9 @@ func UpdateTokenPermissions(ctx *context.Context) { // Update permission mode permissionMode := repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode")) - if permissionMode == repo_model.ActionsTokenPermissionModeRestricted || permissionMode == repo_model.ActionsTokenPermissionModePermissive { + if permissionMode == repo_model.ActionsTokenPermissionModeRestricted || + permissionMode == repo_model.ActionsTokenPermissionModePermissive || + permissionMode == repo_model.ActionsTokenPermissionModeCustom { actionsCfg.TokenPermissionMode = permissionMode } else { ctx.Flash.Error("Invalid token permission mode") @@ -149,6 +154,29 @@ func UpdateTokenPermissions(ctx *context.Context) { return } + if actionsCfg.TokenPermissionMode == repo_model.ActionsTokenPermissionModeCustom { + parsePerm := func(name string) perm.AccessMode { + if ctx.FormBool(name + "_write") { + return perm.AccessModeWrite + } + if ctx.FormBool(name + "_read") { + return perm.AccessModeRead + } + return perm.AccessModeNone + } + + actionsCfg.DefaultTokenPermissions = &repo_model.ActionsTokenPermissions{ + Actions: parsePerm("actions"), + Contents: parsePerm("contents"), + Issues: parsePerm("issues"), + Packages: parsePerm("packages"), + PullRequests: parsePerm("pull_requests"), + Wiki: parsePerm("wiki"), + } + } else { + actionsCfg.DefaultTokenPermissions = nil + } + if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil { ctx.ServerError("UpdateRepoUnit", err) return diff --git a/templates/org/settings/actions_general.tmpl b/templates/org/settings/actions_general.tmpl index 1057d8d8e165e..b66c8121e2f12 100644 --- a/templates/org/settings/actions_general.tmpl +++ b/templates/org/settings/actions_general.tmpl @@ -25,6 +25,103 @@

{{.locale.Tr "actions.general.token_permissions.restricted.description"}}

+
+
+ + +

{{.locale.Tr "actions.general.token_permissions.custom.description"}}

+
+
+ + + + @@ -45,3 +142,22 @@ {{template "org/settings/layout_footer" .}} + + diff --git a/templates/repo/settings/actions_general.tmpl b/templates/repo/settings/actions_general.tmpl index 3f2c429a4b824..6326ab0a87e06 100644 --- a/templates/repo/settings/actions_general.tmpl +++ b/templates/repo/settings/actions_general.tmpl @@ -94,7 +94,107 @@ +
+
+ + +
+
+ + + +

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

@@ -106,3 +206,22 @@ {{end}} + + From 297ecef8dabe2fa2dc56468380edf0512d4e478a Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Thu, 18 Dec 2025 13:37:15 +0100 Subject: [PATCH 05/30] Final core implementation changes --- models/perm/access/repo_permission.go | 1 + options/locale/locale_en-US.ini | 2 + routers/api/packages/api.go | 22 ++--- routers/web/org/setting/actions.go | 31 +++++-- routers/web/repo/setting/actions.go | 21 +++++ templates/org/settings/actions_general.tmpl | 90 ++++++++++++++++++++ templates/repo/settings/actions_general.tmpl | 90 ++++++++++++++++++++ 7 files changed, 241 insertions(+), 16 deletions(-) diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index 3e655d2672c10..453a90ad5c115 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -313,6 +313,7 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito // 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 diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 1780f19e5236c..1ea69b6f9c44f 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3960,6 +3960,8 @@ general.token_permissions.cross_repo_desc = Allow workflows in this 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 822510dce75cb..443a14c575795 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -103,8 +103,13 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) { } if task.RepoID != packageRepoID { - // Not linked to the running repo. - // Check Org Policy + // 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 { @@ -116,16 +121,11 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) { ctx.HTTPError(http.StatusForbidden, "reqPackageAccess", "cross-repository package access is disabled") return } - } else { - // For user-owned packages, maybe stricter? Or same? - // Issue says "only when they have been linked". - // If Owner is User, Cross-Repo setting is not available (it's Org setting). - // Default to Strict for Users? - if task.RepoID != ctx.Package.RepoID { - ctx.HTTPError(http.StatusForbidden, "reqPackageAccess", "package must be linked to the repository") - 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). } } } diff --git a/routers/web/org/setting/actions.go b/routers/web/org/setting/actions.go index 1b3568188953c..fba4e2905c8b3 100644 --- a/routers/web/org/setting/actions.go +++ b/routers/web/org/setting/actions.go @@ -9,12 +9,12 @@ import ( 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/base" + "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/services/context" ) const ( - tplSettingsActionsGeneral base.TplName = "org/settings/actions_general" + tplSettingsActionsGeneral templates.TplName = "org/settings/actions_general" ) // ActionsGeneral renders the actions general settings page @@ -24,7 +24,7 @@ func ActionsGeneral(ctx *context.Context) { ctx.Data["PageIsOrgSettingsActions"] = true // Load Org Actions Config - actionsCfg, err := actions_model.GetOrgActionsConfig(ctx, ctx.Org.Organization.ID) + actionsCfg, err := actions_model.GetOrgActionsConfig(ctx, ctx.Org.Organization.AsUser().ID) if err != nil { ctx.ServerError("GetOrgActionsConfig", err) return @@ -35,6 +35,7 @@ func ActionsGeneral(ctx *context.Context) { 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 @@ -47,7 +48,7 @@ func ActionsGeneralPost(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsOrgSettingsActions"] = true - actionsCfg, err := actions_model.GetOrgActionsConfig(ctx, ctx.Org.Organization.ID) + actionsCfg, err := actions_model.GetOrgActionsConfig(ctx, ctx.Org.Organization.AsUser().ID) if err != nil { ctx.ServerError("GetOrgActionsConfig", err) return @@ -84,10 +85,30 @@ func ActionsGeneralPost(ctx *context.Context) { actionsCfg.DefaultTokenPermissions = nil } + // Update Maximum Permissions + parseMaxPerm := func(name string) perm.AccessMode { + if ctx.FormBool("max_" + name + "_write") { + return perm.AccessModeWrite + } + if ctx.FormBool("max_" + name + "_read") { + return perm.AccessModeRead + } + 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.ID, actionsCfg); err != nil { + if err := actions_model.SetOrgActionsConfig(ctx, ctx.Org.Organization.AsUser().ID, actionsCfg); err != nil { ctx.ServerError("SetOrgActionsConfig", err) return } diff --git a/routers/web/repo/setting/actions.go b/routers/web/repo/setting/actions.go index 79875a00eb304..673af0a0bdb22 100644 --- a/routers/web/repo/setting/actions.go +++ b/routers/web/repo/setting/actions.go @@ -43,6 +43,7 @@ func ActionsGeneralSettings(ctx *context.Context) { 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 := actionsCfg.CollaborativeOwnerIDs @@ -177,6 +178,26 @@ func UpdateTokenPermissions(ctx *context.Context) { actionsCfg.DefaultTokenPermissions = nil } + // Update Maximum Permissions + parseMaxPerm := func(name string) perm.AccessMode { + if ctx.FormBool("max_" + name + "_write") { + return perm.AccessModeWrite + } + if ctx.FormBool("max_" + name + "_read") { + return perm.AccessModeRead + } + 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 diff --git a/templates/org/settings/actions_general.tmpl b/templates/org/settings/actions_general.tmpl index b66c8121e2f12..dbae8a1cbb5f8 100644 --- a/templates/org/settings/actions_general.tmpl +++ b/templates/org/settings/actions_general.tmpl @@ -139,6 +139,96 @@ +
+
{{ctx.Locale.Tr "general.token_permissions.maximum"}}
+

{{ctx.Locale.Tr "general.token_permissions.maximum.description"}}

+
+ +
+
+ +
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + +
+
+
+
+ {{template "org/settings/layout_footer" .}} diff --git a/templates/repo/settings/actions_general.tmpl b/templates/repo/settings/actions_general.tmpl index 6326ab0a87e06..8b2374fa2d52a 100644 --- a/templates/repo/settings/actions_general.tmpl +++ b/templates/repo/settings/actions_general.tmpl @@ -195,6 +195,96 @@ +
+
{{ctx.Locale.Tr "general.token_permissions.maximum"}}
+

{{ctx.Locale.Tr "general.token_permissions.maximum.description"}}

+
+ +
+
+ +
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + +
+
+
+
+

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

From 5317bb0e95403adf0a0f37ac9eeedba67a4757e1 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Thu, 18 Dec 2025 14:06:24 +0100 Subject: [PATCH 06/30] Fix lints --- models/user/setting.go | 1 - routers/api/packages/api.go | 1 - templates/org/settings/actions_general.tmpl | 2 +- templates/repo/settings/actions_general.tmpl | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/models/user/setting.go b/models/user/setting.go index 7b60e4ee83895..c65afae76c84f 100644 --- a/models/user/setting.go +++ b/models/user/setting.go @@ -10,7 +10,6 @@ import ( "strings" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/cache" setting_module "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 443a14c575795..32195cea99e40 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -8,7 +8,6 @@ import ( 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" "code.gitea.io/gitea/modules/setting" diff --git a/templates/org/settings/actions_general.tmpl b/templates/org/settings/actions_general.tmpl index dbae8a1cbb5f8..4c3c5cd2666d9 100644 --- a/templates/org/settings/actions_general.tmpl +++ b/templates/org/settings/actions_general.tmpl @@ -237,7 +237,7 @@ window.addEventListener('load', function() { const customPerms = document.getElementById('custom-permissions'); const radios = document.querySelectorAll('input[name="token_permission_mode"]'); - + function toggleCustom() { const selected = document.querySelector('input[name="token_permission_mode"]:checked'); if (selected && selected.value === 'custom') { diff --git a/templates/repo/settings/actions_general.tmpl b/templates/repo/settings/actions_general.tmpl index 8b2374fa2d52a..93d3da6a896e5 100644 --- a/templates/repo/settings/actions_general.tmpl +++ b/templates/repo/settings/actions_general.tmpl @@ -301,7 +301,7 @@ window.addEventListener('load', function() { const customPerms = document.getElementById('custom-permissions'); const radios = document.querySelectorAll('input[name="token_permission_mode"]'); - + function toggleCustom() { const selected = document.querySelector('input[name="token_permission_mode"]:checked'); if (selected && selected.value === 'custom') { From 0682fd8f0430b0db71155cd46c53a24185e53a44 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Thu, 18 Dec 2025 15:16:51 +0100 Subject: [PATCH 07/30] Fix test --- tests/integration/actions_job_token_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/integration/actions_job_token_test.go b/tests/integration/actions_job_token_test.go index 3b061895b3fd1..d5d0d3f1a6343 100644 --- a/tests/integration/actions_job_token_test.go +++ b/tests/integration/actions_job_token_test.go @@ -12,6 +12,8 @@ import ( actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" @@ -125,8 +127,20 @@ func TestActionsTokenPermissionsModes(t *testing.T) { func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly bool) func(t *testing.T) { return func(t *testing.T) { + // Update repository settings to the requested mode + if mode != "" { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo4", OwnerName: "user5"}) + require.NoError(t, repo.LoadUnits(t.Context())) + actionsUnit := repo.MustGetUnit(t.Context(), unit_model.TypeActions) + actionsCfg := actionsUnit.ActionsConfig() + actionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionMode(mode) + actionsUnit.Config = actionsCfg + require.NoError(t, repo_model.UpdateRepoUnit(t.Context(), actionsUnit)) + } + // Load a task that can be used for testing task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47}) + // Regenerate token to pick up new permissions if any (though currently permissions are checked at runtime) require.NoError(t, task.GenerateToken()) task.Status = actions_model.StatusRunning task.IsForkPullRequest = false // Not a fork PR @@ -154,6 +168,8 @@ func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly boo require.Equal(t, "user5", r.Owner.UserName) })) + var sha string + // Test Write Access context.ExpectedCode = util.Iif(expectReadOnly, http.StatusForbidden, http.StatusCreated) t.Run("API Create File", doAPICreateFile(context, "test-permissions.txt", &structs.CreateFileOptions{ @@ -162,6 +178,8 @@ func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly boo Message: "Create File", }, ContentBase64: base64.StdEncoding.EncodeToString([]byte(`This is a test file for permissions.`)), + }, func(t *testing.T, resp structs.FileResponse) { + sha = resp.Content.SHA })) // Test Delete Access @@ -173,6 +191,7 @@ func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly boo BranchName: "new-branch-permissions", Message: "Delete File", }, + SHA: sha, })) } } From fd1afc5e4c22d87116cef3d10dfc95707cbc6970 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Thu, 18 Dec 2025 15:54:59 +0100 Subject: [PATCH 08/30] Fixing Test Failures for Token Permissions --- tests/integration/actions_job_token_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/actions_job_token_test.go b/tests/integration/actions_job_token_test.go index d5d0d3f1a6343..8db132587dc07 100644 --- a/tests/integration/actions_job_token_test.go +++ b/tests/integration/actions_job_token_test.go @@ -134,6 +134,8 @@ func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly boo actionsUnit := repo.MustGetUnit(t.Context(), unit_model.TypeActions) actionsCfg := actionsUnit.ActionsConfig() actionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionMode(mode) + actionsCfg.DefaultTokenPermissions = nil // Ensure no custom permissions override the mode + actionsCfg.MaxTokenPermissions = nil // Ensure no max permissions interfere actionsUnit.Config = actionsCfg require.NoError(t, repo_model.UpdateRepoUnit(t.Context(), actionsUnit)) } @@ -180,6 +182,7 @@ func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly boo ContentBase64: base64.StdEncoding.EncodeToString([]byte(`This is a test file for permissions.`)), }, func(t *testing.T, resp structs.FileResponse) { sha = resp.Content.SHA + require.NotEmpty(t, sha, "SHA should not be empty") })) // Test Delete Access From a4aae82c187d2149e0863a5cedd4bfec2663216d Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Thu, 18 Dec 2025 16:51:39 +0100 Subject: [PATCH 09/30] Fix test --- tests/integration/actions_job_token_test.go | 26 +++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/tests/integration/actions_job_token_test.go b/tests/integration/actions_job_token_test.go index 8db132587dc07..e57818dee5c82 100644 --- a/tests/integration/actions_job_token_test.go +++ b/tests/integration/actions_job_token_test.go @@ -5,6 +5,7 @@ package integration import ( "encoding/base64" + "fmt" "net/http" "net/url" "testing" @@ -189,13 +190,24 @@ func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly boo context.ExpectedCode = util.Iif(expectReadOnly, http.StatusForbidden, http.StatusNoContent) if !expectReadOnly { // Clean up created file if we had write access - t.Run("API Delete File", doAPIDeleteFile(context, "test-permissions.txt", &structs.DeleteFileOptions{ - FileOptions: structs.FileOptions{ - BranchName: "new-branch-permissions", - Message: "Delete File", - }, - SHA: sha, - })) + t.Run("API Delete File", func(t *testing.T) { + t.Logf("Deleting file with SHA: %s", sha) + require.NotEmpty(t, sha, "SHA must be captured before deletion") + deleteOpts := &structs.DeleteFileOptions{ + FileOptions: structs.FileOptions{ + BranchName: "new-branch-permissions", + Message: "Delete File", + }, + SHA: sha, + } + req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", context.Username, context.Reponame, "test-permissions.txt"), deleteOpts). + AddTokenAuth(context.Token) + if context.ExpectedCode != 0 { + context.Session.MakeRequest(t, req, context.ExpectedCode) + return + } + context.Session.MakeRequest(t, req, http.StatusNoContent) + }) } } } From 65051b17622a32fe4050b61703557223f119838f Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Thu, 18 Dec 2025 18:13:40 +0100 Subject: [PATCH 10/30] Fix checks --- tests/integration/actions_job_token_test.go | 12 ++++++++++-- .../api_helper_for_declarative_test.go | 18 ------------------ 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/tests/integration/actions_job_token_test.go b/tests/integration/actions_job_token_test.go index e57818dee5c82..60b6ee8c2b3fa 100644 --- a/tests/integration/actions_job_token_test.go +++ b/tests/integration/actions_job_token_test.go @@ -174,7 +174,11 @@ func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly boo var sha string // Test Write Access - context.ExpectedCode = util.Iif(expectReadOnly, http.StatusForbidden, http.StatusCreated) + if expectReadOnly { + context.ExpectedCode = http.StatusForbidden + } else { + context.ExpectedCode = 0 + } t.Run("API Create File", doAPICreateFile(context, "test-permissions.txt", &structs.CreateFileOptions{ FileOptions: structs.FileOptions{ NewBranchName: "new-branch-permissions", @@ -187,7 +191,11 @@ func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly boo })) // Test Delete Access - context.ExpectedCode = util.Iif(expectReadOnly, http.StatusForbidden, http.StatusNoContent) + if expectReadOnly { + context.ExpectedCode = http.StatusForbidden + } else { + context.ExpectedCode = 0 + } if !expectReadOnly { // Clean up created file if we had write access t.Run("API Delete File", func(t *testing.T) { diff --git a/tests/integration/api_helper_for_declarative_test.go b/tests/integration/api_helper_for_declarative_test.go index d7dcad953ca1c..b30cdfd0fc3b1 100644 --- a/tests/integration/api_helper_for_declarative_test.go +++ b/tests/integration/api_helper_for_declarative_test.go @@ -374,24 +374,6 @@ func doAPICreateFile(ctx APITestContext, treepath string, options *api.CreateFil } } -func doAPIDeleteFile(ctx APITestContext, treepath string, options *api.DeleteFileOptions, callback ...func(*testing.T, api.FileDeleteResponse)) func(*testing.T) { - return func(t *testing.T) { - req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", ctx.Username, ctx.Reponame, treepath), &options). - AddTokenAuth(ctx.Token) - if ctx.ExpectedCode != 0 { - ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) - return - } - resp := ctx.Session.MakeRequest(t, req, http.StatusOK) - - var contents api.FileDeleteResponse - DecodeJSON(t, resp, &contents) - if len(callback) > 0 { - callback[0](t, contents) - } - } -} - func doAPICreateOrganization(ctx APITestContext, options *api.CreateOrgOption, callback ...func(*testing.T, api.Organization)) func(t *testing.T) { return func(t *testing.T) { req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &options). From a6b6e709d9153bc7abc57d1610c5aeb3868b7b51 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Thu, 18 Dec 2025 19:07:51 +0100 Subject: [PATCH 11/30] update tesr --- tests/integration/actions_job_token_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/actions_job_token_test.go b/tests/integration/actions_job_token_test.go index 60b6ee8c2b3fa..c065625e798e1 100644 --- a/tests/integration/actions_job_token_test.go +++ b/tests/integration/actions_job_token_test.go @@ -181,8 +181,8 @@ func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly boo } t.Run("API Create File", doAPICreateFile(context, "test-permissions.txt", &structs.CreateFileOptions{ FileOptions: structs.FileOptions{ - NewBranchName: "new-branch-permissions", - Message: "Create File", + BranchName: "master", + Message: "Create File", }, ContentBase64: base64.StdEncoding.EncodeToString([]byte(`This is a test file for permissions.`)), }, func(t *testing.T, resp structs.FileResponse) { @@ -203,7 +203,7 @@ func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly boo require.NotEmpty(t, sha, "SHA must be captured before deletion") deleteOpts := &structs.DeleteFileOptions{ FileOptions: structs.FileOptions{ - BranchName: "new-branch-permissions", + BranchName: "master", Message: "Delete File", }, SHA: sha, From 5eb2f12b0ece8029856f9ce44b7e1057132c4106 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Fri, 19 Dec 2025 03:41:22 +0100 Subject: [PATCH 12/30] wip --- tests/integration/actions_job_token_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/actions_job_token_test.go b/tests/integration/actions_job_token_test.go index c065625e798e1..feac4fc8d5cda 100644 --- a/tests/integration/actions_job_token_test.go +++ b/tests/integration/actions_job_token_test.go @@ -214,7 +214,7 @@ func testActionsTokenPermissionsMode(u *url.URL, mode string, expectReadOnly boo context.Session.MakeRequest(t, req, context.ExpectedCode) return } - context.Session.MakeRequest(t, req, http.StatusNoContent) + context.Session.MakeRequest(t, req, http.StatusOK) }) } } From 8daef631cfc69f9d9290ded5a086989c9a5046ef Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Fri, 19 Dec 2025 05:39:06 +0100 Subject: [PATCH 13/30] Adress all reviewer feedback --- models/actions/config.go | 2 +- models/repo/repo_unit.go | 30 +++------ templates/org/settings/actions_general.tmpl | 67 +++++++------------- templates/repo/settings/actions_general.tmpl | 67 +++++++------------- web_src/js/features/repo-settings-actions.ts | 24 +++++++ web_src/js/index-domready.ts | 2 + 6 files changed, 85 insertions(+), 107 deletions(-) create mode 100644 web_src/js/features/repo-settings-actions.ts diff --git a/models/actions/config.go b/models/actions/config.go index bb04f8a3ca8d1..7bd64b74d4ccc 100644 --- a/models/actions/config.go +++ b/models/actions/config.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package actions diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index 4c3b212f7b920..e84a52439c5b0 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -196,8 +196,8 @@ type ActionsTokenPermissions struct { Wiki perm.AccessMode `json:"wiki"` } -// HasRead checks if the permission has read access for the given scope -func (p ActionsTokenPermissions) HasRead(scope string) bool { +// 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": @@ -213,27 +213,17 @@ func (p ActionsTokenPermissions) HasRead(scope string) bool { case "wiki": mode = p.Wiki } - return mode >= perm.AccessModeRead + return mode >= required } -// HasWrite checks if the permission has write access for the given scope +// 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 { - 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 >= perm.AccessModeWrite + return p.HasAccess(scope, perm.AccessModeWrite) } // DefaultActionsTokenPermissions returns the default permissions for permissive mode diff --git a/templates/org/settings/actions_general.tmpl b/templates/org/settings/actions_general.tmpl index 4c3c5cd2666d9..e56925bcff19b 100644 --- a/templates/org/settings/actions_general.tmpl +++ b/templates/org/settings/actions_general.tmpl @@ -43,11 +43,11 @@
- +
- +
@@ -57,11 +57,11 @@
- +
- +
@@ -71,11 +71,11 @@
- +
- +
@@ -85,11 +85,11 @@
- +
- +
@@ -99,11 +99,11 @@
- +
- +
@@ -113,11 +113,11 @@
- +
- +
@@ -148,11 +148,11 @@
- +
- +
@@ -162,11 +162,11 @@
- +
- +
@@ -176,11 +176,11 @@
- +
- +
@@ -190,11 +190,11 @@
- +
- +
@@ -204,11 +204,11 @@
- +
- +
@@ -218,11 +218,11 @@
- +
- +
@@ -232,22 +232,3 @@ {{template "org/settings/layout_footer" .}} - - diff --git a/templates/repo/settings/actions_general.tmpl b/templates/repo/settings/actions_general.tmpl index 93d3da6a896e5..4b801936a7acc 100644 --- a/templates/repo/settings/actions_general.tmpl +++ b/templates/repo/settings/actions_general.tmpl @@ -113,11 +113,11 @@
- +
- +
@@ -127,11 +127,11 @@
- +
- +
@@ -141,11 +141,11 @@
- +
- +
@@ -155,11 +155,11 @@
- +
- +
@@ -169,11 +169,11 @@
- +
- +
@@ -183,11 +183,11 @@
- +
- +
@@ -204,11 +204,11 @@
- +
- +
@@ -218,11 +218,11 @@
- +
- +
@@ -232,11 +232,11 @@
- +
- +
@@ -246,11 +246,11 @@
- +
- +
@@ -260,11 +260,11 @@
- +
- +
@@ -274,11 +274,11 @@
- +
- +
@@ -296,22 +296,3 @@ {{end}} - - diff --git a/web_src/js/features/repo-settings-actions.ts b/web_src/js/features/repo-settings-actions.ts new file mode 100644 index 0000000000000..3b0f93ba57cfd --- /dev/null +++ b/web_src/js/features/repo-settings-actions.ts @@ -0,0 +1,24 @@ +export function initRepoSettingsActionsPermissions(): void { + const radios = document.querySelectorAll( + 'input[name="token_permission_mode"]', + ); + if (!radios.length) return; + + function toggleCustom(): void { + const customPerms = document.querySelector('#custom-permissions'); + if (!customPerms) return; + + const selected = document.querySelector( + 'input[name="token_permission_mode"]:checked', + ); + + customPerms.style.display = + selected?.value === 'custom' ? 'block' : 'none'; + } + + for (const radio of radios) { + radio.addEventListener('change', toggleCustom); + } + + toggleCustom(); +} diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts index 660e5c0989610..da1f3f80d0995 100644 --- a/web_src/js/index-domready.ts +++ b/web_src/js/index-domready.ts @@ -64,6 +64,7 @@ import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts'; import {callInitFunctions} from './modules/init.ts'; import {initRepoViewFileTree} from './features/repo-view-file-tree.ts'; +import {initRepoSettingsActionsPermissions} from './features/repo-settings-actions.ts'; const initStartTime = performance.now(); const initPerformanceTracer = callInitFunctions([ @@ -158,6 +159,7 @@ const initPerformanceTracer = callInitFunctions([ initOAuth2SettingsDisableCheckbox, initRepoFileView, + initRepoSettingsActionsPermissions, ]); // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. From 79a5d079ba4d5323e3e1d1cde7618a5554855105 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Sat, 20 Dec 2025 04:30:49 +0100 Subject: [PATCH 14/30] Completely redesign UI --- routers/web/org/setting/actions.go | 14 +- routers/web/repo/setting/actions.go | 12 +- templates/org/settings/actions_general.tmpl | 407 ++++++++--------- templates/org/settings/navbar.tmpl | 5 +- templates/repo/settings/actions_general.tmpl | 445 ++++++++----------- 5 files changed, 385 insertions(+), 498 deletions(-) diff --git a/routers/web/org/setting/actions.go b/routers/web/org/setting/actions.go index fba4e2905c8b3..e06a06b39047c 100644 --- a/routers/web/org/setting/actions.go +++ b/routers/web/org/setting/actions.go @@ -21,7 +21,7 @@ const ( func ActionsGeneral(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("actions.actions") ctx.Data["PageIsOrgSettings"] = true - ctx.Data["PageIsOrgSettingsActions"] = true + ctx.Data["PageIsOrgSettingsActionsGeneral"] = true // Load Org Actions Config actionsCfg, err := actions_model.GetOrgActionsConfig(ctx, ctx.Org.Organization.AsUser().ID) @@ -85,15 +85,17 @@ func ActionsGeneralPost(ctx *context.Context) { actionsCfg.DefaultTokenPermissions = nil } - // Update Maximum Permissions + // Update Maximum Permissions (radio buttons: none/read/write) parseMaxPerm := func(name string) perm.AccessMode { - if ctx.FormBool("max_" + name + "_write") { + value := ctx.FormString("max_" + name) + switch value { + case "write": return perm.AccessModeWrite - } - if ctx.FormBool("max_" + name + "_read") { + case "read": return perm.AccessModeRead + default: + return perm.AccessModeNone } - return perm.AccessModeNone } actionsCfg.MaxTokenPermissions = &repo_model.ActionsTokenPermissions{ diff --git a/routers/web/repo/setting/actions.go b/routers/web/repo/setting/actions.go index 673af0a0bdb22..a4f0c9488e45a 100644 --- a/routers/web/repo/setting/actions.go +++ b/routers/web/repo/setting/actions.go @@ -178,15 +178,17 @@ func UpdateTokenPermissions(ctx *context.Context) { actionsCfg.DefaultTokenPermissions = nil } - // Update Maximum Permissions + // Update Maximum Permissions (radio buttons: none/read/write) parseMaxPerm := func(name string) perm.AccessMode { - if ctx.FormBool("max_" + name + "_write") { + value := ctx.FormString("max_" + name) + switch value { + case "write": return perm.AccessModeWrite - } - if ctx.FormBool("max_" + name + "_read") { + case "read": return perm.AccessModeRead + default: + return perm.AccessModeNone } - return perm.AccessModeNone } actionsCfg.MaxTokenPermissions = &repo_model.ActionsTokenPermissions{ diff --git a/templates/org/settings/actions_general.tmpl b/templates/org/settings/actions_general.tmpl index e56925bcff19b..ccd42d33617e9 100644 --- a/templates/org/settings/actions_general.tmpl +++ b/templates/org/settings/actions_general.tmpl @@ -1,234 +1,199 @@ {{template "org/settings/layout_head" .}}

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

-
-
-
- {{.CsrfTokenHtml}} -
- -
-
-
- - -

{{.locale.Tr "actions.general.token_permissions.permissive.description"}}

-
-
-
-
- - -

{{.locale.Tr "actions.general.token_permissions.restricted.description"}}

-
-
-
-
- - -

{{.locale.Tr "actions.general.token_permissions.custom.description"}}

-
-
-
-
+ + {{.CsrfTokenHtml}} - + +
+
+ + +
+

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

+
-

{{.locale.Tr "actions.general.token_permissions.cross_repo"}}

-
-
- - -
-
+
+ + +
+ {{ctx.Locale.Tr "general.token_permissions.maximum"}} + * +
+

{{ctx.Locale.Tr "general.token_permissions.maximum.description"}}

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

Access source code, files, commits and branches.

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

Organize bug reports, tasks and milestones.

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

Enable pull requests and code reviews.

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

Write and share documentation with collaborators.

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

Manage repository packages.

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

Manage actions.

+
+
+ + +
+
+
+ + +
+
+
+ + +
+
-
- -
-
-
-
-
{{ctx.Locale.Tr "general.token_permissions.maximum"}}
-

{{ctx.Locale.Tr "general.token_permissions.maximum.description"}}

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