3aac1b456c
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
PR RC Release / Build RC Release (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Successful in 15s
Generic: Project CI / Lint & Validate (pull_request) Successful in 23s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m13s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
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
Adds a single per-org push policy that cascades to every repo of the org and is
enforced in the pre-receive hook:
- Branch/tag name conventions (glob) — a pushed ref name must match. Fail-closed.
- Mandatory secret-scanning block-on-push — org can force secret blocking that a
repo cannot disable (overrides the per-repo scanner config in the orchestrator).
- Max pushed-file size — rejects a tip tree containing a blob over the limit.
- Blocked file-path patterns — rejects pushes changing matching paths (reuses
pull_service.CheckFileProtection).
The two content checks (blocked paths, max size) FAIL OPEN on any error so a
policy/parsing bug can never wedge all pushes; naming is fail-closed.
- models/git/org_push_policy.go: OrgPushPolicy model + CRUD + matchers +
GetOrgPushPolicyForRepo. Migration 364.
- API: GET/PATCH/DELETE /orgs/{org}/push_policy (routers/api/v1/org/push_policy.go,
DTOs in modules/structs/org_push_policy.go, wired in api.go).
- Enforcement: routers/private/hook_pre_receive.go (branch: naming + blocked paths
+ max size; tag: naming) and services/security/orchestrator.go (secret mandate).
Deferred: a repo-facing read-only view of the org push policy (it is an org-wide
config, not per-repo overlay rules; readable via the API for now).
Stacked on #729/#728 for migration ordering (this = 364). Swagger annotations
omitted (can't regenerate without the toolchain).
Note: no Go toolchain available locally, so not compiled/gofmt'd/tested here.
Hand-verified: gofmt (tabs, no blank-in-block), escape sequences in the ls-tree
parser, imports used, migration contiguous (364), fail-open on content checks.
Claude-Session: https://claude.ai/code/session_01Wsno14cxE49MstXFs9G5KT
131 lines
4.4 KiB
Go
131 lines
4.4 KiB
Go
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package git
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
|
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/glob"
|
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
|
|
|
"xorm.io/builder"
|
|
)
|
|
|
|
// OrgPushPolicy is a single org-wide policy enforced in the pre-receive hook on
|
|
// every repository of the organization. Unlike the branch/tag rulesets there is at
|
|
// most one policy per org. Empty pattern / zero fields mean "no constraint". See #727.
|
|
type OrgPushPolicy struct {
|
|
ID int64 `xorm:"pk autoincr"`
|
|
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
|
BranchNamePattern string `xorm:"TEXT"`
|
|
TagNamePattern string `xorm:"TEXT"`
|
|
RequireSecretBlock bool `xorm:"NOT NULL DEFAULT false"`
|
|
MaxFileSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
|
BlockedFilePatterns string `xorm:"TEXT"`
|
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
|
}
|
|
|
|
func init() {
|
|
db.RegisterModel(new(OrgPushPolicy))
|
|
}
|
|
|
|
// nameMatchesPattern reports whether name satisfies a glob pattern. An empty pattern
|
|
// imposes no constraint; an invalid pattern fails open (no constraint) so a
|
|
// misconfigured policy never blocks all pushes.
|
|
func nameMatchesPattern(pattern, name string) bool {
|
|
pattern = strings.TrimSpace(pattern)
|
|
if pattern == "" {
|
|
return true
|
|
}
|
|
g, err := glob.Compile(pattern, '/')
|
|
if err != nil {
|
|
log.Warn("Invalid org push policy name pattern %q: %v", pattern, err)
|
|
return true
|
|
}
|
|
return g.Match(name)
|
|
}
|
|
|
|
// BranchNameAllowed reports whether a branch name satisfies the naming policy.
|
|
func (p *OrgPushPolicy) BranchNameAllowed(name string) bool {
|
|
return nameMatchesPattern(p.BranchNamePattern, name)
|
|
}
|
|
|
|
// TagNameAllowed reports whether a tag name satisfies the naming policy.
|
|
func (p *OrgPushPolicy) TagNameAllowed(name string) bool {
|
|
return nameMatchesPattern(p.TagNamePattern, name)
|
|
}
|
|
|
|
// BlockedFileGlobs parses the ';'-separated blocked file pattern list.
|
|
func (p *OrgPushPolicy) BlockedFileGlobs() []glob.Glob {
|
|
var out []glob.Glob
|
|
for _, expr := range strings.Split(p.BlockedFilePatterns, ";") {
|
|
expr = strings.TrimSpace(strings.ToLower(expr))
|
|
if expr == "" {
|
|
continue
|
|
}
|
|
if g, err := glob.Compile(expr, '.', '/'); err == nil {
|
|
out = append(out, g)
|
|
} else {
|
|
log.Warn("Invalid org push policy blocked file pattern %q: %v", expr, err)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetOrgPushPolicy returns the org's push policy, or nil if none is configured.
|
|
func GetOrgPushPolicy(ctx context.Context, orgID int64) (*OrgPushPolicy, error) {
|
|
policy, exist, err := db.Get[OrgPushPolicy](ctx, builder.Eq{"org_id": orgID})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if !exist {
|
|
return nil, nil //nolint:nilnil
|
|
}
|
|
return policy, nil
|
|
}
|
|
|
|
// GetOrgPushPolicyForRepo returns the push policy of the repo's owning organization,
|
|
// or nil if the owner is not an organization or has no policy.
|
|
func GetOrgPushPolicyForRepo(ctx context.Context, repo *repo_model.Repository) (*OrgPushPolicy, error) {
|
|
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !owner.IsOrganization() {
|
|
return nil, nil //nolint:nilnil
|
|
}
|
|
return GetOrgPushPolicy(ctx, owner.ID)
|
|
}
|
|
|
|
// UpsertOrgPushPolicy creates or updates the single push policy for an org.
|
|
func UpsertOrgPushPolicy(ctx context.Context, policy *OrgPushPolicy) error {
|
|
existing, err := GetOrgPushPolicy(ctx, policy.OrgID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing == nil {
|
|
if _, err := db.GetEngine(ctx).Insert(policy); err != nil {
|
|
return fmt.Errorf("Insert OrgPushPolicy: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
policy.ID = existing.ID
|
|
if _, err := db.GetEngine(ctx).ID(existing.ID).AllCols().Update(policy); err != nil {
|
|
return fmt.Errorf("Update OrgPushPolicy: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteOrgPushPolicy removes an org's push policy.
|
|
func DeleteOrgPushPolicy(ctx context.Context, orgID int64) error {
|
|
_, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Delete(new(OrgPushPolicy))
|
|
return err
|
|
}
|