Org-level branch protection is stored but never enforced (no enforcement/materialization) #727

Closed
opened 2026-07-04 23:41:27 +00:00 by jmiller · 3 comments
Owner

Summary

The org-level branch protection feature is half-implemented: it has a DB model, migration, and full CRUD API, but nothing consumes the rules, so creating an org-level rule has zero effect on pushes or merges.

What exists

  • Model: models/git/org_protected_branch.go (OrgProtectedBranch, team-based whitelists)
  • Migration: models/migrations/v1_27/v332.go
  • API: /orgs/{org}/branch_protections — list/create/get/edit/delete (routers/api/v1/org/branch_protection.go, routes in routers/api/v1/api.go under /orgs/{org})
  • Helpers (unused): OrgProtectedBranch.Match() (:75), OrgProtectedBranch.ToProtectedBranch() (:86), FindOrgBranchRuleForBranch() (:186)

The gap (evidence)

grep -rl OrgProtectedBranch --include=*.go returns only 4 files — the model, the migration (×2), and the API handler. Nothing else references it.

Meanwhile, all actual enforcement reads per-repo rules only:

  • Push pre-receive hook: routers/private/hook_pre_receive.go:194GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)
  • Merge/PR checks: services/pull/merge.go:557,575, check.go:199,240, patch.go:430, commit_status.go:70,129 → all GetFirstMatchProtectedBranchRule(ctx, repo.ID/pr.BaseRepoID, ...)

None of these consult org rules, and ToProtectedBranch() / FindOrgBranchRuleForBranch() are defined but never called from any enforcement or sync path.

Result: org-level rules are inert. Org-wide protection is currently only achievable by the external branch-protection.yml provisioning workflow (mokocli), which writes real per-repo rules and re-asserts them weekly.

Fix options

Option 1 — Enforce at check time (highest fidelity):
In the pre-receive hook and services/pull checks, after fetching the repo rule, also look up the matching org rule (FindOrgBranchRuleForBranch(orgID, branch)) and merge them (repo rule overrides, or most-restrictive wins). Requires touching every enforcement site and defining precedence.

Option 2 — Materialize org rules into per-repo records (simpler, recommended):
On org-rule create/update/delete (and on repo creation / org membership change), call the existing ToProtectedBranch() to upsert matching per-repo ProtectedBranch rows. Enforcement then works unchanged because it already reads per-repo rules. This effectively makes the native org feature replace the external provisioning workflow, with the same "materialized per-repo" model but a native trigger instead of a weekly cron.

Recommendation

Ship Option 2 — least invasive, reuses ToProtectedBranch(), and lets the branch-protection.yml cron be retired once it's live. Add a one-time backfill that materializes existing org rules across all repos.

Filed from a workflow/dev-cycle audit; related: mcp-mokogitea-api#30 (bot allowlists), Template-Generic#53 (workflow correctness).

## Summary The org-level branch protection feature is **half-implemented**: it has a DB model, migration, and full CRUD API, but **nothing consumes the rules**, so creating an org-level rule has **zero effect** on pushes or merges. ## What exists - **Model**: `models/git/org_protected_branch.go` (`OrgProtectedBranch`, team-based whitelists) - **Migration**: `models/migrations/v1_27/v332.go` - **API**: `/orgs/{org}/branch_protections` — list/create/get/edit/delete (`routers/api/v1/org/branch_protection.go`, routes in `routers/api/v1/api.go` under `/orgs/{org}`) - **Helpers (unused)**: `OrgProtectedBranch.Match()` (`:75`), `OrgProtectedBranch.ToProtectedBranch()` (`:86`), `FindOrgBranchRuleForBranch()` (`:186`) ## The gap (evidence) `grep -rl OrgProtectedBranch --include=*.go` returns **only 4 files** — the model, the migration (×2), and the API handler. Nothing else references it. Meanwhile, all actual enforcement reads **per-repo** rules only: - Push pre-receive hook: `routers/private/hook_pre_receive.go:194` → `GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)` - Merge/PR checks: `services/pull/merge.go:557,575`, `check.go:199,240`, `patch.go:430`, `commit_status.go:70,129` → all `GetFirstMatchProtectedBranchRule(ctx, repo.ID/pr.BaseRepoID, ...)` None of these consult org rules, and `ToProtectedBranch()` / `FindOrgBranchRuleForBranch()` are **defined but never called** from any enforcement or sync path. **Result:** org-level rules are inert. Org-wide protection is currently only achievable by the external `branch-protection.yml` provisioning workflow (mokocli), which writes real per-repo rules and re-asserts them weekly. ## Fix options **Option 1 — Enforce at check time (highest fidelity):** In the pre-receive hook and `services/pull` checks, after fetching the repo rule, also look up the matching org rule (`FindOrgBranchRuleForBranch(orgID, branch)`) and merge them (repo rule overrides, or most-restrictive wins). Requires touching every enforcement site and defining precedence. **Option 2 — Materialize org rules into per-repo records (simpler, recommended):** On org-rule create/update/delete (and on repo creation / org membership change), call the existing `ToProtectedBranch()` to upsert matching per-repo `ProtectedBranch` rows. Enforcement then works unchanged because it already reads per-repo rules. This effectively makes the native org feature replace the external provisioning workflow, with the same "materialized per-repo" model but a native trigger instead of a weekly cron. ## Recommendation Ship **Option 2** — least invasive, reuses `ToProtectedBranch()`, and lets the `branch-protection.yml` cron be retired once it's live. Add a one-time backfill that materializes existing org rules across all repos. _Filed from a workflow/dev-cycle audit; related: mcp-mokogitea-api#30 (bot allowlists), Template-Generic#53 (workflow correctness)._
Author
Owner

Branch created: feature/727-org-level-branch-protection-is-stored-bu

git fetch origin
git checkout feature/727-org-level-branch-protection-is-stored-bu
Branch created: [`feature/727-org-level-branch-protection-is-stored-bu`](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork/src/branch/feature/727-org-level-branch-protection-is-stored-bu) ```bash git fetch origin git checkout feature/727-org-level-branch-protection-is-stored-bu ```
Author
Owner

Implemented Option 2 (materialize) in PR #728 (fix/727-materialize-org-branch-protectionmain).

Each org rule is written into every repo of the org as a per-repo ProtectedBranch row tagged with a new OrgRuleID column, so the existing enforcement path (pre-receive hook, services/pull checks) picks it up unchanged.

  • Schema: OrgRuleID column + migration 362; helpers DeleteMaterializedProtectedBranches / HasMaterializedOrgRule / FindAllOrgProtectedBranchRules.
  • Service (services/org/branch_protection.go): SyncOrgProtectedBranch (delete-then-rematerialize, handles pattern renames), RemoveOrgProtectedBranch, SyncOrgRulesToRepo, guarded idempotent BackfillOrgProtectedBranches.
  • Triggers: API create/edit/delete; repo create/fork/transfer into the org via a notifier (avoids the services/repository → services/org import cycle); one-time startup backfill for pre-existing rules.
  • Precedence: repo rule wins — if a repo already defines a rule for the same pattern, the org rule is skipped there (never overwrites).

Once live this can retire the external branch-protection.yml provisioning cron.

⚠️ Not compiled/tested locally (no Go toolchain in the authoring env) — CI must validate build + gofmt + tests before merge; integration testing of actual enforcement recommended.

Implemented **Option 2 (materialize)** in PR #728 (`fix/727-materialize-org-branch-protection` → `main`). Each org rule is written into every repo of the org as a per-repo `ProtectedBranch` row tagged with a new `OrgRuleID` column, so the existing enforcement path (pre-receive hook, `services/pull` checks) picks it up unchanged. - **Schema**: `OrgRuleID` column + migration 362; helpers `DeleteMaterializedProtectedBranches` / `HasMaterializedOrgRule` / `FindAllOrgProtectedBranchRules`. - **Service** (`services/org/branch_protection.go`): `SyncOrgProtectedBranch` (delete-then-rematerialize, handles pattern renames), `RemoveOrgProtectedBranch`, `SyncOrgRulesToRepo`, guarded idempotent `BackfillOrgProtectedBranches`. - **Triggers**: API create/edit/delete; repo create/fork/transfer into the org via a **notifier** (avoids the `services/repository → services/org` import cycle); one-time startup backfill for pre-existing rules. - **Precedence**: repo rule wins — if a repo already defines a rule for the same pattern, the org rule is skipped there (never overwrites). Once live this can retire the external `branch-protection.yml` provisioning cron. ⚠️ **Not compiled/tested locally** (no Go toolchain in the authoring env) — CI must validate build + `gofmt` + tests before merge; integration testing of actual enforcement recommended.
Author
Owner

Approach revised — reworked PR #728 to layering (most-restrictive wins).

Two findings changed the plan:

  1. Org rules are not fully inert. The single enforcement choke point GetFirstMatchProtectedBranchRule (models/git/protected_branch_list.go) already consults org rules via FindOrgBranchRuleForBranch/ToProtectedBranch. The grep OrgProtectedBranch in the issue missed it because those are function calls that never name the type. So org protection already worked — but only as a fallback: any matching repo rule shadowed the org rule entirely, letting a repo opt out.

  2. Layering is therefore a one-function change, not materialization. The materialization approach (per-repo rows, notifier, backfill, OrgRuleID column) was discarded.

Implemented: GetFirstMatchProtectedBranchRule now merges the repo rule and org rule when both match, via a new fail-closed mergeMostRestrictive (models/git/protected_branch_merge.go): allow flags AND'd, gate/require/block flags OR'd, required approvals max'd, status contexts + protected files unioned, whitelists intersected. The org rule becomes a floor a repo can only tighten. Because all ~15 enforcement sites funnel through this choke point, none needed editing.

Diff is now 2 files (+207/−7). ⚠️ Still not compiled/tested locally (no Go toolchain) — CI must validate; integration testing of layered enforcement recommended.

Note: the issue's stated Option 2 (materialize) is superseded; this is effectively Option 1 (enforce-at-check-time) done at the pre-existing choke point.

**Approach revised — reworked PR #728 to layering (most-restrictive wins).** Two findings changed the plan: 1. **Org rules are not fully inert.** The single enforcement choke point `GetFirstMatchProtectedBranchRule` (`models/git/protected_branch_list.go`) *already* consults org rules via `FindOrgBranchRuleForBranch`/`ToProtectedBranch`. The `grep OrgProtectedBranch` in the issue missed it because those are function calls that never name the type. So org protection already worked — but only as a **fallback**: any matching repo rule shadowed the org rule entirely, letting a repo opt out. 2. **Layering is therefore a one-function change,** not materialization. The materialization approach (per-repo rows, notifier, backfill, `OrgRuleID` column) was discarded. **Implemented:** `GetFirstMatchProtectedBranchRule` now merges the repo rule and org rule when both match, via a new fail-closed `mergeMostRestrictive` (`models/git/protected_branch_merge.go`): allow flags AND'd, gate/require/block flags OR'd, required approvals max'd, status contexts + protected files unioned, whitelists intersected. The org rule becomes a floor a repo can only tighten. Because all ~15 enforcement sites funnel through this choke point, none needed editing. Diff is now 2 files (+207/−7). ⚠️ Still not compiled/tested locally (no Go toolchain) — CI must validate; integration testing of layered enforcement recommended. Note: the issue's stated Option 2 (materialize) is superseded; this is effectively Option 1 (enforce-at-check-time) done at the pre-existing choke point.
Sign in to join this conversation.