Skip to main content

max / makenotwork

Reconcile git HTTP read access with SSH (honor collaborators) resolve_repo (the HTTP/browse path) was owner-only for private repos, so a read-collaborator who could clone over SSH got a 404 over HTTPS and in the web browser. Allow owner OR collaborator to read a private repo, matching the SSH model in git_ssh.rs. Public/unlisted stay world-readable; non-collaborators and anonymous users are still denied. This is the authz half of the git-transport reconciliation. CLI clones of private repos over HTTPS still need a credential git can send (Basic auth), which requires a personal-access-token subsystem that doesn't exist yet — scoped separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 23:47 UTC
Commit: 5bedb264a1dfae9be72888e206a46d78afc8a076
Parent: 4d2702e
2 files changed, +42 insertions, -2 deletions
@@ -190,10 +190,20 @@ pub(crate) async fn resolve_repo(
190 190 }
191 191 };
192 192
193 - // 3. Check visibility
193 + // 3. Check read access. A private repo is readable by its owner OR a
194 + // collaborator — the same model SSH uses (`git_ssh.rs`); previously HTTP
195 + // was owner-only, so a read-collaborator who could clone over SSH got a
196 + // 404 over HTTP. Public/unlisted repos are readable by anyone.
197 + // (Run #21 git authz reconciliation / Max's call 2026-06-15.)
194 198 let is_owner = session_user_id == Some(db_user.id);
195 199 if db_repo.visibility == db::Visibility::Private && !is_owner {
196 - return Err(AppError::NotFound);
200 + let is_collaborator = match session_user_id {
201 + Some(uid) => db::repo_collaborators::is_collaborator(&state.db, db_repo.id, uid).await?,
202 + None => false,
203 + };
204 + if !is_collaborator {
205 + return Err(AppError::NotFound);
206 + }
197 207 }
198 208
199 209 // 4. Resolve + validate the on-disk path (segment + symlink-traversal
@@ -323,6 +323,36 @@ async fn git_private_repo_visible_to_owner() {
323 323 assert!(resp.status.is_success(), "Owner should see private repo: {} {}", resp.status, resp.text);
324 324 }
325 325
326 + #[tokio::test]
327 + async fn git_private_repo_visible_to_read_collaborator() {
328 + // Run #21 authz reconciliation: a read-collaborator (who can already clone
329 + // over SSH) must be able to read a private repo over HTTP too — previously
330 + // HTTP was owner-only and 404'd them.
331 + let tmp = tempfile::TempDir::new().unwrap();
332 + make_test_repo(tmp.path());
333 + let mut h = setup_git_harness(&tmp).await;
334 +
335 + let resp = h.client.get("/git/testowner/testrepo").await; // auto-register
336 + assert!(resp.status.is_success());
337 + sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
338 + .execute(&h.db).await.unwrap();
339 +
340 + // A logged-in non-collaborator is still denied.
341 + let outsider = h.signup("outsider", "outsider@example.com", "password123").await;
342 + h.login("outsider", "password123").await;
343 + let resp = h.client.get("/git/testowner/testrepo").await;
344 + assert_eq!(resp.status, 404, "non-collaborator must not see a private repo");
345 +
346 + // Grant read access, then they can see it.
347 + let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
348 + .fetch_one(&h.db).await.unwrap();
349 + sqlx::query("INSERT INTO repo_collaborators (repo_id, user_id, can_push) VALUES ($1, $2, false)")
350 + .bind(repo_id).bind(outsider).execute(&h.db).await.unwrap();
351 +
352 + let resp = h.client.get("/git/testowner/testrepo").await;
353 + assert!(resp.status.is_success(), "read-collaborator should see private repo over HTTP: {} {}", resp.status, resp.text);
354 + }
355 +
326 356 // ── Commit detail ──
327 357
328 358 #[tokio::test]