Skip to content

Commit 2202b00

Browse files
authored
feat(node): enforce per-route authorization across the REST and GraphQL surface (#87)
* test(node): add HTTP-API integration harness with CI Postgres 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. * feat(node): enforce per-route authorization on REST mutations 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. * feat(node): authenticate GraphQL mutations 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. * fix(node): path-scope the get_tree visibility gate 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. * fix(node): authorize task completion and read-gate PR/issue creation 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. * test(node): harden the authz drift-guard against false passes 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. * fix(node): P3 consistency batch from the adversarial review 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. * fix(node): unify protect/visibility owner checks on did_matches 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.
1 parent ff492b4 commit 2202b00

19 files changed

Lines changed: 1153 additions & 91 deletions

File tree

‎.github/workflows/pr-checks.yml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,23 @@ jobs:
5454
toolchain: [stable, beta]
5555
# Beta is informational: an upstream beta regression should warn, not block merges.
5656
continue-on-error: ${{ matrix.toolchain == 'beta' }}
57+
# Postgres for the DB-backed integration tests (#[sqlx::test] provisions an
58+
# isolated database per test off this connection). The health-check gate keeps
59+
# `cargo test` from racing a not-yet-ready server.
60+
services:
61+
postgres:
62+
image: postgres:16
63+
env:
64+
POSTGRES_USER: postgres
65+
POSTGRES_PASSWORD: postgres
66+
POSTGRES_DB: gitlawb_test
67+
ports:
68+
- 5432:5432
69+
options: >-
70+
--health-cmd pg_isready
71+
--health-interval 10s
72+
--health-timeout 5s
73+
--health-retries 5
5774
steps:
5875
- name: Check out repository
5976
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -71,6 +88,8 @@ jobs:
7188
key: ${{ matrix.toolchain }}
7289

7390
- name: cargo test
91+
env:
92+
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
7493
run: cargo test --workspace
7594

7695
build-release:

‎crates/gitlawb-node/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ async-graphql-axum = "7"
2828
tokio-stream = { version = "0.1", features = ["sync"] }
2929
tower = "0.5"
3030
tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "limit"] }
31-
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid"] }
31+
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid", "macros", "migrate"] }
3232
clap = { version = "4", features = ["derive", "env"] }
3333
bytes = "1"
3434
libc = "0.2"

‎crates/gitlawb-node/src/api/bounties.rs‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,9 @@ pub async fn create_bounty(
7979
return Err(AppError::BadRequest("amount must be positive".into()));
8080
}
8181

82-
// Verify repo exists
83-
let _ = state
84-
.db
85-
.get_repo(&owner, &repo)
86-
.await?
87-
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
82+
// Read-gate: anyone who can read the repo may fund a bounty on it (this closes
83+
// the private-repo bounty leak); ownership is not required.
84+
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
8885

8986
let now = Utc::now().to_rfc3339();
9087
let bounty = BountyRecord {
@@ -213,8 +210,12 @@ pub async fn submit_bounty(
213210
bounty.status
214211
)));
215212
}
216-
if bounty.claimant_did.as_deref() != Some(&auth.0) {
217-
return Err(AppError::BadRequest("only the claimant can submit".into()));
213+
let is_claimant = bounty
214+
.claimant_did
215+
.as_deref()
216+
.is_some_and(|c| crate::api::did_matches(&auth.0, c));
217+
if !is_claimant {
218+
return Err(AppError::Forbidden("only the claimant can submit".into()));
218219
}
219220

220221
let now = Utc::now().to_rfc3339();
@@ -249,8 +250,8 @@ pub async fn approve_bounty(
249250
bounty.status
250251
)));
251252
}
252-
if bounty.creator_did != auth.0 {
253-
return Err(AppError::BadRequest(
253+
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
254+
return Err(AppError::Forbidden(
254255
"only the bounty creator can approve".into(),
255256
));
256257
}
@@ -296,8 +297,8 @@ pub async fn cancel_bounty(
296297
bounty.status
297298
)));
298299
}
299-
if bounty.creator_did != auth.0 {
300-
return Err(AppError::BadRequest(
300+
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
301+
return Err(AppError::Forbidden(
301302
"only the bounty creator can cancel".into(),
302303
));
303304
}
@@ -332,6 +333,19 @@ pub async fn dispute_bounty(
332333
)));
333334
}
334335

336+
// Only the bounty creator or the current claimant may dispute (N19 — this
337+
// endpoint had no identity check at all).
338+
let is_creator = crate::api::did_matches(&auth.0, &bounty.creator_did);
339+
let is_claimant = bounty
340+
.claimant_did
341+
.as_deref()
342+
.is_some_and(|c| crate::api::did_matches(&auth.0, c));
343+
if !is_creator && !is_claimant {
344+
return Err(AppError::Forbidden(
345+
"only the bounty creator or claimant can dispute this bounty".into(),
346+
));
347+
}
348+
335349
// Check if deadline exceeded
336350
if let Some(ref claimed_at) = bounty.claimed_at {
337351
if let Ok(claimed) = chrono::DateTime::parse_from_rfc3339(claimed_at) {

‎crates/gitlawb-node/src/api/issues.rs‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ pub async fn create_issue(
3939
Path((owner, repo)): Path<(String, String)>,
4040
Json(req): Json<CreateIssueRequest>,
4141
) -> Result<(StatusCode, Json<IssueRecord>)> {
42-
let record = state
43-
.db
44-
.get_repo(&owner, &repo)
45-
.await?
46-
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
42+
// Authorize the caller as a reader before accepting an issue: a non-reader
43+
// must not be able to file an issue against a private repo they cannot read.
44+
// Mirrors create_issue_comment / create_review / create_bounty.
45+
let (record, _rules) =
46+
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
4747

4848
let issue_id = Uuid::new_v4().to_string();
4949
let now = Utc::now().to_rfc3339();
@@ -161,11 +161,9 @@ pub async fn create_issue_comment(
161161
));
162162
}
163163

164-
let record = state
165-
.db
166-
.get_repo(&owner, &repo)
167-
.await?
168-
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
164+
// Read-gate: a commenter must be able to read the repo, but need not own it.
165+
let (record, _rules) =
166+
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
169167

170168
let disk_path = state
171169
.repo_store
@@ -207,7 +205,7 @@ pub async fn list_issue_comments(
207205
/// POST /api/v1/repos/{owner}/{repo}/issues/{id}/close
208206
pub async fn close_issue(
209207
State(state): State<AppState>,
210-
Extension(_auth): Extension<AuthenticatedDid>,
208+
Extension(auth): Extension<AuthenticatedDid>,
211209
Path((owner, repo, issue_id)): Path<(String, String, String)>,
212210
) -> Result<Json<serde_json::Value>> {
213211
let record = state
@@ -223,6 +221,33 @@ pub async fn close_issue(
223221
.map_err(|e| AppError::Git(e.to_string()))?;
224222
let disk_path = guard.path().to_path_buf();
225223

224+
// Owner OR issue author may close. The author lives in the issue's git-JSON
225+
// blob (not a DB column); a None author (legacy issues) falls back to
226+
// owner-only. Read it under the write guard, before mutating.
227+
let author_did: Option<String> = match git_issues::get_issue(&disk_path, &issue_id) {
228+
Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
229+
.ok()
230+
.and_then(|i| i.author),
231+
Ok(None) => {
232+
guard.release(false).await;
233+
return Err(AppError::NotFound(format!("issue {issue_id} not found")));
234+
}
235+
Err(e) => {
236+
guard.release(false).await;
237+
return Err(AppError::Git(e.to_string()));
238+
}
239+
};
240+
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
241+
let is_author = author_did
242+
.as_deref()
243+
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
244+
if !is_owner && !is_author {
245+
guard.release(false).await;
246+
return Err(AppError::Forbidden(
247+
"only the repo owner or the issue author can close this issue".into(),
248+
));
249+
}
250+
226251
let close_result = git_issues::close_issue(&disk_path, &issue_id);
227252

228253
// Always release the advisory lock — even on error; upload to Tigris only on success.

‎crates/gitlawb-node/src/api/labels.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ pub struct LabelRequest {
1717
/// POST /api/v1/repos/:owner/:repo/labels
1818
pub async fn add_label(
1919
State(state): State<AppState>,
20-
Extension(_auth): Extension<AuthenticatedDid>,
20+
Extension(auth): Extension<AuthenticatedDid>,
2121
Path((owner, name)): Path<(String, String)>,
2222
Json(req): Json<LabelRequest>,
2323
) -> Result<(StatusCode, Json<serde_json::Value>)> {
@@ -39,6 +39,7 @@ pub async fn add_label(
3939
.get_repo(&owner, &name)
4040
.await?
4141
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
42+
crate::api::require_repo_owner(&record, &auth.0)?;
4243

4344
let added = state.db.add_label(&record.id, &label).await?;
4445
let status = if added {
@@ -55,14 +56,15 @@ pub async fn add_label(
5556
/// DELETE /api/v1/repos/:owner/:repo/labels/:label
5657
pub async fn remove_label(
5758
State(state): State<AppState>,
58-
Extension(_auth): Extension<AuthenticatedDid>,
59+
Extension(auth): Extension<AuthenticatedDid>,
5960
Path((owner, name, label)): Path<(String, String, String)>,
6061
) -> Result<Json<serde_json::Value>> {
6162
let record = state
6263
.db
6364
.get_repo(&owner, &name)
6465
.await?
6566
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
67+
crate::api::require_repo_owner(&record, &auth.0)?;
6668

6769
state.db.remove_label(&record.id, &label).await?;
6870
Ok(Json(serde_json::json!({ "label": label, "removed": true })))

0 commit comments

Comments
 (0)