Allow force push to protected branches (#28086)

Fixes #22722 

### Problem
Currently, it is not possible to force push to a branch with branch
protection rules in place. There are often times where this is necessary
(CI workflows/administrative tasks etc).

The current workaround is to rename/remove the branch protection,
perform the force push, and then reinstate the protections.

### Solution
Provide an additional section in the branch protection rules to allow
users to specify which users with push access can also force push to the
branch. The default value of the rule will be set to `Disabled`, and the
UI is intuitive and very similar to the `Push` section.

It is worth noting in this implementation that allowing force push does
not override regular push access, and both will need to be enabled for a
user to force push.

This applies to manual force push to a remote, and also in Gitea UI
updating a PR by rebase (which requires force push)

This modifies the `BranchProtection` API structs to add:
- `enable_force_push bool`
- `enable_force_push_whitelist bool`
- `force_push_whitelist_usernames string[]`
- `force_push_whitelist_teams string[]`
- `force_push_whitelist_deploy_keys bool`

### Updated Branch Protection UI:

<img width="943" alt="image"
src="https://github.com/go-gitea/gitea/assets/79623665/7491899c-d816-45d5-be84-8512abd156bf">

### Pull Request `Update branch by Rebase` option enabled with source
branch `test` being a protected branch:


![image](https://github.com/go-gitea/gitea/assets/79623665/e018e6e9-b7b2-4bd3-808e-4947d7da35cc)
<img width="1038" alt="image"
src="https://github.com/go-gitea/gitea/assets/79623665/57ead13e-9006-459f-b83c-7079e6f4c654">

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
pull/31574/head
Henry Goodman 5 months ago committed by GitHub
parent 9c00dda33a
commit 12cb1d2998
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 124
      models/git/protected_branch.go
  2. 2
      models/migrations/migrations.go
  3. 17
      models/migrations/v1_23/v300.go
  4. 15
      modules/structs/repo_branch.go
  5. 36
      options/locale/locale_en-US.ini
  6. 82
      routers/api/v1/repo/branch.go
  7. 38
      routers/private/hook_pre_receive.go
  8. 27
      routers/web/repo/setting/protected_branch.go
  9. 7
      services/convert/convert.go
  10. 4
      services/forms/repo_form.go
  11. 30
      services/pull/update.go
  12. 63
      templates/repo/settings/protected_branch.tmpl
  13. 78
      templates/swagger/v1_json.tmpl
  14. 106
      tests/integration/git_test.go

@ -44,6 +44,11 @@ type ProtectedBranch struct {
WhitelistDeployKeys bool `xorm:"NOT NULL DEFAULT false"` WhitelistDeployKeys bool `xorm:"NOT NULL DEFAULT false"`
MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT"` MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
MergeWhitelistTeamIDs []int64 `xorm:"JSON TEXT"` MergeWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
CanForcePush bool `xorm:"NOT NULL DEFAULT false"`
EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"`
ForcePushAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
ForcePushAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
ForcePushAllowlistDeployKeys bool `xorm:"NOT NULL DEFAULT false"`
EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"` EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"`
StatusCheckContexts []string `xorm:"JSON TEXT"` StatusCheckContexts []string `xorm:"JSON TEXT"`
EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"` EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"`
@ -143,6 +148,33 @@ func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *use
return in return in
} }
// CanUserForcePush returns if some user could force push to this protected branch
// Since force-push extends normal push, we also check if user has regular push access
func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User) bool {
if !protectBranch.CanForcePush {
return false
}
if !protectBranch.EnableForcePushAllowlist {
return protectBranch.CanUserPush(ctx, user)
}
if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) {
return protectBranch.CanUserPush(ctx, user)
}
if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 {
return false
}
in, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.ForcePushAllowlistTeamIDs)
if err != nil {
log.Error("IsUserInTeams: %v", err)
return false
}
return in && protectBranch.CanUserPush(ctx, user)
}
// IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch // 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 { func IsUserMergeWhitelisted(ctx context.Context, protectBranch *ProtectedBranch, userID int64, permissionInRepo access_model.Permission) bool {
if !protectBranch.EnableMergeWhitelist { if !protectBranch.EnableMergeWhitelist {
@ -301,6 +333,9 @@ type WhitelistOptions struct {
UserIDs []int64 UserIDs []int64
TeamIDs []int64 TeamIDs []int64
ForcePushUserIDs []int64
ForcePushTeamIDs []int64
MergeUserIDs []int64 MergeUserIDs []int64
MergeTeamIDs []int64 MergeTeamIDs []int64
@ -328,6 +363,12 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
} }
protectBranch.WhitelistUserIDs = whitelist protectBranch.WhitelistUserIDs = whitelist
whitelist, err = updateUserWhitelist(ctx, repo, protectBranch.ForcePushAllowlistUserIDs, opts.ForcePushUserIDs)
if err != nil {
return err
}
protectBranch.ForcePushAllowlistUserIDs = whitelist
whitelist, err = updateUserWhitelist(ctx, repo, protectBranch.MergeWhitelistUserIDs, opts.MergeUserIDs) whitelist, err = updateUserWhitelist(ctx, repo, protectBranch.MergeWhitelistUserIDs, opts.MergeUserIDs)
if err != nil { if err != nil {
return err return err
@ -347,6 +388,12 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
} }
protectBranch.WhitelistTeamIDs = whitelist protectBranch.WhitelistTeamIDs = whitelist
whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.ForcePushAllowlistTeamIDs, opts.ForcePushTeamIDs)
if err != nil {
return err
}
protectBranch.ForcePushAllowlistTeamIDs = whitelist
whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.MergeWhitelistTeamIDs, opts.MergeTeamIDs) whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.MergeWhitelistTeamIDs, opts.MergeTeamIDs)
if err != nil { if err != nil {
return err return err
@ -468,43 +515,58 @@ func DeleteProtectedBranch(ctx context.Context, repo *repo_model.Repository, id
return nil return nil
} }
// RemoveUserIDFromProtectedBranch remove all user ids from protected branch options // removeIDsFromProtectedBranch is a helper function to remove IDs from protected branch options
func RemoveUserIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userID int64) error { func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userID, teamID int64, columnNames []string) error {
lenIDs, lenApprovalIDs, lenMergeIDs := len(p.WhitelistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs) lenUserIDs, lenForcePushIDs, lenApprovalIDs, lenMergeIDs := len(p.WhitelistUserIDs), len(p.ForcePushAllowlistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs)
p.WhitelistUserIDs = util.SliceRemoveAll(p.WhitelistUserIDs, userID) lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs)
p.ApprovalsWhitelistUserIDs = util.SliceRemoveAll(p.ApprovalsWhitelistUserIDs, userID)
p.MergeWhitelistUserIDs = util.SliceRemoveAll(p.MergeWhitelistUserIDs, userID) if userID > 0 {
p.WhitelistUserIDs = util.SliceRemoveAll(p.WhitelistUserIDs, userID)
if lenIDs != len(p.WhitelistUserIDs) || lenApprovalIDs != len(p.ApprovalsWhitelistUserIDs) || p.ForcePushAllowlistUserIDs = util.SliceRemoveAll(p.ForcePushAllowlistUserIDs, userID)
lenMergeIDs != len(p.MergeWhitelistUserIDs) { p.ApprovalsWhitelistUserIDs = util.SliceRemoveAll(p.ApprovalsWhitelistUserIDs, userID)
if _, err := db.GetEngine(ctx).ID(p.ID).Cols( p.MergeWhitelistUserIDs = util.SliceRemoveAll(p.MergeWhitelistUserIDs, userID)
"whitelist_user_i_ds", }
"merge_whitelist_user_i_ds",
"approvals_whitelist_user_i_ds", if teamID > 0 {
).Update(p); err != nil { p.WhitelistTeamIDs = util.SliceRemoveAll(p.WhitelistTeamIDs, teamID)
p.ForcePushAllowlistTeamIDs = util.SliceRemoveAll(p.ForcePushAllowlistTeamIDs, teamID)
p.ApprovalsWhitelistTeamIDs = util.SliceRemoveAll(p.ApprovalsWhitelistTeamIDs, teamID)
p.MergeWhitelistTeamIDs = util.SliceRemoveAll(p.MergeWhitelistTeamIDs, teamID)
}
if (lenUserIDs != len(p.WhitelistUserIDs) ||
lenForcePushIDs != len(p.ForcePushAllowlistUserIDs) ||
lenApprovalIDs != len(p.ApprovalsWhitelistUserIDs) ||
lenMergeIDs != len(p.MergeWhitelistUserIDs)) ||
(lenTeamIDs != len(p.WhitelistTeamIDs) ||
lenForcePushTeamIDs != len(p.ForcePushAllowlistTeamIDs) ||
lenApprovalTeamIDs != len(p.ApprovalsWhitelistTeamIDs) ||
lenMergeTeamIDs != len(p.MergeWhitelistTeamIDs)) {
if _, err := db.GetEngine(ctx).ID(p.ID).Cols(columnNames...).Update(p); err != nil {
return fmt.Errorf("updateProtectedBranches: %v", err) return fmt.Errorf("updateProtectedBranches: %v", err)
} }
} }
return nil return nil
} }
// RemoveTeamIDFromProtectedBranch remove all team ids from protected branch options // RemoveUserIDFromProtectedBranch removes all user ids from protected branch options
func RemoveUserIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userID int64) error {
columnNames := []string{
"whitelist_user_i_ds",
"force_push_allowlist_user_i_ds",
"merge_whitelist_user_i_ds",
"approvals_whitelist_user_i_ds",
}
return removeIDsFromProtectedBranch(ctx, p, userID, 0, columnNames)
}
// RemoveTeamIDFromProtectedBranch removes all team ids from protected branch options
func RemoveTeamIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, teamID int64) error { func RemoveTeamIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, teamID int64) error {
lenIDs, lenApprovalIDs, lenMergeIDs := len(p.WhitelistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs) columnNames := []string{
p.WhitelistTeamIDs = util.SliceRemoveAll(p.WhitelistTeamIDs, teamID) "whitelist_team_i_ds",
p.ApprovalsWhitelistTeamIDs = util.SliceRemoveAll(p.ApprovalsWhitelistTeamIDs, teamID) "force_push_allowlist_team_i_ds",
p.MergeWhitelistTeamIDs = util.SliceRemoveAll(p.MergeWhitelistTeamIDs, teamID) "merge_whitelist_team_i_ds",
"approvals_whitelist_team_i_ds",
if lenIDs != len(p.WhitelistTeamIDs) ||
lenApprovalIDs != len(p.ApprovalsWhitelistTeamIDs) ||
lenMergeIDs != len(p.MergeWhitelistTeamIDs) {
if _, err := db.GetEngine(ctx).ID(p.ID).Cols(
"whitelist_team_i_ds",
"merge_whitelist_team_i_ds",
"approvals_whitelist_team_i_ds",
).Update(p); err != nil {
return fmt.Errorf("updateProtectedBranches: %v", err)
}
} }
return nil return removeIDsFromProtectedBranch(ctx, p, 0, teamID, columnNames)
} }

@ -591,6 +591,8 @@ var migrations = []Migration{
// v299 -> v300 // v299 -> v300
NewMigration("Add content version to issue and comment table", v1_23.AddContentVersionToIssueAndComment), NewMigration("Add content version to issue and comment table", v1_23.AddContentVersionToIssueAndComment),
// v300 -> v301
NewMigration("Add force-push branch protection support", v1_23.AddForcePushBranchProtection),
} }
// GetCurrentDBVersion returns the current db version // GetCurrentDBVersion returns the current db version

@ -0,0 +1,17 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_23 //nolint
import "xorm.io/xorm"
func AddForcePushBranchProtection(x *xorm.Engine) error {
type ProtectedBranch struct {
CanForcePush bool `xorm:"NOT NULL DEFAULT false"`
EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"`
ForcePushAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
ForcePushAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
ForcePushAllowlistDeployKeys bool `xorm:"NOT NULL DEFAULT false"`
}
return x.Sync(new(ProtectedBranch))
}

@ -30,6 +30,11 @@ type BranchProtection struct {
PushWhitelistUsernames []string `json:"push_whitelist_usernames"` PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
PushWhitelistTeams []string `json:"push_whitelist_teams"` PushWhitelistTeams []string `json:"push_whitelist_teams"`
PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys"` PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys"`
EnableForcePush bool `json:"enable_force_push"`
EnableForcePushAllowlist bool `json:"enable_force_push_allowlist"`
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
ForcePushAllowlistDeployKeys bool `json:"force_push_allowlist_deploy_keys"`
EnableMergeWhitelist bool `json:"enable_merge_whitelist"` EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"` MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
@ -63,6 +68,11 @@ type CreateBranchProtectionOption struct {
PushWhitelistUsernames []string `json:"push_whitelist_usernames"` PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
PushWhitelistTeams []string `json:"push_whitelist_teams"` PushWhitelistTeams []string `json:"push_whitelist_teams"`
PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys"` PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys"`
EnableForcePush bool `json:"enable_force_push"`
EnableForcePushAllowlist bool `json:"enable_force_push_allowlist"`
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
ForcePushAllowlistDeployKeys bool `json:"force_push_allowlist_deploy_keys"`
EnableMergeWhitelist bool `json:"enable_merge_whitelist"` EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"` MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
@ -89,6 +99,11 @@ type EditBranchProtectionOption struct {
PushWhitelistUsernames []string `json:"push_whitelist_usernames"` PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
PushWhitelistTeams []string `json:"push_whitelist_teams"` PushWhitelistTeams []string `json:"push_whitelist_teams"`
PushWhitelistDeployKeys *bool `json:"push_whitelist_deploy_keys"` PushWhitelistDeployKeys *bool `json:"push_whitelist_deploy_keys"`
EnableForcePush *bool `json:"enable_force_push"`
EnableForcePushAllowlist *bool `json:"enable_force_push_allowlist"`
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
ForcePushAllowlistDeployKeys *bool `json:"force_push_allowlist_deploy_keys"`
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"` EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"` MergeWhitelistTeams []string `json:"merge_whitelist_teams"`

@ -2280,6 +2280,7 @@ settings.event_wiki_desc = Wiki page created, renamed, edited or deleted.
settings.event_release = Release settings.event_release = Release
settings.event_release_desc = Release published, updated or deleted in a repository. settings.event_release_desc = Release published, updated or deleted in a repository.
settings.event_push = Push settings.event_push = Push
settings.event_force_push = Force Push
settings.event_push_desc = Git push to a repository. settings.event_push_desc = Git push to a repository.
settings.event_repository = Repository settings.event_repository = Repository
settings.event_repository_desc = Repository created or deleted. settings.event_repository_desc = Repository created or deleted.
@ -2373,19 +2374,28 @@ settings.protect_this_branch = Enable Branch Protection
settings.protect_this_branch_desc = Prevents deletion and restricts Git pushing and merging to the branch. settings.protect_this_branch_desc = Prevents deletion and restricts Git pushing and merging to the branch.
settings.protect_disable_push = Disable Push settings.protect_disable_push = Disable Push
settings.protect_disable_push_desc = No pushing will be allowed to this branch. settings.protect_disable_push_desc = No pushing will be allowed to this branch.
settings.protect_disable_force_push = Disable Force Push
settings.protect_disable_force_push_desc = No force pushing will be allowed to this branch.
settings.protect_enable_push = Enable Push settings.protect_enable_push = Enable Push
settings.protect_enable_push_desc = Anyone with write access will be allowed to push to this branch (but not force push). settings.protect_enable_push_desc = Anyone with write access will be allowed to push to this branch (but not force push).
settings.protect_enable_force_push_all = Enable Force Push
settings.protect_enable_force_push_all_desc = Anyone with push access will be allowed to force push to this branch.
settings.protect_enable_force_push_allowlist = Allowlist Restricted Force Push
settings.protect_enable_force_push_allowlist_desc = Only allowlisted users or teams with push access will be allowed to force push to this branch.
settings.protect_enable_merge = Enable Merge settings.protect_enable_merge = Enable Merge
settings.protect_enable_merge_desc = Anyone with write access will be allowed to merge the pull requests into this branch. settings.protect_enable_merge_desc = Anyone with write access will be allowed to merge the pull requests into this branch.
settings.protect_whitelist_committers = Whitelist Restricted Push settings.protect_whitelist_committers = Allowlist Restricted Push
settings.protect_whitelist_committers_desc = Only whitelisted users or teams will be allowed to push to this branch (but not force push). settings.protect_whitelist_committers_desc = Only allowlisted users or teams will be allowed to push to this branch (but not force push).
settings.protect_whitelist_deploy_keys = Whitelist deploy keys with write access to push. settings.protect_whitelist_deploy_keys = Allowlist deploy keys with write access to push.
settings.protect_whitelist_users = Whitelisted users for pushing: settings.protect_whitelist_users = Allowlisted users for pushing:
settings.protect_whitelist_teams = Whitelisted teams for pushing: settings.protect_whitelist_teams = Allowlisted teams for pushing:
settings.protect_merge_whitelist_committers = Enable Merge Whitelist settings.protect_force_push_allowlist_users = Allowlisted users for force pushing:
settings.protect_merge_whitelist_committers_desc = Allow only whitelisted users or teams to merge pull requests into this branch. settings.protect_force_push_allowlist_teams = Allowlisted teams for force pushing:
settings.protect_merge_whitelist_users = Whitelisted users for merging: settings.protect_force_push_allowlist_deploy_keys = Allowlist deploy keys with push access to force push.
settings.protect_merge_whitelist_teams = Whitelisted teams for merging: settings.protect_merge_whitelist_committers = Enable Merge Allowlist
settings.protect_merge_whitelist_committers_desc = Allow only allowlisted users or teams to merge pull requests into this branch.
settings.protect_merge_whitelist_users = Allowlisted users for merging:
settings.protect_merge_whitelist_teams = Allowlisted teams for merging:
settings.protect_check_status_contexts = Enable Status Check settings.protect_check_status_contexts = Enable Status Check
settings.protect_status_check_patterns = Status check patterns: settings.protect_status_check_patterns = Status check patterns:
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. 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.
@ -2396,10 +2406,10 @@ settings.protect_invalid_status_check_pattern = Invalid status check pattern: "%
settings.protect_no_valid_status_check_patterns = No valid status check patterns. settings.protect_no_valid_status_check_patterns = No valid status check patterns.
settings.protect_required_approvals = Required approvals: settings.protect_required_approvals = Required approvals:
settings.protect_required_approvals_desc = Allow only to merge pull request with enough positive reviews. settings.protect_required_approvals_desc = Allow only to merge pull request with enough positive reviews.
settings.protect_approvals_whitelist_enabled = Restrict approvals to whitelisted users or teams settings.protect_approvals_whitelist_enabled = Restrict approvals to allowlisted users or teams
settings.protect_approvals_whitelist_enabled_desc = Only reviews from whitelisted users or teams will count to the required approvals. Without approval whitelist, reviews from anyone with write access count to the required approvals. settings.protect_approvals_whitelist_enabled_desc = Only reviews from allowlisted users or teams will count to the required approvals. Without approval allowlist, reviews from anyone with write access count to the required approvals.
settings.protect_approvals_whitelist_users = Whitelisted reviewers: settings.protect_approvals_whitelist_users = Allowlisted reviewers:
settings.protect_approvals_whitelist_teams = Whitelisted teams for reviews: settings.protect_approvals_whitelist_teams = Allowlisted teams for reviews:
settings.dismiss_stale_approvals = Dismiss stale approvals settings.dismiss_stale_approvals = Dismiss stale approvals
settings.dismiss_stale_approvals_desc = When new commits that change the content of the pull request are pushed to the branch, old approvals will be dismissed. settings.dismiss_stale_approvals_desc = When new commits that change the content of the pull request are pushed to the branch, old approvals will be dismissed.
settings.ignore_stale_approvals = Ignore stale approvals settings.ignore_stale_approvals = Ignore stale approvals

@ -553,6 +553,15 @@ func CreateBranchProtection(ctx *context.APIContext) {
ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err)
return return
} }
forcePushAllowlistUsers, err := user_model.GetUserIDsByNames(ctx, form.ForcePushAllowlistUsernames, false)
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err)
return
}
ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err)
return
}
mergeWhitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false) mergeWhitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false)
if err != nil { if err != nil {
if user_model.IsErrUserNotExist(err) { if user_model.IsErrUserNotExist(err) {
@ -571,7 +580,7 @@ func CreateBranchProtection(ctx *context.APIContext) {
ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err)
return return
} }
var whitelistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
if repo.Owner.IsOrganization() { if repo.Owner.IsOrganization() {
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false) whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
if err != nil { if err != nil {
@ -582,6 +591,15 @@ func CreateBranchProtection(ctx *context.APIContext) {
ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err)
return return
} }
forcePushAllowlistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.ForcePushAllowlistTeams, false)
if err != nil {
if organization.IsErrTeamNotExist(err) {
ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err)
return
}
ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err)
return
}
mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false) mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false)
if err != nil { if err != nil {
if organization.IsErrTeamNotExist(err) { if organization.IsErrTeamNotExist(err) {
@ -607,8 +625,11 @@ func CreateBranchProtection(ctx *context.APIContext) {
RuleName: ruleName, RuleName: ruleName,
CanPush: form.EnablePush, CanPush: form.EnablePush,
EnableWhitelist: form.EnablePush && form.EnablePushWhitelist, EnableWhitelist: form.EnablePush && form.EnablePushWhitelist,
EnableMergeWhitelist: form.EnableMergeWhitelist,
WhitelistDeployKeys: form.EnablePush && form.EnablePushWhitelist && form.PushWhitelistDeployKeys, WhitelistDeployKeys: form.EnablePush && form.EnablePushWhitelist && form.PushWhitelistDeployKeys,
CanForcePush: form.EnablePush && form.EnableForcePush,
EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist,
ForcePushAllowlistDeployKeys: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistDeployKeys,
EnableMergeWhitelist: form.EnableMergeWhitelist,
EnableStatusCheck: form.EnableStatusCheck, EnableStatusCheck: form.EnableStatusCheck,
StatusCheckContexts: form.StatusCheckContexts, StatusCheckContexts: form.StatusCheckContexts,
EnableApprovalsWhitelist: form.EnableApprovalsWhitelist, EnableApprovalsWhitelist: form.EnableApprovalsWhitelist,
@ -626,6 +647,8 @@ func CreateBranchProtection(ctx *context.APIContext) {
err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{ err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{
UserIDs: whitelistUsers, UserIDs: whitelistUsers,
TeamIDs: whitelistTeams, TeamIDs: whitelistTeams,
ForcePushUserIDs: forcePushAllowlistUsers,
ForcePushTeamIDs: forcePushAllowlistTeams,
MergeUserIDs: mergeWhitelistUsers, MergeUserIDs: mergeWhitelistUsers,
MergeTeamIDs: mergeWhitelistTeams, MergeTeamIDs: mergeWhitelistTeams,
ApprovalsUserIDs: approvalsWhitelistUsers, ApprovalsUserIDs: approvalsWhitelistUsers,
@ -756,6 +779,27 @@ func EditBranchProtection(ctx *context.APIContext) {
} }
} }
if form.EnableForcePush != nil {
if !*form.EnableForcePush {
protectBranch.CanForcePush = false
protectBranch.EnableForcePushAllowlist = false
protectBranch.ForcePushAllowlistDeployKeys = false
} else {
protectBranch.CanForcePush = true
if form.EnableForcePushAllowlist != nil {
if !*form.EnableForcePushAllowlist {
protectBranch.EnableForcePushAllowlist = false
protectBranch.ForcePushAllowlistDeployKeys = false
} else {
protectBranch.EnableForcePushAllowlist = true
if form.ForcePushAllowlistDeployKeys != nil {
protectBranch.ForcePushAllowlistDeployKeys = *form.ForcePushAllowlistDeployKeys
}
}
}
}
}
if form.EnableMergeWhitelist != nil { if form.EnableMergeWhitelist != nil {
protectBranch.EnableMergeWhitelist = *form.EnableMergeWhitelist protectBranch.EnableMergeWhitelist = *form.EnableMergeWhitelist
} }
@ -808,7 +852,7 @@ func EditBranchProtection(ctx *context.APIContext) {
protectBranch.BlockOnOutdatedBranch = *form.BlockOnOutdatedBranch protectBranch.BlockOnOutdatedBranch = *form.BlockOnOutdatedBranch
} }
var whitelistUsers []int64 var whitelistUsers, forcePushAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers []int64
if form.PushWhitelistUsernames != nil { if form.PushWhitelistUsernames != nil {
whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false) whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false)
if err != nil { if err != nil {
@ -822,7 +866,19 @@ func EditBranchProtection(ctx *context.APIContext) {
} else { } else {
whitelistUsers = protectBranch.WhitelistUserIDs whitelistUsers = protectBranch.WhitelistUserIDs
} }
var mergeWhitelistUsers []int64 if form.ForcePushAllowlistDeployKeys != nil {
forcePushAllowlistUsers, err = user_model.GetUserIDsByNames(ctx, form.ForcePushAllowlistUsernames, false)
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err)
return
}
ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err)
return
}
} else {
forcePushAllowlistUsers = protectBranch.ForcePushAllowlistUserIDs
}
if form.MergeWhitelistUsernames != nil { if form.MergeWhitelistUsernames != nil {
mergeWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false) mergeWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false)
if err != nil { if err != nil {
@ -836,7 +892,6 @@ func EditBranchProtection(ctx *context.APIContext) {
} else { } else {
mergeWhitelistUsers = protectBranch.MergeWhitelistUserIDs mergeWhitelistUsers = protectBranch.MergeWhitelistUserIDs
} }
var approvalsWhitelistUsers []int64
if form.ApprovalsWhitelistUsernames != nil { if form.ApprovalsWhitelistUsernames != nil {
approvalsWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.ApprovalsWhitelistUsernames, false) approvalsWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.ApprovalsWhitelistUsernames, false)
if err != nil { if err != nil {
@ -851,7 +906,7 @@ func EditBranchProtection(ctx *context.APIContext) {
approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs
} }
var whitelistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
if repo.Owner.IsOrganization() { if repo.Owner.IsOrganization() {
if form.PushWhitelistTeams != nil { if form.PushWhitelistTeams != nil {
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false) whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
@ -866,6 +921,19 @@ func EditBranchProtection(ctx *context.APIContext) {
} else { } else {
whitelistTeams = protectBranch.WhitelistTeamIDs whitelistTeams = protectBranch.WhitelistTeamIDs
} }
if form.ForcePushAllowlistTeams != nil {
forcePushAllowlistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.ForcePushAllowlistTeams, false)
if err != nil {
if organization.IsErrTeamNotExist(err) {
ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err)
return
}
ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err)
return
}
} else {
forcePushAllowlistTeams = protectBranch.ForcePushAllowlistTeamIDs
}
if form.MergeWhitelistTeams != nil { if form.MergeWhitelistTeams != nil {
mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false) mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false)
if err != nil { if err != nil {
@ -897,6 +965,8 @@ func EditBranchProtection(ctx *context.APIContext) {
err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{ err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{
UserIDs: whitelistUsers, UserIDs: whitelistUsers,
TeamIDs: whitelistTeams, TeamIDs: whitelistTeams,
ForcePushUserIDs: forcePushAllowlistUsers,
ForcePushTeamIDs: forcePushAllowlistTeams,
MergeUserIDs: mergeWhitelistUsers, MergeUserIDs: mergeWhitelistUsers,
MergeTeamIDs: mergeWhitelistTeams, MergeTeamIDs: mergeWhitelistTeams,
ApprovalsUserIDs: approvalsWhitelistUsers, ApprovalsUserIDs: approvalsWhitelistUsers,

@ -183,6 +183,8 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
return return
} }
isForcePush := false
// 2. Disallow force pushes to protected branches // 2. Disallow force pushes to protected branches
if oldCommitID != objectFormat.EmptyObjectID().String() { if oldCommitID != objectFormat.EmptyObjectID().String() {
output, _, err := git.NewCommand(ctx, "rev-list", "--max-count=1").AddDynamicArguments(oldCommitID, "^"+newCommitID).RunStdString(&git.RunOpts{Dir: repo.RepoPath(), Env: ctx.env}) output, _, err := git.NewCommand(ctx, "rev-list", "--max-count=1").AddDynamicArguments(oldCommitID, "^"+newCommitID).RunStdString(&git.RunOpts{Dir: repo.RepoPath(), Env: ctx.env})
@ -193,11 +195,15 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
}) })
return return
} else if len(output) > 0 { } else if len(output) > 0 {
log.Warn("Forbidden: Branch: %s in %-v is protected from force push", branchName, repo) if protectBranch.CanForcePush {
ctx.JSON(http.StatusForbidden, private.Response{ isForcePush = true
UserMsg: fmt.Sprintf("branch %s is protected from force push", branchName), } else {
}) log.Warn("Forbidden: Branch: %s in %-v is protected from force push", branchName, repo)
return ctx.JSON(http.StatusForbidden, private.Response{
UserMsg: fmt.Sprintf("branch %s is protected from force push", branchName),
})
return
}
} }
} }
@ -244,10 +250,15 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
} }
} }
// 5. Check if the doer is allowed to push // 5. Check if the doer is allowed to push (and force-push if the incoming push is a force-push)
var canPush bool var canPush bool
if ctx.opts.DeployKeyID != 0 { if ctx.opts.DeployKeyID != 0 {
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys) // This flag is only ever true if protectBranch.CanForcePush is true
if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableForcePushAllowlist || protectBranch.ForcePushAllowlistDeployKeys)
} else {
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys)
}
} else { } else {
user, err := user_model.GetUserByID(ctx, ctx.opts.UserID) user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
if err != nil { if err != nil {
@ -257,7 +268,11 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
}) })
return return
} }
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, user) if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, user)
} else {
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, user)
}
} }
// 6. If we're not allowed to push directly // 6. If we're not allowed to push directly
@ -293,6 +308,13 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
} }
// Or we're simply not able to push to this protected branch // Or we're simply not able to push to this protected branch
if isForcePush {
log.Warn("Forbidden: User %d is not allowed to force-push to protected branch: %s in %-v", ctx.opts.UserID, branchName, repo)
ctx.JSON(http.StatusForbidden, private.Response{
UserMsg: fmt.Sprintf("Not allowed to force-push to protected branch %s", branchName),
})
return
}
log.Warn("Forbidden: User %d is not allowed to push to protected branch: %s in %-v", ctx.opts.UserID, branchName, repo) log.Warn("Forbidden: User %d is not allowed to push to protected branch: %s in %-v", ctx.opts.UserID, branchName, repo)
ctx.JSON(http.StatusForbidden, private.Response{ ctx.JSON(http.StatusForbidden, private.Response{
UserMsg: fmt.Sprintf("Not allowed to push to protected branch %s", branchName), UserMsg: fmt.Sprintf("Not allowed to push to protected branch %s", branchName),

@ -77,6 +77,7 @@ func SettingsProtectedBranch(c *context.Context) {
} }
c.Data["Users"] = users c.Data["Users"] = users
c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",") 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["merge_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistUserIDs), ",")
c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",") c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",")
c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n") c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n")
@ -91,6 +92,7 @@ func SettingsProtectedBranch(c *context.Context) {
} }
c.Data["Teams"] = teams c.Data["Teams"] = teams
c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",") 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["merge_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistTeamIDs), ",")
c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistTeamIDs), ",") c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistTeamIDs), ",")
} }
@ -149,7 +151,7 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
} }
} }
var whitelistUsers, whitelistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64 var whitelistUsers, whitelistTeams, forcePushAllowlistUsers, forcePushAllowlistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64
protectBranch.RuleName = f.RuleName protectBranch.RuleName = f.RuleName
if f.RequiredApprovals < 0 { if f.RequiredApprovals < 0 {
ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min")) ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min"))
@ -178,6 +180,27 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
protectBranch.WhitelistDeployKeys = false protectBranch.WhitelistDeployKeys = false
} }
switch f.EnableForcePush {
case "all":
protectBranch.CanForcePush = true
protectBranch.EnableForcePushAllowlist = false
protectBranch.ForcePushAllowlistDeployKeys = false
case "whitelist":
protectBranch.CanForcePush = true
protectBranch.EnableForcePushAllowlist = true
protectBranch.ForcePushAllowlistDeployKeys = f.ForcePushAllowlistDeployKeys
if strings.TrimSpace(f.ForcePushAllowlistUsers) != "" {
forcePushAllowlistUsers, _ = base.StringsToInt64s(strings.Split(f.ForcePushAllowlistUsers, ","))
}
if strings.TrimSpace(f.ForcePushAllowlistTeams) != "" {
forcePushAllowlistTeams, _ = base.StringsToInt64s(strings.Split(f.ForcePushAllowlistTeams, ","))
}
default:
protectBranch.CanForcePush = false
protectBranch.EnableForcePushAllowlist = false
protectBranch.ForcePushAllowlistDeployKeys = false
}
protectBranch.EnableMergeWhitelist = f.EnableMergeWhitelist protectBranch.EnableMergeWhitelist = f.EnableMergeWhitelist
if f.EnableMergeWhitelist { if f.EnableMergeWhitelist {
if strings.TrimSpace(f.MergeWhitelistUsers) != "" { if strings.TrimSpace(f.MergeWhitelistUsers) != "" {
@ -237,6 +260,8 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{ err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{
UserIDs: whitelistUsers, UserIDs: whitelistUsers,
TeamIDs: whitelistTeams, TeamIDs: whitelistTeams,
ForcePushUserIDs: forcePushAllowlistUsers,
ForcePushTeamIDs: forcePushAllowlistTeams,
MergeUserIDs: mergeWhitelistUsers, MergeUserIDs: mergeWhitelistUsers,
MergeTeamIDs: mergeWhitelistTeams, MergeTeamIDs: mergeWhitelistTeams,
ApprovalsUserIDs: approvalsWhitelistUsers, ApprovalsUserIDs: approvalsWhitelistUsers,

@ -136,6 +136,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo
} }
pushWhitelistUsernames := getWhitelistEntities(readers, bp.WhitelistUserIDs) pushWhitelistUsernames := getWhitelistEntities(readers, bp.WhitelistUserIDs)
forcePushAllowlistUsernames := getWhitelistEntities(readers, bp.ForcePushAllowlistUserIDs)
mergeWhitelistUsernames := getWhitelistEntities(readers, bp.MergeWhitelistUserIDs) mergeWhitelistUsernames := getWhitelistEntities(readers, bp.MergeWhitelistUserIDs)
approvalsWhitelistUsernames := getWhitelistEntities(readers, bp.ApprovalsWhitelistUserIDs) approvalsWhitelistUsernames := getWhitelistEntities(readers, bp.ApprovalsWhitelistUserIDs)
@ -145,6 +146,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo
} }
pushWhitelistTeams := getWhitelistEntities(teamReaders, bp.WhitelistTeamIDs) pushWhitelistTeams := getWhitelistEntities(teamReaders, bp.WhitelistTeamIDs)
forcePushAllowlistTeams := getWhitelistEntities(teamReaders, bp.ForcePushAllowlistTeamIDs)
mergeWhitelistTeams := getWhitelistEntities(teamReaders, bp.MergeWhitelistTeamIDs) mergeWhitelistTeams := getWhitelistEntities(teamReaders, bp.MergeWhitelistTeamIDs)
approvalsWhitelistTeams := getWhitelistEntities(teamReaders, bp.ApprovalsWhitelistTeamIDs) approvalsWhitelistTeams := getWhitelistEntities(teamReaders, bp.ApprovalsWhitelistTeamIDs)
@ -161,6 +163,11 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo
PushWhitelistUsernames: pushWhitelistUsernames, PushWhitelistUsernames: pushWhitelistUsernames,
PushWhitelistTeams: pushWhitelistTeams, PushWhitelistTeams: pushWhitelistTeams,
PushWhitelistDeployKeys: bp.WhitelistDeployKeys, PushWhitelistDeployKeys: bp.WhitelistDeployKeys,
EnableForcePush: bp.CanForcePush,
EnableForcePushAllowlist: bp.EnableForcePushAllowlist,
ForcePushAllowlistUsernames: forcePushAllowlistUsernames,
ForcePushAllowlistTeams: forcePushAllowlistTeams,
ForcePushAllowlistDeployKeys: bp.ForcePushAllowlistDeployKeys,
EnableMergeWhitelist: bp.EnableMergeWhitelist, EnableMergeWhitelist: bp.EnableMergeWhitelist,
MergeWhitelistUsernames: mergeWhitelistUsernames, MergeWhitelistUsernames: mergeWhitelistUsernames,
MergeWhitelistTeams: mergeWhitelistTeams, MergeWhitelistTeams: mergeWhitelistTeams,

@ -195,6 +195,10 @@ type ProtectBranchForm struct {
WhitelistUsers string WhitelistUsers string
WhitelistTeams string WhitelistTeams string
WhitelistDeployKeys bool WhitelistDeployKeys bool
EnableForcePush string
ForcePushAllowlistUsers string
ForcePushAllowlistTeams string
ForcePushAllowlistDeployKeys bool
EnableMergeWhitelist bool EnableMergeWhitelist bool
MergeWhitelistUsers string MergeWhitelistUsers string
MergeWhitelistTeams string MergeWhitelistTeams string

@ -117,27 +117,25 @@ func IsUserAllowedToUpdate(ctx context.Context, pull *issues_model.PullRequest,
return false, false, err return false, false, err
} }
// can't do rebase on protected branch because need force push if err := pr.LoadBaseRepo(ctx); err != nil {
if pb == nil { return false, false, err
if err := pr.LoadBaseRepo(ctx); err != nil { }
return false, false, err prUnit, err := pr.BaseRepo.GetUnit(ctx, unit.TypePullRequests)
} if err != nil {
prUnit, err := pr.BaseRepo.GetUnit(ctx, unit.TypePullRequests) if repo_model.IsErrUnitTypeNotExist(err) {
if err != nil { return false, false, nil
if repo_model.IsErrUnitTypeNotExist(err) {
return false, false, nil
}
log.Error("pr.BaseRepo.GetUnit(unit.TypePullRequests): %v", err)
return false, false, err
} }
rebaseAllowed = prUnit.PullRequestsConfig().AllowRebaseUpdate log.Error("pr.BaseRepo.GetUnit(unit.TypePullRequests): %v", err)
return false, false, err
} }
// Update function need push permission rebaseAllowed = prUnit.PullRequestsConfig().AllowRebaseUpdate
// If branch protected, disable rebase unless user is whitelisted to force push (which extends regular push)
if pb != nil { if pb != nil {
pb.Repo = pull.BaseRepo pb.Repo = pull.BaseRepo
if !pb.CanUserPush(ctx, user) { if !pb.CanUserForcePush(ctx, user) {
return false, false, nil rebaseAllowed = false
} }
} }

@ -94,6 +94,69 @@
<p class="help">{{ctx.Locale.Tr "repo.settings.require_signed_commits_desc"}}</p> <p class="help">{{ctx.Locale.Tr "repo.settings.require_signed_commits_desc"}}</p>
</div> </div>
</div> </div>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.event_force_push"}}</h5>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="enable_force_push" value="none" class="toggle-target-disabled" data-target="#force_push_allowlist_box" {{if not .Rule.CanForcePush}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.protect_disable_force_push"}}</label>
<p class="help">{{ctx.Locale.Tr "repo.settings.protect_disable_force_push_desc"}}</p>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="enable_force_push" value="all" class="toggle-target-disabled" data-target="#force_push_allowlist_box" {{if and (.Rule.CanForcePush) (not .Rule.EnableForcePushAllowlist)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.protect_enable_force_push_all"}}</label>
<p class="help">{{ctx.Locale.Tr "repo.settings.protect_enable_force_push_all_desc"}}</p>
</div>
</div>
<div class="grouped fields">
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="enable_force_push" value="whitelist" class="toggle-target-enabled" data-target="#force_push_allowlist_box" {{if and (.Rule.CanForcePush) (.Rule.EnableForcePushAllowlist)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.protect_enable_force_push_allowlist"}}</label>
<p class="help">{{ctx.Locale.Tr "repo.settings.protect_enable_force_push_allowlist_desc"}}</p>
</div>
</div>
<div id="force_push_allowlist_box" class="grouped fields {{if not .Rule.EnableForcePushAllowlist}}disabled{{end}}">
<div class="checkbox-sub-item field">
<label>{{ctx.Locale.Tr "repo.settings.protect_force_push_allowlist_users"}}</label>
<div class="ui multiple search selection dropdown">
<input type="hidden" name="force_push_allowlist_users" value="{{.force_push_allowlist_users}}">
<div class="default text">{{ctx.Locale.Tr "search.user_kind"}}</div>
<div class="menu">
{{range .Users}}
<div class="item" data-value="{{.ID}}">
{{ctx.AvatarUtils.Avatar . 28 "mini"}}{{template "repo/search_name" .}}
</div>
{{end}}
</div>
</div>
</div>
{{if .Owner.IsOrganization}}
<div class="checkbox-sub-item field">
<label>{{ctx.Locale.Tr "repo.settings.protect_force_push_allowlist_teams"}}</label>
<div class="ui multiple search selection dropdown">
<input type="hidden" name="force_push_allowlist_teams" value="{{.force_push_allowlist_teams}}">
<div class="default text">{{ctx.Locale.Tr "search.team_kind"}}</div>
<div class="menu">
{{range .Teams}}
<div class="item" data-value="{{.ID}}">
{{svg "octicon-people"}}
{{.Name}}
</div>
{{end}}
</div>
</div>
</div>
{{end}}
<div class="checkbox-sub-item field">
<div class="ui checkbox">
<input type="checkbox" name="force_push_allowlist_deploy_keys" {{if .Rule.ForcePushAllowlistDeployKeys}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.protect_force_push_allowlist_deploy_keys"}}</label>
</div>
</div>
</div>
</div>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.event_pull_request_approvals"}}</h5> <h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.event_pull_request_approvals"}}</h5>
<div class="field"> <div class="field">
<label>{{ctx.Locale.Tr "repo.settings.protect_required_approvals"}}</label> <label>{{ctx.Locale.Tr "repo.settings.protect_required_approvals"}}</label>

@ -18714,6 +18714,14 @@
"type": "boolean", "type": "boolean",
"x-go-name": "EnableApprovalsWhitelist" "x-go-name": "EnableApprovalsWhitelist"
}, },
"enable_force_push": {
"type": "boolean",
"x-go-name": "EnableForcePush"
},
"enable_force_push_allowlist": {
"type": "boolean",
"x-go-name": "EnableForcePushAllowlist"
},
"enable_merge_whitelist": { "enable_merge_whitelist": {
"type": "boolean", "type": "boolean",
"x-go-name": "EnableMergeWhitelist" "x-go-name": "EnableMergeWhitelist"
@ -18730,6 +18738,24 @@
"type": "boolean", "type": "boolean",
"x-go-name": "EnableStatusCheck" "x-go-name": "EnableStatusCheck"
}, },
"force_push_allowlist_deploy_keys": {
"type": "boolean",
"x-go-name": "ForcePushAllowlistDeployKeys"
},
"force_push_allowlist_teams": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "ForcePushAllowlistTeams"
},
"force_push_allowlist_usernames": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "ForcePushAllowlistUsernames"
},
"ignore_stale_approvals": { "ignore_stale_approvals": {
"type": "boolean", "type": "boolean",
"x-go-name": "IgnoreStaleApprovals" "x-go-name": "IgnoreStaleApprovals"
@ -19378,6 +19404,14 @@
"type": "boolean", "type": "boolean",
"x-go-name": "EnableApprovalsWhitelist" "x-go-name": "EnableApprovalsWhitelist"
}, },
"enable_force_push": {
"type": "boolean",
"x-go-name": "EnableForcePush"
},
"enable_force_push_allowlist": {
"type": "boolean",
"x-go-name": "EnableForcePushAllowlist"
},
"enable_merge_whitelist": { "enable_merge_whitelist": {
"type": "boolean", "type": "boolean",
"x-go-name": "EnableMergeWhitelist" "x-go-name": "EnableMergeWhitelist"
@ -19394,6 +19428,24 @@
"type": "boolean", "type": "boolean",
"x-go-name": "EnableStatusCheck" "x-go-name": "EnableStatusCheck"
}, },
"force_push_allowlist_deploy_keys": {
"type": "boolean",
"x-go-name": "ForcePushAllowlistDeployKeys"
},
"force_push_allowlist_teams": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "ForcePushAllowlistTeams"
},
"force_push_allowlist_usernames": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "ForcePushAllowlistUsernames"
},
"ignore_stale_approvals": { "ignore_stale_approvals": {
"type": "boolean", "type": "boolean",
"x-go-name": "IgnoreStaleApprovals" "x-go-name": "IgnoreStaleApprovals"
@ -20562,6 +20614,14 @@
"type": "boolean", "type": "boolean",
"x-go-name": "EnableApprovalsWhitelist" "x-go-name": "EnableApprovalsWhitelist"
}, },
"enable_force_push": {
"type": "boolean",
"x-go-name": "EnableForcePush"
},
"enable_force_push_allowlist": {
"type": "boolean",
"x-go-name": "EnableForcePushAllowlist"
},
"enable_merge_whitelist": { "enable_merge_whitelist": {
"type": "boolean", "type": "boolean",
"x-go-name": "EnableMergeWhitelist" "x-go-name": "EnableMergeWhitelist"
@ -20578,6 +20638,24 @@
"type": "boolean", "type": "boolean",
"x-go-name": "EnableStatusCheck" "x-go-name": "EnableStatusCheck"
}, },
"force_push_allowlist_deploy_keys": {
"type": "boolean",
"x-go-name": "ForcePushAllowlistDeployKeys"
},
"force_push_allowlist_teams": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "ForcePushAllowlistTeams"
},
"force_push_allowlist_usernames": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "ForcePushAllowlistUsernames"
},
"ignore_stale_approvals": { "ignore_stale_approvals": {
"type": "boolean", "type": "boolean",
"x-go-name": "IgnoreStaleApprovals" "x-go-name": "IgnoreStaleApprovals"

@ -360,12 +360,62 @@ func doBranchProtectPRMerge(baseCtx *APITestContext, dstPath string) func(t *tes
t.Run("PushProtectedBranch", doGitPushTestRepository(dstPath, "origin", "protected")) t.Run("PushProtectedBranch", doGitPushTestRepository(dstPath, "origin", "protected"))
ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository) ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository)
t.Run("ProtectProtectedBranchNoWhitelist", doProtectBranch(ctx, "protected", "", ""))
t.Run("GenerateCommit", func(t *testing.T) { // Protect branch without any whitelisting
t.Run("ProtectBranchNoWhitelist", func(t *testing.T) {
doProtectBranch(ctx, "protected", "", "", "")
})
// Try to push without permissions, which should fail
t.Run("TryPushWithoutPermissions", func(t *testing.T) {
_, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-")
assert.NoError(t, err)
doGitPushTestRepositoryFail(dstPath, "origin", "protected")
})
// Set up permissions for normal push but not force push
t.Run("SetupNormalPushPermissions", func(t *testing.T) {
doProtectBranch(ctx, "protected", baseCtx.Username, "", "")
})
// Normal push should work
t.Run("NormalPushWithPermissions", func(t *testing.T) {
_, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-") _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-")
assert.NoError(t, err) assert.NoError(t, err)
doGitPushTestRepository(dstPath, "origin", "protected")
})
// Try to force push without force push permissions, which should fail
t.Run("ForcePushWithoutForcePermissions", func(t *testing.T) {
t.Run("CreateDivergentHistory", func(t *testing.T) {
git.NewCommand(git.DefaultContext, "reset", "--hard", "HEAD~1").Run(&git.RunOpts{Dir: dstPath})
_, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-new")
assert.NoError(t, err)
})
doGitPushTestRepositoryFail(dstPath, "-f", "origin", "protected")
})
// Set up permissions for force push but not normal push
t.Run("SetupForcePushPermissions", func(t *testing.T) {
doProtectBranch(ctx, "protected", "", baseCtx.Username, "")
}) })
t.Run("FailToPushToProtectedBranch", doGitPushTestRepositoryFail(dstPath, "origin", "protected"))
// Try to force push without normal push permissions, which should fail
t.Run("ForcePushWithoutNormalPermissions", func(t *testing.T) {
doGitPushTestRepositoryFail(dstPath, "-f", "origin", "protected")
})
// Set up permissions for normal and force push (both are required to force push)
t.Run("SetupNormalAndForcePushPermissions", func(t *testing.T) {
doProtectBranch(ctx, "protected", baseCtx.Username, baseCtx.Username, "")
})
// Force push should now work
t.Run("ForcePushWithPermissions", func(t *testing.T) {
doGitPushTestRepository(dstPath, "-f", "origin", "protected")
})
t.Run("ProtectProtectedBranchNoWhitelist", doProtectBranch(ctx, "protected", "", "", ""))
t.Run("PushToUnprotectedBranch", doGitPushTestRepository(dstPath, "origin", "protected:unprotected")) t.Run("PushToUnprotectedBranch", doGitPushTestRepository(dstPath, "origin", "protected:unprotected"))
var pr api.PullRequest var pr api.PullRequest
var err error var err error
@ -387,14 +437,14 @@ func doBranchProtectPRMerge(baseCtx *APITestContext, dstPath string) func(t *tes
t.Run("MergePR", doAPIMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) t.Run("MergePR", doAPIMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
t.Run("PullProtected", doGitPull(dstPath, "origin", "protected")) t.Run("PullProtected", doGitPull(dstPath, "origin", "protected"))
t.Run("ProtectProtectedBranchUnprotectedFilePaths", doProtectBranch(ctx, "protected", "", "unprotected-file-*")) t.Run("ProtectProtectedBranchUnprotectedFilePaths", doProtectBranch(ctx, "protected", "", "", "unprotected-file-*"))
t.Run("GenerateCommit", func(t *testing.T) { t.Run("GenerateCommit", func(t *testing.T) {
_, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "unprotected-file-") _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "unprotected-file-")
assert.NoError(t, err) assert.NoError(t, err)
}) })
t.Run("PushUnprotectedFilesToProtectedBranch", doGitPushTestRepository(dstPath, "origin", "protected")) t.Run("PushUnprotectedFilesToProtectedBranch", doGitPushTestRepository(dstPath, "origin", "protected"))
t.Run("ProtectProtectedBranchWhitelist", doProtectBranch(ctx, "protected", baseCtx.Username, "")) t.Run("ProtectProtectedBranchWhitelist", doProtectBranch(ctx, "protected", baseCtx.Username, "", ""))
t.Run("CheckoutMaster", doGitCheckoutBranch(dstPath, "master")) t.Run("CheckoutMaster", doGitCheckoutBranch(dstPath, "master"))
t.Run("CreateBranchForced", doGitCreateBranch(dstPath, "toforce")) t.Run("CreateBranchForced", doGitCreateBranch(dstPath, "toforce"))
@ -409,33 +459,37 @@ func doBranchProtectPRMerge(baseCtx *APITestContext, dstPath string) func(t *tes
} }
} }
func doProtectBranch(ctx APITestContext, branch, userToWhitelist, unprotectedFilePatterns string) func(t *testing.T) { func doProtectBranch(ctx APITestContext, branch, userToWhitelistPush, userToWhitelistForcePush, unprotectedFilePatterns string) func(t *testing.T) {
// We are going to just use the owner to set the protection. // We are going to just use the owner to set the protection.
return func(t *testing.T) { return func(t *testing.T) {
csrf := GetCSRF(t, ctx.Session, fmt.Sprintf("/%s/%s/settings/branches", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame))) csrf := GetCSRF(t, ctx.Session, fmt.Sprintf("/%s/%s/settings/branches", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)))
if userToWhitelist == "" { formData := map[string]string{
// Change branch to protected "_csrf": csrf,
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), map[string]string{ "rule_name": branch,
"_csrf": csrf, "unprotected_file_patterns": unprotectedFilePatterns,
"rule_name": branch, }
"unprotected_file_patterns": unprotectedFilePatterns,
}) if userToWhitelistPush != "" {
ctx.Session.MakeRequest(t, req, http.StatusSeeOther) user, err := user_model.GetUserByName(db.DefaultContext, userToWhitelistPush)
} else {
user, err := user_model.GetUserByName(db.DefaultContext, userToWhitelist)
assert.NoError(t, err) assert.NoError(t, err)
// Change branch to protected formData["whitelist_users"] = strconv.FormatInt(user.ID, 10)
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), map[string]string{ formData["enable_push"] = "whitelist"
"_csrf": csrf, formData["enable_whitelist"] = "on"
"rule_name": branch,
"enable_push": "whitelist",
"enable_whitelist": "on",
"whitelist_users": strconv.FormatInt(user.ID, 10),
"unprotected_file_patterns": unprotectedFilePatterns,
})
ctx.Session.MakeRequest(t, req, http.StatusSeeOther)
} }
if userToWhitelistForcePush != "" {
user, err := user_model.GetUserByName(db.DefaultContext, userToWhitelistForcePush)
assert.NoError(t, err)
formData["force_push_allowlist_users"] = strconv.FormatInt(user.ID, 10)
formData["enable_force_push"] = "whitelist"
formData["enable_force_push_allowlist"] = "on"
}
// Send the request to update branch protection settings
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), formData)
ctx.Session.MakeRequest(t, req, http.StatusSeeOther)
// Check if master branch has been locked successfully // Check if master branch has been locked successfully
flashCookie := ctx.Session.GetCookie(gitea_context.CookieNameFlash) flashCookie := ctx.Session.GetCookie(gitea_context.CookieNameFlash)
assert.NotNil(t, flashCookie) assert.NotNil(t, flashCookie)

Loading…
Cancel
Save