Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions crates/gitlawb-node/src/api/replicas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ pub async fn register_replica(
) -> Result<(StatusCode, Json<serde_json::Value>)> {
validate_replica_url(&req.url)?;

let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// The mutation response carries repo metadata (name, replica count), so
// registration applies the same read-visibility decision as the listing:
// a caller who may not read the repo gets the same 404 as a missing one.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;

let replica_did = &auth.0;

Expand Down Expand Up @@ -93,11 +93,10 @@ pub async fn unregister_replica(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// Same gate as register_replica: removal mutates repo-scoped metadata and
// its response discloses the replica count.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;

let replica_did = &auth.0;
state.db.unregister_replica(&record.id, replica_did).await?;
Expand Down
294 changes: 294 additions & 0 deletions crates/gitlawb-node/src/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1427,6 +1427,300 @@ mod tests {
assert_eq!(resp.status(), StatusCode::NOT_FOUND, "absent repo → 404");
}

/// #435: register_replica / unregister_replica apply the same read-visibility
/// gate as list_replicas. Both mutate caller-bound replica metadata and their
/// responses disclose the replica count, so a non-reader of a private repo
/// gets the same 404 as a missing repo and nothing is written. Authorized
/// readers keep self-registering and self-removing.
#[sqlx::test]
async fn replica_register_unregister_are_read_visibility_gated(pool: PgPool) {
use crate::db::VisibilityMode;
let owner = "did:key:zREPMUTOWNERRRRRRRRRRRRRRRRRRRRRRRRRR";
let reader = "did:key:zREPMUTREADERRRRRRRRRRRRRRRRRRRRRRRRR";
let stranger = "did:key:zREPMUTSTRGRRRRRRRRRRRRRRRRRRRRRRRRR";
let state = test_state(pool).await;

let mut priv_repo = seed_repo(owner, "repmut-priv");
priv_repo.is_public = false;
state
.db
.create_repo(&priv_repo)
.await
.expect("seed private repo");
let pub_repo = seed_repo(owner, "repmut-pub");
state
.db
.create_repo(&pub_repo)
.await
.expect("seed public repo");
// A listed reader of the private repo keeps mutation rights.
state
.db
.set_visibility_rule(
&priv_repo.id,
"/",
VisibilityMode::B,
&[reader.to_string()],
owner,
)
.await
.expect("seed reader rule");

let router = || {
Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/replicas",
axum::routing::put(crate::api::replicas::register_replica)
.delete(crate::api::replicas::unregister_replica),
)
.with_state(state.clone())
};
let replica_url = "https://replica.example.com/me";
let body = || Body::from(format!(r#"{{"url":"{replica_url}"}}"#));
let leaks = |bytes: &[u8]| {
let text = String::from_utf8_lossy(bytes);
text.contains(replica_url) || text.contains(&priv_repo.id)
};

// Private repo, non-reader stranger: register → 404, nothing written.
let resp = router()
.oneshot(signed_request_as(
stranger,
Method::PUT,
&format!("/api/v1/repos/{owner}/repmut-priv/replicas"),
body(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"a non-reader must not register on a private repo"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert!(
!leaks(&bytes),
"a denied register body must not leak the replica url or repo data"
);
assert!(
state
.db
.list_replicas(&priv_repo.id)
.await
.unwrap()
.is_empty(),
"a denied register must not create a replica row"
);

// Private repo, non-reader stranger: unregister → 404.
let resp = router()
.oneshot(signed_request_as(
stranger,
Method::DELETE,
&format!("/api/v1/repos/{owner}/repmut-priv/replicas"),
Body::empty(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"a non-reader must not unregister on a private repo"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert!(
!leaks(&bytes),
"a denied unregister body must not leak the replica url or repo data"
);

// Private repo, listed reader: register → 201, unregister → 200.
let resp = router()
.oneshot(signed_request_as(
reader,
Method::PUT,
&format!("/api/v1/repos/{owner}/repmut-priv/replicas"),
body(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"a listed reader keeps self-registration on a private repo"
);
let resp = router()
.oneshot(signed_request_as(
reader,
Method::DELETE,
&format!("/api/v1/repos/{owner}/repmut-priv/replicas"),
Body::empty(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"a listed reader keeps self-removal on a private repo"
);

// Public repo, authenticated stranger: register → 201.
let resp = router()
.oneshot(signed_request_as(
stranger,
Method::PUT,
&format!("/api/v1/repos/{owner}/repmut-pub/replicas"),
body(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"public-repo registration stays open to any authenticated replica"
);

// Owner self-registration is still refused.
let resp = router()
.oneshot(signed_request_as(
owner,
Method::PUT,
&format!("/api/v1/repos/{owner}/repmut-pub/replicas"),
body(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"the owner still cannot register as their own replica"
);
}

/// #435 end-to-end: drive the replica mutations through the PRODUCTION
/// router (`app` → require_signature → handler) with real RFC-9421
/// signatures, so the whole verify-then-authorize stack is exercised, not
/// just the handler with an injected DID. A stranger on a private repo
/// gets the same 404 as a missing repo with nothing written; a listed
/// reader registers and removes itself.
#[sqlx::test]
async fn replica_mutations_enforce_visibility_through_real_signature_e2e(pool: PgPool) {
use crate::db::VisibilityMode;
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;

let owner_kp = Keypair::generate();
let owner_did = owner_kp.did().to_string();
// Short owner form in the path keeps the signed @path byte-identical
// to what the node sees (no colons) while did_matches still resolves.
let short = owner_did.split(':').next_back().unwrap().to_string();
let stranger_kp = Keypair::generate();
let reader_kp = Keypair::generate();
let reader_did = reader_kp.did().to_string();

let state = test_state(pool.clone()).await;
let mut repo = seed_repo(&owner_did, "sig-repl-priv");
repo.is_public = false;
state
.db
.create_repo(&repo)
.await
.expect("seed private repo");
state
.db
.set_visibility_rule(&repo.id, "/", VisibilityMode::B, &[reader_did], &owner_did)
.await
.expect("seed reader rule");

let router = app(pool).await;
let path = format!("/api/v1/repos/{short}/sig-repl-priv/replicas");
let replica_url = "https://replica.example.com/e2e";
let reg_body: &[u8] = br#"{"url":"https://replica.example.com/e2e"}"#;
let leaks = |bytes: &[u8]| {
let text = String::from_utf8_lossy(bytes);
text.contains(replica_url) || text.contains(&repo.id)
};
let signed_req = |kp: &Keypair, method: &str, body: &'static [u8]| {
let signed = sign_request(kp, method, &path, body);
Request::builder()
.method(method)
.uri(&path)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::from(body))
.unwrap()
};

// Stranger (verified signature, not a reader) → 404, no row written.
let resp = router
.clone()
.oneshot(signed_req(&stranger_kp, "PUT", reg_body))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"a verified non-reader must not register on a private repo"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert!(
!leaks(&bytes),
"a denied register body must not leak the replica url or repo data"
);
assert!(
state.db.list_replicas(&repo.id).await.unwrap().is_empty(),
"a denied register must not create a replica row"
);

// Stranger DELETE → 404.
let resp = router
.clone()
.oneshot(signed_req(&stranger_kp, "DELETE", b""))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"a verified non-reader must not unregister on a private repo"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert!(
!leaks(&bytes),
"a denied unregister body must not leak the replica url or repo data"
);

// Listed reader: PUT → 201, DELETE → 200.
let resp = router
.clone()
.oneshot(signed_req(&reader_kp, "PUT", reg_body))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"a listed reader registers through the real middleware"
);
let resp = router
.clone()
.oneshot(signed_req(&reader_kp, "DELETE", b""))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"a listed reader unregisters through the real middleware"
);
}

/// #94 sibling: list_labels is read-visibility-gated. A public repo's labels
/// stay anonymously listable; a private repo's label names must not leak to a
/// non-reader (404). A listed reader of the private repo reads the label; the
Expand Down
Loading