18181599
Refuse a settings push that leaves a roster with no rules
a73x 2026-08-11 18:35
Commit message
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. |