Skip to content

feat(node): enforce per-route authorization across the REST and GraphQL surface#87

Merged
kevincodex1 merged 8 commits into
mainfrom
fix/get-tree-path-scope
Jun 23, 2026
Merged

feat(node): enforce per-route authorization across the REST and GraphQL surface#87
kevincodex1 merged 8 commits into
mainfrom
fix/get-tree-path-scope

Conversation

@beardthelion

@beardthelion beardthelion commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Authenticated never meant authorized on the node's signed API. Identities are self-issued did:key, so a valid signature passed every gate regardless of who the caller was. This locks down the write surface: each mutation checks the caller against the resource it touches, and a DB-backed test harness exercises those checks against real Postgres in CI.

What's enforced

  • Owner-only mutations. Merge, branch protect/unprotect, webhooks, and labels require the repo owner (403 otherwise). Closing a PR or issue allows the owner or the original author.
  • Read-gated writes. Opening a PR or issue, reviewing, commenting, creating a bounty, and forking now require read access, so a non-reader cannot write to (or fork) a private repo they cannot read. Some of these were already gated; this makes it consistent across all of them.
  • Signer-bound actions. Task create/claim/complete/fail, bounty claim, profile, repo create, registration, and starring bind the acting DID to the authenticated signer instead of trusting a value from the request body. Completing or failing a task additionally requires being its assignee, and only a claimed task can transition.
  • GraphQL mutations require a verified signer and the same DID binding; queries and subscriptions stay open.
  • get_tree gates on the requested subtree rather than the repo root, so a caller denied a withheld path can no longer enumerate its filenames and blob SHAs.

A source-level drift guard asserts every in-scope handler still carries its gate, so a future change that drops one fails CI.

Test harness

The node had no DB-backed tests, and CI ran without Postgres, so the entire handler-to-database path was uncovered. This adds a Postgres service to the PR pipeline and a #[sqlx::test] harness, with behavioral tests for the authorization paths above.

Known limitation

did_matches compares did:key identities by their multibase string. A key presented in a non-canonical multibase encoding (for example base16 rather than base58btc) will not match the canonical form, so a key-holder on a non-conforming client could be denied their own owner gate. This is a false-negative only, never an impersonation vector (producing any encoding still requires the private key), and the conforming client always emits canonical base58btc. Left as-is rather than decoding keys in the auth path; noted here so it is on record.

Verification

143 node tests pass against real Postgres; clippy and rustfmt are clean. The commits are ordered harness, REST gates, GraphQL, get_tree, then three follow-ups (task-completion authorization, drift-guard hardening, and a small consistency batch) so they can be reviewed or bisected independently.

Summary by CodeRabbit

  • Bug Fixes

    • Strengthened DID-based authorization across API and GraphQL: actions that create/modify/terminate resources are now properly gated for authenticated callers (including owner/author/assignee/claimant checks).
    • Standardized authorization failures to return the correct “Forbidden” responses instead of other error types.
    • Scoped subtree visibility to the requested path to prevent enumeration of withheld repository content.
  • Tests

    • Expanded PostgreSQL-backed integration testing in CI and added an end-to-end test harness with additional authorization coverage.

Adds a #[cfg(test)] test-support module: a migrated AppState over a real
#[sqlx::test] pool (test_state), a DB-free variant (test_state_lazy), the
assembled router (app), and signed_request_as for injecting an authenticated
DID without RFC-9421 signing. A test-callable run_migrations reuses the
production migrate() path, and sqlx gains the macros/migrate features so
#[sqlx::test] is available. CI's test job gets a postgres service behind a
pg_isready health gate so the DB-backed tests can run.

The proving test exercises the owner gate on PUT /visibility end to end:
non-owner is rejected with 400, owner succeeds. signed_request_as sets a JSON
content-type so the Json extractor does not 415 before the handler runs.
Closes the missing-authorization cluster on the signed REST surface, where
'authenticated' meant 'any participant' because identities are permissionless.
Adds two shared helpers in api/mod.rs: require_repo_owner (403) and did_matches,
a method-safe DID comparison that collapses the full did:key vs bare short form
without matching across methods (did:web/did:gitlawb share the base58 space).

Per-route gates:
- merge_pr is owner-only (subsumes branch protection); close_pr and close_issue
  allow the repo owner or the PR/issue author (the issue author is read from the
  git-JSON blob, with a None author falling back to owner-only).
- create_review, create_comment, create_issue_comment, and create_bounty are
  read-gated (participants need read access, not ownership; this also closes the
  private-repo bounty leak).
- the task handlers bind the acting DID to the authenticated signer.
- dispute_bounty, previously ungated, now requires the creator or claimant; the
  other bounty and replica identity checks switch from raw equality to did_matches.

A source-level guard test asserts every in-scope mutation handler still carries
its expected gate marker, so a removed gate or an unclassified new route fails
CI. DB-backed tests (via the integration harness) cover merge owner-only and the
task signer-binding; did_matches has unit coverage for the cross-method case.
GraphQL mutations were a fully unauthenticated parallel write path to the task
system (N2): the /graphql route had no auth layer and the resolvers took the
acting DID as plain arguments. Apply optional_signature to the /graphql POST so
a verified DID is attached when a signature is present (queries stay open), and
thread it into request-scoped data. Each mutation now reads the verified signer
via require_signer and binds the acting DID to it (rejecting an unsigned request
or one whose claimed delegator/assignee/by_did differs from the signer), reusing
did_matches for the comparison. Read-only subscriptions (/graphql/ws) are left
open. A resolver-level test covers the unsigned, mismatched, and matching cases.
get_tree gated on the repo root ("/") regardless of the requested subtree, so
a caller denied a withheld subtree could still enumerate its filenames and blob
SHAs (N3). Gate on the requested path instead, mirroring get_blob, and reject
traversal segments. A cross-caller test shows the rejection is path-scoped: a
non-reader is denied the withheld subtree but passes the gate on a non-withheld
path.
Two authorization gaps surfaced by an adversarial review of the auth stack,
both necessary-but-insufficient siblings of fixes already in it.

complete_task/fail_task bound the acting DID to the signer but never checked
the caller against the task's assignee, and finish_task updated by id alone, so
any authenticated did:key could finish or fail any task in any state. Load the
task and require the caller to be its assignee (REST and GraphQL), and add an
'AND status=claimed' predicate so only a claimed task transitions. The
now-unused by_did request fields are removed.

create_pr/create_issue resolved the repo with get_repo but skipped
authorize_repo_read, so a non-reader could open a PR (firing the owner's
webhooks) or file an issue against a private repo they cannot read. Gate both
on read access like their create_review/create_comment/create_bounty siblings.

Adds DB-backed tests covering non-assignee rejection (including the empty-body
bypass), the claimed-state predicate, and private-repo PR/issue denial.
The guard asserted a marker STRING appeared in each handler body, which a bare
identifier in a comment or log line could satisfy even after the real gate was
deleted, and its body slice over-ran on any handler not declared exactly
'pub async fn'.

- Markers are now gate-shaped: a call (require_repo_owner(, did_matches(,
  authorize_repo_read() or a binding/comparison expression
  (caller != &record.owner_did, let owner_did = auth.0), never a field name.
- Full-line comments are stripped before matching, so a gate that survives only
  as a comment no longer counts as enforced.
- The body slice bounds at the next top-level fn item across pub async,
  pub(crate) async, async, pub, and bare fn forms.
- Adds rows for the now-read-gated create_pr/create_issue and for fork_repo,
  create_repo, set_profile, claim_bounty; register is excluded with a note (it
  trusts the body did, tracked as P3 D3-1).
- Adds a self-test proving a comment-only marker does not satisfy a row.
Five low-severity items from the stack review. The sixth (a did_matches
false-negative on non-canonical multibase encodings of the same key) is left
as-is: it is a self-inflicted owner lockout unreachable with the conforming
client, never an impersonation vector, so decoding keys in the auth path is not
warranted.

- register bound the registered DID to the request body, not the signer, so a
  signed caller could create or refresh a trust row under a victim DID. Bind it
  to auth.0 (403 on mismatch) and add a drift-guard row now that it is gated.
- Bounty submit/approve/cancel returned 400 for an identity-mismatch denial
  where dispute returned 403. Return Forbidden, matching the rest of the surface.
- The visibility/protect owner gates (require_owner and the protect.rs inline
  checks) returned 400 for a non-owner; return 403 like require_repo_owner, and
  assert the exact code in the harness.
- get_tree rejected ./.. segments but not empty interior segments, so a path
  like secret//x could reach git while the gate saw a different string. Reject
  empty interior segments too (the empty path remains the root listing).
- Tighten the get_tree path-scope test to assert an exact 200 on the
  non-withheld branch so a future upstream 4xx/5xx cannot masquerade as
  gate-pass.

Adds a register-binding regression test.
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

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: ba3c6764-397f-48e3-b7c2-8a24e85a17e0

📥 Commits

Reviewing files that changed from the base of the PR and between 8e87100 and 5c5c92b.

📒 Files selected for processing (3)
  • crates/gitlawb-node/src/api/mod.rs
  • crates/gitlawb-node/src/api/protect.rs
  • crates/gitlawb-node/src/api/visibility.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/gitlawb-node/src/api/mod.rs

📝 Walkthrough

Walkthrough

A DID-aware authorization framework is added with did_matches and require_repo_owner utilities, then applied uniformly across all REST and GraphQL mutation handlers to enforce authenticated-signer binding, owner-only gates, and read-access guards. Error classification is corrected from BadRequest to Forbidden. The task state machine is tightened to reject invalid state transitions, get_tree visibility is scoped to the specific requested subtree path, and a full Postgres-backed integration test harness is introduced along with CI Postgres service provisioning.

Changes

Authorization Enforcement Across All API Handlers

Layer / File(s) Summary
DID matching and repo-owner utilities
crates/gitlawb-node/src/api/mod.rs
Adds did_matches (normalizes did:key:<id> vs bare form, blocks cross-method collisions) and require_repo_owner (returns Forbidden on owner mismatch), plus unit tests and an authz drift-guard test that asserts every mutation handler contains an expected gate marker.
REST handler repo authorization and access control
crates/gitlawb-node/src/api/issues.rs, crates/gitlawb-node/src/api/labels.rs, crates/gitlawb-node/src/api/pulls.rs, crates/gitlawb-node/src/api/webhooks.rs, crates/gitlawb-node/src/api/register.rs, crates/gitlawb-node/src/api/replicas.rs, crates/gitlawb-node/src/api/protect.rs, crates/gitlawb-node/src/api/visibility.rs
Replaces direct DB lookups with authorize_repo_read or require_repo_owner calls across create_issue, create_issue_comment, close_issue, add_label, remove_label, create_pr, merge_pr, close_pr, create_review, create_comment, create_webhook, delete_webhook, protect_branch, unprotect_branch, require_owner, and register. Authorization failures uniformly return 403 Forbidden instead of 400 BadRequest.
Bounty handler DID-aware authorization
crates/gitlawb-node/src/api/bounties.rs
Switches submit_bounty, approve_bounty, and cancel_bounty from raw string equality to did_matches; adds a new creator-or-claimant gate to dispute_bounty; all failures return Forbidden.
Path-scoped subtree visibility gate
crates/gitlawb-node/src/api/repos.rs
get_tree validates and normalizes the requested tree path, then passes the specific subtree path into authorize_repo_read instead of always using the repo root /.
Task handler signer binding, state machine, and authorization
crates/gitlawb-node/src/api/tasks.rs, crates/gitlawb-node/src/db/mod.rs
All four task handlers receive Extension<AuthenticatedDid> and enforce signer-to-actor binding; complete_task and fail_task load the existing task and authorize by assignee_did. FailTaskBody.by_did is removed. finish_task DB query is tightened to only update tasks in claimed state.
GraphQL mutation signer enforcement and server integration
crates/gitlawb-node/src/graphql/mutation.rs, crates/gitlawb-node/src/server.rs
Adds require_signer helper in GraphQL context; updates create_task, claim_task, complete_task, fail_task mutations with signer and assignee checks. graphql_handler injects the optional AuthenticatedDid extension into the GraphQL request; optional_signature middleware is added to the /graphql route.
CI Postgres provisioning, test harness, and end-to-end authorization tests
.github/workflows/pr-checks.yml, crates/gitlawb-node/Cargo.toml, crates/gitlawb-node/src/main.rs, crates/gitlawb-node/src/test_support.rs, crates/gitlawb-node/src/db/mod.rs
Adds a Postgres 16 service to CI with DATABASE_URL; adds macros and migrate sqlx features; adds run_migrations DB helper; creates the full test harness (test_state, test_state_lazy, build_state, app, signed_request_as) with eight sqlx::test end-to-end authorization tests covering visibility, merge, task delegation, tree paths, PR/issue read-gating, and DID binding.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Handler
  participant authorize_repo_read
  participant did_matches
  participant DB

  Client->>Handler: HTTP request + AuthenticatedDid
  Handler->>authorize_repo_read: caller DID + repo path
  authorize_repo_read->>DB: load repo record + visibility rules
  DB-->>authorize_repo_read: (record, rules)
  authorize_repo_read->>did_matches: normalize caller vs owner DID
  did_matches-->>authorize_repo_read: match true/false
  alt match passes
    authorize_repo_read-->>Handler: Ok(record, rules)
    Handler-->>Client: 200 / 201
  else match fails
    authorize_repo_read-->>Handler: Forbidden / NotFound
    Handler-->>Client: 403 / 404
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related issues

  • Repo write authorization (Phase 1): owner-only push behind an opt-in flag #31: Establishes DID-based authorization helpers (did_matches, require_repo_owner) and applies them across API handlers, providing the exact infrastructure needed to implement push authorization gating, though this PR does not add the GITLAWB_ENFORCE_OWNER_PUSH flag or modify git_receive_pack that #31 specifically calls for.

Possibly related PRs

  • Gitlawb/node#25: This PR extends path-scoped visibility enforcement to get_tree (using normalized gate_path instead of always /), building on #25's per-path authorization model in git operations.
  • Gitlawb/node#52: Both PRs route REST repo read authorization through the shared authorize_repo_read logic, with overlapping changes in repos.rs and pulls.rs.
  • Gitlawb/node#57: Both PRs modify GitHub Actions pr-checks.yml—this PR adds Postgres service provisioning while #57 restructures CI job matrix/configuration.

Suggested labels

kind:feature, crate:node, subsystem:identity, subsystem:authorization, sev:medium

Suggested reviewers

  • kevincodex1

Poem

🐇 Hop hop, the gates are set,
No stranger DID shall slip the net!
did_matches sniffs each key with care,
Forbidden where before was BadRequest there.
The task machine knows who's assigned,
And tests in Postgres run aligned. 🗄️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: enforcing authorization checks across the REST and GraphQL API surface of the node.
Description check ✅ Passed The description comprehensively covers the summary, motivation, kind of change, concrete changes across multiple crates, testing approach, and known limitations, closely matching the template structure.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/get-tree-path-scope

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

@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.

Actionable comments posted: 1

Caution

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

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

28-35: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use did_matches inside require_owner.

Visibility owner checks still authorize via the last colon-delimited segment. That misses bare/full did:key normalization and preserves the cross-method collision shape the shared helper was added to avoid.

🔒 Proposed fix
 fn require_owner(record: &crate::db::RepoRecord, caller: &str) -> Result<()> {
-    let owner_short = record
-        .owner_did
-        .split(':')
-        .next_back()
-        .unwrap_or(&record.owner_did);
-    if caller != record.owner_did && caller != owner_short {
+    if !crate::api::did_matches(caller, &record.owner_did) {
         return Err(AppError::Forbidden(
             "only the repo owner can manage visibility".into(),
         ));
🤖 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 28 - 35, The
require_owner function is manually splitting the owner_did string to extract the
last colon-delimited segment for comparison, which duplicates logic and misses
proper DID normalization. Replace the manual string splitting logic (the
split(':').next_back().unwrap_or() call) with a call to the did_matches helper
function, passing record.owner_did and caller as arguments. This will ensure
consistent DID normalization across all visibility checks and eliminate the
cross-method collision vulnerability.
crates/gitlawb-node/src/api/protect.rs (1)

27-35: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use did_matches for branch owner checks.

These owner gates still compare against split(':').next_back(), bypassing the new DID-safe normalization. That keeps the full/bare did:key mismatch and trailing-segment collision risk in branch protection.

🔒 Proposed fix
     // Only the repo owner can protect branches
     let caller = &auth.0;
-    let owner_short = record
-        .owner_did
-        .split(':')
-        .next_back()
-        .unwrap_or(&record.owner_did);
-    if caller != &record.owner_did && caller != owner_short {
+    if !crate::api::did_matches(caller.as_str(), &record.owner_did) {
         return Err(AppError::Forbidden(
             "only the repo owner can protect branches".into(),
         ));
     }
@@
 
     let caller = &auth.0;
-    let owner_short = record
-        .owner_did
-        .split(':')
-        .next_back()
-        .unwrap_or(&record.owner_did);
-    if caller != &record.owner_did && caller != owner_short {
+    if !crate::api::did_matches(caller.as_str(), &record.owner_did) {
         return Err(AppError::Forbidden(
             "only the repo owner can unprotect branches".into(),
         ));
     }

Also applies to: 65-72

🤖 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/protect.rs` around lines 27 - 35, Replace the
manual DID comparison logic in the branch protection owner gates with the new
did_matches function. Instead of using split(':').next_back() to extract and
compare the DID segment, locate the did_matches function and use it to safely
compare the caller DID against record.owner_did. This change needs to be applied
in two locations: the owner gate check around line 27-35 where the split and
comparison occurs, and the second location mentioned at lines 65-72. Remove the
owner_short variable and the split logic, replacing it with a did_matches call
that properly handles DID normalization.
crates/gitlawb-node/src/api/repos.rs (1)

312-334: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use the authorized normalized path for the git lookup.

gate_path is built from normalized, but ls_tree still receives the original tree_path, so the path checked by visibility rules can differ from the path resolved by git. Pass the normalized path through to the lookup and response.

🛡️ Proposed fix
     let gate_path = if normalized.is_empty() {
         "/".to_string()
     } else {
         format!("/{normalized}")
     };
     let (record, _rules) =
         crate::api::authorize_repo_read(&state, &owner, &name, caller, &gate_path).await?;

@@
     let head_ref = store::resolve_head(&disk_path, &record.default_branch);
-    let entries = store::ls_tree(&disk_path, &head_ref, &tree_path).unwrap_or_default();
+    let entries = store::ls_tree(&disk_path, &head_ref, normalized).unwrap_or_default();

     Ok(Json(
-        serde_json::json!({ "entries": entries, "path": tree_path }),
+        serde_json::json!({ "entries": entries, "path": normalized }),
     ))
 }
🤖 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 312 - 334, The function
validates and normalizes the tree_path into normalized and constructs gate_path
for authorization checking, but then passes the original tree_path to the
store::ls_tree function. This creates a mismatch where the authorization check
uses the normalized path while the git lookup uses the unnormalized path,
potentially allowing path traversal bypass. Replace the tree_path argument
passed to store::ls_tree with the normalized variable to ensure the git lookup
uses the same validated path that was authorized.
🧹 Nitpick comments (3)
crates/gitlawb-node/src/server.rs (1)

23-35: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add one HTTP-level GraphQL auth regression test.

The resolver tests inject AuthenticatedDid directly, so they do not prove optional_signature plus graphql_handler actually threads the verified signer into GraphQL request data. A small /graphql router test with a signed mutation would protect this integration seam.

Also applies to: 63-66

🤖 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/server.rs` around lines 23 - 35, The graphql_handler
function threads the verified AuthenticatedDid into GraphQL request data via the
optional_signature middleware, but this integration is not tested at the HTTP
level (only resolver-level tests inject AuthenticatedDid directly). Add a
regression test for the /graphql router that sends a signed mutation request and
verifies that the authenticated signer is properly threaded through the
optional_signature middleware into the GraphQL request data, ensuring the
integration seam between authentication and request-scoped GraphQL data is
protected.
crates/gitlawb-node/src/db/mod.rs (1)

1965-1984: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Make task finishing authorization part of the atomic update.

complete_task/fail_task authorize via a prior get_task, but finish_task only updates by id and status. Passing the expected stored assignee into this query and adding it to the WHERE clause keeps the auth check and state transition inseparable.

🔒 Proposed hardening
     pub async fn finish_task(
         &self,
         id: &str,
         new_status: &str,
         result: Option<&str>,
+        expected_assignee_did: &str,
     ) -> Result<AgentTask> {
         let now = Utc::now().to_rfc3339();
         let row = sqlx::query(
             "UPDATE agent_tasks SET status=$2, result=$3, updated_at=$4
-             WHERE id=$1 AND status='claimed'
+             WHERE id=$1 AND status='claimed' AND assignee_did=$5
              RETURNING id, repo_id, kind, status, delegator_did, assignee_did, capability, ucan_token, payload, result, created_at, updated_at, deadline",
         )
         .bind(id)
         .bind(new_status)
         .bind(result)
         .bind(&now)
+        .bind(expected_assignee_did)
         .fetch_optional(&self.pool)
         .await?;
         row.map(row_to_task)
-            .ok_or_else(|| anyhow::anyhow!("task not found or not in claimed state"))
+            .ok_or_else(|| anyhow::anyhow!("task not found, not in claimed state, or not assigned to caller"))
     }
🤖 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/db/mod.rs` around lines 1965 - 1984, The finish_task
method currently authorizes via a separate prior get_task call, but the atomic
update only checks id and claimed status. Add an expected_assignee parameter to
the finish_task function signature and include a check for assignee_did in the
WHERE clause of the UPDATE query (comparing against the passed expected_assignee
value). This makes the authorization verification and state transition
inseparable at the database level, preventing race conditions where
authorization could change between the separate authorization check and the
actual update.
crates/gitlawb-node/src/test_support.rs (1)

45-51: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider using a more obviously invalid database URL.

The placeholder URL postgres://localhost/gitlawb_test_placeholder could accidentally connect if localhost has a database with that name. Consider using an obviously fake URL like postgres://test-placeholder-never-connect/invalid to make accidental connections fail fast with a clear error.

🤖 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/test_support.rs` around lines 45 - 51, The database
URL passed to connect_lazy in the test_state_lazy function uses a plausible
database name that could potentially connect to an actual database if someone
creates it locally. Replace the current URL
postgres://localhost/gitlawb_test_placeholder with an obviously invalid and
unreachable URL such as postgres://test-placeholder-never-connect/invalid to
ensure the lazy connection fails fast with a clear error if it's ever actually
attempted.
🤖 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.

Inline comments:
In `@crates/gitlawb-node/src/api/mod.rs`:
- Around line 207-212: The guard expressions for the protect and visibility
operations need to be updated to enforce DID-safe owner matching. For the
protect rows (protect_branch and unprotect_branch), replace the legacy raw owner
comparison "caller != &record.owner_did" with a guard that uses proper DID-safe
comparison instead of direct string equality. For the visibility rows
(set_visibility, remove_visibility, and list_visibility), replace the current
guard pattern "require_owner(" which only checks for method presence with a
guard that verifies the actual use of did_matches for owner validation, ensuring
the implementation properly validates DIDs rather than using trailing-segment
matching.

---

Outside diff comments:
In `@crates/gitlawb-node/src/api/protect.rs`:
- Around line 27-35: Replace the manual DID comparison logic in the branch
protection owner gates with the new did_matches function. Instead of using
split(':').next_back() to extract and compare the DID segment, locate the
did_matches function and use it to safely compare the caller DID against
record.owner_did. This change needs to be applied in two locations: the owner
gate check around line 27-35 where the split and comparison occurs, and the
second location mentioned at lines 65-72. Remove the owner_short variable and
the split logic, replacing it with a did_matches call that properly handles DID
normalization.

In `@crates/gitlawb-node/src/api/repos.rs`:
- Around line 312-334: The function validates and normalizes the tree_path into
normalized and constructs gate_path for authorization checking, but then passes
the original tree_path to the store::ls_tree function. This creates a mismatch
where the authorization check uses the normalized path while the git lookup uses
the unnormalized path, potentially allowing path traversal bypass. Replace the
tree_path argument passed to store::ls_tree with the normalized variable to
ensure the git lookup uses the same validated path that was authorized.

In `@crates/gitlawb-node/src/api/visibility.rs`:
- Around line 28-35: The require_owner function is manually splitting the
owner_did string to extract the last colon-delimited segment for comparison,
which duplicates logic and misses proper DID normalization. Replace the manual
string splitting logic (the split(':').next_back().unwrap_or() call) with a call
to the did_matches helper function, passing record.owner_did and caller as
arguments. This will ensure consistent DID normalization across all visibility
checks and eliminate the cross-method collision vulnerability.

---

Nitpick comments:
In `@crates/gitlawb-node/src/db/mod.rs`:
- Around line 1965-1984: The finish_task method currently authorizes via a
separate prior get_task call, but the atomic update only checks id and claimed
status. Add an expected_assignee parameter to the finish_task function signature
and include a check for assignee_did in the WHERE clause of the UPDATE query
(comparing against the passed expected_assignee value). This makes the
authorization verification and state transition inseparable at the database
level, preventing race conditions where authorization could change between the
separate authorization check and the actual update.

In `@crates/gitlawb-node/src/server.rs`:
- Around line 23-35: The graphql_handler function threads the verified
AuthenticatedDid into GraphQL request data via the optional_signature
middleware, but this integration is not tested at the HTTP level (only
resolver-level tests inject AuthenticatedDid directly). Add a regression test
for the /graphql router that sends a signed mutation request and verifies that
the authenticated signer is properly threaded through the optional_signature
middleware into the GraphQL request data, ensuring the integration seam between
authentication and request-scoped GraphQL data is protected.

In `@crates/gitlawb-node/src/test_support.rs`:
- Around line 45-51: The database URL passed to connect_lazy in the
test_state_lazy function uses a plausible database name that could potentially
connect to an actual database if someone creates it locally. Replace the current
URL postgres://localhost/gitlawb_test_placeholder with an obviously invalid and
unreachable URL such as postgres://test-placeholder-never-connect/invalid to
ensure the lazy connection fails fast with a clear error if it's ever actually
attempted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cc8f8fdc-22dd-4bed-be14-8d3bec94a10a

📥 Commits

Reviewing files that changed from the base of the PR and between ff492b4 and 8e87100.

📒 Files selected for processing (19)
  • .github/workflows/pr-checks.yml
  • crates/gitlawb-node/Cargo.toml
  • crates/gitlawb-node/src/api/bounties.rs
  • crates/gitlawb-node/src/api/issues.rs
  • crates/gitlawb-node/src/api/labels.rs
  • crates/gitlawb-node/src/api/mod.rs
  • crates/gitlawb-node/src/api/protect.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/api/replicas.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/api/tasks.rs
  • crates/gitlawb-node/src/api/visibility.rs
  • crates/gitlawb-node/src/api/webhooks.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/graphql/mutation.rs
  • crates/gitlawb-node/src/main.rs
  • crates/gitlawb-node/src/server.rs
  • crates/gitlawb-node/src/test_support.rs

Comment thread crates/gitlawb-node/src/api/mod.rs
CodeRabbit flagged that protect_branch/unprotect_branch and visibility's
require_owner used a trailing-segment owner compare (split(':').next_back())
distinct from the hardened did_matches, so the two owner-match idioms could
drift. It is not exploitable (the authenticated caller is always the full did:
form while the short form is a bare suffix, so no wrong caller passes), but it
is a real inconsistency, and the trailing-segment form is a false-negative for a
bare-stored owner. Replace both with did_matches (collapses did:key full vs bare
on both sides, never across methods), and extend the drift guard to assert
require_owner itself uses did_matches, not just that it is called.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

@kevincodex1 ready for you when you have a chance. CI is green and CodeRabbit's one comment is addressed and resolved. The description has the full rundown; short version, it locks down per-route authorization across the REST and GraphQL write surface and adds the DB-backed test harness that was missing (143 node tests now run against real Postgres in CI).

@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.

Looks good to me!

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.

2 participants