From 4178e7f23ecb885bd234a1a9e7669ca88bfb785c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 27 Jun 2026 15:35:13 -0500 Subject: [PATCH 1/4] feat: add delete allowlist for branch protection rules (#696) Add configurable per-user/team/deploy-key allowlist for deleting protected branches. Previously, protected branches could never be deleted via git push. Now admins can configure deletion permissions with the same granularity as force-push allowlists. - 6 new model fields: CanDelete, EnableDeleteAllowlist, DeleteAllowlistUserIDs/TeamIDs, DeleteAllowlistDeployKeys, DeleteAllowlistActionsUser - CanUserDelete() method with admin-level default (higher than push) - Migration v361 adds columns to protected_branch table - Pre-receive hook checks delete allowlist instead of unconditional block - CanDeleteBranch service uses CanUserDelete instead of IsBranchProtected - API create/edit endpoints support delete allowlist fields - Web UI settings page with radio buttons and user/team dropdowns - 12 new locale strings for the delete allowlist UI Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- CHANGELOG.md | 1 + models/git/protected_branch.go | 75 ++++++++++++++++- models/migrations/migrations.go | 1 + models/migrations/v1_27/v361.go | 20 +++++ modules/structs/repo_branch.go | 24 +++++- options/locale/locale_en-US.json | 11 +++ routers/api/v1/repo/branch.go | 84 ++++++++++++++++++- routers/private/hook_pre_receive.go | 27 ++++-- routers/web/repo/setting/protected_branch.go | 30 ++++++- services/convert/convert.go | 10 ++- services/forms/repo_form.go | 5 ++ services/repository/branch.go | 8 +- templates/repo/settings/protected_branch.tmpl | 69 +++++++++++++++ 13 files changed, 345 insertions(+), 20 deletions(-) create mode 100644 models/migrations/v1_27/v361.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ec2a42e041..4db6fd594a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- Branch protection delete allowlist: configurable per-user/team/deploy-key allowlist for deleting protected branches (#696) - Workflow subdirectory discovery: workflows in subdirectories of `.mokogitea/workflows/` are now auto-discovered (#693) - API token scope `read:licensing` / `write:licensing` for licensing endpoints (#697) - Edit API token scopes: PATCH /users/{username}/tokens/{id} API endpoint + web UI edit button (#697) diff --git a/models/git/protected_branch.go b/models/git/protected_branch.go index 70ba944490..948e7886fa 100644 --- a/models/git/protected_branch.go +++ b/models/git/protected_branch.go @@ -51,6 +51,12 @@ type ProtectedBranch struct { WhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"` MergeWhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"` ForcePushAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"` + CanDelete bool `xorm:"NOT NULL DEFAULT false"` + EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"` + DeleteAllowlistUserIDs []int64 `xorm:"JSON TEXT"` + DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"` + DeleteAllowlistDeployKeys bool `xorm:"NOT NULL DEFAULT false"` + DeleteAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"` EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"` StatusCheckContexts []string `xorm:"JSON TEXT"` EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"` @@ -194,6 +200,46 @@ func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user return in && protectBranch.CanUserPush(ctx, user) } +// CanUserDelete returns if some user could delete this protected branch +func (protectBranch *ProtectedBranch) CanUserDelete(ctx context.Context, user *user_model.User) bool { + if !protectBranch.CanDelete { + return false + } + + if user.IsActions() && protectBranch.DeleteAllowlistActionsUser { + return true + } + + if !protectBranch.EnableDeleteAllowlist { + if err := protectBranch.LoadRepo(ctx); err != nil { + log.Error("LoadRepo: %v", err) + return false + } + + isAdmin, err := access_model.HasAccessUnit(ctx, user, protectBranch.Repo, unit.TypeCode, perm.AccessModeAdmin) + if err != nil { + log.Error("HasAccessUnit: %v", err) + return false + } + return isAdmin + } + + if slices.Contains(protectBranch.DeleteAllowlistUserIDs, user.ID) { + return true + } + + if len(protectBranch.DeleteAllowlistTeamIDs) == 0 { + return false + } + + in, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.DeleteAllowlistTeamIDs) + if err != nil { + log.Error("IsUserInTeams: %v", err) + return false + } + return in +} + // IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch func IsUserMergeWhitelisted(ctx context.Context, protectBranch *ProtectedBranch, userID int64, permissionInRepo access_model.Permission) bool { // Allow the actions bot user if explicitly whitelisted. @@ -365,6 +411,9 @@ type WhitelistOptions struct { ApprovalsUserIDs []int64 ApprovalsTeamIDs []int64 + + DeleteUserIDs []int64 + DeleteTeamIDs []int64 } // UpdateProtectBranch saves branch protection options of repository. @@ -430,6 +479,18 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote } protectBranch.ApprovalsWhitelistTeamIDs = whitelist + whitelist, err = updateUserWhitelist(ctx, repo, protectBranch.DeleteAllowlistUserIDs, opts.DeleteUserIDs) + if err != nil { + return err + } + protectBranch.DeleteAllowlistUserIDs = whitelist + + whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.DeleteAllowlistTeamIDs, opts.DeleteTeamIDs) + if err != nil { + return err + } + protectBranch.DeleteAllowlistTeamIDs = whitelist + // Looks like it's a new rule if protectBranch.ID == 0 { // as it's a new rule and if priority was not set, we need to calc it. @@ -574,14 +635,15 @@ func DeleteProtectedBranch(ctx context.Context, repo *repo_model.Repository, id // removeIDsFromProtectedBranch is a helper function to remove IDs from protected branch options func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userID, teamID int64, columnNames []string) error { - lenUserIDs, lenForcePushIDs, lenApprovalIDs, lenMergeIDs := len(p.WhitelistUserIDs), len(p.ForcePushAllowlistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs) - lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs) + lenUserIDs, lenForcePushIDs, lenApprovalIDs, lenMergeIDs, lenDeleteIDs := len(p.WhitelistUserIDs), len(p.ForcePushAllowlistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs), len(p.DeleteAllowlistUserIDs) + lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs, lenDeleteTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs), len(p.DeleteAllowlistTeamIDs) if userID > 0 { p.WhitelistUserIDs = util.SliceRemoveAll(p.WhitelistUserIDs, userID) p.ForcePushAllowlistUserIDs = util.SliceRemoveAll(p.ForcePushAllowlistUserIDs, userID) p.ApprovalsWhitelistUserIDs = util.SliceRemoveAll(p.ApprovalsWhitelistUserIDs, userID) p.MergeWhitelistUserIDs = util.SliceRemoveAll(p.MergeWhitelistUserIDs, userID) + p.DeleteAllowlistUserIDs = util.SliceRemoveAll(p.DeleteAllowlistUserIDs, userID) } if teamID > 0 { @@ -589,16 +651,19 @@ func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userI p.ForcePushAllowlistTeamIDs = util.SliceRemoveAll(p.ForcePushAllowlistTeamIDs, teamID) p.ApprovalsWhitelistTeamIDs = util.SliceRemoveAll(p.ApprovalsWhitelistTeamIDs, teamID) p.MergeWhitelistTeamIDs = util.SliceRemoveAll(p.MergeWhitelistTeamIDs, teamID) + p.DeleteAllowlistTeamIDs = util.SliceRemoveAll(p.DeleteAllowlistTeamIDs, teamID) } if (lenUserIDs != len(p.WhitelistUserIDs) || lenForcePushIDs != len(p.ForcePushAllowlistUserIDs) || lenApprovalIDs != len(p.ApprovalsWhitelistUserIDs) || - lenMergeIDs != len(p.MergeWhitelistUserIDs)) || + lenMergeIDs != len(p.MergeWhitelistUserIDs) || + lenDeleteIDs != len(p.DeleteAllowlistUserIDs)) || (lenTeamIDs != len(p.WhitelistTeamIDs) || lenForcePushTeamIDs != len(p.ForcePushAllowlistTeamIDs) || lenApprovalTeamIDs != len(p.ApprovalsWhitelistTeamIDs) || - lenMergeTeamIDs != len(p.MergeWhitelistTeamIDs)) { + lenMergeTeamIDs != len(p.MergeWhitelistTeamIDs) || + lenDeleteTeamIDs != len(p.DeleteAllowlistTeamIDs)) { if _, err := db.GetEngine(ctx).ID(p.ID).Cols(columnNames...).Update(p); err != nil { return fmt.Errorf("updateProtectedBranches: %v", err) } @@ -613,6 +678,7 @@ func RemoveUserIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, us "force_push_allowlist_user_i_ds", "merge_whitelist_user_i_ds", "approvals_whitelist_user_i_ds", + "delete_allowlist_user_i_ds", } return removeIDsFromProtectedBranch(ctx, p, userID, 0, columnNames) } @@ -624,6 +690,7 @@ func RemoveTeamIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, te "force_push_allowlist_team_i_ds", "merge_whitelist_team_i_ds", "approvals_whitelist_team_i_ds", + "delete_allowlist_team_i_ds", } return removeIDsFromProtectedBranch(ctx, p, 0, teamID, columnNames) } diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index bb083cc2b9..8fd75de67a 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -437,6 +437,7 @@ func prepareMigrationTasks() []*migration { newMigration(357, "Drop display_name from repo manifest and update stream config", v1_27.DropDisplayNameColumns), newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables), newMigration(359, "Add deploy fields to repo manifest", v1_27.AddDeployFieldsToRepoManifest), + newMigration(360, "Add delete allowlist to protected branch", v1_27.AddDeleteAllowlistToProtectedBranch), } return preparedMigrations } diff --git a/models/migrations/v1_27/v361.go b/models/migrations/v1_27/v361.go new file mode 100644 index 0000000000..56096bbc91 --- /dev/null +++ b/models/migrations/v1_27/v361.go @@ -0,0 +1,20 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import ( + "xorm.io/xorm" +) + +func AddDeleteAllowlistToProtectedBranch(x *xorm.Engine) error { + type ProtectedBranch struct { + CanDelete bool `xorm:"NOT NULL DEFAULT false"` + EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"` + DeleteAllowlistUserIDs []int64 `xorm:"JSON TEXT"` + DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"` + DeleteAllowlistDeployKeys bool `xorm:"NOT NULL DEFAULT false"` + DeleteAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"` + } + return x.Sync(new(ProtectedBranch)) +} diff --git a/modules/structs/repo_branch.go b/modules/structs/repo_branch.go index 77b82a11a4..f771e644d4 100644 --- a/modules/structs/repo_branch.go +++ b/modules/structs/repo_branch.go @@ -48,7 +48,13 @@ type BranchProtection struct { ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"` ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"` ForcePushAllowlistDeployKeys bool `json:"force_push_allowlist_deploy_keys"` - ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"` + ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"` + EnableDelete bool `json:"enable_delete"` + EnableDeleteAllowlist bool `json:"enable_delete_allowlist"` + DeleteAllowlistUsernames []string `json:"delete_allowlist_usernames"` + DeleteAllowlistTeams []string `json:"delete_allowlist_teams"` + DeleteAllowlistDeployKeys bool `json:"delete_allowlist_deploy_keys"` + DeleteAllowlistActionsUser bool `json:"delete_allowlist_actions_user"` EnableMergeWhitelist bool `json:"enable_merge_whitelist"` MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` MergeWhitelistTeams []string `json:"merge_whitelist_teams"` @@ -93,7 +99,13 @@ type CreateBranchProtectionOption struct { ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"` ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"` ForcePushAllowlistDeployKeys bool `json:"force_push_allowlist_deploy_keys"` - ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"` + ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"` + EnableDelete bool `json:"enable_delete"` + EnableDeleteAllowlist bool `json:"enable_delete_allowlist"` + DeleteAllowlistUsernames []string `json:"delete_allowlist_usernames"` + DeleteAllowlistTeams []string `json:"delete_allowlist_teams"` + DeleteAllowlistDeployKeys bool `json:"delete_allowlist_deploy_keys"` + DeleteAllowlistActionsUser bool `json:"delete_allowlist_actions_user"` EnableMergeWhitelist bool `json:"enable_merge_whitelist"` MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` MergeWhitelistTeams []string `json:"merge_whitelist_teams"` @@ -129,7 +141,13 @@ type EditBranchProtectionOption struct { ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"` ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"` ForcePushAllowlistDeployKeys *bool `json:"force_push_allowlist_deploy_keys"` - ForcePushAllowlistActionsUser *bool `json:"force_push_allowlist_actions_user"` + ForcePushAllowlistActionsUser *bool `json:"force_push_allowlist_actions_user"` + EnableDelete *bool `json:"enable_delete"` + EnableDeleteAllowlist *bool `json:"enable_delete_allowlist"` + DeleteAllowlistUsernames []string `json:"delete_allowlist_usernames"` + DeleteAllowlistTeams []string `json:"delete_allowlist_teams"` + DeleteAllowlistDeployKeys *bool `json:"delete_allowlist_deploy_keys"` + DeleteAllowlistActionsUser *bool `json:"delete_allowlist_actions_user"` EnableMergeWhitelist *bool `json:"enable_merge_whitelist"` MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` MergeWhitelistTeams []string `json:"merge_whitelist_teams"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 30033105a2..f38506433a 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2439,6 +2439,17 @@ "repo.settings.protect_force_push_allowlist_teams": "Allowlisted teams for force pushing:", "repo.settings.protect_force_push_allowlist_deploy_keys": "Allowlist deploy keys with push access to force push.", "repo.settings.protect_force_push_allowlist_actions_user": "Allowlist actions bot user to force push.", + "repo.settings.event_delete": "Branch Deletion", + "repo.settings.protect_disable_delete": "Disable Deletion", + "repo.settings.protect_disable_delete_desc": "This branch cannot be deleted.", + "repo.settings.protect_enable_delete_all": "Enable Deletion", + "repo.settings.protect_enable_delete_all_desc": "Anyone with admin access will be allowed to delete this branch.", + "repo.settings.protect_enable_delete_allowlist": "Allowlist Restricted Deletion", + "repo.settings.protect_enable_delete_allowlist_desc": "Only allowlisted users or teams will be allowed to delete this branch.", + "repo.settings.protect_delete_allowlist_users": "Allowlisted users for deletion:", + "repo.settings.protect_delete_allowlist_teams": "Allowlisted teams for deletion:", + "repo.settings.protect_delete_allowlist_deploy_keys": "Allowlist deploy keys with write access to delete.", + "repo.settings.protect_delete_allowlist_actions_user": "Allowlist actions bot user to delete.", "repo.settings.protect_merge_whitelist_committers": "Enable Merge Allowlist", "repo.settings.protect_merge_whitelist_committers_desc": "Allow only allowlisted users or teams to merge pull requests into this branch.", "repo.settings.protect_merge_whitelist_users": "Allowlisted users for merging:", diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index b36e03104e..31cddff6c3 100644 --- a/routers/api/v1/repo/branch.go +++ b/routers/api/v1/repo/branch.go @@ -693,6 +693,15 @@ func CreateBranchProtection(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } + deleteAllowlistUsers, err := user_model.GetUserIDsByNames(ctx, form.DeleteAllowlistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + ctx.APIErrorInternal(err) + return + } mergeWhitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false) if err != nil { if user_model.IsErrUserNotExist(err) { @@ -711,7 +720,7 @@ func CreateBranchProtection(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } - var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 + var whitelistTeams, forcePushAllowlistTeams, deleteAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 if repo.Owner.IsOrganization() { whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false) if err != nil { @@ -731,6 +740,15 @@ func CreateBranchProtection(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } + deleteAllowlistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.DeleteAllowlistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + ctx.APIErrorInternal(err) + return + } mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false) if err != nil { if organization.IsErrTeamNotExist(err) { @@ -763,6 +781,10 @@ func CreateBranchProtection(ctx *context.APIContext) { EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist, ForcePushAllowlistDeployKeys: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistDeployKeys, ForcePushAllowlistActionsUser: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistActionsUser, + CanDelete: form.EnableDelete, + EnableDeleteAllowlist: form.EnableDelete && form.EnableDeleteAllowlist, + DeleteAllowlistDeployKeys: form.EnableDelete && form.EnableDeleteAllowlist && form.DeleteAllowlistDeployKeys, + DeleteAllowlistActionsUser: form.EnableDelete && form.EnableDeleteAllowlist && form.DeleteAllowlistActionsUser, EnableMergeWhitelist: form.EnableMergeWhitelist, MergeWhitelistActionsUser: form.EnableMergeWhitelist && form.MergeWhitelistActionsUser, EnableStatusCheck: form.EnableStatusCheck, @@ -785,6 +807,8 @@ func CreateBranchProtection(ctx *context.APIContext) { TeamIDs: whitelistTeams, ForcePushUserIDs: forcePushAllowlistUsers, ForcePushTeamIDs: forcePushAllowlistTeams, + DeleteUserIDs: deleteAllowlistUsers, + DeleteTeamIDs: deleteAllowlistTeams, MergeUserIDs: mergeWhitelistUsers, MergeTeamIDs: mergeWhitelistTeams, ApprovalsUserIDs: approvalsWhitelistUsers, @@ -911,6 +935,32 @@ func EditBranchProtection(ctx *context.APIContext) { } } + if form.EnableDelete != nil { + if !*form.EnableDelete { + protectBranch.CanDelete = false + protectBranch.EnableDeleteAllowlist = false + protectBranch.DeleteAllowlistDeployKeys = false + protectBranch.DeleteAllowlistActionsUser = false + } else { + protectBranch.CanDelete = true + if form.EnableDeleteAllowlist != nil { + if !*form.EnableDeleteAllowlist { + protectBranch.EnableDeleteAllowlist = false + protectBranch.DeleteAllowlistDeployKeys = false + protectBranch.DeleteAllowlistActionsUser = false + } else { + protectBranch.EnableDeleteAllowlist = true + if form.DeleteAllowlistDeployKeys != nil { + protectBranch.DeleteAllowlistDeployKeys = *form.DeleteAllowlistDeployKeys + } + if form.DeleteAllowlistActionsUser != nil { + protectBranch.DeleteAllowlistActionsUser = *form.DeleteAllowlistActionsUser + } + } + } + } + } + if form.Priority != nil { protectBranch.Priority = *form.Priority } @@ -977,7 +1027,7 @@ func EditBranchProtection(ctx *context.APIContext) { protectBranch.BlockAdminMergeOverride = *form.BlockAdminMergeOverride } - var whitelistUsers, forcePushAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers []int64 + var whitelistUsers, forcePushAllowlistUsers, deleteAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers []int64 if form.PushWhitelistUsernames != nil { whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false) if err != nil { @@ -1004,6 +1054,19 @@ func EditBranchProtection(ctx *context.APIContext) { } else { forcePushAllowlistUsers = protectBranch.ForcePushAllowlistUserIDs } + if form.DeleteAllowlistUsernames != nil { + deleteAllowlistUsers, err = user_model.GetUserIDsByNames(ctx, form.DeleteAllowlistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + ctx.APIErrorInternal(err) + return + } + } else { + deleteAllowlistUsers = protectBranch.DeleteAllowlistUserIDs + } if form.MergeWhitelistUsernames != nil { mergeWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false) if err != nil { @@ -1031,7 +1094,7 @@ func EditBranchProtection(ctx *context.APIContext) { approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs } - var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 + var whitelistTeams, forcePushAllowlistTeams, deleteAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 if repo.Owner.IsOrganization() { if form.PushWhitelistTeams != nil { whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false) @@ -1059,6 +1122,19 @@ func EditBranchProtection(ctx *context.APIContext) { } else { forcePushAllowlistTeams = protectBranch.ForcePushAllowlistTeamIDs } + if form.DeleteAllowlistTeams != nil { + deleteAllowlistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.DeleteAllowlistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + ctx.APIErrorInternal(err) + return + } + } else { + deleteAllowlistTeams = protectBranch.DeleteAllowlistTeamIDs + } if form.MergeWhitelistTeams != nil { mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false) if err != nil { @@ -1092,6 +1168,8 @@ func EditBranchProtection(ctx *context.APIContext) { TeamIDs: whitelistTeams, ForcePushUserIDs: forcePushAllowlistUsers, ForcePushTeamIDs: forcePushAllowlistTeams, + DeleteUserIDs: deleteAllowlistUsers, + DeleteTeamIDs: deleteAllowlistTeams, MergeUserIDs: mergeWhitelistUsers, MergeTeamIDs: mergeWhitelistTeams, ApprovalsUserIDs: approvalsWhitelistUsers, diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index 07862aebfb..0dc1c9a9a0 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -182,12 +182,29 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r // // First of all we need to enforce absolutely: // - // 1. Detect and prevent deletion of the branch + // 1. Detect and prevent deletion of the branch (unless user is in delete allowlist) if newCommitID == objectFormat.EmptyObjectID().String() { - log.Warn("Forbidden: Branch: %s in %-v is protected from deletion", branchName, repo) - ctx.JSON(http.StatusForbidden, private.Response{ - UserMsg: fmt.Sprintf("branch %s is protected from deletion", branchName), - }) + canDelete := false + if ctx.opts.DeployKeyID != 0 { + canDelete = protectBranch.CanDelete && (!protectBranch.EnableDeleteAllowlist || protectBranch.DeleteAllowlistDeployKeys) + } else { + user, err := user_model.GetUserByID(ctx, ctx.opts.UserID) + if err != nil { + log.Error("Unable to GetUserByID for delete check in %-v: %v", repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to GetUserByID: %v", err), + }) + return + } + canDelete = protectBranch.CanUserDelete(ctx, user) + } + if !canDelete { + log.Warn("Forbidden: Branch: %s in %-v is protected from deletion", branchName, repo) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("branch %s is protected from deletion", branchName), + }) + return + } return } diff --git a/routers/web/repo/setting/protected_branch.go b/routers/web/repo/setting/protected_branch.go index afb06ebf04..c8881838ab 100644 --- a/routers/web/repo/setting/protected_branch.go +++ b/routers/web/repo/setting/protected_branch.go @@ -82,6 +82,7 @@ func SettingsProtectedBranch(c *context.Context) { c.Data["Users"] = users c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",") c.Data["force_push_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistUserIDs), ",") + c.Data["delete_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.DeleteAllowlistUserIDs), ",") c.Data["merge_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistUserIDs), ",") c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",") c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n") @@ -97,6 +98,7 @@ func SettingsProtectedBranch(c *context.Context) { c.Data["Teams"] = teams c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",") c.Data["force_push_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistTeamIDs), ",") + c.Data["delete_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.DeleteAllowlistTeamIDs), ",") c.Data["merge_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistTeamIDs), ",") c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistTeamIDs), ",") } @@ -155,7 +157,7 @@ func SettingsProtectedBranchPost(ctx *context.Context) { } } - var whitelistUsers, whitelistTeams, forcePushAllowlistUsers, forcePushAllowlistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64 + var whitelistUsers, whitelistTeams, forcePushAllowlistUsers, forcePushAllowlistTeams, deleteAllowlistUsers, deleteAllowlistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64 protectBranch.RuleName = f.RuleName if f.RequiredApprovals < 0 { ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min")) @@ -211,6 +213,30 @@ func SettingsProtectedBranchPost(ctx *context.Context) { protectBranch.ForcePushAllowlistActionsUser = false } + switch f.EnableDelete { + case "all": + protectBranch.CanDelete = true + protectBranch.EnableDeleteAllowlist = false + protectBranch.DeleteAllowlistDeployKeys = false + protectBranch.DeleteAllowlistActionsUser = false + case "whitelist": + protectBranch.CanDelete = true + protectBranch.EnableDeleteAllowlist = true + protectBranch.DeleteAllowlistDeployKeys = f.DeleteAllowlistDeployKeys + protectBranch.DeleteAllowlistActionsUser = f.DeleteAllowlistActionsUser + if strings.TrimSpace(f.DeleteAllowlistUsers) != "" { + deleteAllowlistUsers, _ = base.StringsToInt64s(strings.Split(f.DeleteAllowlistUsers, ",")) + } + if strings.TrimSpace(f.DeleteAllowlistTeams) != "" { + deleteAllowlistTeams, _ = base.StringsToInt64s(strings.Split(f.DeleteAllowlistTeams, ",")) + } + default: + protectBranch.CanDelete = false + protectBranch.EnableDeleteAllowlist = false + protectBranch.DeleteAllowlistDeployKeys = false + protectBranch.DeleteAllowlistActionsUser = false + } + protectBranch.EnableMergeWhitelist = f.EnableMergeWhitelist protectBranch.MergeWhitelistActionsUser = f.EnableMergeWhitelist && f.MergeWhitelistActionsUser if f.EnableMergeWhitelist { @@ -274,6 +300,8 @@ func SettingsProtectedBranchPost(ctx *context.Context) { TeamIDs: whitelistTeams, ForcePushUserIDs: forcePushAllowlistUsers, ForcePushTeamIDs: forcePushAllowlistTeams, + DeleteUserIDs: deleteAllowlistUsers, + DeleteTeamIDs: deleteAllowlistTeams, MergeUserIDs: mergeWhitelistUsers, MergeTeamIDs: mergeWhitelistTeams, ApprovalsUserIDs: approvalsWhitelistUsers, diff --git a/services/convert/convert.go b/services/convert/convert.go index 028cc17ed7..0ed63ec109 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -146,6 +146,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo pushWhitelistUsernames := getWhitelistEntities(readers, bp.WhitelistUserIDs) forcePushAllowlistUsernames := getWhitelistEntities(readers, bp.ForcePushAllowlistUserIDs) + deleteAllowlistUsernames := getWhitelistEntities(readers, bp.DeleteAllowlistUserIDs) mergeWhitelistUsernames := getWhitelistEntities(readers, bp.MergeWhitelistUserIDs) approvalsWhitelistUsernames := getWhitelistEntities(readers, bp.ApprovalsWhitelistUserIDs) @@ -156,6 +157,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo pushWhitelistTeams := getWhitelistEntities(teamReaders, bp.WhitelistTeamIDs) forcePushAllowlistTeams := getWhitelistEntities(teamReaders, bp.ForcePushAllowlistTeamIDs) + deleteAllowlistTeams := getWhitelistEntities(teamReaders, bp.DeleteAllowlistTeamIDs) mergeWhitelistTeams := getWhitelistEntities(teamReaders, bp.MergeWhitelistTeamIDs) approvalsWhitelistTeams := getWhitelistEntities(teamReaders, bp.ApprovalsWhitelistTeamIDs) @@ -179,7 +181,13 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo ForcePushAllowlistUsernames: forcePushAllowlistUsernames, ForcePushAllowlistTeams: forcePushAllowlistTeams, ForcePushAllowlistDeployKeys: bp.ForcePushAllowlistDeployKeys, - ForcePushAllowlistActionsUser: bp.ForcePushAllowlistActionsUser, + ForcePushAllowlistActionsUser: bp.ForcePushAllowlistActionsUser, + EnableDelete: bp.CanDelete, + EnableDeleteAllowlist: bp.EnableDeleteAllowlist, + DeleteAllowlistUsernames: deleteAllowlistUsernames, + DeleteAllowlistTeams: deleteAllowlistTeams, + DeleteAllowlistDeployKeys: bp.DeleteAllowlistDeployKeys, + DeleteAllowlistActionsUser: bp.DeleteAllowlistActionsUser, EnableMergeWhitelist: bp.EnableMergeWhitelist, MergeWhitelistUsernames: mergeWhitelistUsernames, MergeWhitelistTeams: mergeWhitelistTeams, diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 9272a82417..d066eb8ba0 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -192,6 +192,11 @@ type ProtectBranchForm struct { ForcePushAllowlistTeams string ForcePushAllowlistDeployKeys bool ForcePushAllowlistActionsUser bool + EnableDelete string + DeleteAllowlistUsers string + DeleteAllowlistTeams string + DeleteAllowlistDeployKeys bool + DeleteAllowlistActionsUser bool EnableMergeWhitelist bool MergeWhitelistUsers string MergeWhitelistTeams string diff --git a/services/repository/branch.go b/services/repository/branch.go index 6a9c86521d..c511ff5dc3 100644 --- a/services/repository/branch.go +++ b/services/repository/branch.go @@ -563,12 +563,14 @@ func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchNam return util.NewPermissionDeniedErrorf("permission denied to access repo %d unit %s", repo.ID, unit.TypeCode.LogString()) } - isProtected, err := git_model.IsBranchProtected(ctx, repo.ID, branchName) + protectBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName) if err != nil { return err } - if isProtected { - return git_model.ErrBranchIsProtected + if protectBranch != nil { + if !protectBranch.CanUserDelete(ctx, doer) { + return git_model.ErrBranchIsProtected + } } return nil } diff --git a/templates/repo/settings/protected_branch.tmpl b/templates/repo/settings/protected_branch.tmpl index 01803890c3..2c7bd8dcc0 100644 --- a/templates/repo/settings/protected_branch.tmpl +++ b/templates/repo/settings/protected_branch.tmpl @@ -172,6 +172,75 @@ +
{{ctx.Locale.Tr "repo.settings.event_delete"}}
+
+
+ + +

{{ctx.Locale.Tr "repo.settings.protect_disable_delete_desc"}}

+
+
+
+
+ + +

{{ctx.Locale.Tr "repo.settings.protect_enable_delete_all_desc"}}

+
+
+
+
+
+ + +

{{ctx.Locale.Tr "repo.settings.protect_enable_delete_allowlist_desc"}}

+
+
+
+
+ + +
+ {{if .Owner.IsOrganization}} +
+ + +
+ {{end}} +
+
+ + +
+
+
+
+ + +
+
+
+
{{ctx.Locale.Tr "repo.settings.event_pull_request_approvals"}}
-- 2.52.0 From c47013edb010cb61d097856047e4d8d62a9be665 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 27 Jun 2026 15:40:15 -0500 Subject: [PATCH 2/4] fix: restore original whitespace alignment in convert.go Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- services/convert/convert.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/convert/convert.go b/services/convert/convert.go index 0ed63ec109..a3dc8a4853 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -181,7 +181,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo ForcePushAllowlistUsernames: forcePushAllowlistUsernames, ForcePushAllowlistTeams: forcePushAllowlistTeams, ForcePushAllowlistDeployKeys: bp.ForcePushAllowlistDeployKeys, - ForcePushAllowlistActionsUser: bp.ForcePushAllowlistActionsUser, + ForcePushAllowlistActionsUser: bp.ForcePushAllowlistActionsUser, EnableDelete: bp.CanDelete, EnableDeleteAllowlist: bp.EnableDeleteAllowlist, DeleteAllowlistUsernames: deleteAllowlistUsernames, -- 2.52.0 From 81d20e25bf27668a9d41af0f16cbbab63d0effd8 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 27 Jun 2026 19:32:26 -0500 Subject: [PATCH 3/4] fix: set protectBranch.Repo before CanUserDelete to avoid extra DB load Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- services/repository/branch.go | 1 + 1 file changed, 1 insertion(+) diff --git a/services/repository/branch.go b/services/repository/branch.go index c511ff5dc3..eea1464fd5 100644 --- a/services/repository/branch.go +++ b/services/repository/branch.go @@ -568,6 +568,7 @@ func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchNam return err } if protectBranch != nil { + protectBranch.Repo = repo if !protectBranch.CanUserDelete(ctx, doer) { return git_model.ErrBranchIsProtected } -- 2.52.0 From d2a38272020de712de720c0bb235478a19fd34fb Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 27 Jun 2026 20:16:53 -0500 Subject: [PATCH 4/4] fix: remove duplicate cascade-dev.yml synced to top-level (belongs in custom/) Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- .mokogitea/workflows/cascade-dev.yml | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 .mokogitea/workflows/cascade-dev.yml diff --git a/.mokogitea/workflows/cascade-dev.yml b/.mokogitea/workflows/cascade-dev.yml deleted file mode 100644 index 5f7c1d7273..0000000000 --- a/.mokogitea/workflows/cascade-dev.yml +++ /dev/null @@ -1,10 +0,0 @@ -# DISABLED — auto-release Step 11 recreates dev from main after every release. -# Cascade-dev is redundant and causes version conflicts when both main and dev -# have different version numbers in templateDetails.xml / manifest.xml. -name: "Cascade Main → Dev (DISABLED)" -on: workflow_dispatch -jobs: - noop: - runs-on: ubuntu-latest - steps: - - run: echo "Cascade disabled — auto-release handles dev recreation" -- 2.52.0