Skip to content

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

Merged
kevincodex1 merged 3 commits into
Gitlawb:mainfrom
FrozenRaspberry:feat/enforce-owner-push
Jun 24, 2026
Merged

feat(node): owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH (#31)#68
kevincodex1 merged 3 commits 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

    • Added optional owner-only push enforcement. When enabled via configuration, only the repository owner can push changes; non-owner pushes are rejected with HTTP 403 before any ref updates.
  • Documentation

    • Added hardening guide explaining the new owner-only push feature, including configuration instructions and current limitations with delegated push workflows.

…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
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 28e6bb19-a325-4a2c-93da-1627e2899784

📥 Commits

Reviewing files that changed from the base of the PR and between f9df6eb and 8ab7e50.

📒 Files selected for processing (2)
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/auth/mod.rs

📝 Walkthrough

Walkthrough

Adds an opt-in GITLAWB_ENFORCE_OWNER_PUSH flag to Config, introduces a caller_authorized_to_push auth helper using did_matches, refactors git_receive_pack to use Extension<AuthenticatedDid> instead of header re-parsing, inserts an early owner-only enforcement gate, removes the legacy extract_did_from_auth helper, and adds documentation and unit tests.

Changes

Owner-Only Push Enforcement

Layer / File(s) Summary
Config flag and caller_authorized_to_push helper
crates/gitlawb-node/src/config.rs, crates/gitlawb-node/src/auth/mod.rs, .env.example
Adds enforce_owner_push: bool to Config (default false, clap arg --GITLAWB_ENFORCE_OWNER_PUSH). Introduces caller_authorized_to_push in auth/mod.rs delegating owner-DID comparison to crate::api::did_matches. Documents the new env var in .env.example.
git_receive_pack enforcement gate and DID plumbing refactor
crates/gitlawb-node/src/api/repos.rs
Replaces HeaderMap + extract_did_from_auth with Extension<AuthenticatedDid>. Adds owner_push_rejection helper implementing fail-closed gate. Inserts early enforcement block before branch-protection checks. Updates branch-protection ownership check, trust/cert plumbing, and webhook pusher.did to use auth.0 directly. Removes extract_did_from_auth. Adds unit tests for all owner/non-owner/flag-off scenarios.
Node operator docs
docs/RUN-A-NODE.md
Adds "Hardening: owner-only push" section documenting default behavior, the GITLAWB_ENFORCE_OWNER_PUSH=true opt-in, full/bare DID matching, HTTP 403 pre-ref-update rejection, and the caveat on delegated push rights.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • #31 (Repo write authorization Phase 1: owner-only push behind an opt-in flag) — This PR directly implements the proposal from issue #31: adds GITLAWB_ENFORCE_OWNER_PUSH, factors owner-match logic into a shared helper, gates git_receive_pack on owner DID, and adds tests and docs as specified.

Possibly related PRs

  • Gitlawb/node#34: Both PRs modify git_receive_pack in crates/gitlawb-node/src/api/repos.rs to gate or restructure push/replication dissemination, with overlapping changes to how the pusher DID flows through replication and announce tasks.

Suggested reviewers

  • kevincodex1

Poem

🐇 Hippity-hop, the gate is set,
No stranger shall push without the owner's crest!
A DID must match — full form or bare —
Or a 403 flies through the air.
The rabbit enforces, safe in its warren,
One flag to harden, no branches forsworn! 🔐

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main feature: owner-only push enforcement with the controlling configuration flag, matching the changeset's core objective.
Description check ✅ Passed The description covers all critical sections: it clearly states what changed (owner-push enforcement flag), why it matters (security hardening for unprotected branches), the implementation approach with specific technical details, testing verification, and documentation updates.
Linked Issues check ✅ Passed The PR implementation fully satisfies the Phase 1 requirements in #31: opt-in GITLAWB_ENFORCE_OWNER_PUSH flag (default false), owner-only enforcement before any ref update, shared is_repo_owner helper, comprehensive tests, and documentation updates.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the Phase 1 scope: configuration flag, authorization logic, push validation, helper consolidation, tests, and documentation. Out-of-scope items (UCAN delegation, pulls/merge, hooks) are correctly deferred and noted.
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Took a full pass over the owner-push gate. The core is solid: it fails closed, runs ahead of branch protection, the did:key suffix match isn't spoofable since the authenticated caller is always the full did:key: form, and the new Extension(auth) extractor is safe because the route sits behind require_signature. owner_push_rejection being a pure, unit-tested function is the right call. A few things to tighten before it's ready to merge:

Findings

  • [P2] Return 403 for the authorization denial, not 400
    crates/gitlawb-node/src/api/repos.rs:484
    owner_push_rejection rejects with AppError::BadRequest, but AppError::Forbidden already exists and maps to 403, the correct status for "authenticated but not the owner." 400 reads as a malformed request and some git/CI clients retry it. The same applies to the branch-protection denial and the owner checks in protect.rs / visibility.rs, so this is cleanest as one small sweep.

  • [P2] Fold the remaining inline owner-matches into is_repo_owner
    crates/gitlawb-node/src/api/repos.rs:549
    This PR adds is_repo_owner and routes protect/visibility through it, but two hand-rolled split(':').next_back() checks remain: the branch-protection check here (which also reads the pusher DID from extract_did_from_auth(&headers) rather than the verified auth.0), and the list filter at repos.rs:165. Routing both through the helper finishes the consolidation and drops the second identity source on the push path.

  • [P2] Assert the error variant in the rejection tests
    crates/gitlawb-node/src/api/repos.rs:1315
    The rejection tests assert .is_some(), so the 400 to 403 change above would pass silently. Match the variant instead. Worth adding a handler-level test that runs git_receive_pack with the flag on so a wiring regression on Extension(auth) is caught, plus direct tests for is_repo_owner / caller_authorized_to_push.

  • [P3] Warn that enabling the flag blocks delegated and CI agents
    docs/RUN-A-NODE.md:478
    The route verifies a UCAN git/push chain in require_ucan_chain then discards it; caller_authorized_to_push only accepts the literal owner. So turning this on today hard-blocks every delegated or CI agent that isn't the owner, and the gl ucan_delegate path for git/push is a dead end until Phase 2. One line of caution in the hardening section would save someone locking out their own automation.

The path_glob validation issue is pre-existing (identical on main, untouched here) and filed separately as #74, so it's out of scope here.

…tch consolidation

Per @beardthelion's review:

- Return 403 Forbidden (not 400) for authorization denials: the owner-push gate,
  the branch-protection denial, and the owner checks in protect.rs / visibility.rs.
  400 reads as a malformed request and some git/CI clients retry it.
- Finish the is_repo_owner consolidation: route the branch-protection check and
  the repos list filter through the helper. The branch-protection check now uses
  the verified AuthenticatedDid (auth.0) instead of re-parsing the headers.
- Drop the second identity source on the push path entirely: the post-push event
  recording also uses auth.0, so extract_did_from_auth (and the handler's
  HeaderMap param + import) are removed.
- Tests assert the Forbidden variant (so a future status regression fails loudly)
  and add direct coverage for is_repo_owner / caller_authorized_to_push.
- Docs: warn that enabling the flag blocks delegated and CI agents until the
  Phase 2 UCAN git/push capability is honored.

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

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough pass. All addressed in f9df6eb:

[P2] 403 not 400 — Done as one sweep: the owner-push gate, the branch-protection denial, and the owner checks in protect.rs / visibility.rs now return AppError::Forbidden (403).

[P2] Finish the is_repo_owner consolidation — The branch-protection check and the list filter at repos.rs:165 now both route through is_repo_owner. The branch-protection check uses the verified auth.0 rather than extract_did_from_auth(&headers). I went one step further and also switched the post-push event-recording site to auth.0, which let me delete extract_did_from_auth (and the handler's HeaderMap param + import) outright — so there's a single identity source on the push path now. Shout if you'd rather I'd left the event-recording site alone.

[P2] Assert the variant — The rejection tests now match Some(AppError::Forbidden(_)) (via an assert_forbidden helper), so the 400→403 change would have failed loudly. Added direct tests for is_repo_owner and caller_authorized_to_push (owner full + short DID, non-owner).

On the handler-level git_receive_pack test: I looked at wiring one up but it isn't runnable in CI as-is. git_receive_pack calls state.db.get_repo(...) before reaching the gate, and pr-checks.yml runs cargo test --workspace with no Postgres service (the test harness uses lazy placeholder pools), so a handler test would fail at the DB call rather than exercise the gate. The Extension(auth) wiring is at least compile-enforced — the gate consumes auth.0, so removing the extractor won't build. If you'd like a real end-to-end handler test, I'm happy to add a Postgres service to pr-checks.yml and write it on its own track — just didn't want to bundle a CI-infra change into this PR. Let me know your preference.

[P3] Doc caution — Added to the hardening section: enabling the flag blocks every non-owner pusher including delegated/CI agents (UCAN git/push is verified but not yet honored for authz), with a "don't enable until every pusher is the owner" warning.

Re #74 (the path_glob validation bypass) — agreed it's pre-existing and out of scope here; thanks for filing it separately.

beardthelion
beardthelion previously approved these changes Jun 22, 2026

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. I verified the security core against the code rather than the PR summary, and confirmed the prior round's four findings are fully resolved.

What I checked:

  • Identity on the push path is signature-bound: the verifying key is derived from the keyid, the signature is checked against it, and AuthenticatedDid is set only after every check passes (crates/gitlawb-node/src/auth/mod.rs:257). The route sits behind require_signature via add_auth_layers (crates/gitlawb-node/src/server.rs:159), so auth.0 cannot be spoofed or absent at the handler.
  • The gate fails closed and runs before branch protection, acquire_write, and the receive-pack spawn.
  • is_repo_owner short/full matching has no forgeable collision: the z6Mk… form is a 1:1 encoding of the Ed25519 key and the push caller always arrives as the full verified DID.
  • All four items from the prior round are fully resolved: 403 at every owner-mismatch site, the consolidation routes through is_repo_owner on the verified auth.0 (crates/gitlawb-node/src/api/repos.rs:165 and :547), assert_forbidden pins the variant so a regression to 400 fails loudly, and the docs caution is in place. extract_did_from_auth is gone with no dangling references. Build is green, clippy is clean, and the 7 new tests pass.

None of the following block the merge. Noting them so they are tracked.

Findings (non-blocking)

  • [P3] Extend the owner-match consolidation one hop further. crates/gitlawb-node/src/visibility.rs:17 still carries its own is_owner with the same split(':').next_back() idiom. It predates this PR and is behavior-identical, but a shared is_did_owner(owner_did, caller) that both it and is_repo_owner delegate to would close the last copy.
  • [P3] Document that GITLAWB_ENFORCE_OWNER_PUSH takes effect on restart only. It is read once into Config at startup (crates/gitlawb-node/src/config.rs:58), so flipping it on a live node is a no-op until restart. A one-line note in RUN-A-NODE.md and .env.example would set the right expectation.
  • [P3] The rejection warn! fires on every blocked push (crates/gitlawb-node/src/api/repos.rs:523). When the flag is intentionally on, rejections are expected traffic, so a probing or misconfigured client can inflate log volume. info! or a per-DID rate-limited line would be calmer.
  • [P3, pre-existing] The git remote helper bails on a non-2xx without reading the body (crates/git-remote-gitlawb/src/main.rs:297), so the descriptive rejection message never reaches the pushing user. Filed as #77.

Phase 2 (tracked on #31, not this PR): once UCAN git/push is honored, add owner_did and a structured error code to the 403 body so agent clients can act on the rejection rather than just log it, and plan the capability-injection path from require_ucan_chain into the push authorization check.

@kevincodex1 this is ready when you are. The security core is sound, the prior round is fully resolved, and everything above is a non-blocking follow-up.

@beardthelion beardthelion added kind:feature New capability or surface crate:node gitlawb-node — the serving node and REST API subsystem:identity DID/UCAN, http-sig auth, push authorization sev:high Major break or real security/trust risk, no easy workaround labels Jun 22, 2026
@kevincodex1

Copy link
Copy Markdown
Contributor

hi @FrozenRaspberry thank you for this contribution. kindly rebase to main and please fix conflicts.

# Conflicts:
#	crates/gitlawb-node/src/api/protect.rs
#	crates/gitlawb-node/src/api/visibility.rs
@FrozenRaspberry

Copy link
Copy Markdown
Contributor Author

Resolved the merge conflict with main (8ab7e50).

main landed #87 (per-route authorization), which introduced the canonical crate::api::did_matches owner comparison — DID-safe on both sides (full did:key: vs bare suffix), and cross-method-safe — and routed protect.rs / visibility.rs through it. My branch had added a weaker auth::is_repo_owner (owner side only) for the same purpose.

Reconciled onto main's helper rather than keeping mine:

  • Removed auth::is_repo_owner; caller_authorized_to_push now delegates to did_matches, keeping the intent-named Phase 2 seam (did_matches(..) || ucan_grants_push(..)).
  • Branch-protection check and the repos list filter now use did_matches too.
  • Dropped my now-redundant is_repo_owner unit test (the canonical helper has its own tests in api); kept the caller_authorized_to_push + owner_push_rejection (Forbidden-variant) tests.

This also closes the loop on the prior P3 "fold the last owner-match into a shared helper" — there's now a single owner-comparison (did_matches) across receive-pack, branch protection, protect, and visibility. The git_receive_pack owner-push gate itself is unchanged.

cargo fmt/clippy -D warnings clean. The owner-push, caller_authorized_to_push, and did_matches tests pass locally; the remaining local failures are the Postgres-backed #[sqlx::test] suite (no local DB) and the pre-existing blob:limit=10g sync tests — both environmental and green under CI's Postgres service.

@beardthelion beardthelion dismissed their stale review June 24, 2026 02:43

Dismissing my own stale approval: it predates the merge head 8ab7e50, which reconciled onto did_matches and removed is_repo_owner. Re-reviewing the new head.

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-approving on the merge head 8ab7e50a. The reconciliation onto did_matches (removing is_repo_owner) is substantive, so I re-verified the security core against the merged code rather than the diff summary.

What I checked:

  • did_matches (crates/gitlawb-node/src/api/mod.rs:63) is sound on both sides: exact-match short-circuit, strips did:key: from each operand, and the !ka.contains(':') && !kb.contains(':') guard blocks cross-method collisions (did:gitlawb:X/did:web:X keep their colon after the no-op strip, so they cannot match a bare X). It is symmetric, so the differing argument order between the push gate and the branch-protection check is harmless.
  • The owner-push gate fails closed and runs before branch protection, acquire_write, and the receive-pack spawn (repos.rs:531 < 557 < 577). auth.0 is the verified sig.key_id from require_signature, and git_receive_pack is wired through add_auth_layers, so the non-Option Extension(auth) extractor cannot be absent or header-spoofed.
  • extract_did_from_auth is fully removed with no remaining callers — this closes the prior header-reparse divergence. The cert / p2p / webhook-payload paths now use the verified DID directly; nothing depended on the old "unknown"/empty sentinels.
  • The dropped is_repo_owner unit test is covered by did_matches's own tests; the retained owner-push and Forbidden-variant tests pass. Build green, clippy clean.

Two non-blocking follow-ups, both cheap:

Findings

  • [P2] Enroll git_receive_pack in the owner-gate guard test. crates/gitlawb-node/src/api/mod.rs:171 — the new opt-in push gate is the one in-scope mutation handler with no row in authz_guard::every_in_scope_mutation_has_its_gate. That table is the mechanical guard against a gate being silently removed, so the new gate should be enrolled: add (repos, "git_receive_pack", "owner_push_rejection("). The marker is real code in the handler body (repos.rs:531), so it satisfies the comment-stripped fn_body check.
  • [P3] Unit-test the branch-protection rejection arm. crates/gitlawb-node/src/api/repos.rs:557 — the did_matches substitution and the 400→403 change on the protected-branch path have no test. Correct by inspection, but worth pinning: protected + non-owner → Forbidden, protected + owner → passes, unprotected + non-owner → passes.

Neither blocks. @kevincodex1 this is ready when you are.

@kevincodex1 kevincodex1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

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

Labels

crate:node gitlawb-node — the serving node and REST API kind:feature New capability or surface sev:high Major break or real security/trust risk, no easy workaround subsystem:identity DID/UCAN, http-sig auth, push authorization

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

3 participants