Skip to content

Commit 6abaf1d

Browse files
authored
feat: path-scoped repository visibility (Phase 1) for #18 (#25)
* feat: path-scoped repository visibility (Phase 1) Implements per-path read visibility for repositories, addressing #18. A repo's private status becomes a property of a path subtree rather than a whole-repo boolean, enforced on the git read path via the node's existing DID identity and RFC 9421 HTTP signatures. Phase 1 scope (whole-repo enforcement, data model for the rest): - visibility_rules data model (path_glob, mode, reader_dids) + DB access - visibility_check pure decision function (owner-always-allow, most- specific rule wins, reader-DID allow-list, is_public fallback) - enforcement on git_info_refs and git_upload_pack: unauthorized reads return 404 byte-identical to a missing repo (no existence leak) - owner-only management API: PUT/DELETE/GET /api/v1/repos/{o}/{r}/visibility - gl visibility set/remove/list CLI mode A (hide) is whole-repo ("/") only by construction: hiding a subtree's existence would rewrite ancestor tree hashes and diverge history. Subtrees use mode B (content withheld, SHAs intact); B-subtree clone filtering is deferred to a later phase and the CLI/API say so. 260 tests (69 node + 191 gl); fmt + clippy clean. * fix: address review on path-scoped visibility - migrations: move visibility_rules DDL out of the v1 migration into a new v3 (CodeRabbit, critical). Appending to v1 meant nodes that had already recorded v1 would skip the statements forever, so the table would be missing after upgrade. v3 matches the convention #23 set with v2 (agent_profiles) and the documented 'never append to v1' rule. - api: validate path_glob on set (must start with '/', no trailing '/', only a trailing '/**' wildcard, '/' for whole-repo not '/**'). Empty or slash-less globs would otherwise silently misconfigure access. Added unit tests for the accepted/rejected forms. 281 -> 283 tests pass (88 node + 195 gl); fmt + clippy clean.
1 parent 9e6e832 commit 6abaf1d

10 files changed

Lines changed: 959 additions & 4 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ pub mod repos;
1717
pub mod resolve;
1818
pub mod stars;
1919
pub mod tasks;
20+
pub mod visibility;
2021
pub mod webhooks;

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::cert;
1414
use crate::error::{AppError, Result};
1515
use crate::git::{smart_http, store};
1616
use crate::state::AppState;
17+
use crate::visibility::{visibility_check, Decision};
1718
use crate::webhooks;
1819

1920
// ── Request / Response types ───────────────────────────────────────────────
@@ -308,6 +309,7 @@ pub async fn git_info_refs(
308309
State(state): State<AppState>,
309310
Path((owner, repo)): Path<(String, String)>,
310311
Query(query): Query<InfoRefsQuery>,
312+
auth: Option<Extension<AuthenticatedDid>>,
311313
) -> Result<Response> {
312314
let name = repo.trim_end_matches(".git");
313315
tracing::info!(owner = %owner, repo = %name, "info/refs request");
@@ -322,6 +324,20 @@ pub async fn git_info_refs(
322324
.ok_or_else(|| AppError::BadRequest("missing ?service= parameter".into()))?;
323325
tracing::debug!(service = %service, repo = %name, "info/refs service");
324326

327+
// Enforce read (clone/fetch) visibility. The push advertisement
328+
// (service=git-receive-pack) is authorized separately on the
329+
// git-receive-pack POST, so leave it untouched here.
330+
if service == "git-upload-pack" {
331+
let rules = state.db.list_visibility_rules(&record.id).await?;
332+
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
333+
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/")
334+
== Decision::Deny
335+
{
336+
tracing::debug!(repo = %name, caller = ?caller, "info/refs read denied by visibility");
337+
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
338+
}
339+
}
340+
325341
// For receive-pack (push), download the latest from Tigris so the client
326342
// sees the same refs that acquire_write() will operate on.
327343
let disk_path = if service == "git-receive-pack" {
@@ -352,6 +368,7 @@ pub async fn git_info_refs(
352368
pub async fn git_upload_pack(
353369
State(state): State<AppState>,
354370
Path((owner, repo)): Path<(String, String)>,
371+
auth: Option<Extension<AuthenticatedDid>>,
355372
body: Bytes,
356373
) -> Result<Response> {
357374
let name = repo.trim_end_matches(".git");
@@ -361,6 +378,14 @@ pub async fn git_upload_pack(
361378
.await?
362379
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
363380

381+
let rules = state.db.list_visibility_rules(&record.id).await?;
382+
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
383+
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
384+
{
385+
tracing::debug!(repo = %name, caller = ?caller, "upload-pack read denied by visibility");
386+
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
387+
}
388+
364389
let disk_path = state
365390
.repo_store
366391
.acquire(&record.owner_did, &record.name)
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
//! Path-scoped visibility management API. Owner-only, mirrors `api/protect.rs`.
2+
3+
use axum::extract::{Extension, Path, State};
4+
use axum::http::StatusCode;
5+
use axum::Json;
6+
use serde::Deserialize;
7+
8+
use crate::auth::AuthenticatedDid;
9+
use crate::db::VisibilityMode;
10+
use crate::error::{AppError, Result};
11+
use crate::state::AppState;
12+
13+
#[derive(Deserialize)]
14+
pub struct SetVisibilityRequest {
15+
pub path_glob: String,
16+
/// "a" or "b"; defaults to "b" if absent or unrecognized.
17+
#[serde(default)]
18+
pub mode: Option<String>,
19+
#[serde(default)]
20+
pub reader_dids: Vec<String>,
21+
}
22+
23+
#[derive(Deserialize)]
24+
pub struct RemoveVisibilityRequest {
25+
pub path_glob: String,
26+
}
27+
28+
fn require_owner(record: &crate::db::RepoRecord, caller: &str) -> Result<()> {
29+
let owner_short = record
30+
.owner_did
31+
.split(':')
32+
.next_back()
33+
.unwrap_or(&record.owner_did);
34+
if caller != record.owner_did && caller != owner_short {
35+
return Err(AppError::BadRequest(
36+
"only the repo owner can manage visibility".into(),
37+
));
38+
}
39+
Ok(())
40+
}
41+
42+
/// Reject malformed globs before they reach the store, where they would
43+
/// silently misconfigure access (an empty glob behaves like "/", and a glob
44+
/// without a leading "/" never matches a real repo path). The accepted forms
45+
/// match what `visibility_check` understands: "/", "/prefix", or "/prefix/**".
46+
fn validate_path_glob(path_glob: &str) -> Result<()> {
47+
if !path_glob.starts_with('/') {
48+
return Err(AppError::BadRequest("path_glob must start with '/'".into()));
49+
}
50+
if path_glob == "/**" {
51+
return Err(AppError::BadRequest(
52+
"use '/' for whole-repo scope, not '/**'".into(),
53+
));
54+
}
55+
if path_glob != "/" && path_glob.ends_with('/') {
56+
return Err(AppError::BadRequest(
57+
"path_glob must not end with '/'".into(),
58+
));
59+
}
60+
if path_glob.contains('*') && !path_glob.ends_with("/**") {
61+
return Err(AppError::BadRequest(
62+
"the only supported wildcard is a trailing '/**'".into(),
63+
));
64+
}
65+
Ok(())
66+
}
67+
68+
/// PUT /api/v1/repos/{owner}/{repo}/visibility
69+
pub async fn set_visibility(
70+
State(state): State<AppState>,
71+
Extension(auth): Extension<AuthenticatedDid>,
72+
Path((owner, repo)): Path<(String, String)>,
73+
Json(req): Json<SetVisibilityRequest>,
74+
) -> Result<(StatusCode, Json<serde_json::Value>)> {
75+
let record = state
76+
.db
77+
.get_repo(&owner, &repo)
78+
.await?
79+
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
80+
require_owner(&record, &auth.0)?;
81+
validate_path_glob(&req.path_glob)?;
82+
83+
let mode = match req.mode.as_deref() {
84+
Some("a") => VisibilityMode::A,
85+
_ => VisibilityMode::B,
86+
};
87+
88+
// Mode A hides existence and is only coherent for the whole repo; a subtree
89+
// cannot hide its existence without rewriting git history (see spec).
90+
if mode == VisibilityMode::A && req.path_glob != "/" {
91+
return Err(AppError::BadRequest(
92+
"mode 'a' (hide) is only allowed for the whole repo (path_glob '/'); use mode 'b' for subtrees".into(),
93+
));
94+
}
95+
96+
// An empty reader_dids list is valid and intentional: the owner is always
97+
// allowed by visibility_check, so a "/" rule with no readers is exactly the
98+
// whole-repo "private to owner only" case.
99+
state
100+
.db
101+
.set_visibility_rule(&record.id, &req.path_glob, mode, &req.reader_dids, &auth.0)
102+
.await?;
103+
104+
let subtree_warning = req.path_glob != "/";
105+
tracing::info!(
106+
repo = %repo, caller = %auth.0, path_glob = %req.path_glob, mode = %mode.as_str(),
107+
subtree_pending = subtree_warning,
108+
"visibility rule set"
109+
);
110+
111+
Ok((
112+
StatusCode::CREATED,
113+
Json(serde_json::json!({
114+
"status": "set",
115+
"repo": format!("{owner}/{repo}"),
116+
"path_glob": req.path_glob,
117+
"mode": mode.as_str(),
118+
"reader_dids": req.reader_dids,
119+
"subtree_clone_enforcement_pending": subtree_warning,
120+
})),
121+
))
122+
}
123+
124+
/// DELETE /api/v1/repos/{owner}/{repo}/visibility
125+
pub async fn remove_visibility(
126+
State(state): State<AppState>,
127+
Extension(auth): Extension<AuthenticatedDid>,
128+
Path((owner, repo)): Path<(String, String)>,
129+
Json(req): Json<RemoveVisibilityRequest>,
130+
) -> Result<Json<serde_json::Value>> {
131+
let record = state
132+
.db
133+
.get_repo(&owner, &repo)
134+
.await?
135+
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
136+
require_owner(&record, &auth.0)?;
137+
138+
state
139+
.db
140+
.remove_visibility_rule(&record.id, &req.path_glob)
141+
.await?;
142+
143+
tracing::info!(
144+
repo = %repo, caller = %auth.0, path_glob = %req.path_glob,
145+
"visibility rule removed"
146+
);
147+
148+
Ok(Json(serde_json::json!({
149+
"status": "removed",
150+
"repo": format!("{owner}/{repo}"),
151+
"path_glob": req.path_glob,
152+
})))
153+
}
154+
155+
/// GET /api/v1/repos/{owner}/{repo}/visibility
156+
pub async fn list_visibility(
157+
State(state): State<AppState>,
158+
Extension(auth): Extension<AuthenticatedDid>,
159+
Path((owner, repo)): Path<(String, String)>,
160+
) -> Result<Json<serde_json::Value>> {
161+
let record = state
162+
.db
163+
.get_repo(&owner, &repo)
164+
.await?
165+
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
166+
require_owner(&record, &auth.0)?;
167+
168+
let rules = state.db.list_visibility_rules(&record.id).await?;
169+
let rules_json: Vec<_> = rules
170+
.into_iter()
171+
.map(|r| {
172+
serde_json::json!({
173+
"path_glob": r.path_glob,
174+
"mode": r.mode.as_str(),
175+
"reader_dids": r.reader_dids,
176+
"created_by": r.created_by,
177+
})
178+
})
179+
.collect();
180+
181+
Ok(Json(serde_json::json!({
182+
"repo": format!("{owner}/{repo}"),
183+
"count": rules_json.len(),
184+
"rules": rules_json,
185+
})))
186+
}
187+
188+
#[cfg(test)]
189+
mod tests {
190+
use super::validate_path_glob;
191+
192+
#[test]
193+
fn accepts_supported_globs() {
194+
for g in ["/", "/secret", "/secret/**", "/a/b/c", "/a/b/**"] {
195+
assert!(validate_path_glob(g).is_ok(), "{g} should be valid");
196+
}
197+
}
198+
199+
#[test]
200+
fn rejects_malformed_globs() {
201+
// empty, no leading slash, whole-repo via "/**", trailing slash, and
202+
// non-trailing wildcards are all rejected.
203+
for g in ["", "secret/**", "/**", "/secret/", "/a*b", "/*/x"] {
204+
assert!(validate_path_glob(g).is_err(), "{g} should be rejected");
205+
}
206+
}
207+
}

0 commit comments

Comments
 (0)