Skip to content

Commit 5f14098

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 0b056f3 commit 5f14098

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
@@ -16,4 +16,5 @@ pub mod repos;
1616
pub mod resolve;
1717
pub mod stars;
1818
pub mod tasks;
19+
pub mod visibility;
1920
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
@@ -21,6 +21,51 @@ pub struct RepoRecord {
2121
pub machine_id: Option<String>,
2222
}
2323

24+
/// Per-rule replication mode for a visibility rule.
25+
/// `A` hides existence entirely (only valid at whole-repo scope `/`).
26+
/// `B` keeps object SHAs and the path visible but withholds content
27+
/// (the only mode allowed for subtrees; enforced on clones in Phase 3).
28+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29+
#[serde(rename_all = "lowercase")]
30+
pub enum VisibilityMode {
31+
A,
32+
B,
33+
}
34+
35+
impl VisibilityMode {
36+
pub fn as_str(&self) -> &'static str {
37+
match self {
38+
VisibilityMode::A => "a",
39+
VisibilityMode::B => "b",
40+
}
41+
}
42+
43+
pub fn from_db(s: &str) -> Self {
44+
match s {
45+
"a" => VisibilityMode::A,
46+
"b" => VisibilityMode::B,
47+
other => {
48+
tracing::warn!("unknown visibility mode in DB: {other:?}, defaulting to B");
49+
VisibilityMode::B
50+
}
51+
}
52+
}
53+
}
54+
55+
/// A path-scoped visibility rule. `path_glob` is "/" for whole-repo, or a
56+
/// subtree pattern such as "/secret-pkg/**". The repo owner is always an
57+
/// implicit reader regardless of `reader_dids`.
58+
#[derive(Debug, Clone, Serialize, Deserialize)]
59+
pub struct VisibilityRule {
60+
pub id: String,
61+
pub repo_id: String,
62+
pub path_glob: String,
63+
pub mode: VisibilityMode,
64+
pub reader_dids: Vec<String>,
65+
pub created_by: String,
66+
pub created_at: DateTime<Utc>,
67+
}
68+
2469
#[derive(Debug, Clone, Serialize, Deserialize)]
2570
pub struct PullRequest {
2671
pub id: String,
@@ -378,6 +423,18 @@ impl Db {
378423
UNIQUE(repo_id, branch)
379424
)"#,
380425
"CREATE INDEX IF NOT EXISTS idx_protected_branches_repo ON protected_branches(repo_id)",
426+
// ── Path-scoped visibility ────────────────────────────────────────
427+
r#"CREATE TABLE IF NOT EXISTS visibility_rules (
428+
id TEXT NOT NULL PRIMARY KEY,
429+
repo_id TEXT NOT NULL,
430+
path_glob TEXT NOT NULL,
431+
mode TEXT NOT NULL,
432+
reader_dids TEXT NOT NULL,
433+
created_by TEXT NOT NULL,
434+
created_at TEXT NOT NULL,
435+
UNIQUE(repo_id, path_glob)
436+
)"#,
437+
"CREATE INDEX IF NOT EXISTS idx_visibility_rules_repo ON visibility_rules(repo_id)",
381438
// ── Repo stars ──────────────────────────────────────────────────
382439
r#"CREATE TABLE IF NOT EXISTS repo_stars (
383440
id TEXT NOT NULL PRIMARY KEY,
@@ -1874,6 +1931,80 @@ impl Db {
18741931
}
18751932
}
18761933

1934+
// ── Path-scoped Visibility ────────────────────────────────────────────────────
1935+
1936+
impl Db {
1937+
pub async fn set_visibility_rule(
1938+
&self,
1939+
repo_id: &str,
1940+
path_glob: &str,
1941+
mode: VisibilityMode,
1942+
reader_dids: &[String],
1943+
created_by: &str,
1944+
) -> Result<()> {
1945+
let id = Uuid::new_v4().to_string();
1946+
let now = Utc::now().to_rfc3339();
1947+
let readers = serde_json::to_string(reader_dids).unwrap_or_else(|_| "[]".to_string());
1948+
sqlx::query(
1949+
"INSERT INTO visibility_rules
1950+
(id, repo_id, path_glob, mode, reader_dids, created_by, created_at)
1951+
VALUES ($1, $2, $3, $4, $5, $6, $7)
1952+
ON CONFLICT (repo_id, path_glob) DO UPDATE
1953+
SET mode = EXCLUDED.mode,
1954+
reader_dids = EXCLUDED.reader_dids,
1955+
created_by = EXCLUDED.created_by,
1956+
created_at = EXCLUDED.created_at",
1957+
)
1958+
.bind(&id)
1959+
.bind(repo_id)
1960+
.bind(path_glob)
1961+
.bind(mode.as_str())
1962+
.bind(&readers)
1963+
.bind(created_by)
1964+
.bind(&now)
1965+
.execute(&self.pool)
1966+
.await?;
1967+
Ok(())
1968+
}
1969+
1970+
pub async fn remove_visibility_rule(&self, repo_id: &str, path_glob: &str) -> Result<()> {
1971+
sqlx::query("DELETE FROM visibility_rules WHERE repo_id = $1 AND path_glob = $2")
1972+
.bind(repo_id)
1973+
.bind(path_glob)
1974+
.execute(&self.pool)
1975+
.await?;
1976+
Ok(())
1977+
}
1978+
1979+
pub async fn list_visibility_rules(&self, repo_id: &str) -> Result<Vec<VisibilityRule>> {
1980+
let rows = sqlx::query(
1981+
"SELECT id, repo_id, path_glob, mode, reader_dids, created_by, created_at
1982+
FROM visibility_rules WHERE repo_id = $1 ORDER BY path_glob",
1983+
)
1984+
.bind(repo_id)
1985+
.fetch_all(&self.pool)
1986+
.await?;
1987+
Ok(rows
1988+
.into_iter()
1989+
.map(|r| {
1990+
let readers: String = r.get("reader_dids");
1991+
let created_at: String = r.get("created_at");
1992+
VisibilityRule {
1993+
id: r.get("id"),
1994+
repo_id: r.get("repo_id"),
1995+
path_glob: r.get("path_glob"),
1996+
mode: VisibilityMode::from_db(&r.get::<String, _>("mode")),
1997+
reader_dids: serde_json::from_str(&readers).unwrap_or_default(),
1998+
created_by: r.get("created_by"),
1999+
created_at: created_at
2000+
.parse::<DateTime<Utc>>()
2001+
.unwrap_or_else(|_| Utc::now()),
2002+
}
2003+
})
2004+
.collect())
2005+
}
2006+
}
2007+
18772008
// ── Repo Stars ────────────────────────────────────────────────────────────────
18782009

18792010
impl Db {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod rate_limit;
1616
mod server;
1717
mod state;
1818
mod sync;
19+
mod visibility;
1920
mod webhooks;
2021

2122
use anyhow::{Context, Result};

0 commit comments

Comments
 (0)