Skip to content

feat(node): owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH (#31)#68

Open
FrozenRaspberry wants to merge 1 commit into
Gitlawb:mainfrom
FrozenRaspberry:feat/enforce-owner-push
Open

feat(node): owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH (#31)#68
FrozenRaspberry wants to merge 1 commit into
Gitlawb:mainfrom
FrozenRaspberry:feat/enforce-owner-push

Conversation

@FrozenRaspberry

@FrozenRaspberry FrozenRaspberry commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Closes #31.

What

Adds an opt-in flag GITLAWB_ENFORCE_OWNER_PUSH (default false) that requires the authenticated pusher to be the repo owner on every branch of git-receive-pack, not just protected ones.

Why

A valid did:key RFC 9421 signature is authentication, not authorization: did:key is self-certifying, so anyone can generate a key, derive its DID, sign, and push. Today the owner check only runs when the target branch is is_branch_protected, so a non-owner can write to unprotected branches of an arbitrary repo. This is the first item under Security hardening in docs/MAINTAINER-ROADMAP.md and the Phase 1 slice agreed on in #31.

Default false (mirroring GITLAWB_REQUIRE_SIGNED_PEER_WRITES) means live nodes are unaffected until an operator opts in.

How — addressing the review on #31

  • Authorize on the verified identity. The handler now takes Extension<AuthenticatedDid> (the canonical DID injected by require_signature) instead of re-scraping keyid from the headers. The legacy branch-protection check is left as-is per your note.
  • Fail closed. The new gate rejects on an absent identity and on a non-owner caller — no unwrap_or(false) fall-through. The decision is a pure fn (owner_push_rejection) so it's unit-testable without a DB or git backend.
  • No "reject unsigned" logic. require_signature already rejects unsigned requests upstream; the gate only compares the authenticated DID against the owner.
  • Helper scoped to genuine owner-match sites. Factored the duplicated owner-match into auth::is_repo_owner(record, caller), reused by receive-pack, protect.rs, and visibility.rs. Each call site keeps its own error message. events.rs and peers.rs are intentionally left out — they aren't owner checks.
  • Named by intent. The push-side gate is caller_authorized_to_push, so the Phase 2 UCAN git/push capability becomes a pure addition (is_repo_owner(..) || ucan_grants_push(..)) rather than a rewrite.
  • Ordering. The gate runs before branch protection, so when the flag is on a non-owner is rejected with a single error body regardless of whether the branch is also protected.

Out of scope (per your note)

pulls/{n}/merge and hooks warrant the same gating — kept out of this PR so it stays scoped to owner-push; happy to take the broader write-path review on its own track.

Testing

  • cargo fmt --all -- --check clean; cargo clippy -p gitlawb-node --all-targets -- -D warnings clean.
  • New unit tests for the push authorization path (all passing): owner allowed (full did:key:… and bare suffix form), non-owner rejected, missing-DID rejected (fail-closed), and flag-off legacy behavior.
  • Full gitlawb-node bin suite: 113 passing. The only failures are 3 pre-existing sync::tests cases that fail with invalid filter-spec 'blob:limit=10g' from the local git binary — reproduced identically on a clean tree without this change, so unrelated.

Docs

  • .env.example: documented GITLAWB_ENFORCE_OWNER_PUSH=false.
  • docs/RUN-A-NODE.md: new "Hardening: owner-only push" section (default off, when to enable, effect).

Summary by CodeRabbit

  • New Features

    • Introduced GITLAWB_ENFORCE_OWNER_PUSH configuration option to enforce owner-only pushes, requiring authenticated pushers to be the repository owner.
  • Improvements

    • Enhanced path glob validation to reject malformed patterns and enforce correct formatting.
  • Documentation

    • Added hardening guide documenting push authentication behavior and owner-only authorization controls.

…PUSH (Gitlawb#31)

A valid did:key signature on git-receive-pack is authentication, not
authorization: any party can generate a key, derive its DID, sign, and push to
an unprotected branch. Owner checks only ran on protected branches, so a
non-owner could write to unprotected branches of an arbitrary repo.

Add an opt-in flag GITLAWB_ENFORCE_OWNER_PUSH (default false, mirroring
GITLAWB_REQUIRE_SIGNED_PEER_WRITES) that requires the authenticated pusher to be
the repo owner on every branch. Default-off keeps live nodes unaffected.

Design follows the maintainer's review on Gitlawb#31:
- Authorize on the canonical AuthenticatedDid injected by require_signature, via
  Extension<AuthenticatedDid> on the handler — not a re-parse of the headers.
  The legacy branch-protection check is left as-is.
- Fail closed: an absent identity or a non-owner caller is rejected. The policy
  lives in a pure fn (owner_push_rejection) so it is unit-tested without a DB.
- The gate runs before branch protection, so one rejection yields one error body.
- Factor the duplicated owner-match into auth::is_repo_owner, reused by
  receive-pack, protect.rs, and visibility.rs (each keeps its own message).
  events.rs / peers.rs are left out — they aren't owner checks.
- Name the push gate by intent (caller_authorized_to_push) so the planned
  Phase 2 UCAN git/push capability is a pure addition, not a rewrite.

Tests: owner (full + short DID) allowed, non-owner rejected, missing-DID
rejected, flag-off legacy. Docs: .env.example + docs/RUN-A-NODE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an opt-in GITLAWB_ENFORCE_OWNER_PUSH flag to require authenticated git pushers to be the repository owner. Extracts shared is_repo_owner and caller_authorized_to_push auth helpers, wires an early ownership gate into git_receive_pack, refactors existing owner checks in protect and visibility endpoints to use the helpers, adds path_glob input validation, and documents the new hardening option.

Changes

Owner-Only Push Enforcement

Layer / File(s) Summary
Shared auth helpers
crates/gitlawb-node/src/auth/mod.rs
Adds is_repo_owner matching caller DID against full and short owner_did forms, and caller_authorized_to_push delegating to it for owner-only push policy.
Config and env example
crates/gitlawb-node/src/config.rs, .env.example
Adds enforce_owner_push: bool to Config (default false, clap arg --GITLAWB_ENFORCE_OWNER_PUSH) and documents the new env var in .env.example.
Owner-push gate in git_receive_pack
crates/gitlawb-node/src/api/repos.rs
Adds owner_push_rejection helper, extends handler to accept Extension(auth), inserts early enforcement check before branch-protection logic, and adds unit tests for all allow/reject cases.
Refactor protect/unprotect endpoints
crates/gitlawb-node/src/api/protect.rs
Replaces manual owner DID short-form derivation in protect_branch and unprotect_branch with calls to is_repo_owner.
Refactor visibility + path_glob validation
crates/gitlawb-node/src/api/visibility.rs
Updates require_owner to use is_repo_owner, adds validate_path_glob enforcing glob format rules, wires it into set_visibility before persistence, and extends tests for valid/invalid patterns.
Operator docs
docs/RUN-A-NODE.md
Adds "Hardening: owner-only push" section documenting default authentication-only behavior and the GITLAWB_ENFORCE_OWNER_PUSH=true opt-in switch.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant git_receive_pack
  participant owner_push_rejection
  participant BranchProtection
  participant ReceivePack

  Client->>git_receive_pack: POST /{owner}/{repo}/git-receive-pack (signed)
  git_receive_pack->>owner_push_rejection: enforce_owner_push, RepoRecord, Option<AuthenticatedDid>
  alt enforce=true AND caller not owner
    owner_push_rejection-->>git_receive_pack: Err(AppError::Forbidden)
    git_receive_pack-->>Client: 403 Forbidden
  else enforce=false OR caller is owner
    owner_push_rejection-->>git_receive_pack: Ok(())
    git_receive_pack->>BranchProtection: is_branch_protected?
    git_receive_pack->>ReceivePack: spawn git-receive-pack
    git_receive_pack-->>Client: 200 OK (pack data)
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Gitlawb/node#25: Touches visibility.rs with repo-owner authorization and path_glob logic, directly overlapping with the visibility refactor and new glob validation in this PR.
  • Gitlawb/node#34: Modifies git_receive_pack in repos.rs to add visibility-based decisions, overlapping with the new owner-push enforcement gate added to the same handler.

Suggested reviewers

  • kevincodex1

Poem

🐇 Hop hop, the push gate swings,
Only the owner who the keypair brings!
A flag named ENFORCE_OWNER_PUSH says true,
Non-owners get a 403 — no passing through.
Shared helpers trimmed the copy-paste away,
The warren is hardened — hip-hip-hooray! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely describes the main change: adding owner-only push enforcement via the GITLAWB_ENFORCE_OWNER_PUSH flag.
Description check ✅ Passed The PR description comprehensively covers all template sections: summary, motivation (issue #31), kind of change (Feature), concrete changes across crates, verification steps, and addresses all pre-review checklist items including testing, formatting, and documentation updates.
Linked Issues check ✅ Passed The PR fully implements Phase 1 requirements from issue #31: adds GITLAWB_ENFORCE_OWNER_PUSH flag (default false), enforces owner-only checks before branch protection, factors owner-match logic into shared helper, provides comprehensive test coverage, and updates documentation.
Out of Scope Changes check ✅ Passed All changes are directly scoped to owner-only push enforcement: config option, auth helpers, receive-pack handler, protect/visibility refactoring, documentation, and tests. No unrelated refactoring or churn is present.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/gitlawb-node/src/api/visibility.rs (1)

55-58: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

path_glob wildcard validation is bypassable for non-trailing * patterns.

Line 55 only rejects * when the string does not end with /**, so values like /a*b/** (and /a/**/**) currently pass even though they violate the “only trailing /** wildcard” rule.

Suggested fix
 fn validate_path_glob(path_glob: &str) -> Result<()> {
@@
-    if path_glob.contains('*') && !path_glob.ends_with("/**") {
+    if let Some(prefix) = path_glob.strip_suffix("/**") {
+        if prefix.contains('*') {
+            return Err(AppError::BadRequest(
+                "the only supported wildcard is a single trailing '/**'".into(),
+            ));
+        }
+    } else if path_glob.contains('*') {
         return Err(AppError::BadRequest(
-            "the only supported wildcard is a trailing '/**'".into(),
+            "the only supported wildcard is a single trailing '/**'".into(),
         ));
     }
     Ok(())
 }
     fn rejects_malformed_globs() {
@@
-        for g in ["", "secret/**", "/**", "/secret/", "/a*b", "/*/x"] {
+        for g in [
+            "",
+            "secret/**",
+            "/**",
+            "/secret/",
+            "/a*b",
+            "/*/x",
+            "/a*b/**",
+            "/a/**/**",
+        ] {
             assert!(validate_path_glob(g).is_err(), "{g} should be rejected");
         }
     }

Also applies to: 240-241

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/visibility.rs` around lines 55 - 58, The wildcard
validation in the path_glob check at line 55 is incomplete and allows invalid
patterns to pass through. The current logic only rejects paths that contain '*'
and don't end with "/**", but patterns like "/a*b/**" bypass this check because
they still end with "/**" despite having invalid wildcards elsewhere. Fix this
by ensuring that '*' characters only appear as part of the trailing "/**"
pattern and nowhere else. You can verify this by checking if the path_glob
contains '*' anywhere outside of the "/**" suffix (for example, by checking if
removing the trailing "/**" still leaves a '*' in the remaining string). Apply
the same fix at both locations mentioned (line 55-58 and line 240-241).
🧹 Nitpick comments (1)
crates/gitlawb-node/src/api/repos.rs (1)

514-523: ⚡ Quick win

Use one canonical DID source for all push authorization checks.

Line 518 establishes auth.0 as the canonical identity, but the protected-branch path later re-parses headers. Keeping both paths on AuthenticatedDid avoids drift and parser mismatch between authorization checks.

Suggested refactor
-    let pusher_did_for_check = extract_did_from_auth(&headers);
+    let pusher_did_for_check = Some(auth.0.as_str());
@@
-            let owner_short = record
-                .owner_did
-                .split(':')
-                .next_back()
-                .unwrap_or(&record.owner_did);
-            let is_owner = pusher_did_for_check
-                .as_deref()
-                .map(|did| did == record.owner_did || did == owner_short)
-                .unwrap_or(false);
+            let is_owner = pusher_did_for_check
+                .map(|did| crate::auth::is_repo_owner(&record, did))
+                .unwrap_or(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/repos.rs` around lines 514 - 523, The
authorization checks are using inconsistent DID sources: the
owner_push_rejection function correctly uses auth.0 (AuthenticatedDid) as the
canonical identity, but the protected-branch authorization path re-parses
headers instead. Refactor the protected-branch authorization check to use the
same auth.0 AuthenticatedDid value consistently, removing any header re-parsing
in that path to ensure both authorization checks use the same canonical identity
source and avoid parser mismatch.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/gitlawb-node/src/api/visibility.rs`:
- Around line 55-58: The wildcard validation in the path_glob check at line 55
is incomplete and allows invalid patterns to pass through. The current logic
only rejects paths that contain '*' and don't end with "/**", but patterns like
"/a*b/**" bypass this check because they still end with "/**" despite having
invalid wildcards elsewhere. Fix this by ensuring that '*' characters only
appear as part of the trailing "/**" pattern and nowhere else. You can verify
this by checking if the path_glob contains '*' anywhere outside of the "/**"
suffix (for example, by checking if removing the trailing "/**" still leaves a
'*' in the remaining string). Apply the same fix at both locations mentioned
(line 55-58 and line 240-241).

---

Nitpick comments:
In `@crates/gitlawb-node/src/api/repos.rs`:
- Around line 514-523: The authorization checks are using inconsistent DID
sources: the owner_push_rejection function correctly uses auth.0
(AuthenticatedDid) as the canonical identity, but the protected-branch
authorization path re-parses headers instead. Refactor the protected-branch
authorization check to use the same auth.0 AuthenticatedDid value consistently,
removing any header re-parsing in that path to ensure both authorization checks
use the same canonical identity source and avoid parser mismatch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e39834c-ba63-4bad-a7b4-137ba8def076

📥 Commits

Reviewing files that changed from the base of the PR and between e37ea7f and 5c5de15.

📒 Files selected for processing (7)
  • .env.example
  • crates/gitlawb-node/src/api/protect.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/api/visibility.rs
  • crates/gitlawb-node/src/auth/mod.rs
  • crates/gitlawb-node/src/config.rs
  • docs/RUN-A-NODE.md

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Repo write authorization (Phase 1): owner-only push behind an opt-in flag

1 participant