a73x

18181599

Refuse a settings push that leaves a roster with no rules

a73x   2026-08-11 18:35

Commit message
Refuse a settings push that leaves a roster with no rules

`keydir/` without `conf/access.conf` is not a half-written configuration,
it is an exposure. An absent `conf/access.conf` is an authoritative
absence, so the server stays ungoverned — and an ungoverned server lists
and serves that repository like any other, publishing who has access to
the forge and how many of them there are. It is also exactly the
bootstrap sequence a first-time operator follows, since `keydir/` is the
thing you have before you have rules.

Closed at the source: the push that would create the state is refused,
so the mistake is reported where it is made. That keeps the
closed-by-default posture as the one mechanism — no repository singled
out by name on the way out — and it composes with the validation that
already rejects a config nobody could push again.

The check is on the resulting tree, not the diff, so a push that deletes
`conf/access.conf` and leaves the roster standing is refused by the same
rule and with the same message. The message names the fix, because an
operator hitting this is mid-bootstrap and has no other clue.

Rule 3 is unaffected: no `settings.git` at all, and a `settings.git` with
neither file, both stay ungoverned and behave exactly as before. Only a
roster *without* rules is refused.

A repository already in this state is on disk where no hook can reach
it, so it is reported once at startup and nothing more. Hiding it would
reinstate the second mechanism, and refusing to start would take down a
server over a repository it has been serving all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Patch: 1beeb03e3f61a0e5b7ab6b154b1554e30eb492d5

src/server/governance/hook.rs
Old New
@@ -210,6 +210,15 @@ fn validate_settings_push(
210 .tree() 210 .tree()
211 .map_err(|e| format!("{refname}: cannot read the pushed tree: {e}"))?; 211 .map_err(|e| format!("{refname}: cannot read the pushed tree: {e}"))?;
212 212
213 // A roster with no rules is refused, whether it arrives by pushing
214 // `keydir/` first or by deleting `conf/access.conf` and leaving the roster
215 // standing. The resulting tree is what matters, not the diff: both leave a
216 // repository that governs nothing while naming everyone who would be
217 // governed, and an ungoverned server publishes it like any other.
218 if let Err(reason) = super::check_roster_has_rules(&tree) {
219 return Err(format!("{refname}: {reason}"));
220 }
221
213 if !live_is_config && !has_access_conf(&tree) { 222 if !live_is_config && !has_access_conf(&tree) {
214 return Ok(()); 223 return Ok(());
215 } 224 }
src/server/governance/mod.rs
Old New
@@ -288,6 +288,53 @@ pub fn validate_settings_tree(
288 Ok(governance) 288 Ok(governance)
289 } 289 }
290 290
291 /// Refuse a settings tree that holds a roster but no rules.
292 ///
293 /// `keydir/` without `conf/access.conf` is not a half-written configuration,
294 /// it is an exposure: the repository governs nothing — an absent
295 /// `conf/access.conf` is an authoritative absence, so the server stays
296 /// ungoverned — while naming every operator who has access to the forge and
297 /// how many of them there are. On an ungoverned server that repository is
298 /// listed and clonable like any other, so the roster is published to anyone.
299 ///
300 /// It is refused at the push that would create it rather than hidden at the
301 /// request that would serve it: the mistake is reported where it is made, and
302 /// the closed-by-default posture stays the one mechanism, with no repository
303 /// singled out by name on the way out.
304 ///
305 /// This is deliberately *not* the same thing as an empty `settings.git`. A
306 /// repository with neither file is an ordinary repository that happens to be
307 /// called `settings`, and stays as pushable as it ever was.
308 pub fn check_roster_has_rules(tree: &git2::Tree<'_>) -> Result<(), String> {
309 let has_rules = tree.get_path(Path::new(ACCESS_CONF_PATH)).is_ok();
310 let has_roster = tree.get_path(Path::new(KEYDIR)).is_ok();
311 if has_roster && !has_rules {
312 return Err(format!(
313 "{KEYDIR}/ without {ACCESS_CONF_PATH}: a roster with no rules governs nothing, \
314 so this repository would be served like any other and would publish who has \
315 access to this server.\n \
316 Push both together, or push {ACCESS_CONF_PATH} first."
317 ));
318 }
319 Ok(())
320 }
321
322 /// The same check against what is on disk right now, for a repository that
323 /// reached this state before the check existed. Returns the reason if the
324 /// server's settings repository is holding a roster with no rules.
325 ///
326 /// A hook cannot fix an existing repository — it is already there — so this is
327 /// reported at startup and nothing more: hiding it would be the second
328 /// mechanism the closed-by-default posture exists to avoid, and refusing to
329 /// start would take a running server down over a repository it has been
330 /// serving all along.
331 pub fn unruled_roster(repos_dir: &Path) -> Option<String> {
332 let path = settings_repo_path(repos_dir);
333 let repo = git2::Repository::open_bare(&path).ok()?;
334 let tree = repo.head().ok()?.peel_to_commit().ok()?.tree().ok()?;
335 check_roster_has_rules(&tree).err()
336 }
337
291 /// Record which principal created a repository, for `CREATOR` rules. 338 /// Record which principal created a repository, for `CREATOR` rules.
292 pub fn record_creator(repo_path: &Path, name: &str) -> std::io::Result<()> { 339 pub fn record_creator(repo_path: &Path, name: &str) -> std::io::Result<()> {
293 let dir = repo_path.join(".collab"); 340 let dir = repo_path.join(".collab");
@@ -348,6 +395,86 @@ mod tests {
348 assert!(matches!(load(tmp.path()), GovernanceState::Absent)); 395 assert!(matches!(load(tmp.path()), GovernanceState::Absent));
349 } 396 }
350 397
398 /// Build a settings tree from `path -> content` pairs and return it.
399 fn tree_of<'a>(repo: &'a git2::Repository, files: &[(&str, &str)]) -> git2::Tree<'a> {
400 let mut index = repo.index().unwrap();
401 for (path, content) in files {
402 let blob = repo.blob(content.as_bytes()).unwrap();
403 let mut entry = git2::IndexEntry {
404 ctime: git2::IndexTime::new(0, 0),
405 mtime: git2::IndexTime::new(0, 0),
406 dev: 0,
407 ino: 0,
408 mode: 0o100644,
409 uid: 0,
410 gid: 0,
411 file_size: 0,
412 id: blob,
413 flags: 0,
414 flags_extended: 0,
415 path: path.as_bytes().to_vec(),
416 };
417 entry.file_size = content.len() as u32;
418 index.add(&entry).unwrap();
419 }
420 let oid = index.write_tree().unwrap();
421 repo.find_tree(oid).unwrap()
422 }
423
424 #[test]
425 fn a_roster_without_rules_is_refused_and_anything_else_is_not() {
426 let tmp = tempfile::TempDir::new().unwrap();
427 let repo = git2::Repository::init_bare(tmp.path().join("settings.git")).unwrap();
428
429 let roster_only = tree_of(&repo, &[("keydir/alex.pub", "ssh-ed25519 AAAA alex")]);
430 let reason = check_roster_has_rules(&roster_only)
431 .expect_err("a roster with no rules must be refused");
432 assert!(reason.contains(KEYDIR) && reason.contains(ACCESS_CONF_PATH));
433 assert!(
434 reason.contains("Push both together"),
435 "the refusal must name the fix; got {reason}"
436 );
437
438 // Both halves: the configuration this exists to get to.
439 assert!(check_roster_has_rules(&tree_of(
440 &repo,
441 &[
442 ("conf/access.conf", "repo settings\n RW+ = alex\n"),
443 ("keydir/alex.pub", "ssh-ed25519 AAAA alex"),
444 ],
445 ))
446 .is_ok());
447
448 // Neither half: an ordinary repository that happens to be called
449 // `settings`, and none of this business.
450 assert!(check_roster_has_rules(&tree_of(&repo, &[("README.md", "hi")])).is_ok());
451
452 // Rules without a roster is a different error, caught elsewhere by
453 // the lockout check rather than here.
454 assert!(check_roster_has_rules(&tree_of(
455 &repo,
456 &[("conf/access.conf", "repo settings\n RW+ = alex\n")]
457 ))
458 .is_ok());
459 }
460
461 #[test]
462 fn an_existing_unruled_roster_is_reported_and_an_absent_one_is_not() {
463 let tmp = tempfile::TempDir::new().unwrap();
464 // Nothing there at all.
465 assert_eq!(unruled_roster(tmp.path()), None);
466
467 let repo = git2::Repository::init_bare(tmp.path().join("settings.git")).unwrap();
468 // There, but empty: no commit, so nothing to report.
469 assert_eq!(unruled_roster(tmp.path()), None);
470
471 let tree = tree_of(&repo, &[("keydir/alex.pub", "ssh-ed25519 AAAA alex")]);
472 let who = git2::Signature::now("Ops", "ops@example.com").unwrap();
473 repo.commit(Some("HEAD"), &who, &who, "roster", &tree, &[])
474 .unwrap();
475 assert!(unruled_roster(tmp.path()).is_some());
476 }
477
351 #[test] 478 #[test]
352 fn creator_round_trips_and_is_absent_by_default() { 479 fn creator_round_trips_and_is_absent_by_default() {
353 let tmp = tempfile::TempDir::new().unwrap(); 480 let tmp = tempfile::TempDir::new().unwrap();
src/server/main.rs
Old New
@@ -87,6 +87,16 @@ async fn main() {
87 info!("Site title: {}", config.site_title); 87 info!("Site title: {}", config.site_title);
88 info!("SSH host key fingerprint: {}", fingerprint); 88 info!("SSH host key fingerprint: {}", fingerprint);
89 89
90 // A repository that reached this state before the push check existed is
91 // already on disk, where no hook can reach it. Said out loud once, at the
92 // only moment an operator is looking, rather than silently worked around.
93 if let Some(reason) = governance::unruled_roster(&config.repos_dir) {
94 tracing::warn!(
95 "{:?}: {reason}",
96 config.repos_dir.join("settings.git").display()
97 );
98 }
99
90 let app_state = http::AppState { 100 let app_state = http::AppState {
91 repos_dir: config.repos_dir.clone(), 101 repos_dir: config.repos_dir.clone(),
92 site_title: config.site_title.clone(), 102 site_title: config.site_title.clone(),
tests/common/mod.rs
Old New
@@ -933,6 +933,44 @@ impl ServerHarness {
933 git(&work, &["commit", "-q", "--allow-empty", "-m", "settings"]); 933 git(&work, &["commit", "-q", "--allow-empty", "-m", "settings"]);
934 } 934 }
935 935
936 /// Stage `keydir/` entries with no `conf/access.conf` at all — the
937 /// bootstrap half-state, where the repository names everyone who has
938 /// access without holding any rule about it.
939 ///
940 /// Removes the rules if they are already staged, so the same helper
941 /// produces the state by either route: never written, or deleted.
942 pub fn stage_keydir_only(&self, keys: &[(&str, &str)]) {
943 self.ensure_settings_repos();
944 let work = self.settings_work();
945
946 let conf_dir = work.join("conf");
947 if conf_dir.exists() {
948 std::fs::remove_dir_all(&conf_dir).unwrap();
949 }
950 for (rel, key_name) in keys {
951 let dest = work.join("keydir").join(rel);
952 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
953 let pubkey =
954 std::fs::read_to_string(self.named_key(key_name).with_extension("pub")).unwrap();
955 std::fs::write(dest, pubkey).unwrap();
956 }
957
958 git(&work, &["add", "-A"]);
959 git(&work, &["commit", "-q", "--allow-empty", "-m", "keydir"]);
960 }
961
962 /// Whether anything has landed in `settings.git` at all: whether its HEAD
963 /// resolves to a commit.
964 pub fn settings_has_content(&self) -> bool {
965 Command::new("git")
966 .args(["rev-parse", "-q", "--verify", "HEAD"])
967 .current_dir(self.settings_bare())
968 .output()
969 .expect("failed to run git rev-parse")
970 .status
971 .success()
972 }
973
936 /// The `conf/access.conf` the server would actually read right now, from 974 /// The `conf/access.conf` the server would actually read right now, from
937 /// the tip of `settings.git`'s live branch. 975 /// the tip of `settings.git`'s live branch.
938 pub fn live_access_conf(&self) -> String { 976 pub fn live_access_conf(&self) -> String {
tests/governance_test.rs
Old New
@@ -588,6 +588,116 @@ fn release_downloads_follow_the_anonymous_read_grant() {
588 assert_eq!(body, b"payload"); 588 assert_eq!(body, b"payload");
589 } 589 }
590 590
591 // ---- The bootstrap half-state -------------------------------------------
592 //
593 // The closed-by-default posture is a property of the *governed* world, and a
594 // `settings.git` holding `keydir/` but no `conf/access.conf` governs nothing:
595 // it is an ordinary repository on an ungoverned server, listed and clonable,
596 // publishing who has access to the forge and how many of them there are. That
597 // window is closed at the source — the push that would create it is refused —
598 // rather than by special-casing the repository on the way out.
599
600 /// The mistake a first-time operator makes, because `keydir/` is the thing you
601 /// have before you have rules.
602 #[test]
603 fn a_roster_pushed_without_rules_is_refused_and_nothing_lands() {
604 let harness = ServerHarness::new("alpha");
605 let key = harness.ssh_client_key();
606
607 harness.stage_keydir_only(&[("alex.pub", "alex")]);
608 let refused = harness.push_settings_over_ssh(&key);
609 assert_refused(&refused, "a keydir with no access.conf");
610
611 // The operator is mid-bootstrap and has no idea why this bounced, so the
612 // message has to name the fix.
613 let message = stderr(&refused);
614 assert!(
615 message.contains("conf/access.conf") && message.contains("keydir/"),
616 "the refusal must name both halves; got:\n{message}"
617 );
618 assert!(
619 message.contains("push both") || message.contains("first"),
620 "the refusal must say what to do instead; got:\n{message}"
621 );
622
623 // Nothing landed, so there is no roster on the server to publish.
624 assert!(
625 !harness.settings_has_content(),
626 "the refused push must not have updated the settings repository"
627 );
628
629 // And the server is still ungoverned: the repository it hosts is
630 // untouched by any of this.
631 assert!(readable_anonymously(&harness, "alpha"));
632 }
633
634 /// The same exposure by the other route: the resulting tree is what matters,
635 /// not the diff that produced it.
636 #[test]
637 fn a_push_that_deletes_the_rules_and_keeps_the_roster_is_refused() {
638 let harness = ServerHarness::new("governed");
639 harness.bootstrap_settings(ACCESS_CONF, KEYS);
640 let admin = harness.named_key("alex");
641
642 harness.stage_keydir_only(KEYS);
643 let refused = harness.push_settings_over_ssh(&admin);
644 assert_refused(&refused, "a push that removes access.conf but keeps keydir");
645 // Specifically the roster check, not the generic "you removed the rules"
646 // one: both routes leave the same tree and must say the same thing.
647 assert!(
648 stderr(&refused).contains("keydir/ without conf/access.conf"),
649 "got:\n{}",
650 stderr(&refused)
651 );
652
653 // The previously-live config still governs.
654 assert!(harness.live_access_conf().contains("repo settings"));
655 assert!(!readable_anonymously(&harness, "settings"));
656 }
657
658 /// The fix the message tells the operator to apply: push both together.
659 #[test]
660 fn pushing_the_roster_and_the_rules_together_is_accepted() {
661 let harness = ServerHarness::new("alpha");
662 let key = harness.ssh_client_key();
663
664 harness.stage_settings(ACCESS_CONF, KEYS);
665 assert_accepted(
666 &harness.push_settings_over_ssh(&key),
667 "the first config push, roster and rules together",
668 );
669
670 assert!(harness.live_access_conf().contains("repo settings"));
671 // And from that moment the server is governed, so the repository holding
672 // the roster is closed by the default.
673 assert!(!readable_anonymously(&harness, "settings"));
674 assert!(!listed_anonymously(&harness, "settings"));
675 }
676
677 /// Rule 3, at the boundary: a `settings.git` with neither file governs
678 /// nothing and is refused nothing. Only a roster *without* rules is.
679 #[test]
680 fn a_settings_repo_with_neither_file_is_an_ordinary_repository() {
681 let harness = ServerHarness::new("alpha");
682 let key = harness.ssh_client_key();
683
684 harness
685 .work_repo()
686 .commit_file("readme.md", "hello", "docs");
687 let pushed = harness.ssh_push_from(
688 harness.work_repo().dir.path(),
689 &key,
690 "settings",
691 "main:main",
692 );
693 assert_accepted(
694 &pushed,
695 "an ordinary push to a repository that happens to be called settings",
696 );
697 assert!(harness.settings_has_content());
698 assert!(readable_anonymously(&harness, "settings"));
699 }
700
591 /// Creating `settings.git` must not silently publish the key roster and the 701 /// Creating `settings.git` must not silently publish the key roster and the
592 /// access rules to an internet-facing web UI. This used to be a hand-coded 702 /// access rules to an internet-facing web UI. This used to be a hand-coded
593 /// special case; it is now just the default with no rule to lift it. 703 /// special case; it is now just the default with no rule to lift it.