fabrica

hanna/fabrica

feat(ssh): push-to-create a private repo on first push

8a86690 · hanna committed on 2026-07-25

A push (receive-pack) to a non-existent repo owned by the authenticated
user — or by an admin — now auto-creates it at the instance default
visibility: any missing group prefix, the database row, and the bare repo on
disk (rolled back if the on-disk step fails). Any other case stays a 404 so a
stranger cannot probe for repositories. Also log this session's visibility,
settings, and push-to-create decisions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
2 files changed · +104 −3UnifiedSplit
crates/ssh/src/server.rs +72 −3
@@ -269,14 +269,26 @@ impl GitHandler {
269 .ok()269 .ok()
270 .flatten()270 .flatten()
271 .ok_or_else(not_found)?;271 .ok_or_else(not_found)?;
272 let repo = self272 let existing = self
273 .shared273 .shared
274 .store274 .store
275 .repo_by_owner_path(&owner_user.id, repo_path)275 .repo_by_owner_path(&owner_user.id, repo_path)
276 .await276 .await
277 .ok()277 .ok()
278 .flatten()278 .flatten();
279 .ok_or_else(not_found)?;279 // Push-to-create: a push (receive-pack) to a non-existent repo owned by
280 // the pushing user (or by an admin) auto-creates it, at the instance
281 // default visibility. Any other case is a 404 so a stranger cannot probe
282 // for repositories.
283 let repo = if let Some(repo) = existing {
284 repo
285 } else if matches!(service, git::pack::Service::ReceivePack)
286 && (identity.user_id == owner_user.id || identity.is_admin)
287 {
288 self.create_on_push(&owner_user, repo_path).await?
289 } else {
290 return Err(not_found());
291 };
280292
281 let collaborator = self293 let collaborator = self
282 .shared294 .shared
@@ -310,6 +322,63 @@ impl GitHandler {
310 Err(not_found())322 Err(not_found())
311 }323 }
312 }324 }
325
326 /// Create a repository on first push: any missing group prefix, the database
327 /// row (visibility = instance default), and the bare repo on disk. Rolls the
328 /// row back if the on-disk creation fails.
329 async fn create_on_push(
330 &self,
331 owner: &model::User,
332 repo_path: &str,
333 ) -> Result<model::Repo, String> {
334 let (group_path, leaf) = match repo_path.rsplit_once('/') {
335 Some((group, leaf)) => (Some(group), leaf),
336 None => (None, repo_path),
337 };
338 let group_id = match group_path {
339 Some(gp) => Some(
340 self.shared
341 .store
342 .ensure_group_path(&owner.id, gp)
343 .await
344 .map_err(|e| e.to_string())?
345 .id,
346 ),
347 None => None,
348 };
349 let branch = self.shared.config.instance.default_branch.clone();
350 let visibility = model::Visibility::from_token(
351 self.shared.config.instance.default_visibility.as_token(),
352 )
353 .unwrap_or(model::Visibility::Private);
354 let repo = self
355 .shared
356 .store
357 .create_repo(store::NewRepo {
358 owner_id: owner.id.clone(),
359 group_id,
360 name: leaf.to_string(),
361 path: repo_path.to_string(),
362 description: None,
363 visibility,
364 default_branch: branch.clone(),
365 })
366 .await
367 .map_err(|e| e.to_string())?;
368
369 let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica"));
370 if let Err(err) = git::create_bare(
371 &self.shared.config.storage.repo_dir,
372 &repo.id,
373 &branch,
374 &hook,
375 ) {
376 let _ = self.shared.store.delete_repo(&repo.id).await;
377 return Err(format!("Could not create repository: {err}"));
378 }
379 tracing::info!(owner = %owner.username, path = %repo_path, "push-to-create: repository created");
380 Ok(repo)
381 }
313}382}
314383
315/// Run the SSH server until the process exits. A no-op when `ssh.enabled` is false.384/// Run the SSH server until the process exits. A no-op when `ssh.enabled` is false.
docs/decisions.md +32 −0
@@ -584,3 +584,35 @@ subset. Uploads are limited to raster types (PNG/JPEG/GIF/WebP, ≤ 1 MiB); SVG
584is refused so no user-supplied markup is ever served, while our own identicons are584is refused so no user-supplied markup is ever served, while our own identicons are
585SVG. Both mutating routes (`POST /settings`, `POST /settings/avatar`) verify the585SVG. Both mutating routes (`POST /settings`, `POST /settings/avatar`) verify the
586double-submit CSRF token; `/settings` redirects anonymous visitors to `/login`.586double-submit CSRF token; `/settings` redirects anonymous visitors to `/login`.
587
588## 2026-07-25 — Three-level visibility, repo settings, and SSH push-to-create
589
590**Decision:** Repositories carry a GitLab-style **visibility** — `public`
591(everyone, anonymous gated by `allow_anonymous`), `internal` (any signed-in
592user), or `private` (owner/collaborators/admins) — replacing the old two-state
593`is_private` flag. `auth::access` decides read on visibility after the
594admin/owner/collaborator rules; `instance.default_visibility` (default `private`)
595sets the default for new repos. Owners/admins get a **Settings** repo tab
596(rename, visibility, delete) whose mutations post to a fixed `/repo-settings/{id}`
597prefix (never colliding with the `/{owner}/{*rest}` catch-all) and re-check Admin
598access, returning `404` — never `403` — to non-admins. An SSH **push to a
599non-existent repo** owned by the pushing user (or an admin) **auto-creates** it at
600the default visibility (row + bare repo on disk, rolled back on disk failure).
601
602**Migration:** `0004_repo_visibility` adds a `visibility TEXT NOT NULL DEFAULT
603'private'` column backfilled from `is_private`; the `is_private` column is left in
604place (SQLite cannot drop columns) but no longer read or written.
605
606**Alternatives:** keeping the boolean and bolting "internal" on as a second flag
607(two booleans encode four states, one nonsensical); a dedicated POST route per
608repo path (impossible — repo paths are variable-length under groups); refusing
609push-to-create (an extra round-trip to the CLes/UI for every new repo).
610
611**Rationale:** three named states model real instances (internal is the common
612"logged-in only" tier) and keep `access` a single exhaustively-tested function —
613its matrix test now enumerates all three visibilities × viewer kinds × the anon
614flag. `allow_anonymous` now gates only anonymous access to *public* repos, which
615is what it always meant. The fixed `/repo-settings/{id}` prefix sidesteps the
616catch-all cleanly and keys on the id (rename-safe). Push-to-create matches the
617muscle memory of every other forge and stays safe: only the authenticated owner
618(or an admin) can trigger it, and only on receive-pack.