Skip to content

Commit b5fc7dd

Browse files
committed
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.
1 parent 09a3397 commit b5fc7dd

10 files changed

Lines changed: 906 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: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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+
/// PUT /api/v1/repos/{owner}/{repo}/visibility
43+
pub async fn set_visibility(
44+
State(state): State<AppState>,
45+
Extension(auth): Extension<AuthenticatedDid>,
46+
Path((owner, repo)): Path<(String, String)>,
47+
Json(req): Json<SetVisibilityRequest>,
48+
) -> Result<(StatusCode, Json<serde_json::Value>)> {
49+
let record = state
50+
.db
51+
.get_repo(&owner, &repo)
52+
.await?
53+
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
54+
require_owner(&record, &auth.0)?;
55+
56+
let mode = match req.mode.as_deref() {
57+
Some("a") => VisibilityMode::A,
58+
_ => VisibilityMode::B,
59+
};
60+
61+
// Mode A hides existence and is only coherent for the whole repo; a subtree
62+
// cannot hide its existence without rewriting git history (see spec).
63+
if mode == VisibilityMode::A && req.path_glob != "/" {
64+
return Err(AppError::BadRequest(
65+
"mode 'a' (hide) is only allowed for the whole repo (path_glob '/'); use mode 'b' for subtrees".into(),
66+
));
67+
}
68+
69+
// An empty reader_dids list is valid and intentional: the owner is always
70+
// allowed by visibility_check, so a "/" rule with no readers is exactly the
71+
// whole-repo "private to owner only" case.
72+
state
73+
.db
74+
.set_visibility_rule(&record.id, &req.path_glob, mode, &req.reader_dids, &auth.0)
75+
.await?;
76+
77+
let subtree_warning = req.path_glob != "/";
78+
tracing::info!(
79+
repo = %repo, caller = %auth.0, path_glob = %req.path_glob, mode = %mode.as_str(),
80+
subtree_pending = subtree_warning,
81+
"visibility rule set"
82+
);
83+
84+
Ok((
85+
StatusCode::CREATED,
86+
Json(serde_json::json!({
87+
"status": "set",
88+
"repo": format!("{owner}/{repo}"),
89+
"path_glob": req.path_glob,
90+
"mode": mode.as_str(),
91+
"reader_dids": req.reader_dids,
92+
"subtree_clone_enforcement_pending": subtree_warning,
93+
})),
94+
))
95+
}
96+
97+
/// DELETE /api/v1/repos/{owner}/{repo}/visibility
98+
pub async fn remove_visibility(
99+
State(state): State<AppState>,
100+
Extension(auth): Extension<AuthenticatedDid>,
101+
Path((owner, repo)): Path<(String, String)>,
102+
Json(req): Json<RemoveVisibilityRequest>,
103+
) -> Result<Json<serde_json::Value>> {
104+
let record = state
105+
.db
106+
.get_repo(&owner, &repo)
107+
.await?
108+
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
109+
require_owner(&record, &auth.0)?;
110+
111+
state
112+
.db
113+
.remove_visibility_rule(&record.id, &req.path_glob)
114+
.await?;
115+
116+
tracing::info!(
117+
repo = %repo, caller = %auth.0, path_glob = %req.path_glob,
118+
"visibility rule removed"
119+
);
120+
121+
Ok(Json(serde_json::json!({
122+
"status": "removed",
123+
"repo": format!("{owner}/{repo}"),
124+
"path_glob": req.path_glob,
125+
})))
126+
}
127+
128+
/// GET /api/v1/repos/{owner}/{repo}/visibility
129+
pub async fn list_visibility(
130+
State(state): State<AppState>,
131+
Extension(auth): Extension<AuthenticatedDid>,
132+
Path((owner, repo)): Path<(String, String)>,
133+
) -> Result<Json<serde_json::Value>> {
134+
let record = state
135+
.db
136+
.get_repo(&owner, &repo)
137+
.await?
138+
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
139+
require_owner(&record, &auth.0)?;
140+
141+
let rules = state.db.list_visibility_rules(&record.id).await?;
142+
let rules_json: Vec<_> = rules
143+
.into_iter()
144+
.map(|r| {
145+
serde_json::json!({
146+
"path_glob": r.path_glob,
147+
"mode": r.mode.as_str(),
148+
"reader_dids": r.reader_dids,
149+
"created_by": r.created_by,
150+
})
151+
})
152+
.collect();
153+
154+
Ok(Json(serde_json::json!({
155+
"repo": format!("{owner}/{repo}"),
156+
"count": rules_json.len(),
157+
"rules": rules_json,
158+
})))
159+
}

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

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,51 @@ pub struct RepoRecord {
2222
pub machine_id: Option<String>,
2323
}
2424

25+
/// Per-rule replication mode for a visibility rule.
26+
/// `A` hides existence entirely (only valid at whole-repo scope `/`).
27+
/// `B` keeps object SHAs and the path visible but withholds content
28+
/// (the only mode allowed for subtrees; enforced on clones in Phase 3).
29+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30+
#[serde(rename_all = "lowercase")]
31+
pub enum VisibilityMode {
32+
A,
33+
B,
34+
}
35+
36+
impl VisibilityMode {
37+
pub fn as_str(&self) -> &'static str {
38+
match self {
39+
VisibilityMode::A => "a",
40+
VisibilityMode::B => "b",
41+
}
42+
}
43+
44+
pub fn from_db(s: &str) -> Self {
45+
match s {
46+
"a" => VisibilityMode::A,
47+
"b" => VisibilityMode::B,
48+
other => {
49+
tracing::warn!("unknown visibility mode in DB: {other:?}, defaulting to B");
50+
VisibilityMode::B
51+
}
52+
}
53+
}
54+
}
55+
56+
/// A path-scoped visibility rule. `path_glob` is "/" for whole-repo, or a
57+
/// subtree pattern such as "/secret-pkg/**". The repo owner is always an
58+
/// implicit reader regardless of `reader_dids`.
59+
#[derive(Debug, Clone, Serialize, Deserialize)]
60+
pub struct VisibilityRule {
61+
pub id: String,
62+
pub repo_id: String,
63+
pub path_glob: String,
64+
pub mode: VisibilityMode,
65+
pub reader_dids: Vec<String>,
66+
pub created_by: String,
67+
pub created_at: DateTime<Utc>,
68+
}
69+
2570
#[derive(Debug, Clone, Serialize, Deserialize)]
2671
pub struct PullRequest {
2772
pub id: String,
@@ -564,6 +609,18 @@ const MIGRATIONS: &[Migration] = &[
564609
UNIQUE(repo_id, branch)
565610
)"#,
566611
"CREATE INDEX IF NOT EXISTS idx_protected_branches_repo ON protected_branches(repo_id)",
612+
// ── Path-scoped visibility ────────────────────────────────────────
613+
r#"CREATE TABLE IF NOT EXISTS visibility_rules (
614+
id TEXT NOT NULL PRIMARY KEY,
615+
repo_id TEXT NOT NULL,
616+
path_glob TEXT NOT NULL,
617+
mode TEXT NOT NULL,
618+
reader_dids TEXT NOT NULL,
619+
created_by TEXT NOT NULL,
620+
created_at TEXT NOT NULL,
621+
UNIQUE(repo_id, path_glob)
622+
)"#,
623+
"CREATE INDEX IF NOT EXISTS idx_visibility_rules_repo ON visibility_rules(repo_id)",
567624
// ── Repo stars ──────────────────────────────────────────────────
568625
r#"CREATE TABLE IF NOT EXISTS repo_stars (
569626
id TEXT NOT NULL PRIMARY KEY,
@@ -2072,6 +2129,80 @@ impl Db {
20722129
}
20732130
}
20742131

2132+
// ── Path-scoped Visibility ────────────────────────────────────────────────────
2133+
2134+
impl Db {
2135+
pub async fn set_visibility_rule(
2136+
&self,
2137+
repo_id: &str,
2138+
path_glob: &str,
2139+
mode: VisibilityMode,
2140+
reader_dids: &[String],
2141+
created_by: &str,
2142+
) -> Result<()> {
2143+
let id = Uuid::new_v4().to_string();
2144+
let now = Utc::now().to_rfc3339();
2145+
let readers = serde_json::to_string(reader_dids).unwrap_or_else(|_| "[]".to_string());
2146+
sqlx::query(
2147+
"INSERT INTO visibility_rules
2148+
(id, repo_id, path_glob, mode, reader_dids, created_by, created_at)
2149+
VALUES ($1, $2, $3, $4, $5, $6, $7)
2150+
ON CONFLICT (repo_id, path_glob) DO UPDATE
2151+
SET mode = EXCLUDED.mode,
2152+
reader_dids = EXCLUDED.reader_dids,
2153+
created_by = EXCLUDED.created_by,
2154+
created_at = EXCLUDED.created_at",
2155+
)
2156+
.bind(&id)
2157+
.bind(repo_id)
2158+
.bind(path_glob)
2159+
.bind(mode.as_str())
2160+
.bind(&readers)
2161+
.bind(created_by)
2162+
.bind(&now)
2163+
.execute(&self.pool)
2164+
.await?;
2165+
Ok(())
2166+
}
2167+
2168+
pub async fn remove_visibility_rule(&self, repo_id: &str, path_glob: &str) -> Result<()> {
2169+
sqlx::query("DELETE FROM visibility_rules WHERE repo_id = $1 AND path_glob = $2")
2170+
.bind(repo_id)
2171+
.bind(path_glob)
2172+
.execute(&self.pool)
2173+
.await?;
2174+
Ok(())
2175+
}
2176+
2177+
pub async fn list_visibility_rules(&self, repo_id: &str) -> Result<Vec<VisibilityRule>> {
2178+
let rows = sqlx::query(
2179+
"SELECT id, repo_id, path_glob, mode, reader_dids, created_by, created_at
2180+
FROM visibility_rules WHERE repo_id = $1 ORDER BY path_glob",
2181+
)
2182+
.bind(repo_id)
2183+
.fetch_all(&self.pool)
2184+
.await?;
2185+
Ok(rows
2186+
.into_iter()
2187+
.map(|r| {
2188+
let readers: String = r.get("reader_dids");
2189+
let created_at: String = r.get("created_at");
2190+
VisibilityRule {
2191+
id: r.get("id"),
2192+
repo_id: r.get("repo_id"),
2193+
path_glob: r.get("path_glob"),
2194+
mode: VisibilityMode::from_db(&r.get::<String, _>("mode")),
2195+
reader_dids: serde_json::from_str(&readers).unwrap_or_default(),
2196+
created_by: r.get("created_by"),
2197+
created_at: created_at
2198+
.parse::<DateTime<Utc>>()
2199+
.unwrap_or_else(|_| Utc::now()),
2200+
}
2201+
})
2202+
.collect())
2203+
}
2204+
}
2205+
20752206
// ── Repo Stars ────────────────────────────────────────────────────────────────
20762207

20772208
impl Db {

‎crates/gitlawb-node/src/main.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ mod rate_limit;
1717
mod server;
1818
mod state;
1919
mod sync;
20+
mod visibility;
2021
mod webhooks;
2122

2223
use anyhow::{Context, Result};

0 commit comments

Comments
 (0)