Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b108b00
Add bypass allowlist for branch protection
bircni Mar 13, 2026
42e04fa
Merge branch 'main' into feature/bypass-branch-protection-allowlist
bircni Mar 16, 2026
d2c9157
change to v328 migration
bircni Mar 16, 2026
c429020
Merge remote-tracking branch 'upstream/main' into feature/bypass-bran…
bircni Mar 16, 2026
e16164e
Add a migration test test
bircni Mar 16, 2026
24db2e2
rename migration to 330
bircni Mar 23, 2026
994f5f5
Merge remote-tracking branch 'origin/main' into feature/bypass-branch…
bircni Mar 23, 2026
d3b4ae8
Merge branch 'main' into feature/bypass-branch-protection-allowlist
silverwind Mar 25, 2026
a75de17
fixes
bircni Mar 25, 2026
7e9d0ce
Merge branch 'main' into feature/bypass-branch-protection-allowlist
bircni Mar 31, 2026
6c444bb
rename migration
bircni Apr 5, 2026
ac2f907
Merge branch 'main' into feature/bypass-branch-protection-allowlist
bircni Apr 5, 2026
96f4123
fix
bircni Apr 5, 2026
8a006ac
Merge branch 'main' into feature/bypass-branch-protection-allowlist
bircni Apr 5, 2026
342f7e8
Merge branch 'main' into feature/bypass-branch-protection-allowlist
GiteaBot Apr 5, 2026
6c3c829
Merge branch 'main' into feature/bypass-branch-protection-allowlist
GiteaBot Apr 5, 2026
3510261
Merge branch 'main' into feature/bypass-branch-protection-allowlist
GiteaBot Apr 5, 2026
c7fb30f
fix CanBypassBranchProtection
wxiaoguang Apr 6, 2026
56accf4
refactor
wxiaoguang Apr 6, 2026
13dc7b9
fix variable name
wxiaoguang Apr 6, 2026
9642ce5
fine tune
wxiaoguang Apr 6, 2026
0125c5a
fix layout (due to recent checkbox changes)
wxiaoguang Apr 6, 2026
b3591f9
fix logic
wxiaoguang Apr 6, 2026
060a5e4
fix logic
wxiaoguang Apr 6, 2026
a1d3638
Merge branch 'main' into feature/bypass-branch-protection-allowlist
GiteaBot Apr 6, 2026
02551a2
remove bypass-allowlist team cache
bircni Apr 14, 2026
2a6516b
Merge branch 'main' into feature/bypass-branch-protection-allowlist
bircni Apr 14, 2026
68b505d
fix merge box
bircni Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions models/git/protected_branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ type ProtectedBranch struct {
WhitelistDeployKeys bool `xorm:"NOT NULL DEFAULT false"`
MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
MergeWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
EnableBypassAllowlist bool `xorm:"NOT NULL DEFAULT false"`
BypassAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
BypassAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
CanForcePush bool `xorm:"NOT NULL DEFAULT false"`
EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"`
ForcePushAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
Expand Down Expand Up @@ -204,6 +207,35 @@ func IsUserMergeWhitelisted(ctx context.Context, protectBranch *ProtectedBranch,
return in
}

// CanBypassBranchProtection reports whether the user can bypass branch protection checks (status checks, approvals, protected files)
// Either a repo admin (when not blocked) or a user/team on the bypass allowlist can bypass.
func CanBypassBranchProtection(ctx context.Context, protectBranch *ProtectedBranch, user *user_model.User, isRepoAdmin bool) (byAdmin, byAllowList bool) {
if !protectBranch.BlockAdminMergeOverride && isRepoAdmin {
return true, true
}
return false, canBypassBranchProtectionByAllowList(ctx, protectBranch, user)
}

func canBypassBranchProtectionByAllowList(ctx context.Context, protectBranch *ProtectedBranch, user *user_model.User) bool {
if !protectBranch.EnableBypassAllowlist {
return false
}
if slices.Contains(protectBranch.BypassAllowlistUserIDs, user.ID) {
return true
}

if len(protectBranch.BypassAllowlistTeamIDs) == 0 {
return false
}

in, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.BypassAllowlistTeamIDs)
if err != nil {
log.Error("IsUserInTeams failed: userID=%d, repoID=%d, allowlistTeamIDs=%v, err=%v", user.ID, protectBranch.RepoID, protectBranch.BypassAllowlistTeamIDs, err)
return false
}
return in
Comment thread
bircni marked this conversation as resolved.
}

// IsUserOfficialReviewer check if user is official reviewer for the branch (counts towards required approvals)
func IsUserOfficialReviewer(ctx context.Context, protectBranch *ProtectedBranch, user *user_model.User) (bool, error) {
repo, err := repo_model.GetRepositoryByID(ctx, protectBranch.RepoID)
Expand Down Expand Up @@ -347,6 +379,9 @@ type WhitelistOptions struct {

ApprovalsUserIDs []int64
ApprovalsTeamIDs []int64

BypassUserIDs []int64
BypassTeamIDs []int64
}

// UpdateProtectBranch saves branch protection options of repository.
Expand Down Expand Up @@ -387,6 +422,12 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
}
protectBranch.ApprovalsWhitelistUserIDs = whitelist

whitelist, err = updateUserWhitelist(ctx, repo, protectBranch.BypassAllowlistUserIDs, opts.BypassUserIDs)
if err != nil {
return err
}
protectBranch.BypassAllowlistUserIDs = whitelist

// if the repo is in an organization
whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.WhitelistTeamIDs, opts.TeamIDs)
if err != nil {
Expand All @@ -412,6 +453,12 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
}
protectBranch.ApprovalsWhitelistTeamIDs = whitelist

whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.BypassAllowlistTeamIDs, opts.BypassTeamIDs)
if err != nil {
return err
}
protectBranch.BypassAllowlistTeamIDs = 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.
Expand Down
56 changes: 56 additions & 0 deletions models/git/protected_branch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"

"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -153,3 +154,58 @@ func TestNewProtectBranchPriority(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, int64(2), savedPB2.Priority)
}

func TestCanBypassBranchProtection(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
Comment thread
bircni marked this conversation as resolved.

pb := &ProtectedBranch{
EnableBypassAllowlist: true,
BypassAllowlistUserIDs: []int64{user.ID},
}

type expected struct {
byAdmin, byAllowList bool
}
testBypass := func(t *testing.T, exp expected, pb *ProtectedBranch, doer *user_model.User, isAdmin bool) {
actualAdmin, actualAllowList := CanBypassBranchProtection(t.Context(), pb, doer, isAdmin)
assert.Equal(t, exp.byAdmin, actualAdmin, "admin bypass mismatch")
assert.Equal(t, exp.byAllowList, actualAllowList, "allowlist bypass mismatch")
}
// User bypasses via explicit allowlist.
testBypass(t, expected{false, true}, pb, user, false)

// Non-admin cannot bypass when allowlist is disabled.
pb.EnableBypassAllowlist = false
testBypass(t, expected{false, false}, pb, user, false)

// Repo admin can bypass independently of allowlist when not blocked.
testBypass(t, expected{true, true}, pb, user, true)

// Admin override block still allows bypass for allowlisted users.
pb.EnableBypassAllowlist = true
pb.BlockAdminMergeOverride = true
testBypass(t, expected{false, true}, pb, user, false)

// admin cannot bypass without allowlist membership.
pb.BypassAllowlistUserIDs = nil
testBypass(t, expected{false, false}, pb, user, true)

// admin can bypass when allowlisted.
pb.BypassAllowlistUserIDs = []int64{user.ID}
testBypass(t, expected{false, true}, pb, user, true)

teamMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
nonTeamMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})

// User bypasses via team allowlist membership.
pb.EnableBypassAllowlist = true
pb.BlockAdminMergeOverride = false
pb.BypassAllowlistUserIDs = nil
pb.BypassAllowlistTeamIDs = []int64{1} // team 1 contains user 2 in test fixtures
testBypass(t, expected{false, true}, pb, teamMember, false)

// User does not bypass when not in allowlisted teams.
testBypass(t, expected{false, false}, pb, nonTeamMember, false)
}
Comment thread
bircni marked this conversation as resolved.
1 change: 1 addition & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ func prepareMigrationTasks() []*migration {
newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge),
newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook),
newMigration(331, "Add bypass allowlist to branch protection", v1_26.AddBranchProtectionBypassAllowlist),
}
return preparedMigrations
}
Expand Down
20 changes: 20 additions & 0 deletions models/migrations/v1_26/v331.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package v1_26

import "xorm.io/xorm"

func AddBranchProtectionBypassAllowlist(x *xorm.Engine) error {
type ProtectedBranch struct {
EnableBypassAllowlist bool `xorm:"NOT NULL DEFAULT false"`
BypassAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
BypassAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
}

_, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreConstrains: true,
IgnoreIndices: true,
}, new(ProtectedBranch))
return err
}
60 changes: 60 additions & 0 deletions models/migrations/v1_26/v331_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package v1_26

import (
"testing"

"code.gitea.io/gitea/models/migrations/base"

"github.com/stretchr/testify/require"
)

func Test_AddBranchProtectionBypassAllowlist(t *testing.T) {
type ProtectedBranch struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
BranchName string `xorm:"INDEX"`
EnableBypassAllowlist bool `xorm:"NOT NULL DEFAULT false"`
BypassAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
BypassAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
}

x, deferable := base.PrepareTestEnv(t, 0, new(ProtectedBranch))
defer deferable()

// Test with default values
_, err := x.Insert(&ProtectedBranch{RepoID: 1, BranchName: "main"})
require.NoError(t, err)

// Test with populated allowlist
_, err = x.Insert(&ProtectedBranch{
RepoID: 1,
BranchName: "develop",
EnableBypassAllowlist: true,
BypassAllowlistUserIDs: []int64{1, 2, 3},
BypassAllowlistTeamIDs: []int64{10, 20},
})
require.NoError(t, err)

require.NoError(t, AddBranchProtectionBypassAllowlist(x))

// Verify the default values record
var pb ProtectedBranch
has, err := x.Where("repo_id = ? AND branch_name = ?", 1, "main").Get(&pb)
require.NoError(t, err)
require.True(t, has)
require.False(t, pb.EnableBypassAllowlist)
require.Nil(t, pb.BypassAllowlistUserIDs)
require.Nil(t, pb.BypassAllowlistTeamIDs)

// Verify the populated allowlist record
var pb2 ProtectedBranch
has, err = x.Where("repo_id = ? AND branch_name = ?", 1, "develop").Get(&pb2)
require.NoError(t, err)
require.True(t, has)
require.True(t, pb2.EnableBypassAllowlist)
require.Equal(t, []int64{1, 2, 3}, pb2.BypassAllowlistUserIDs)
require.Equal(t, []int64{10, 20}, pb2.BypassAllowlistTeamIDs)
}
1 change: 1 addition & 0 deletions modules/cachegroup/cachegroup.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ const (
UserEmailAddresses = "user_email_addresses"
GPGKeyWithSubKeys = "gpg_key_with_subkeys"
RepoUserPermission = "repo_user_permission"
BypassAllowlist = "bypass_allowlist"
)
9 changes: 9 additions & 0 deletions modules/structs/repo_branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ type BranchProtection struct {
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
EnableBypassAllowlist bool `json:"enable_bypass_allowlist"`
BypassAllowlistUsernames []string `json:"bypass_allowlist_usernames"`
BypassAllowlistTeams []string `json:"bypass_allowlist_teams"`
EnableStatusCheck bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"`
RequiredApprovals int64 `json:"required_approvals"`
Expand Down Expand Up @@ -90,6 +93,9 @@ type CreateBranchProtectionOption struct {
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
EnableBypassAllowlist bool `json:"enable_bypass_allowlist"`
BypassAllowlistUsernames []string `json:"bypass_allowlist_usernames"`
BypassAllowlistTeams []string `json:"bypass_allowlist_teams"`
EnableStatusCheck bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"`
RequiredApprovals int64 `json:"required_approvals"`
Expand Down Expand Up @@ -123,6 +129,9 @@ type EditBranchProtectionOption struct {
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
EnableBypassAllowlist *bool `json:"enable_bypass_allowlist"`
BypassAllowlistUsernames []string `json:"bypass_allowlist_usernames"`
BypassAllowlistTeams []string `json:"bypass_allowlist_teams"`
EnableStatusCheck *bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"`
RequiredApprovals *int64 `json:"required_approvals"`
Expand Down
8 changes: 7 additions & 1 deletion options/locale/locale_en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -1821,6 +1821,7 @@
"repo.pulls.required_status_check_failed": "Some required checks were not successful.",
"repo.pulls.required_status_check_missing": "Some required checks are missing.",
"repo.pulls.required_status_check_administrator": "As an administrator, you may still merge this pull request.",
"repo.pulls.required_status_check_bypass_allowlist": "You are allowed to bypass branch protection rules for this merge.",
"repo.pulls.blocked_by_approvals": "This pull request doesn't have enough required approvals yet. %d of %d official approvals granted.",
"repo.pulls.blocked_by_approvals_whitelisted": "This pull request doesn't have enough required approvals yet. %d of %d approvals granted from users or teams on the allowlist.",
"repo.pulls.blocked_by_rejection": "This pull request has changes requested by an official reviewer.",
Expand Down Expand Up @@ -2415,6 +2416,11 @@
"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:",
"repo.settings.protect_merge_whitelist_teams": "Allowlisted teams for merging:",
"repo.settings.protect_bypass_allowlist": "Bypass branch protection",
"repo.settings.protect_enable_bypass_allowlist": "Allow selected users or teams to bypass branch protection",
"repo.settings.protect_enable_bypass_allowlist_desc": "Allowlisted users or teams can merge or push even when required approvals, status checks, or protected-file rules would otherwise block them.",
"repo.settings.protect_bypass_allowlist_users": "Allowlisted users for bypassing protection:",
"repo.settings.protect_bypass_allowlist_teams": "Allowlisted teams for bypassing protection:",
"repo.settings.protect_check_status_contexts": "Enable Status Check",
"repo.settings.protect_status_check_patterns": "Status check patterns:",
"repo.settings.protect_status_check_patterns_desc": "Enter patterns to specify which status checks must pass before branches can be merged into a branch that matches this rule. Each line specifies a pattern. Patterns cannot be empty.",
Expand Down Expand Up @@ -2456,7 +2462,7 @@
"repo.settings.block_outdated_branch": "Block merge if pull request is outdated",
"repo.settings.block_outdated_branch_desc": "Merging will not be possible when head branch is behind base branch.",
"repo.settings.block_admin_merge_override": "Administrators must follow branch protection rules",
"repo.settings.block_admin_merge_override_desc": "Administrators must follow branch protection rules and cannot circumvent it.",
"repo.settings.block_admin_merge_override_desc": "Administrators must follow branch protection rules and cannot circumvent it. Users or teams in the bypass allowlist can still bypass these rules if bypass allowlist is enabled.",
"repo.settings.default_branch_desc": "Select a default branch for code commits.",
"repo.settings.default_target_branch_desc": "Pull requests can use different default target branch if it is set in the Pull Requests section of Repository Advance Settings.",
"repo.settings.merge_style_desc": "Merge Styles",
Expand Down
Loading