Files
MokoGitea-Fork/models/git/org_protected_branch.go
jmiller cac06c2ac7
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 14s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 1m3s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m42s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
feat: org branch protection per-user allowlists + Actions-bot toggle (#727)
Extend org-level branch protection to support per-user allowlists (resolved
from username OR email) and an "allow Actions bot" toggle, alongside the
existing team allowlists, for all five categories (push, merge, force-push,
delete, approvals).

- models/git/org_protected_branch.go: add WhitelistUserIDs, MergeWhitelistUserIDs,
  ForcePushAllowlistUserIDs, DeleteAllowlistUserIDs, ApprovalsWhitelistUserIDs
  ([]int64) plus WhitelistActionsUser, MergeWhitelistActionsUser,
  ForcePushAllowlistActionsUser, DeleteAllowlistActionsUser (bool); copy all 9
  into ProtectedBranch in ToProtectedBranch().
- models/migrations/v1_27/v368.go: migration 367 adds the 9 columns.
- modules/structs/org_branch.go: add *Usernames []string and *ActionsUser bool
  to Create/Edit options and the response, matching repo-level json names.
- routers/api/v1/org/branch_protection.go: resolveUserIDs (username then email,
  dedupe, 422 on unknown); wire into Create + Edit and toAPIOrgBranchProtection.
- models/git/protected_branch_merge.go: add mergeAllowFlag and merge the four
  Actions-user flags most-restrictively (org can now express them); deploy-key
  flags stay repo-only pass-through.
- models/git/protected_branch_merge_test.go: mergeAllowFlag truth table, user-ID
  intersection, deploy-key pass-through.
- regenerate swagger v1 + openapi3 specs.

Claude-Session: https://claude.ai/code/session_01Wsno14cxE49MstXFs9G5KT
2026-07-05 17:48:47 -05:00

223 lines
9.6 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"fmt"
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/glob"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"xorm.io/builder"
)
// OrgProtectedBranch represents an org-level branch protection ruleset.
// These rules cascade to all repositories within the organization.
type OrgProtectedBranch struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"UNIQUE(s) index"`
RuleName string `xorm:"UNIQUE(s)"` // branch glob pattern
Priority int64 `xorm:"NOT NULL DEFAULT 0"`
globRule glob.Glob `xorm:"-"`
isPlainName bool `xorm:"-"`
CanPush bool `xorm:"NOT NULL DEFAULT false"`
EnableWhitelist bool `xorm:"NOT NULL DEFAULT false"`
WhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
WhitelistUserIDs []int64 `xorm:"JSON TEXT"`
WhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
EnableMergeWhitelist bool `xorm:"NOT NULL DEFAULT false"`
MergeWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
MergeWhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
CanForcePush bool `xorm:"NOT NULL DEFAULT false"`
EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"`
ForcePushAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
ForcePushAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
ForcePushAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
CanDelete bool `xorm:"NOT NULL DEFAULT false"`
EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"`
DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
DeleteAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
DeleteAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"`
StatusCheckContexts []string `xorm:"JSON TEXT"`
RequiredApprovals int64 `xorm:"NOT NULL DEFAULT 0"`
EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"`
ApprovalsWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
ApprovalsWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
BlockOnRejectedReviews bool `xorm:"NOT NULL DEFAULT false"`
BlockOnOfficialReviewRequests bool `xorm:"NOT NULL DEFAULT false"`
BlockOnOutdatedBranch bool `xorm:"NOT NULL DEFAULT false"`
DismissStaleApprovals bool `xorm:"NOT NULL DEFAULT false"`
IgnoreStaleApprovals bool `xorm:"NOT NULL DEFAULT false"`
RequireSignedCommits bool `xorm:"NOT NULL DEFAULT false"`
ProtectedFilePatterns string `xorm:"TEXT"`
UnprotectedFilePatterns string `xorm:"TEXT"`
BlockAdminMergeOverride bool `xorm:"NOT NULL DEFAULT false"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
func init() {
db.RegisterModel(new(OrgProtectedBranch))
}
func (o *OrgProtectedBranch) loadGlob() {
if o.isPlainName || o.globRule != nil {
return
}
if !IsRuleNameSpecial(o.RuleName) {
o.isPlainName = true
return
}
var err error
o.globRule, err = glob.Compile(o.RuleName, '/')
if err != nil {
log.Warn("Invalid glob rule for OrgProtectedBranch[%d]: %s %v", o.ID, o.RuleName, err)
o.globRule = glob.MustCompile(glob.QuoteMeta(o.RuleName), '/')
}
}
// Match tests if branchName matches this org rule
func (o *OrgProtectedBranch) Match(branchName string) bool {
o.loadGlob()
if o.isPlainName {
return strings.EqualFold(o.RuleName, branchName)
}
return o.globRule.Match(branchName)
}
// ToProtectedBranch converts an org-level rule to a ProtectedBranch for use
// in the standard enforcement path. Both team-scoped and user-scoped allowlists
// (plus the Actions-bot toggles) are carried across; deploy-key allowances remain
// repo-only because an org rule cannot express them.
func (o *OrgProtectedBranch) ToProtectedBranch() *ProtectedBranch {
return &ProtectedBranch{
ID: o.ID,
RuleName: o.RuleName,
Priority: o.Priority,
CanPush: o.CanPush,
EnableWhitelist: o.EnableWhitelist,
WhitelistTeamIDs: o.WhitelistTeamIDs,
WhitelistUserIDs: o.WhitelistUserIDs,
WhitelistActionsUser: o.WhitelistActionsUser,
EnableMergeWhitelist: o.EnableMergeWhitelist,
MergeWhitelistTeamIDs: o.MergeWhitelistTeamIDs,
MergeWhitelistUserIDs: o.MergeWhitelistUserIDs,
MergeWhitelistActionsUser: o.MergeWhitelistActionsUser,
CanForcePush: o.CanForcePush,
EnableForcePushAllowlist: o.EnableForcePushAllowlist,
ForcePushAllowlistTeamIDs: o.ForcePushAllowlistTeamIDs,
ForcePushAllowlistUserIDs: o.ForcePushAllowlistUserIDs,
ForcePushAllowlistActionsUser: o.ForcePushAllowlistActionsUser,
CanDelete: o.CanDelete,
EnableDeleteAllowlist: o.EnableDeleteAllowlist,
DeleteAllowlistTeamIDs: o.DeleteAllowlistTeamIDs,
DeleteAllowlistUserIDs: o.DeleteAllowlistUserIDs,
DeleteAllowlistActionsUser: o.DeleteAllowlistActionsUser,
EnableStatusCheck: o.EnableStatusCheck,
StatusCheckContexts: o.StatusCheckContexts,
RequiredApprovals: o.RequiredApprovals,
EnableApprovalsWhitelist: o.EnableApprovalsWhitelist,
ApprovalsWhitelistTeamIDs: o.ApprovalsWhitelistTeamIDs,
ApprovalsWhitelistUserIDs: o.ApprovalsWhitelistUserIDs,
BlockOnRejectedReviews: o.BlockOnRejectedReviews,
BlockOnOfficialReviewRequests: o.BlockOnOfficialReviewRequests,
BlockOnOutdatedBranch: o.BlockOnOutdatedBranch,
DismissStaleApprovals: o.DismissStaleApprovals,
IgnoreStaleApprovals: o.IgnoreStaleApprovals,
RequireSignedCommits: o.RequireSignedCommits,
ProtectedFilePatterns: o.ProtectedFilePatterns,
UnprotectedFilePatterns: o.UnprotectedFilePatterns,
BlockAdminMergeOverride: o.BlockAdminMergeOverride,
CreatedUnix: o.CreatedUnix,
UpdatedUnix: o.UpdatedUnix,
}
}
// GetOrgProtectedBranchByName retrieves a single org rule by org ID and rule name
func GetOrgProtectedBranchByName(ctx context.Context, orgID int64, ruleName string) (*OrgProtectedBranch, error) {
rule, exist, err := db.Get[OrgProtectedBranch](ctx, builder.Eq{"org_id": orgID, "rule_name": ruleName})
if err != nil {
return nil, err
} else if !exist {
return nil, nil //nolint:nilnil
}
return rule, nil
}
// GetOrgProtectedBranchByID retrieves a single org rule by ID
func GetOrgProtectedBranchByID(ctx context.Context, orgID, id int64) (*OrgProtectedBranch, error) {
rule, exist, err := db.Get[OrgProtectedBranch](ctx, builder.Eq{"org_id": orgID, "id": id})
if err != nil {
return nil, err
} else if !exist {
return nil, nil //nolint:nilnil
}
return rule, nil
}
// FindOrgProtectedBranchRules loads all org-level branch protection rules
func FindOrgProtectedBranchRules(ctx context.Context, orgID int64) ([]*OrgProtectedBranch, error) {
var rules []*OrgProtectedBranch
err := db.GetEngine(ctx).Where("org_id = ?", orgID).Asc("priority", "created_unix").Find(&rules)
return rules, err
}
// CreateOrgProtectedBranch creates a new org-level branch protection rule
func CreateOrgProtectedBranch(ctx context.Context, rule *OrgProtectedBranch) error {
if rule.Priority == 0 {
var maxPrio int64
if _, err := db.GetEngine(ctx).SQL(`SELECT MAX(priority) FROM org_protected_branch WHERE org_id = ?`, rule.OrgID).
Get(&maxPrio); err != nil {
return err
}
rule.Priority = maxPrio + 1
}
_, err := db.GetEngine(ctx).Insert(rule)
if err != nil {
return fmt.Errorf("Insert OrgProtectedBranch: %v", err)
}
return nil
}
// UpdateOrgProtectedBranch updates an existing org-level branch protection rule
func UpdateOrgProtectedBranch(ctx context.Context, rule *OrgProtectedBranch) error {
_, err := db.GetEngine(ctx).ID(rule.ID).AllCols().Update(rule)
if err != nil {
return fmt.Errorf("Update OrgProtectedBranch: %v", err)
}
return nil
}
// DeleteOrgProtectedBranch deletes an org-level branch protection rule
func DeleteOrgProtectedBranch(ctx context.Context, orgID, id int64) error {
affected, err := db.GetEngine(ctx).Where("org_id = ? AND id = ?", orgID, id).Delete(new(OrgProtectedBranch))
if err != nil {
return err
}
if affected == 0 {
return fmt.Errorf("org branch protection rule ID(%d) not found", id)
}
return nil
}
// FindOrgBranchRuleForBranch finds the first matching org rule for a given branch name
func FindOrgBranchRuleForBranch(ctx context.Context, orgID int64, branchName string) (*OrgProtectedBranch, error) {
rules, err := FindOrgProtectedBranchRules(ctx, orgID)
if err != nil {
return nil, err
}
for _, rule := range rules {
if rule.Match(branchName) {
return rule, nil
}
}
return nil, nil //nolint:nilnil
}