diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index a04023462a..353ec5402a 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 - custom permissions defined by MaxTokenPermissions + ActionsTokenPermissionModeCustom ActionsTokenPermissionMode = "custom" ) // ActionsTokenPermissions defines the permissions for different repository units @@ -272,6 +274,10 @@ type ActionsConfig struct { MaxTokenPermissions *ActionsTokenPermissions `json:"max_token_permissions,omitempty"` // AllowCrossRepoAccess indicates if actions in this repo/org can access other repos in the same org AllowCrossRepoAccess bool `json:"allow_cross_repo_access,omitempty"` + // AllowedCrossRepoIDs is a list of specific repo IDs that can be accessed cross-repo (empty means all if AllowCrossRepoAccess is true) + AllowedCrossRepoIDs []int64 `json:"allowed_cross_repo_ids,omitempty"` + // FollowOrgConfig indicates if this repository should follow the organization-level configuration + FollowOrgConfig bool `json:"follow_org_config,omitempty"` } func (cfg *ActionsConfig) EnableWorkflow(file string) { @@ -361,6 +367,20 @@ func (cfg *ActionsConfig) ClampPermissions(perms ActionsTokenPermissions) Action } } +// IsRepoAllowedCrossAccess checks if a specific repo is allowed for cross-repo access +// Returns true if AllowCrossRepoAccess is enabled AND (AllowedCrossRepoIDs is empty OR repoID is in the list) +func (cfg *ActionsConfig) IsRepoAllowedCrossAccess(repoID int64) bool { + if !cfg.AllowCrossRepoAccess { + return false + } + // If no specific repos are configured, allow all + if len(cfg.AllowedCrossRepoIDs) == 0 { + return true + } + // Check if repo is in the allowed list + return slices.Contains(cfg.AllowedCrossRepoIDs, repoID) +} + // FromDB fills up a ActionsConfig from serialized format. func (cfg *ActionsConfig) FromDB(bs []byte) error { return json.UnmarshalHandleDoubleEncode(bs, &cfg) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 64fc9d09eb..144360d4b7 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3740,8 +3740,12 @@ "actions.general.token_permissions.none": "None", "actions.general.token_permissions.cross_repo": "Allow Cross-Repository Access", "actions.general.token_permissions.cross_repo_desc": "Control whether the token can access other repositories and packages within this organization.", + "actions.general.token_permissions.cross_repo_all": "All repositories in this organization", + "actions.general.token_permissions.cross_repo_selected": "Selected repositories only", "actions.general.token_permissions.allowed_repos": "Allowed Repositories", "actions.general.token_permissions.add_repo": "Add Repository", + "actions.general.token_permissions.follow_org": "Follow organization-level configuration", + "actions.general.token_permissions.follow_org_desc": "Use the Actions settings configured at the organization level instead of repository-specific settings.", "all_repositories": "All Repositories", "specific_repositories": "Specific Repositories" } diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index e084dc13e1..49ef440d03 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -105,21 +105,26 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) { packageRepoID = ctx.Package.Descriptor.Package.RepoID } + // If package is not linked to any repo (org-level package), deny access from Actions + // Actions tokens should only access packages linked to repos + if packageRepoID == 0 { + ctx.HTTPError(http.StatusForbidden, "reqPackageAccess", "Actions tokens cannot access packages not linked to a repository") + return + } + if task.RepoID != packageRepoID { - // Cross-repository access - check org policy first + // Cross-repository access - check org policy cfg, err := actions_model.GetOrgActionsConfig(ctx, ctx.Package.Owner.ID) if err != nil { log.Error("GetOrgActionsConfig: %v", err) ctx.HTTPError(http.StatusInternalServerError, "GetOrgActionsConfig", err.Error()) return } - if !cfg.AllowCrossRepoAccess { - ctx.HTTPError(http.StatusForbidden, "reqPackageAccess", "cross-repository package access is disabled") + // Use selective cross-repo access check + if !cfg.IsRepoAllowedCrossAccess(packageRepoID) { + ctx.HTTPError(http.StatusForbidden, "reqPackageAccess", "cross-repository package access is not allowed for this repository") return } - - // Cross-repo is enabled. For org-level packages (RepoID=0), allow access. - // For repo-linked packages, allow read access (fallthrough to permission check below). } } } diff --git a/routers/web/org/setting/actions.go b/routers/web/org/setting/actions.go index 44c6b58bda..475a70d724 100644 --- a/routers/web/org/setting/actions.go +++ b/routers/web/org/setting/actions.go @@ -33,9 +33,11 @@ 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["MaxTokenPermissions"] = actionsCfg.GetMaxTokenPermissions() ctx.Data["AllowCrossRepoAccess"] = actionsCfg.AllowCrossRepoAccess + ctx.Data["HasSelectedRepos"] = len(actionsCfg.AllowedCrossRepoIDs) > 0 ctx.HTML(http.StatusOK, tplSettingsActionsGeneral) } @@ -55,7 +57,8 @@ 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 { + permissionMode == repo_model.ActionsTokenPermissionModePermissive || + permissionMode == repo_model.ActionsTokenPermissionModeCustom { actionsCfg.TokenPermissionMode = permissionMode } @@ -81,8 +84,19 @@ func ActionsGeneralPost(ctx *context.Context) { Wiki: parseMaxPerm("wiki"), } - // Update Cross-Repo Access - actionsCfg.AllowCrossRepoAccess = ctx.FormBool("allow_cross_repo_access") + // Update Cross-Repo Access Mode + crossRepoMode := ctx.FormString("cross_repo_mode") + switch crossRepoMode { + case "none": + actionsCfg.AllowCrossRepoAccess = false + actionsCfg.AllowedCrossRepoIDs = nil + case "all": + actionsCfg.AllowCrossRepoAccess = true + actionsCfg.AllowedCrossRepoIDs = nil + case "selected": + actionsCfg.AllowCrossRepoAccess = true + // Keep existing AllowedCrossRepoIDs, will be updated by separate API + } if err := actions_model.SetOrgActionsConfig(ctx, ctx.Org.Organization.AsUser().ID, actionsCfg); err != nil { ctx.ServerError("SetOrgActionsConfig", err) diff --git a/routers/web/repo/setting/actions.go b/routers/web/repo/setting/actions.go index a9b4e3761c..0ba12e9f30 100644 --- a/routers/web/repo/setting/actions.go +++ b/routers/web/repo/setting/actions.go @@ -41,8 +41,13 @@ 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["MaxTokenPermissions"] = actionsCfg.GetMaxTokenPermissions() + // Follow org config (only for repos in orgs) + ctx.Data["IsInOrg"] = ctx.Repo.Repository.Owner.IsOrganization() + ctx.Data["FollowOrgConfig"] = actionsCfg.FollowOrgConfig + if ctx.Repo.Repository.IsPrivate { collaborativeOwnerIDs := actionsCfg.CollaborativeOwnerIDs collaborativeOwners, err := user_model.GetUsersByIDs(ctx, collaborativeOwnerIDs) @@ -141,15 +146,21 @@ func UpdateTokenPermissions(ctx *context.Context) { actionsCfg := actionsUnit.ActionsConfig() - // Update permission mode - permissionMode := repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode")) - if permissionMode == repo_model.ActionsTokenPermissionModeRestricted || - permissionMode == repo_model.ActionsTokenPermissionModePermissive { - actionsCfg.TokenPermissionMode = permissionMode - } else { - ctx.Flash.Error("Invalid token permission mode") - ctx.Redirect(redirectURL) - return + // Update Follow Org Config (for repos in orgs) + actionsCfg.FollowOrgConfig = ctx.FormBool("follow_org_config") + + // Update permission mode (only if not following org config) + if !actionsCfg.FollowOrgConfig { + permissionMode := repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode")) + if permissionMode == repo_model.ActionsTokenPermissionModeRestricted || + permissionMode == repo_model.ActionsTokenPermissionModePermissive || + permissionMode == repo_model.ActionsTokenPermissionModeCustom { + actionsCfg.TokenPermissionMode = permissionMode + } else { + ctx.Flash.Error("Invalid token permission mode") + ctx.Redirect(redirectURL) + return + } } // Update Maximum Permissions (radio buttons: none/read/write) diff --git a/templates/org/settings/actions_general.tmpl b/templates/org/settings/actions_general.tmpl index e98f895f9b..871ddcc4b2 100644 --- a/templates/org/settings/actions_general.tmpl +++ b/templates/org/settings/actions_general.tmpl @@ -8,12 +8,30 @@ {{.CsrfTokenHtml}} -
-
- - +
+ {{ctx.Locale.Tr "actions.general.token_permissions.cross_repo"}} +
+

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

+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
-

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

@@ -25,29 +43,38 @@
- - + +
-

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

+

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

- - + +
-

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

+

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

+
+
+
+ + +
+

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

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

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

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

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

+
- +
diff --git a/templates/repo/settings/actions_general.tmpl b/templates/repo/settings/actions_general.tmpl index 920dfd0646..962fc8a8b0 100644 --- a/templates/repo/settings/actions_general.tmpl +++ b/templates/repo/settings/actions_general.tmpl @@ -32,22 +32,42 @@ {{.CsrfTokenHtml}} - + {{if .IsInOrg}} +
+
+ + +
+

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

+
+ +
+ {{end}} + + +
- - -

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

+ + +

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

- - -

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

+ + +

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

+
+
+
+
+ + +

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

@@ -58,13 +78,15 @@
-
- {{ctx.Locale.Tr "actions.general.token_permissions.maximum"}} - * -
-

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

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

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

+
-
{{ctx.Locale.Tr "units.unit"}}
+
diff --git a/tests/integration/actions_job_token_test.go b/tests/integration/actions_job_token_test.go index d127b6cfd1..839e9067cd 100644 --- a/tests/integration/actions_job_token_test.go +++ b/tests/integration/actions_job_token_test.go @@ -15,6 +15,7 @@ import ( auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" org_model "code.gitea.io/gitea/models/organization" + packages_model "code.gitea.io/gitea/models/packages" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" @@ -376,17 +377,18 @@ func TestActionsCrossRepoAccess(t *testing.T) { uploadReq := NewRequestWithBody(t, "PUT", packageURL, bytes.NewReader(content)).AddBasicAuth("user2") MakeRequest(t, uploadReq, http.StatusCreated) - // Disable cross-repo access to test denied state - require.NoError(t, actions_model.SetOrgActionsConfig(t.Context(), org.ID, &repo_model.ActionsConfig{ - AllowCrossRepoAccess: false, - })) + // Link the package to repo-B (per reviewer feedback: packages must be linked to repos) + pkg, err := packages_model.GetPackageByName(t.Context(), org.ID, packages_model.TypeGeneric, packageName) + require.NoError(t, err) + require.NoError(t, packages_model.SetRepositoryLink(t.Context(), pkg.ID, repoBID)) + // By default, cross-repo is disabled // Try to download with cross-repo disabled - should fail downloadReqDenied := NewRequest(t, "GET", packageURL) downloadReqDenied.Header.Set("Authorization", "Bearer "+task.Token) MakeRequest(t, downloadReqDenied, http.StatusForbidden) - // Re-enable cross-repo access + // Enable cross-repo access require.NoError(t, actions_model.SetOrgActionsConfig(t.Context(), org.ID, &repo_model.ActionsConfig{ AllowCrossRepoAccess: true, })) 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 0000000000..6dc549d329 --- /dev/null +++ b/web_src/js/features/repo-settings-actions.ts @@ -0,0 +1,46 @@ +export function initActionsPermissionsTable(): void { + const modeRadios = document.querySelectorAll('.js-permission-mode-radio'); + const permTable = document.querySelector('table.js-permissions-table'); + const tableSection = document.querySelector('#max-permissions-section'); + const followOrgCheckbox = document.querySelector('.js-follow-org-config'); + const modeSection = document.querySelector('.js-permission-mode-section'); + + if (!modeRadios.length) return; + + function updateTableState(): void { + const followOrg = followOrgCheckbox?.checked ?? false; + const selectedMode = document.querySelector('input[name="token_permission_mode"]:checked'); + const isCustom = selectedMode?.value === 'custom'; + + // Disable entire form when following org config + for (const radio of modeRadios) { + radio.disabled = followOrg; + } + + if (modeSection) { + modeSection.style.opacity = followOrg ? '0.5' : '1'; + } + + // Disable table if not custom OR following org + const tableDisabled = !isCustom || followOrg; + if (permTable) { + const inputs = permTable.querySelectorAll('input[type="radio"]'); + for (const input of inputs) { + input.disabled = tableDisabled; + } + permTable.style.opacity = tableDisabled ? '0.5' : '1'; + } + + if (tableSection) { + tableSection.style.opacity = tableDisabled ? '0.5' : '1'; + } + } + + for (const radio of modeRadios) { + radio.addEventListener('change', updateTableState); + } + + followOrgCheckbox?.addEventListener('change', updateTableState); + + updateTableState(); +} diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts index 660e5c0989..576029af32 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 {initActionsPermissionsTable} from './features/repo-settings-actions.ts'; const initStartTime = performance.now(); const initPerformanceTracer = callInitFunctions([ @@ -158,6 +159,7 @@ const initPerformanceTracer = callInitFunctions([ initOAuth2SettingsDisableCheckbox, initRepoFileView, + initActionsPermissionsTable, ]); // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
{{ctx.Locale.Tr "units.unit"}}