mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 22:13:26 +09:00
feat: Add bypass allowlist for branch protection (#36514)
- Introduce a “Bypass Protection Allowlist” on branch rules (users/teams) alongside admins, with BlockAdminMergeOverride still respected. - Surface the allowlist in API (create/edit options, structs) and settings UI; merge box now shows the red button + message for bypass-capable users. - Apply bypass logic to merge checks and pre-receive so allowlisted users can override unmet approvals/status checks/ protected files when force-merging. - Add migration for new columns, locale strings, and unit tests (bypass helper; queue test tweak). <img width="1069" height="218" alt="image" src="https://github.com/user-attachments/assets/0b61bc2a-a27f-47f3-a923-613688008e65" /> Fixes #36476 --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Giteabot <teabot@gitea.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Codex GPT-5.3 <codex@openai.com> Co-authored-by: GPT-5.2 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
co-authored by
silverwind
Giteabot
wxiaoguang
Codex GPT-5.3
GPT-5.2
Cursor
Claude
parent
54ff68b0a9
commit
eb93981d45
@@ -711,7 +711,19 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
|
||||
var bypassAllowlistUsers []int64
|
||||
if form.EnableBypassAllowlist {
|
||||
bypassAllowlistUsers, err = user_model.GetUserIDsByNames(ctx, form.BypassAllowlistUsernames, false)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams, bypassAllowlistTeams []int64
|
||||
if repo.Owner.IsOrganization() {
|
||||
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
|
||||
if err != nil {
|
||||
@@ -749,6 +761,17 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if form.EnableBypassAllowlist {
|
||||
bypassAllowlistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.BypassAllowlistTeams, false)
|
||||
if err != nil {
|
||||
if organization.IsErrTeamNotExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protectBranch = &git_model.ProtectedBranch{
|
||||
@@ -762,6 +785,7 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
||||
EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist,
|
||||
ForcePushAllowlistDeployKeys: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistDeployKeys,
|
||||
EnableMergeWhitelist: form.EnableMergeWhitelist,
|
||||
EnableBypassAllowlist: form.EnableBypassAllowlist,
|
||||
EnableStatusCheck: form.EnableStatusCheck,
|
||||
StatusCheckContexts: form.StatusCheckContexts,
|
||||
EnableApprovalsWhitelist: form.EnableApprovalsWhitelist,
|
||||
@@ -786,6 +810,8 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
||||
MergeTeamIDs: mergeWhitelistTeams,
|
||||
ApprovalsUserIDs: approvalsWhitelistUsers,
|
||||
ApprovalsTeamIDs: approvalsWhitelistTeams,
|
||||
BypassUserIDs: bypassAllowlistUsers,
|
||||
BypassTeamIDs: bypassAllowlistTeams,
|
||||
}); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@@ -906,6 +932,10 @@ func EditBranchProtection(ctx *context.APIContext) {
|
||||
protectBranch.EnableMergeWhitelist = *form.EnableMergeWhitelist
|
||||
}
|
||||
|
||||
if form.EnableBypassAllowlist != nil {
|
||||
protectBranch.EnableBypassAllowlist = *form.EnableBypassAllowlist
|
||||
}
|
||||
|
||||
if form.EnableStatusCheck != nil {
|
||||
protectBranch.EnableStatusCheck = *form.EnableStatusCheck
|
||||
}
|
||||
@@ -958,7 +988,7 @@ func EditBranchProtection(ctx *context.APIContext) {
|
||||
protectBranch.BlockAdminMergeOverride = *form.BlockAdminMergeOverride
|
||||
}
|
||||
|
||||
var whitelistUsers, forcePushAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers []int64
|
||||
var whitelistUsers, forcePushAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers, bypassAllowlistUsers []int64
|
||||
if form.PushWhitelistUsernames != nil {
|
||||
whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false)
|
||||
if err != nil {
|
||||
@@ -1011,8 +1041,21 @@ func EditBranchProtection(ctx *context.APIContext) {
|
||||
} else {
|
||||
approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs
|
||||
}
|
||||
if form.BypassAllowlistUsernames != nil {
|
||||
bypassAllowlistUsers, err = user_model.GetUserIDsByNames(ctx, form.BypassAllowlistUsernames, false)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
bypassAllowlistUsers = protectBranch.BypassAllowlistUserIDs
|
||||
}
|
||||
|
||||
var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
|
||||
var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams, bypassAllowlistTeams []int64
|
||||
if repo.Owner.IsOrganization() {
|
||||
if form.PushWhitelistTeams != nil {
|
||||
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
|
||||
@@ -1066,6 +1109,23 @@ func EditBranchProtection(ctx *context.APIContext) {
|
||||
} else {
|
||||
approvalsWhitelistTeams = protectBranch.ApprovalsWhitelistTeamIDs
|
||||
}
|
||||
if form.BypassAllowlistTeams != nil {
|
||||
bypassAllowlistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.BypassAllowlistTeams, false)
|
||||
if err != nil {
|
||||
if organization.IsErrTeamNotExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
bypassAllowlistTeams = protectBranch.BypassAllowlistTeamIDs
|
||||
}
|
||||
}
|
||||
if !protectBranch.EnableBypassAllowlist {
|
||||
bypassAllowlistUsers = nil
|
||||
bypassAllowlistTeams = nil
|
||||
}
|
||||
|
||||
err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{
|
||||
@@ -1077,6 +1137,8 @@ func EditBranchProtection(ctx *context.APIContext) {
|
||||
MergeTeamIDs: mergeWhitelistTeams,
|
||||
ApprovalsUserIDs: approvalsWhitelistUsers,
|
||||
ApprovalsTeamIDs: approvalsWhitelistTeams,
|
||||
BypassUserIDs: bypassAllowlistUsers,
|
||||
BypassTeamIDs: bypassAllowlistTeams,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
|
||||
@@ -371,8 +371,8 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
return
|
||||
}
|
||||
|
||||
// If we're an admin for the repository we can ignore status checks, reviews and override protected files
|
||||
if ctx.userPerm.IsAdmin() {
|
||||
// If we can bypass branch protection we can ignore status checks, reviews and protected files
|
||||
if git_model.CanBypassBranchProtection(ctx, protectBranch, ctx.user, ctx.userPerm.IsAdmin()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -907,7 +907,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue *
|
||||
if !canWriteToHeadRepo { // maintainers maybe allowed to push to head repo even if they can't write to it
|
||||
canWriteToHeadRepo = pull.AllowMaintainerEdit && perm.CanWrite(unit.TypeCode)
|
||||
}
|
||||
data.allowMerge, err = pull_service.IsUserAllowedToMerge(ctx, pull, perm, ctx.Doer)
|
||||
data.hasPermToMerge, err = pull_service.IsUserAllowedToMerge(ctx, pull, perm, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.ServerError("IsUserAllowedToMerge", err)
|
||||
return
|
||||
@@ -954,16 +954,19 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue *
|
||||
// this logic is from:
|
||||
// {{$notAllOverridableChecksOk := or .IsBlockedByApprovals .IsBlockedByRejection .IsBlockedByOfficialReviewRequests .IsBlockedByOutdatedBranch .IsBlockedByChangedProtectedFiles (and .EnableStatusCheck (not $requiredStatusCheckState.IsSuccess))}}
|
||||
// HINT: if a PR's status is not mergeable, then it is a non-overridable blocker, such logic is handled separately (see IsStatusMergeable)
|
||||
data.HasOverridableBlockers = data.isBlockedByApprovals || data.isBlockedByRejection ||
|
||||
data.hasOverridableBlockers = data.isBlockedByApprovals || data.isBlockedByRejection ||
|
||||
data.isBlockedByOfficialReviewRequests || data.isBlockedByOutdatedBranch || data.isBlockedByChangedProtectedFiles ||
|
||||
data.hasStatusCheckBlocker
|
||||
|
||||
// this logic is from:
|
||||
// {{$canMergeNow := and (or (and (not $.ProtectedBranch.BlockAdminMergeOverride) $.IsRepoAdmin) (not $notAllOverridableChecksOk)) (or (not .AllowMerge) (not .RequireSigned) .WillSign)}}
|
||||
// HINT: legacy "(not .AllowMerge)" is not right (always false, does nothing), fixed here
|
||||
data.canBypassProtection = isRepoAdmin
|
||||
data.canBypassProtectionAsAdmin = isRepoAdmin
|
||||
if ctx.IsSigned && prInfo.ProtectedBranchRule != nil {
|
||||
data.canBypassProtection = git_model.CanBypassBranchProtection(ctx, prInfo.ProtectedBranchRule, ctx.Doer, isRepoAdmin)
|
||||
data.canBypassProtectionAsAdmin = isRepoAdmin && !prInfo.ProtectedBranchRule.BlockAdminMergeOverride
|
||||
}
|
||||
|
||||
// CanMergeNow means: if the doer has write permission, whether the PR can be merged now
|
||||
adminCanOverrideBlockers := (prInfo.ProtectedBranchRule == nil || !prInfo.ProtectedBranchRule.BlockAdminMergeOverride) && isRepoAdmin
|
||||
data.CanMergeNow = (!data.HasOverridableBlockers || adminCanOverrideBlockers) && // status checks are satisfied
|
||||
data.canMergeNow = (!data.hasOverridableBlockers || data.canBypassProtection) && // status checks are satisfied
|
||||
(!data.requireSigned || data.willSign) // signing requirement is satisfied
|
||||
|
||||
prInfo.prepareMergeBoxFormProps(ctx)
|
||||
|
||||
@@ -276,9 +276,11 @@ type pullMergeBoxData struct {
|
||||
StatusCheckData *pullCommitStatusCheckData
|
||||
ShowStatusCheck bool
|
||||
|
||||
HasOverridableBlockers bool
|
||||
CanMergeNow bool // PR is mergeable, either no blocker, or doer is admin and can bypass the blockers
|
||||
allowMerge bool // doer has permission to merge
|
||||
hasOverridableBlockers bool
|
||||
canMergeNow bool // PR is mergeable, either no blocker, or doer can bypass the blockers
|
||||
hasPermToMerge bool // doer has permission to merge
|
||||
canBypassProtection bool
|
||||
canBypassProtectionAsAdmin bool
|
||||
|
||||
ShowUpdatePullInfo bool
|
||||
UpdatePrimaryAction *pullUpdateAction
|
||||
|
||||
@@ -162,18 +162,22 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxInfoItems(ctx *context.Context
|
||||
}
|
||||
}
|
||||
|
||||
if !data.allowMerge {
|
||||
if !data.hasPermToMerge {
|
||||
prInfo.MergeBoxData.infoProtectionBlockers.AddInfoItem(
|
||||
svg.RenderHTML("octicon-info"),
|
||||
ctx.Locale.Tr("repo.pulls.no_merge_access"),
|
||||
)
|
||||
}
|
||||
|
||||
if data.CanMergeNow {
|
||||
if data.HasOverridableBlockers {
|
||||
if data.canMergeNow {
|
||||
if data.hasOverridableBlockers {
|
||||
prompt := ctx.Locale.Tr("repo.pulls.required_status_check_bypass_allowlist")
|
||||
if data.canBypassProtectionAsAdmin {
|
||||
prompt = ctx.Locale.Tr("repo.pulls.required_status_check_administrator")
|
||||
}
|
||||
prInfo.MergeBoxData.infoMergePrompts.AddInfoItem(
|
||||
svg.RenderHTML("octicon-dot-fill"),
|
||||
ctx.Locale.Tr("repo.pulls.required_status_check_administrator"),
|
||||
prompt,
|
||||
)
|
||||
} else if pull.IsStatusMergeable() || pull.IsEmpty() {
|
||||
prInfo.MergeBoxData.infoMergePrompts.AddInfoItem(
|
||||
|
||||
@@ -20,7 +20,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
if pull.HasMerged || prInfo.issue.IsClosed {
|
||||
return
|
||||
}
|
||||
if !prInfo.MergeBoxData.allowMerge {
|
||||
if !prInfo.MergeBoxData.hasPermToMerge {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
defaultSquashMergeCommitMessages = pull_service.GetSquashMergeCommitMessages(ctx, pull)
|
||||
}
|
||||
|
||||
allOverridableChecksOk := !prInfo.MergeBoxData.HasOverridableBlockers
|
||||
allOverridableChecksOk := !prInfo.MergeBoxData.hasOverridableBlockers
|
||||
mergeFormProps := map[string]any{
|
||||
"baseLink": prInfo.issue.Link(),
|
||||
"textCancel": ctx.Locale.Tr("cancel"),
|
||||
@@ -88,7 +88,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
"textClearMergeMessageHint": ctx.Locale.Tr("repo.pulls.clear_merge_message_hint"),
|
||||
"textMergeCommitId": ctx.Locale.Tr("repo.pulls.merge_commit_id"),
|
||||
|
||||
"canMergeNow": prInfo.MergeBoxData.CanMergeNow,
|
||||
"canMergeNow": prInfo.MergeBoxData.canMergeNow,
|
||||
"allOverridableChecksOk": allOverridableChecksOk,
|
||||
"emptyCommit": pull.IsEmpty(),
|
||||
"pullHeadCommitID": prInfo.CompareInfo.HeadCommitID,
|
||||
@@ -103,7 +103,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
}
|
||||
|
||||
// if this pr can be merged now, then hide the auto merge
|
||||
generalHideAutoMerge := prInfo.MergeBoxData.CanMergeNow && allOverridableChecksOk
|
||||
generalHideAutoMerge := prInfo.MergeBoxData.canMergeNow && allOverridableChecksOk
|
||||
|
||||
var mergeStyles []any
|
||||
if pull.IsStatusMergeable() {
|
||||
|
||||
@@ -83,6 +83,7 @@ func SettingsProtectedBranch(c *context.Context) {
|
||||
c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",")
|
||||
c.Data["force_push_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistUserIDs), ",")
|
||||
c.Data["merge_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistUserIDs), ",")
|
||||
c.Data["bypass_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.BypassAllowlistUserIDs), ",")
|
||||
c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",")
|
||||
c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n")
|
||||
contexts, _ := git_model.FindRepoRecentCommitStatusContexts(c, c.Repo.Repository.ID, 7*24*time.Hour) // Find last week status check contexts
|
||||
@@ -98,6 +99,7 @@ func SettingsProtectedBranch(c *context.Context) {
|
||||
c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",")
|
||||
c.Data["force_push_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistTeamIDs), ",")
|
||||
c.Data["merge_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistTeamIDs), ",")
|
||||
c.Data["bypass_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.BypassAllowlistTeamIDs), ",")
|
||||
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, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams, bypassAllowlistUsers, bypassAllowlistTeams []int64
|
||||
protectBranch.RuleName = f.RuleName
|
||||
if f.RequiredApprovals < 0 {
|
||||
ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min"))
|
||||
@@ -215,6 +217,16 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
protectBranch.EnableBypassAllowlist = f.EnableBypassAllowlist
|
||||
if f.EnableBypassAllowlist {
|
||||
if strings.TrimSpace(f.BypassAllowlistUsers) != "" {
|
||||
bypassAllowlistUsers, _ = base.StringsToInt64s(strings.Split(f.BypassAllowlistUsers, ","))
|
||||
}
|
||||
if strings.TrimSpace(f.BypassAllowlistTeams) != "" {
|
||||
bypassAllowlistTeams, _ = base.StringsToInt64s(strings.Split(f.BypassAllowlistTeams, ","))
|
||||
}
|
||||
}
|
||||
|
||||
protectBranch.EnableStatusCheck = f.EnableStatusCheck
|
||||
if f.EnableStatusCheck {
|
||||
patterns := strings.Split(strings.ReplaceAll(f.StatusCheckContexts, "\r", "\n"), "\n")
|
||||
@@ -271,6 +283,8 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
|
||||
MergeTeamIDs: mergeWhitelistTeams,
|
||||
ApprovalsUserIDs: approvalsWhitelistUsers,
|
||||
ApprovalsTeamIDs: approvalsWhitelistTeams,
|
||||
BypassUserIDs: bypassAllowlistUsers,
|
||||
BypassTeamIDs: bypassAllowlistTeams,
|
||||
}); err != nil {
|
||||
ctx.ServerError("CreateOrUpdateProtectedBranch", err)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user