80539887
Governance: unlisted by default, published by explicit rule
a73x 2026-08-11 18:27
Commit message
src/server/governance/conf.rs
| Old | New | ||
|---|---|---|---|
| @@ -80,6 +80,12 @@ impl Perm { | |||
| 80 | } | 80 | } |
| 81 | } | 81 | } |
| 82 | 82 | ||
| 83 | /// The reserved principal standing for a request that carries no identity at | ||
| 84 | /// all. It is deliberately *not* a member of `@all`, which means every | ||
| 85 | /// enrolled key: "every key we know" and "anybody at all" are different sets, | ||
| 86 | /// and the whole exposure model turns on the difference. | ||
| 87 | pub const ANONYMOUS: &str = "@anonymous"; | ||
| 88 | |||
| 83 | /// Who is asking, and of which repository. | 89 | /// Who is asking, and of which repository. |
| 84 | #[derive(Debug, Clone, Copy)] | 90 | #[derive(Debug, Clone, Copy)] |
| 85 | pub struct Subject<'a> { | 91 | pub struct Subject<'a> { |
| @@ -88,6 +94,8 @@ pub struct Subject<'a> { | |||
| 88 | /// The recorded creator of the repository being accessed, if it has one. | 94 | /// The recorded creator of the repository being accessed, if it has one. |
| 89 | /// `CREATOR` in a rule's user list matches only when this equals `name`. | 95 | /// `CREATOR` in a rule's user list matches only when this equals `name`. |
| 90 | pub creator: Option<&'a str>, | 96 | pub creator: Option<&'a str>, |
| 97 | /// Whether this is the unauthenticated reader rather than a principal. | ||
| 98 | anonymous: bool, | ||
| 91 | } | 99 | } |
| 92 | 100 | ||
| 93 | impl<'a> Subject<'a> { | 101 | impl<'a> Subject<'a> { |
| @@ -95,11 +103,26 @@ impl<'a> Subject<'a> { | |||
| 95 | Self { | 103 | Self { |
| 96 | name, | 104 | name, |
| 97 | creator: None, | 105 | creator: None, |
| 106 | anonymous: false, | ||
| 98 | } | 107 | } |
| 99 | } | 108 | } |
| 100 | 109 | ||
| 101 | pub fn with_creator(name: &'a str, creator: Option<&'a str>) -> Self { | 110 | pub fn with_creator(name: &'a str, creator: Option<&'a str>) -> Self { |
| 102 | Self { name, creator } | 111 | Self { |
| 112 | name, | ||
| 113 | creator, | ||
| 114 | anonymous: false, | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | /// A request with no identity. Matched by `@anonymous` and by nothing | ||
| 119 | /// else — not by `@all`, not by a group, not by a bare name. | ||
| 120 | pub fn anonymous() -> Self { | ||
| 121 | Self { | ||
| 122 | name: ANONYMOUS, | ||
| 123 | creator: None, | ||
| 124 | anonymous: true, | ||
| 125 | } | ||
| 103 | } | 126 | } |
| 104 | } | 127 | } |
| 105 | 128 | ||
| @@ -129,6 +152,35 @@ struct Rule { | |||
| 129 | struct Block { | 152 | struct Block { |
| 130 | patterns: Vec<RepoPattern>, | 153 | patterns: Vec<RepoPattern>, |
| 131 | rules: Vec<Rule>, | 154 | rules: Vec<Rule>, |
| 155 | /// `option listed = yes|no`, and the line it was written on. `None` means | ||
| 156 | /// the block says nothing about listing. | ||
| 157 | listed: Option<(bool, usize)>, | ||
| 158 | } | ||
| 159 | |||
| 160 | impl Block { | ||
| 161 | fn new(patterns: Vec<RepoPattern>) -> Self { | ||
| 162 | Self { | ||
| 163 | patterns, | ||
| 164 | rules: Vec::new(), | ||
| 165 | listed: None, | ||
| 166 | } | ||
| 167 | } | ||
| 168 | } | ||
| 169 | |||
| 170 | /// Which question the rules are being asked. | ||
| 171 | #[derive(Debug, Clone, Copy)] | ||
| 172 | enum Scope<'a> { | ||
| 173 | /// "May this principal do X to *some* ref here?" Refexes are not | ||
| 174 | /// consulted and `-` is skipped rather than denying: a deny on one refex | ||
| 175 | /// must not make the whole repository unreachable. | ||
| 176 | Repository, | ||
| 177 | /// "…to this ref?" First-match-wins, and `-` denies. | ||
| 178 | Ref(&'a str), | ||
| 179 | /// "…to the repository, wholesale?" Refexes are not consulted and `-` | ||
| 180 | /// *does* deny. This is the anonymous surface, which is all-or-nothing — | ||
| 181 | /// an anonymous clone hands over every ref there is — so a deny anywhere | ||
| 182 | /// has to close the door rather than narrow it. | ||
| 183 | Whole, | ||
| 132 | } | 184 | } |
| 133 | 185 | ||
| 134 | #[derive(Debug)] | 186 | #[derive(Debug)] |
| @@ -210,10 +262,7 @@ impl AccessConf { | |||
| 210 | if patterns.is_empty() { | 262 | if patterns.is_empty() { |
| 211 | return Err(ConfError::syntax(line, "`repo` needs at least one name")); | 263 | return Err(ConfError::syntax(line, "`repo` needs at least one name")); |
| 212 | } | 264 | } |
| 213 | current = Some(Block { | 265 | current = Some(Block::new(patterns)); |
| 214 | patterns, | ||
| 215 | rules: Vec::new(), | ||
| 216 | }); | ||
| 217 | continue; | 266 | continue; |
| 218 | } | 267 | } |
| 219 | if text == "repo" { | 268 | if text == "repo" { |
| @@ -245,6 +294,12 @@ impl AccessConf { | |||
| 245 | "@all is built in and cannot be defined", | 294 | "@all is built in and cannot be defined", |
| 246 | )); | 295 | )); |
| 247 | } | 296 | } |
| 297 | if group == anonymous_group() { | ||
| 298 | return Err(ConfError::syntax( | ||
| 299 | line, | ||
| 300 | format!("{ANONYMOUS} is built in and cannot be defined"), | ||
| 301 | )); | ||
| 302 | } | ||
| 248 | let members = conf.expand_members(rhs, line)?; | 303 | let members = conf.expand_members(rhs, line)?; |
| 249 | conf.groups | 304 | conf.groups |
| 250 | .entry(group.to_string()) | 305 | .entry(group.to_string()) |
| @@ -253,6 +308,31 @@ impl AccessConf { | |||
| 253 | continue; | 308 | continue; |
| 254 | } | 309 | } |
| 255 | 310 | ||
| 311 | if lhs == "option" || lhs.starts_with("option ") { | ||
| 312 | let block = current.as_mut().ok_or_else(|| { | ||
| 313 | ConfError::syntax(line, "an option must appear inside a `repo` block") | ||
| 314 | })?; | ||
| 315 | let name = lhs["option".len()..].trim(); | ||
| 316 | if name != LISTED_OPTION { | ||
| 317 | return Err(ConfError::syntax( | ||
| 318 | line, | ||
| 319 | format!("unknown option {name:?}; the only option is `{LISTED_OPTION}`"), | ||
| 320 | )); | ||
| 321 | } | ||
| 322 | let value = match rhs { | ||
| 323 | "yes" => true, | ||
| 324 | "no" => false, | ||
| 325 | other => { | ||
| 326 | return Err(ConfError::syntax( | ||
| 327 | line, | ||
| 328 | format!("`option {LISTED_OPTION}` takes yes or no, got {other:?}"), | ||
| 329 | )) | ||
| 330 | } | ||
| 331 | }; | ||
| 332 | block.listed = Some((value, line)); | ||
| 333 | continue; | ||
| 334 | } | ||
| 335 | |||
| 256 | let block = current.as_mut().ok_or_else(|| { | 336 | let block = current.as_mut().ok_or_else(|| { |
| 257 | ConfError::syntax(line, "a rule must appear inside a `repo` block") | 337 | ConfError::syntax(line, "a rule must appear inside a `repo` block") |
| 258 | })?; | 338 | })?; |
| @@ -285,6 +365,20 @@ impl AccessConf { | |||
| 285 | if !is_plain_name(bare) { | 365 | if !is_plain_name(bare) { |
| 286 | return Err(ConfError::syntax(line, format!("bad principal {user:?}"))); | 366 | return Err(ConfError::syntax(line, format!("bad principal {user:?}"))); |
| 287 | } | 367 | } |
| 368 | // A request with no identity cannot be held responsible for a | ||
| 369 | // write, so a permission that grants one to it is a mistake | ||
| 370 | // rather than a permissive choice. Rejected here rather than | ||
| 371 | // ignored at evaluation time: a rule that silently means | ||
| 372 | // nothing is the worst thing a config file can contain. | ||
| 373 | if user == ANONYMOUS && !matches!(perm, Perm::Deny | Perm::R) { | ||
| 374 | return Err(ConfError::syntax( | ||
| 375 | line, | ||
| 376 | format!( | ||
| 377 | "{ANONYMOUS} may only be granted R (or denied with -); \ | ||
| 378 | {perm_token} would grant write access to an unauthenticated request" | ||
| 379 | ), | ||
| 380 | )); | ||
| 381 | } | ||
| 288 | } | 382 | } |
| 289 | 383 | ||
| 290 | block.rules.push(Rule { perm, refex, users }); | 384 | block.rules.push(Rule { perm, refex, users }); |
| @@ -293,9 +387,64 @@ impl AccessConf { | |||
| 293 | if let Some(block) = current.take() { | 387 | if let Some(block) = current.take() { |
| 294 | conf.blocks.push(block); | 388 | conf.blocks.push(block); |
| 295 | } | 389 | } |
| 390 | conf.check_listed_repos_are_readable()?; | ||
| 296 | Ok(conf) | 391 | Ok(conf) |
| 297 | } | 392 | } |
| 298 | 393 | ||
| 394 | /// `option listed = yes` requires `R = @anonymous` to be in force for the | ||
| 395 | /// repository, and is a config error without it. | ||
| 396 | /// | ||
| 397 | /// Web UI authentication is out of scope, so an HTTP request has no | ||
| 398 | /// identity: there is no authenticated viewer for a "listed but private" | ||
| 399 | /// repository to be listed *to*, and the pair would advertise a name that | ||
| 400 | /// 404s. The check is by evaluation rather than by looking inside the | ||
| 401 | /// block, so the grant may come from anywhere in the file. | ||
| 402 | /// | ||
| 403 | /// A wild pattern names no repository this can enumerate, so a block like | ||
| 404 | /// `repo agents/[a-z]+` is left to the evaluator, where `is_listed` is | ||
| 405 | /// gated on the same read grant and so fails closed. | ||
| 406 | fn check_listed_repos_are_readable(&self) -> Result<(), ConfError> { | ||
| 407 | for block in &self.blocks { | ||
| 408 | let Some((true, line)) = block.listed else { | ||
| 409 | continue; | ||
| 410 | }; | ||
| 411 | for repo in block.patterns.iter().flat_map(|p| self.enumerate(p)) { | ||
| 412 | if !self.anonymous_may_read(&repo) { | ||
| 413 | return Err(ConfError::syntax( | ||
| 414 | line, | ||
| 415 | format!( | ||
| 416 | "`option {LISTED_OPTION} = yes` on {repo} without `R = {ANONYMOUS}`: \ | ||
| 417 | an HTTP request has no identity, so listing a repository nobody \ | ||
| 418 | may read would advertise a name that 404s" | ||
| 419 | ), | ||
| 420 | )); | ||
| 421 | } | ||
| 422 | } | ||
| 423 | } | ||
| 424 | Ok(()) | ||
| 425 | } | ||
| 426 | |||
| 427 | /// The concrete repository names a pattern names, where there are any. A | ||
| 428 | /// regex names an open set and yields none. | ||
| 429 | fn enumerate(&self, pattern: &RepoPattern) -> Vec<String> { | ||
| 430 | match pattern { | ||
| 431 | RepoPattern::Exact(name) => vec![name.clone()], | ||
| 432 | RepoPattern::Wild(_) => Vec::new(), | ||
| 433 | RepoPattern::Group(name) if name == "all" => Vec::new(), | ||
| 434 | RepoPattern::Group(name) => self | ||
| 435 | .groups | ||
| 436 | .get(name) | ||
| 437 | .map(|members| { | ||
| 438 | members | ||
| 439 | .iter() | ||
| 440 | .filter(|m| !is_wild_pattern(m)) | ||
| 441 | .cloned() | ||
| 442 | .collect() | ||
| 443 | }) | ||
| 444 | .unwrap_or_default(), | ||
| 445 | } | ||
| 446 | } | ||
| 447 | |||
| 299 | /// Expand a group's member list, resolving `@`-references to groups | 448 | /// Expand a group's member list, resolving `@`-references to groups |
| 300 | /// already defined. An unknown group reference is an error rather than an | 449 | /// already defined. An unknown group reference is an error rather than an |
| 301 | /// empty expansion: a typo'd group name must not silently grant nothing | 450 | /// empty expansion: a typo'd group name must not silently grant nothing |
| @@ -354,6 +503,15 @@ impl AccessConf { | |||
| 354 | } | 503 | } |
| 355 | 504 | ||
| 356 | fn user_matches(&self, token: &str, subject: &Subject<'_>) -> bool { | 505 | fn user_matches(&self, token: &str, subject: &Subject<'_>) -> bool { |
| 506 | // The unauthenticated reader is matched by its own token and by | ||
| 507 | // nothing else. In particular `@all` does not reach it: that is every | ||
| 508 | // *enrolled* key, and an anonymous request holds none of them. | ||
| 509 | if subject.anonymous { | ||
| 510 | return token == ANONYMOUS; | ||
| 511 | } | ||
| 512 | if token == ANONYMOUS { | ||
| 513 | return false; | ||
| 514 | } | ||
| 357 | if token == "@all" { | 515 | if token == "@all" { |
| 358 | return true; | 516 | return true; |
| 359 | } | 517 | } |
| @@ -369,16 +527,12 @@ impl AccessConf { | |||
| 369 | token == subject.name | 527 | token == subject.name |
| 370 | } | 528 | } |
| 371 | 529 | ||
| 372 | /// The one evaluation path. `refname` of `None` asks the repository-level | 530 | /// The one evaluation path; `scope` says which question is being asked. |
| 373 | /// question ("may this principal do this to *some* ref here?"), in which | ||
| 374 | /// case refexes are not consulted and `-` rules are skipped rather than | ||
| 375 | /// denying — a deny on one refex must not make the whole repository | ||
| 376 | /// unreachable. | ||
| 377 | fn evaluate( | 531 | fn evaluate( |
| 378 | &self, | 532 | &self, |
| 379 | repo: &str, | 533 | repo: &str, |
| 380 | subject: &Subject<'_>, | 534 | subject: &Subject<'_>, |
| 381 | refname: Option<&str>, | 535 | scope: Scope<'_>, |
| 382 | access: Access, | 536 | access: Access, |
| 383 | ) -> bool { | 537 | ) -> bool { |
| 384 | for block in &self.blocks { | 538 | for block in &self.blocks { |
| @@ -397,13 +551,18 @@ impl AccessConf { | |||
| 397 | { | 551 | { |
| 398 | continue; | 552 | continue; |
| 399 | } | 553 | } |
| 400 | match refname { | 554 | match scope { |
| 401 | None => { | 555 | Scope::Repository => { |
| 402 | if rule.perm == Perm::Deny { | 556 | if rule.perm == Perm::Deny { |
| 403 | continue; | 557 | continue; |
| 404 | } | 558 | } |
| 405 | } | 559 | } |
| 406 | Some(name) => { | 560 | Scope::Whole => { |
| 561 | if rule.perm == Perm::Deny { | ||
| 562 | return false; | ||
| 563 | } | ||
| 564 | } | ||
| 565 | Scope::Ref(name) => { | ||
| 407 | if !rule.refex.is_match(name) { | 566 | if !rule.refex.is_match(name) { |
| 408 | continue; | 567 | continue; |
| 409 | } | 568 | } |
| @@ -466,7 +625,7 @@ impl AccessConf { | |||
| 466 | /// May this principal do `access` to *some* ref of this repository? Used | 625 | /// May this principal do `access` to *some* ref of this repository? Used |
| 467 | /// at dispatch, where the verb is known but the refs are not yet. | 626 | /// at dispatch, where the verb is known but the refs are not yet. |
| 468 | pub fn allows_repo(&self, repo: &str, subject: &Subject<'_>, access: Access) -> bool { | 627 | pub fn allows_repo(&self, repo: &str, subject: &Subject<'_>, access: Access) -> bool { |
| 469 | self.evaluate(repo, subject, None, access) | 628 | self.evaluate(repo, subject, Scope::Repository, access) |
| 470 | } | 629 | } |
| 471 | 630 | ||
| 472 | /// May this principal do `access` to this specific ref? First-match-wins. | 631 | /// May this principal do `access` to this specific ref? First-match-wins. |
| @@ -477,10 +636,51 @@ impl AccessConf { | |||
| 477 | refname: &str, | 636 | refname: &str, |
| 478 | access: Access, | 637 | access: Access, |
| 479 | ) -> bool { | 638 | ) -> bool { |
| 480 | self.evaluate(repo, subject, Some(refname), access) | 639 | self.evaluate(repo, subject, Scope::Ref(refname), access) |
| 481 | } | 640 | } |
| 641 | |||
| 642 | /// May a request carrying no identity read this repository at all? | ||
| 643 | /// | ||
| 644 | /// This is the whole anonymous surface — web pages, smart-HTTP clone, and | ||
| 645 | /// release downloads alike — because none of them can carry an identity | ||
| 646 | /// and none of them can serve half a repository. | ||
| 647 | pub fn anonymous_may_read(&self, repo: &str) -> bool { | ||
| 648 | self.evaluate(repo, &Subject::anonymous(), Scope::Whole, Access::Read) | ||
| 649 | } | ||
| 650 | |||
| 651 | /// Is this repository advertised in the repository list? | ||
| 652 | /// | ||
| 653 | /// Listing is a display concern rather than an access one, so it is an | ||
| 654 | /// `option` rather than a rule — but it can never exceed the access, so | ||
| 655 | /// it is gated on the anonymous read grant here as well as at parse time. | ||
| 656 | /// Later blocks override earlier ones, which is how `option listed = no` | ||
| 657 | /// takes one repository back out of a list a broader block put it in. | ||
| 658 | pub fn is_listed(&self, repo: &str) -> bool { | ||
| 659 | if !self.anonymous_may_read(repo) { | ||
| 660 | return false; | ||
| 661 | } | ||
| 662 | self.blocks | ||
| 663 | .iter() | ||
| 664 | .filter(|block| { | ||
| 665 | block | ||
| 666 | .patterns | ||
| 667 | .iter() | ||
| 668 | .any(|pattern| self.pattern_matches(pattern, repo)) | ||
| 669 | }) | ||
| 670 | .filter_map(|block| block.listed.map(|(value, _)| value)) | ||
| 671 | .next_back() | ||
| 672 | .unwrap_or(false) | ||
| 673 | } | ||
| 674 | } | ||
| 675 | |||
| 676 | /// `@anonymous` without its sigil, for the places that compare bare names. | ||
| 677 | fn anonymous_group() -> &'static str { | ||
| 678 | ANONYMOUS.trim_start_matches('@') | ||
| 482 | } | 679 | } |
| 483 | 680 | ||
| 681 | /// The only `option` this dialect understands. | ||
| 682 | const LISTED_OPTION: &str = "listed"; | ||
| 683 | |||
| 484 | fn strip_comment(line: &str) -> &str { | 684 | fn strip_comment(line: &str) -> &str { |
| 485 | match line.find('#') { | 685 | match line.find('#') { |
| 486 | Some(index) => &line[..index], | 686 | Some(index) => &line[..index], |
| @@ -629,8 +829,18 @@ repo tools | |||
| 629 | let ref_name = Some("refs/heads/main"); | 829 | let ref_name = Some("refs/heads/main"); |
| 630 | 830 | ||
| 631 | // What the real evaluator says: order decides, so the answers differ. | 831 | // What the real evaluator says: order decides, so the answers differ. |
| 632 | assert!(!deny_first.evaluate("tools", &agent, ref_name, Access::Write)); | 832 | assert!(!deny_first.evaluate( |
| 633 | assert!(allow_first.evaluate("tools", &agent, ref_name, Access::Write)); | 833 | "tools", |
| 834 | &agent, | ||
| 835 | Scope::Ref("refs/heads/main"), | ||
| 836 | Access::Write | ||
| 837 | )); | ||
| 838 | assert!(allow_first.evaluate( | ||
| 839 | "tools", | ||
| 840 | &agent, | ||
| 841 | Scope::Ref("refs/heads/main"), | ||
| 842 | Access::Write | ||
| 843 | )); | ||
| 634 | 844 | ||
| 635 | // What an order-blind evaluator says: the same answer to both, and it | 845 | // What an order-blind evaluator says: the same answer to both, and it |
| 636 | // is the wrong answer for the second. | 846 | // is the wrong answer for the second. |
| @@ -916,6 +1126,149 @@ repo private/tools | |||
| 916 | assert!(AccessConf::parse("@all = alex\n").is_err()); | 1126 | assert!(AccessConf::parse("@all = alex\n").is_err()); |
| 917 | } | 1127 | } |
| 918 | 1128 | ||
| 1129 | // ---- @anonymous: the unauthenticated reader ----------------------- | ||
| 1130 | // | ||
| 1131 | // The whole exposure model rests on this token meaning something no other | ||
| 1132 | // token does, so each of its properties is asserted separately. | ||
| 1133 | |||
| 1134 | #[test] | ||
| 1135 | fn at_all_does_not_include_the_anonymous_reader() { | ||
| 1136 | // The distinction the model is built on: `@all` is every *enrolled* | ||
| 1137 | // key, which is a strictly smaller set than "anybody at all". | ||
| 1138 | let conf = conf("repo tools\n R = @all\n"); | ||
| 1139 | assert!(conf.allows_repo("tools", &Subject::new("alex"), Access::Read)); | ||
| 1140 | assert!(!conf.anonymous_may_read("tools")); | ||
| 1141 | } | ||
| 1142 | |||
| 1143 | #[test] | ||
| 1144 | fn an_anonymous_read_grant_opens_the_repository_to_no_one_else() { | ||
| 1145 | let conf = conf("repo open\n R = @anonymous\n"); | ||
| 1146 | assert!(conf.anonymous_may_read("open")); | ||
| 1147 | // `@anonymous` is not a name a key can hold, so it grants an enrolled | ||
| 1148 | // principal nothing. | ||
| 1149 | assert!(!conf.allows_repo("open", &Subject::new("alex"), Access::Read)); | ||
| 1150 | } | ||
| 1151 | |||
| 1152 | #[test] | ||
| 1153 | fn a_repository_with_no_anonymous_rule_is_closed() { | ||
| 1154 | let conf = conf(SPEC_EXAMPLE); | ||
| 1155 | assert!(!conf.anonymous_may_read("tools")); | ||
| 1156 | assert!(!conf.anonymous_may_read("settings")); | ||
| 1157 | assert!(!conf.anonymous_may_read("nothing-names-this")); | ||
| 1158 | } | ||
| 1159 | |||
| 1160 | /// `-` denies it back, by the same first-match-wins evaluation as any | ||
| 1161 | /// other principal. | ||
| 1162 | #[test] | ||
| 1163 | fn an_earlier_deny_closes_the_anonymous_door_a_later_rule_opens() { | ||
| 1164 | let denied = conf("repo t\n - = @anonymous\n R = @anonymous\n"); | ||
| 1165 | assert!(!denied.anonymous_may_read("t")); | ||
| 1166 | |||
| 1167 | let allowed = conf("repo t\n R = @anonymous\n - = @anonymous\n"); | ||
| 1168 | assert!( | ||
| 1169 | allowed.anonymous_may_read("t"), | ||
| 1170 | "the allow is first, so the later deny is never reached" | ||
| 1171 | ); | ||
| 1172 | } | ||
| 1173 | |||
| 1174 | /// Unlike the per-ref question, an anonymous deny on *any* refex closes | ||
| 1175 | /// the repository: an anonymous clone is all-or-nothing, so there is no | ||
| 1176 | /// way to serve a partial view and pretending otherwise would leak. | ||
| 1177 | #[test] | ||
| 1178 | fn a_deny_on_one_refex_closes_the_anonymous_surface_entirely() { | ||
| 1179 | let conf = conf("repo t\n - refs/heads/secret = @anonymous\n R = @anonymous\n"); | ||
| 1180 | assert!(!conf.anonymous_may_read("t")); | ||
| 1181 | } | ||
| 1182 | |||
| 1183 | #[test] | ||
| 1184 | fn a_write_grant_to_the_anonymous_reader_is_a_config_error() { | ||
| 1185 | for source in [ | ||
| 1186 | "repo t\n RW = @anonymous\n", | ||
| 1187 | "repo t\n RW+ = @anonymous\n", | ||
| 1188 | "repo t\n C = @anonymous\n", | ||
| 1189 | "repo t\n RW refs/collab/ = alex @anonymous\n", | ||
| 1190 | ] { | ||
| 1191 | let err = AccessConf::parse(source) | ||
| 1192 | .expect_err("a write grant to @anonymous must be rejected, not ignored"); | ||
| 1193 | assert!(err.to_string().contains("line 2"), "got {err}"); | ||
| 1194 | assert!(err.to_string().contains("@anonymous"), "got {err}"); | ||
| 1195 | } | ||
| 1196 | } | ||
| 1197 | |||
| 1198 | #[test] | ||
| 1199 | fn the_anonymous_reader_is_not_a_group_an_operator_may_define() { | ||
| 1200 | assert!(AccessConf::parse("@anonymous = alex\n").is_err()); | ||
| 1201 | assert!(AccessConf::parse("repo @anonymous\n R = alex\n").is_err()); | ||
| 1202 | } | ||
| 1203 | |||
| 1204 | // ---- option listed ------------------------------------------------ | ||
| 1205 | |||
| 1206 | #[test] | ||
| 1207 | fn a_repository_is_unlisted_until_an_option_says_otherwise() { | ||
| 1208 | let conf = conf("repo t\n R = @anonymous\n"); | ||
| 1209 | assert!(conf.anonymous_may_read("t")); | ||
| 1210 | assert!( | ||
| 1211 | !conf.is_listed("t"), | ||
| 1212 | "reachable by name is not the same as advertised" | ||
| 1213 | ); | ||
| 1214 | } | ||
| 1215 | |||
| 1216 | #[test] | ||
| 1217 | fn option_listed_advertises_a_repository_that_anonymous_may_read() { | ||
| 1218 | let conf = conf("repo t\n R = @anonymous\n option listed = yes\n"); | ||
| 1219 | assert!(conf.is_listed("t")); | ||
| 1220 | } | ||
| 1221 | |||
| 1222 | #[test] | ||
| 1223 | fn option_listed_without_an_anonymous_read_grant_is_a_config_error() { | ||
| 1224 | // Web UI auth is out of scope, so HTTP has no identity: listing a repo | ||
| 1225 | // nobody may read advertises a name that 404s. | ||
| 1226 | let err = AccessConf::parse("repo t\n R = @all\n option listed = yes\n") | ||
| 1227 | .expect_err("listed without an anonymous read grant must be rejected"); | ||
| 1228 | assert!(err.to_string().contains("line 3"), "got {err}"); | ||
| 1229 | assert!(err.to_string().contains("@anonymous"), "got {err}"); | ||
| 1230 | } | ||
| 1231 | |||
| 1232 | /// The grant does not have to be in the same block — it has to be in | ||
| 1233 | /// force, which is a question about the whole file. | ||
| 1234 | #[test] | ||
| 1235 | fn the_anonymous_grant_may_come_from_another_block() { | ||
| 1236 | let conf = conf("repo @all\n R = @anonymous\n\nrepo t\n option listed = yes\n"); | ||
| 1237 | assert!(conf.is_listed("t")); | ||
| 1238 | } | ||
| 1239 | |||
| 1240 | #[test] | ||
| 1241 | fn option_listed_no_takes_a_repository_back_out_of_the_list() { | ||
| 1242 | let conf = conf( | ||
| 1243 | "repo @all\n R = @anonymous\n option listed = yes\n\n\ | ||
| 1244 | repo secret\n option listed = no\n", | ||
| 1245 | ); | ||
| 1246 | assert!(conf.is_listed("public")); | ||
| 1247 | assert!(!conf.is_listed("secret")); | ||
| 1248 | assert!( | ||
| 1249 | conf.anonymous_may_read("secret"), | ||
| 1250 | "unlisted is not unreadable" | ||
| 1251 | ); | ||
| 1252 | } | ||
| 1253 | |||
| 1254 | #[test] | ||
| 1255 | fn an_unknown_option_is_rejected_rather_than_ignored() { | ||
| 1256 | let err = AccessConf::parse("repo t\n option gitweb.owner = alex\n").unwrap_err(); | ||
| 1257 | assert!(err.to_string().contains("line 2"), "got {err}"); | ||
| 1258 | } | ||
| 1259 | |||
| 1260 | #[test] | ||
| 1261 | fn an_option_outside_a_repo_block_is_rejected() { | ||
| 1262 | assert!(AccessConf::parse("option listed = yes\n").is_err()); | ||
| 1263 | } | ||
| 1264 | |||
| 1265 | #[test] | ||
| 1266 | fn option_listed_takes_yes_or_no_and_nothing_else() { | ||
| 1267 | assert!( | ||
| 1268 | AccessConf::parse("repo t\n R = @anonymous\n option listed = maybe\n").is_err() | ||
| 1269 | ); | ||
| 1270 | } | ||
| 1271 | |||
| 919 | #[test] | 1272 | #[test] |
| 920 | fn group_definitions_must_precede_repo_blocks() { | 1273 | fn group_definitions_must_precede_repo_blocks() { |
| 921 | assert!(AccessConf::parse("repo t\n RW = @late\n@late = alex\n").is_err()); | 1274 | assert!(AccessConf::parse("repo t\n RW = @late\n@late = alex\n").is_err()); |
src/server/governance/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -34,12 +34,12 @@ | |||
| 34 | //! |---|---|---| | 34 | //! |---|---|---| |
| 35 | //! | An authenticated principal's access | `conf/access.conf`, alone | `server.toml`'s `[access]` | | 35 | //! | An authenticated principal's access | `conf/access.conf`, alone | `server.toml`'s `[access]` | |
| 36 | //! | Which keys authenticate at all | `keydir/` | the `authorized_keys` file | | 36 | //! | Which keys authenticate at all | `keydir/` | the `authorized_keys` file | |
| 37 | //! | The anonymous HTTP surface | `server.toml` | `server.toml` | | 37 | //! | The anonymous HTTP surface | `conf/access.conf`, alone | `server.toml` | |
| 38 | //! | 38 | //! |
| 39 | //! So when `settings.git` is present, `access.conf` **supersedes** | 39 | //! So when `settings.git` is present, `access.conf` **supersedes** |
| 40 | //! `server.toml`'s `[access] read`/`write` outright: those lists are not | 40 | //! `server.toml` outright: `[access] read`/`write`, `visibility`, |
| 41 | //! consulted, not intersected, not unioned. `keydir/` likewise supersedes | 41 | //! `[ui] anonymous` and `[http] anonymous_clone` are not consulted, not |
| 42 | //! `authorized_keys`. | 42 | //! intersected, not unioned. `keydir/` likewise supersedes `authorized_keys`. |
| 43 | //! | 43 | //! |
| 44 | //! Superseding rather than layering is deliberate. Layering (requiring both to | 44 | //! Superseding rather than layering is deliberate. Layering (requiring both to |
| 45 | //! allow) would let a `server.toml` nobody remembers editing silently subtract | 45 | //! allow) would let a `server.toml` nobody remembers editing silently subtract |
| @@ -47,16 +47,29 @@ | |||
| 47 | //! disagreeing, with the disagreement invisible in the file you are reading. | 47 | //! disagreeing, with the disagreement invisible in the file you are reading. |
| 48 | //! Superseding means exactly one file answers the question. | 48 | //! Superseding means exactly one file answers the question. |
| 49 | //! | 49 | //! |
| 50 | //! `visibility`, `[ui] anonymous` and `[http] anonymous_clone` stay in | 50 | //! # The anonymous surface |
| 51 | //! `server.toml` because they are a different question. `access.conf` grants | 51 | //! |
| 52 | //! access to *named principals*; `@all` means every enrolled key, not the | 52 | //! `access.conf` grants access to *named principals*, and `@all` means every |
| 53 | //! public. An anonymous HTTP request has no principal at all, so there is | 53 | //! enrolled key rather than the public, so for a while nothing in the language |
| 54 | //! nothing for a rule to match and no rule that could express it. That is why | 54 | //! could describe an unauthenticated request and `server.toml` had to keep |
| 55 | //! `server.toml` survives this change rather than being deleted. | 55 | //! that axis. It no longer does: `@anonymous` is a reserved principal in the |
| 56 | //! rule table (see `conf.rs`) and the default is inverted, so **a governed | ||
| 57 | //! repository is unlisted and unreadable without authentication unless a rule | ||
| 58 | //! says otherwise**. `settings.git` expresses only the exception, which is a | ||
| 59 | //! positive grant — what the language is already good at. | ||
| 60 | //! | ||
| 61 | //! That default is also what keeps the governance repository off the anonymous | ||
| 62 | //! surface: no rule grants `@anonymous` anything until an operator writes one, | ||
| 63 | //! so creating `settings.git` cannot publish the key roster by accident. It | ||
| 64 | //! used to take a hand-coded special case in `repos.rs`; it now falls out of | ||
| 65 | //! the rule. | ||
| 56 | //! | 66 | //! |
| 57 | //! When `settings.git` does not exist, none of this is on: authentication, | 67 | //! When `settings.git` does not exist, none of this is on: authentication, |
| 58 | //! authorization and repo creation behave exactly as they did before, and no | 68 | //! authorization, repo creation and the anonymous surface behave exactly as |
| 59 | //! hook is installed on any repository except `settings` itself. | 69 | //! they did before, and no hook is installed on any repository except |
| 70 | //! `settings` itself. The inverted default belongs to the governed world, not | ||
| 71 | //! to the binary — flipping it globally would silently hide every repository | ||
| 72 | //! on every deployment that upgraded. | ||
| 60 | 73 | ||
| 61 | pub mod conf; | 74 | pub mod conf; |
| 62 | pub mod hook; | 75 | pub mod hook; |
src/server/http/git_http.rs
| Old | New | ||
|---|---|---|---|
| @@ -44,7 +44,7 @@ pub async fn info_refs( | |||
| 44 | } | 44 | } |
| 45 | }; | 45 | }; |
| 46 | 46 | ||
| 47 | if !entry.policy.allows_anonymous_http() { | 47 | if !entry.allows_anonymous_http() { |
| 48 | return ( | 48 | return ( |
| 49 | StatusCode::NOT_FOUND, | 49 | StatusCode::NOT_FOUND, |
| 50 | format!("Repository '{}' not found.", repo_name), | 50 | format!("Repository '{}' not found.", repo_name), |
| @@ -128,7 +128,7 @@ pub async fn upload_pack( | |||
| 128 | } | 128 | } |
| 129 | }; | 129 | }; |
| 130 | 130 | ||
| 131 | if !entry.policy.allows_anonymous_http() { | 131 | if !entry.allows_anonymous_http() { |
| 132 | return ( | 132 | return ( |
| 133 | StatusCode::NOT_FOUND, | 133 | StatusCode::NOT_FOUND, |
| 134 | format!("Repository '{}' not found.", repo_name), | 134 | format!("Repository '{}' not found.", repo_name), |
src/server/http/repo/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -194,7 +194,7 @@ fn open_repo( | |||
| 194 | } | 194 | } |
| 195 | }; | 195 | }; |
| 196 | 196 | ||
| 197 | if !entry.policy.allows_anonymous_ui() { | 197 | if !entry.allows_anonymous_ui() { |
| 198 | return Err(not_found( | 198 | return Err(not_found( |
| 199 | state, | 199 | state, |
| 200 | format!("Repository '{}' not found.", repo_name), | 200 | format!("Repository '{}' not found.", repo_name), |
src/server/http/repo/releases.rs
| Old | New | ||
|---|---|---|---|
| @@ -59,7 +59,7 @@ pub async fn releases( | |||
| 59 | open_patches, | 59 | open_patches, |
| 60 | open_issues, | 60 | open_issues, |
| 61 | versions, | 61 | versions, |
| 62 | downloads_available: entry.policy.allows_anonymous_http(), | 62 | downloads_available: entry.allows_anonymous_http(), |
| 63 | } | 63 | } |
| 64 | .into_response() | 64 | .into_response() |
| 65 | } | 65 | } |
| @@ -74,7 +74,7 @@ pub async fn release_download( | |||
| 74 | None => return plain_404(), | 74 | None => return plain_404(), |
| 75 | }; | 75 | }; |
| 76 | // Downloads are data distribution, like clone. | 76 | // Downloads are data distribution, like clone. |
| 77 | if !entry.policy.allows_anonymous_http() { | 77 | if !entry.allows_anonymous_http() { |
| 78 | return plain_404(); | 78 | return plain_404(); |
| 79 | } | 79 | } |
| 80 | 80 | ||
src/server/http/repo_list.rs
| Old | New | ||
|---|---|---|---|
| @@ -34,7 +34,7 @@ fn build_repo_list(state: &AppState) -> Vec<RepoListItem> { | |||
| 34 | 34 | ||
| 35 | entries | 35 | entries |
| 36 | .into_iter() | 36 | .into_iter() |
| 37 | .filter(|entry| entry.policy.allows_anonymous_ui()) | 37 | .filter(|entry| entry.is_listed()) |
| 38 | .map(|entry| { | 38 | .map(|entry| { |
| 39 | let repo = crate::repos::open(&entry).ok(); | 39 | let repo = crate::repos::open(&entry).ok(); |
| 40 | let description = resolve_description(&entry, repo.as_ref()); | 40 | let description = resolve_description(&entry, repo.as_ref()); |
| @@ -154,6 +154,7 @@ mod tests { | |||
| 154 | path: path.to_path_buf(), | 154 | path: path.to_path_buf(), |
| 155 | bare: false, | 155 | bare: false, |
| 156 | policy: crate::repos::RepoPolicy::default(), | 156 | policy: crate::repos::RepoPolicy::default(), |
| 157 | exposure: crate::repos::Exposure::Ungoverned, | ||
| 157 | } | 158 | } |
| 158 | } | 159 | } |
| 159 | 160 | ||
| @@ -163,6 +164,7 @@ mod tests { | |||
| 163 | path: path.to_path_buf(), | 164 | path: path.to_path_buf(), |
| 164 | bare: true, | 165 | bare: true, |
| 165 | policy: crate::repos::RepoPolicy::default(), | 166 | policy: crate::repos::RepoPolicy::default(), |
| 167 | exposure: crate::repos::Exposure::Ungoverned, | ||
| 166 | } | 168 | } |
| 167 | } | 169 | } |
| 168 | 170 | ||
src/server/repos.rs
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,63 @@ pub struct RepoEntry { | |||
| 9 | pub path: PathBuf, | 9 | pub path: PathBuf, |
| 10 | pub bare: bool, | 10 | pub bare: bool, |
| 11 | pub policy: RepoPolicy, | 11 | pub policy: RepoPolicy, |
| 12 | pub exposure: Exposure, | ||
| 13 | } | ||
| 14 | |||
| 15 | /// What an unauthenticated request may see of a repository, and who decided. | ||
| 16 | /// | ||
| 17 | /// The two variants are not two policies but two *worlds*. Where governance is | ||
| 18 | /// in force the answer comes from `settings.git` and the default is closed; | ||
| 19 | /// where it is not, the answer comes from `server.toml` and the default is | ||
| 20 | /// open, exactly as it was before governance existed. Flipping the default | ||
| 21 | /// globally would silently hide every repository on every deployment that | ||
| 22 | /// upgraded, so the inverted default is a property of the governed world only. | ||
| 23 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 24 | pub enum Exposure { | ||
| 25 | /// No `settings.git`: `server.toml` decides, as it always has. | ||
| 26 | Ungoverned, | ||
| 27 | /// `settings.git` decides, and says nothing unless a rule says it. | ||
| 28 | Governed { anonymous_read: bool, listed: bool }, | ||
| 29 | } | ||
| 30 | |||
| 31 | impl Exposure { | ||
| 32 | /// Everything shut. The posture for a server that cannot read its own | ||
| 33 | /// rules: it must not guess at them. | ||
| 34 | const CLOSED: Exposure = Exposure::Governed { | ||
| 35 | anonymous_read: false, | ||
| 36 | listed: false, | ||
| 37 | }; | ||
| 38 | } | ||
| 39 | |||
| 40 | impl RepoEntry { | ||
| 41 | /// Whether an unauthenticated web request may see this repository's pages. | ||
| 42 | pub fn allows_anonymous_ui(&self) -> bool { | ||
| 43 | match self.exposure { | ||
| 44 | Exposure::Ungoverned => self.policy.allows_anonymous_ui(), | ||
| 45 | Exposure::Governed { anonymous_read, .. } => anonymous_read, | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | /// Whether an unauthenticated request may clone it or fetch its release | ||
| 50 | /// artifacts. Under governance this is the same grant as the UI: a rule | ||
| 51 | /// says a repository is anonymously readable or it does not, and a clone | ||
| 52 | /// and a rendered page disclose the same thing. | ||
| 53 | pub fn allows_anonymous_http(&self) -> bool { | ||
| 54 | match self.exposure { | ||
| 55 | Exposure::Ungoverned => self.policy.allows_anonymous_http(), | ||
| 56 | Exposure::Governed { anonymous_read, .. } => anonymous_read, | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | /// Whether it is advertised in the repository list. Never true of a | ||
| 61 | /// repository an anonymous request may not read — a listing that 404s is | ||
| 62 | /// worse than no listing. | ||
| 63 | pub fn is_listed(&self) -> bool { | ||
| 64 | match self.exposure { | ||
| 65 | Exposure::Ungoverned => self.policy.allows_anonymous_ui(), | ||
| 66 | Exposure::Governed { listed, .. } => listed, | ||
| 67 | } | ||
| 68 | } | ||
| 12 | } | 69 | } |
| 13 | 70 | ||
| 14 | #[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)] | 71 | #[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)] |
| @@ -108,19 +165,6 @@ impl RepoPolicy { | |||
| 108 | access_allows(&self.access.write, principal) | 165 | access_allows(&self.access.write, principal) |
| 109 | } | 166 | } |
| 110 | 167 | ||
| 111 | /// The same policy with every anonymous door shut, leaving what an | ||
| 112 | /// authenticated principal may do untouched. | ||
| 113 | fn without_anonymous_access(self) -> Self { | ||
| 114 | Self { | ||
| 115 | visibility: RepoVisibility::Private, | ||
| 116 | ui: RepoUiPolicy { anonymous: false }, | ||
| 117 | http: RepoHttpPolicy { | ||
| 118 | anonymous_clone: false, | ||
| 119 | }, | ||
| 120 | ..self | ||
| 121 | } | ||
| 122 | } | ||
| 123 | |||
| 124 | pub fn normalized_description(&self) -> Option<String> { | 168 | pub fn normalized_description(&self) -> Option<String> { |
| 125 | let description = self.description.as_deref()?.trim(); | 169 | let description = self.description.as_deref()?.trim(); |
| 126 | if description.is_empty() { | 170 | if description.is_empty() { |
| @@ -295,13 +339,14 @@ fn load_policy(path: &Path, bare: bool) -> RepoPolicy { | |||
| 295 | } | 339 | } |
| 296 | } | 340 | } |
| 297 | 341 | ||
| 298 | fn repo_entry_from_path(path: &Path, name: String) -> Option<RepoEntry> { | 342 | fn repo_entry_from_path(path: &Path, name: String, exposure: Exposure) -> Option<RepoEntry> { |
| 299 | if path.join("HEAD").is_file() { | 343 | if path.join("HEAD").is_file() { |
| 300 | Some(RepoEntry { | 344 | Some(RepoEntry { |
| 301 | name, | 345 | name, |
| 302 | path: path.to_path_buf(), | 346 | path: path.to_path_buf(), |
| 303 | bare: true, | 347 | bare: true, |
| 304 | policy: load_policy(path, true), | 348 | policy: load_policy(path, true), |
| 349 | exposure, | ||
| 305 | }) | 350 | }) |
| 306 | } else if path.join(".git").is_dir() { | 351 | } else if path.join(".git").is_dir() { |
| 307 | Some(RepoEntry { | 352 | Some(RepoEntry { |
| @@ -309,6 +354,7 @@ fn repo_entry_from_path(path: &Path, name: String) -> Option<RepoEntry> { | |||
| 309 | path: path.to_path_buf(), | 354 | path: path.to_path_buf(), |
| 310 | bare: false, | 355 | bare: false, |
| 311 | policy: load_policy(path, false), | 356 | policy: load_policy(path, false), |
| 357 | exposure, | ||
| 312 | }) | 358 | }) |
| 313 | } else { | 359 | } else { |
| 314 | None | 360 | None |
| @@ -322,40 +368,55 @@ fn repo_entry_from_path(path: &Path, name: String) -> Option<RepoEntry> { | |||
| 322 | /// repository by path and HTTP by name, and the two have to be the same | 368 | /// repository by path and HTTP by name, and the two have to be the same |
| 323 | /// repository or a repository reachable over one is invisible over the other. | 369 | /// repository or a repository reachable over one is invisible over the other. |
| 324 | pub fn entry_for_path(repos_dir: &Path, path: &Path) -> Option<RepoEntry> { | 370 | pub fn entry_for_path(repos_dir: &Path, path: &Path) -> Option<RepoEntry> { |
| 371 | entry_under(repos_dir, path, &crate::governance::load(repos_dir)) | ||
| 372 | } | ||
| 373 | |||
| 374 | /// `entry_for_path` against a configuration that has already been read, so a | ||
| 375 | /// directory walk pays for reading `settings.git` once rather than once per | ||
| 376 | /// repository it finds. | ||
| 377 | fn entry_under( | ||
| 378 | repos_dir: &Path, | ||
| 379 | path: &Path, | ||
| 380 | governance: &crate::governance::GovernanceState, | ||
| 381 | ) -> Option<RepoEntry> { | ||
| 325 | if !path.is_dir() { | 382 | if !path.is_dir() { |
| 326 | return None; | 383 | return None; |
| 327 | } | 384 | } |
| 328 | 385 | ||
| 329 | let name = repo_name_for(repos_dir, path)?; | 386 | let name = repo_name_for(repos_dir, path)?; |
| 330 | let mut entry = repo_entry_from_path(path, name)?; | 387 | let exposure = exposure_for(repos_dir, path, governance); |
| 331 | apply_governance_default(repos_dir, &mut entry); | 388 | repo_entry_from_path(path, name, exposure) |
| 332 | Some(entry) | ||
| 333 | } | 389 | } |
| 334 | 390 | ||
| 335 | /// Apply the governance repository's closed-by-default posture. | 391 | /// What the live configuration says an anonymous request may see of the |
| 392 | /// repository at `path`. | ||
| 336 | /// | 393 | /// |
| 337 | /// Creating `settings.git` must not silently publish the key roster and the | 394 | /// The closed default is what makes `settings.git` safe to create: nothing |
| 338 | /// access rules to the internet. An operator who wants them browsable can say | 395 | /// grants `@anonymous` anything until an operator writes a rule, so the key |
| 339 | /// so in `settings.git/.collab/server.toml`, and then this steps out of the | 396 | /// roster and the access rules are off the anonymous surface without anything |
| 340 | /// way. Authenticated access over SSH is unaffected — it never comes through | 397 | /// having to special-case them. Authenticated SSH access never comes through |
| 341 | /// here — so a contributor can still read the rules it is subject to. | 398 | /// here, so a contributor can still read the rules it is subject to. |
| 342 | /// | 399 | fn exposure_for( |
| 343 | /// Applied wherever an entry is built, not only in `discover`: the anonymous | 400 | repos_dir: &Path, |
| 344 | /// HTTP surface reaches a repository by resolving a name, and a default that | 401 | path: &Path, |
| 345 | /// only the listing page honoured would not be a default at all. | 402 | governance: &crate::governance::GovernanceState, |
| 346 | fn apply_governance_default(repos_dir: &Path, entry: &mut RepoEntry) { | 403 | ) -> Exposure { |
| 347 | if is_governance_repo(repos_dir, entry) | 404 | use crate::governance::GovernanceState; |
| 348 | && !repo_server_config_path(&entry.path, entry.bare).exists() | 405 | match governance { |
| 349 | { | 406 | GovernanceState::Absent => Exposure::Ungoverned, |
| 350 | entry.policy = std::mem::take(&mut entry.policy).without_anonymous_access(); | 407 | GovernanceState::Unreadable(_) => Exposure::CLOSED, |
| 408 | GovernanceState::Active(governance) => { | ||
| 409 | let Some(key) = crate::governance::repo_key(repos_dir, path) else { | ||
| 410 | return Exposure::CLOSED; | ||
| 411 | }; | ||
| 412 | Exposure::Governed { | ||
| 413 | anonymous_read: governance.conf.anonymous_may_read(&key), | ||
| 414 | listed: governance.conf.is_listed(&key), | ||
| 415 | } | ||
| 416 | } | ||
| 351 | } | 417 | } |
| 352 | } | 418 | } |
| 353 | 419 | ||
| 354 | /// Whether this entry is the repository that governs the server. | ||
| 355 | fn is_governance_repo(repos_dir: &Path, entry: &RepoEntry) -> bool { | ||
| 356 | entry.path == repos_dir.join(format!("{}.git", crate::governance::SETTINGS_REPO)) | ||
| 357 | } | ||
| 358 | |||
| 359 | /// Scan `repos_dir` for git repositories, recursively. | 420 | /// Scan `repos_dir` for git repositories, recursively. |
| 360 | /// | 421 | /// |
| 361 | /// Nesting is the point: governance hands an agent a wild repo at | 422 | /// Nesting is the point: governance hands an agent a wild repo at |
| @@ -380,7 +441,10 @@ pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> { | |||
| 380 | // repos_dir is a real error. Failures further down are not worth | 441 | // repos_dir is a real error. Failures further down are not worth |
| 381 | // blanking the whole listing for. | 442 | // blanking the whole listing for. |
| 382 | let read_dir = std::fs::read_dir(repos_dir)?; | 443 | let read_dir = std::fs::read_dir(repos_dir)?; |
| 383 | collect(repos_dir, read_dir, 1, &mut entries); | 444 | // Read once for the whole walk. Every entry asks the same configuration |
| 445 | // the same question, and one listing must not be able to answer from two. | ||
| 446 | let governance = crate::governance::load(repos_dir); | ||
| 447 | collect(repos_dir, read_dir, 1, &governance, &mut entries); | ||
| 384 | entries.sort_by(|a, b| a.name.cmp(&b.name)); | 448 | entries.sort_by(|a, b| a.name.cmp(&b.name)); |
| 385 | Ok(entries) | 449 | Ok(entries) |
| 386 | } | 450 | } |
| @@ -389,6 +453,7 @@ fn collect( | |||
| 389 | repos_dir: &Path, | 453 | repos_dir: &Path, |
| 390 | read_dir: std::fs::ReadDir, | 454 | read_dir: std::fs::ReadDir, |
| 391 | depth: usize, | 455 | depth: usize, |
| 456 | governance: &crate::governance::GovernanceState, | ||
| 392 | entries: &mut Vec<RepoEntry>, | 457 | entries: &mut Vec<RepoEntry>, |
| 393 | ) { | 458 | ) { |
| 394 | for entry in read_dir { | 459 | for entry in read_dir { |
| @@ -409,7 +474,7 @@ fn collect( | |||
| 409 | continue; | 474 | continue; |
| 410 | } | 475 | } |
| 411 | 476 | ||
| 412 | if let Some(repo) = entry_for_path(repos_dir, &path) { | 477 | if let Some(repo) = entry_under(repos_dir, &path, governance) { |
| 413 | entries.push(repo); | 478 | entries.push(repo); |
| 414 | continue; | 479 | continue; |
| 415 | } | 480 | } |
| @@ -422,7 +487,7 @@ fn collect( | |||
| 422 | } | 487 | } |
| 423 | 488 | ||
| 424 | match std::fs::read_dir(&path) { | 489 | match std::fs::read_dir(&path) { |
| 425 | Ok(read_dir) => collect(repos_dir, read_dir, depth + 1, entries), | 490 | Ok(read_dir) => collect(repos_dir, read_dir, depth + 1, governance, entries), |
| 426 | Err(error) => { | 491 | Err(error) => { |
| 427 | tracing::warn!("skipping unreadable directory {:?}: {}", path, error) | 492 | tracing::warn!("skipping unreadable directory {:?}: {}", path, error) |
| 428 | } | 493 | } |
| @@ -841,68 +906,139 @@ mod tests { | |||
| 841 | assert!(!entry.policy.allows_write("key:SHA256:other")); | 906 | assert!(!entry.policy.allows_write("key:SHA256:other")); |
| 842 | } | 907 | } |
| 843 | 908 | ||
| 909 | // --- exposure ------------------------------------------------------ | ||
| 910 | |||
| 911 | /// Populate `settings.git` with a configuration, the way an init | ||
| 912 | /// container would: a commit on HEAD holding `conf/access.conf`. | ||
| 913 | fn init_governance(repos_dir: &Path, access_conf: &str) { | ||
| 914 | let repo = git2::Repository::init_bare(repos_dir.join("settings.git")).unwrap(); | ||
| 915 | let blob = repo.blob(access_conf.as_bytes()).unwrap(); | ||
| 916 | let mut conf_dir = repo.treebuilder(None).unwrap(); | ||
| 917 | conf_dir.insert("access.conf", blob, 0o100644).unwrap(); | ||
| 918 | let conf_dir = conf_dir.write().unwrap(); | ||
| 919 | let mut root = repo.treebuilder(None).unwrap(); | ||
| 920 | root.insert("conf", conf_dir, 0o040000).unwrap(); | ||
| 921 | let root = repo.find_tree(root.write().unwrap()).unwrap(); | ||
| 922 | let who = git2::Signature::now("Ops", "ops@example.com").unwrap(); | ||
| 923 | repo.commit(Some("HEAD"), &who, &who, "settings", &root, &[]) | ||
| 924 | .unwrap(); | ||
| 925 | } | ||
| 926 | |||
| 927 | fn named<'a>(repos: &'a [RepoEntry], name: &str) -> &'a RepoEntry { | ||
| 928 | repos.iter().find(|r| r.name == name).expect(name) | ||
| 929 | } | ||
| 930 | |||
| 931 | /// The inverted default: under governance a repository is unlisted and | ||
| 932 | /// unreadable unless a rule says otherwise, and that includes the | ||
| 933 | /// repository holding the rules. | ||
| 844 | #[test] | 934 | #[test] |
| 845 | fn the_governance_repo_is_not_anonymously_browsable_by_default() { | 935 | fn governance_closes_every_repository_no_rule_opens() { |
| 846 | let tmp = TempDir::new().unwrap(); | 936 | let tmp = TempDir::new().unwrap(); |
| 847 | init_bare(tmp.path(), "settings.git"); | ||
| 848 | init_bare(tmp.path(), "ordinary.git"); | 937 | init_bare(tmp.path(), "ordinary.git"); |
| 938 | init_governance(tmp.path(), "repo ordinary\n RW+ = alex\n R = @all\n"); | ||
| 849 | 939 | ||
| 850 | let repos = discover(tmp.path()).unwrap(); | 940 | let repos = discover(tmp.path()).unwrap(); |
| 851 | let settings = repos.iter().find(|r| r.name == "settings").unwrap(); | 941 | for name in ["ordinary", "settings"] { |
| 852 | let ordinary = repos.iter().find(|r| r.name == "ordinary").unwrap(); | 942 | let entry = named(&repos, name); |
| 853 | 943 | assert!(!entry.allows_anonymous_ui(), "{name} must not be readable"); | |
| 854 | assert!(!settings.policy.allows_anonymous_ui()); | 944 | assert!( |
| 855 | assert!(!settings.policy.allows_anonymous_http()); | 945 | !entry.allows_anonymous_http(), |
| 856 | // Only the governance repo; nothing else changes. | 946 | "{name} must not be clonable" |
| 857 | assert!(ordinary.policy.allows_anonymous_ui()); | 947 | ); |
| 858 | assert!(ordinary.policy.allows_anonymous_http()); | 948 | assert!(!entry.is_listed(), "{name} must not be listed"); |
| 949 | } | ||
| 859 | } | 950 | } |
| 860 | 951 | ||
| 861 | #[test] | 952 | #[test] |
| 862 | fn an_explicit_policy_on_the_governance_repo_is_honoured() { | 953 | fn a_rule_opens_a_repository_and_an_option_advertises_it() { |
| 863 | let tmp = TempDir::new().unwrap(); | 954 | let tmp = TempDir::new().unwrap(); |
| 864 | let repo_path = tmp.path().join("settings.git"); | 955 | init_bare(tmp.path(), "open.git"); |
| 865 | init_bare(tmp.path(), "settings.git"); | 956 | init_bare(tmp.path(), "byname.git"); |
| 866 | write_policy(&repo_path, true, "visibility = \"public\"\n"); | 957 | init_governance( |
| 958 | tmp.path(), | ||
| 959 | "repo open\n R = @anonymous\n option listed = yes\n\ | ||
| 960 | \nrepo byname\n R = @anonymous\n", | ||
| 961 | ); | ||
| 867 | 962 | ||
| 868 | let repos = discover(tmp.path()).unwrap(); | 963 | let repos = discover(tmp.path()).unwrap(); |
| 869 | let settings = repos.iter().find(|r| r.name == "settings").unwrap(); | 964 | let open = named(&repos, "open"); |
| 870 | assert!(settings.policy.allows_anonymous_ui()); | 965 | assert!(open.allows_anonymous_ui() && open.allows_anonymous_http()); |
| 966 | assert!(open.is_listed()); | ||
| 967 | |||
| 968 | let byname = named(&repos, "byname"); | ||
| 969 | assert!(byname.allows_anonymous_ui() && byname.allows_anonymous_http()); | ||
| 970 | assert!(!byname.is_listed(), "reachable by name is not advertised"); | ||
| 971 | |||
| 972 | // Resolution by name and the directory walk must agree, or a | ||
| 973 | // repository is listed but unreachable, or the reverse. | ||
| 974 | assert!(resolve(tmp.path(), "byname").unwrap().allows_anonymous_ui()); | ||
| 975 | assert!(!resolve(tmp.path(), "byname").unwrap().is_listed()); | ||
| 871 | } | 976 | } |
| 872 | 977 | ||
| 873 | /// A repository called `settings` that is not *the* settings repository — | 978 | /// A repository whose last segment is `settings` is not the repository |
| 874 | /// nested under another directory — is an ordinary repository. Now that | 979 | /// that governs the server: the rules are written against the whole |
| 875 | /// discovery recurses it is actually reachable, so this matters: a wild | 980 | /// key, so `agents/settings` gets the wild block's grant while `settings` |
| 876 | /// repo an agent creates at `agents/settings` must not inherit the | 981 | /// gets nothing. |
| 877 | /// governance repository's closed-by-default posture, and more to the | ||
| 878 | /// point must not be mistaken for the thing that governs the server. | ||
| 879 | #[test] | 982 | #[test] |
| 880 | fn only_the_top_level_settings_repo_gets_the_governance_default() { | 983 | fn a_nested_settings_repo_is_not_the_governance_repo() { |
| 881 | let tmp = TempDir::new().unwrap(); | 984 | let tmp = TempDir::new().unwrap(); |
| 882 | init_bare(tmp.path(), "settings.git"); | ||
| 883 | let agents = tmp.path().join("agents"); | 985 | let agents = tmp.path().join("agents"); |
| 884 | std::fs::create_dir_all(&agents).unwrap(); | 986 | std::fs::create_dir_all(&agents).unwrap(); |
| 885 | init_bare(&agents, "settings.git"); | 987 | init_bare(&agents, "settings.git"); |
| 988 | init_governance( | ||
| 989 | tmp.path(), | ||
| 990 | "repo agents/[a-z]+\n R = @anonymous\n option listed = yes\n", | ||
| 991 | ); | ||
| 886 | 992 | ||
| 887 | let repos = discover(tmp.path()).unwrap(); | 993 | let repos = discover(tmp.path()).unwrap(); |
| 888 | let nested = repos.iter().find(|r| r.name == "agents/settings").unwrap(); | 994 | let nested = named(&repos, "agents/settings"); |
| 889 | let governance = repos.iter().find(|r| r.name == "settings").unwrap(); | 995 | assert!(nested.allows_anonymous_ui() && nested.is_listed()); |
| 890 | 996 | ||
| 891 | assert!(!is_governance_repo(tmp.path(), nested)); | 997 | let governance = named(&repos, "settings"); |
| 892 | assert!(is_governance_repo(tmp.path(), governance)); | 998 | assert!(!governance.allows_anonymous_ui() && !governance.is_listed()); |
| 893 | assert!(nested.policy.allows_anonymous_ui()); | 999 | } |
| 894 | assert!(nested.policy.allows_anonymous_http()); | ||
| 895 | assert!(!governance.policy.allows_anonymous_ui()); | ||
| 896 | 1000 | ||
| 897 | // And the same conclusion by the route a request actually takes. | 1001 | /// Rule 3 of the exposure model: the inverted default belongs to the |
| 898 | assert!(resolve(tmp.path(), "agents/settings") | 1002 | /// governed world, not to the binary. An ungoverned server goes on |
| 899 | .unwrap() | 1003 | /// listing and serving everything it has, `server.toml` deciding. |
| 900 | .policy | 1004 | #[test] |
| 901 | .allows_anonymous_ui()); | 1005 | fn an_ungoverned_server_exposes_its_repositories_exactly_as_before() { |
| 902 | assert!(!resolve(tmp.path(), "settings") | 1006 | let tmp = TempDir::new().unwrap(); |
| 903 | .unwrap() | 1007 | init_bare(tmp.path(), "ordinary.git"); |
| 904 | .policy | 1008 | // An unpopulated `settings.git` governs nothing — an absent |
| 905 | .allows_anonymous_ui()); | 1009 | // `conf/access.conf` is an absence, not a configuration — so it is an |
| 1010 | // ordinary repository here too, with no rules to hide it and no | ||
| 1011 | // roster in it to hide. | ||
| 1012 | init_bare(tmp.path(), "settings.git"); | ||
| 1013 | |||
| 1014 | let repos = discover(tmp.path()).unwrap(); | ||
| 1015 | for name in ["ordinary", "settings"] { | ||
| 1016 | let entry = named(&repos, name); | ||
| 1017 | assert_eq!(entry.exposure, Exposure::Ungoverned); | ||
| 1018 | assert!(entry.allows_anonymous_ui() && entry.is_listed(), "{name}"); | ||
| 1019 | } | ||
| 1020 | |||
| 1021 | // And `server.toml` is still the authority that decides otherwise. | ||
| 1022 | write_policy( | ||
| 1023 | &tmp.path().join("ordinary.git"), | ||
| 1024 | true, | ||
| 1025 | "visibility = \"private\"\n", | ||
| 1026 | ); | ||
| 1027 | let repos = discover(tmp.path()).unwrap(); | ||
| 1028 | assert!(!named(&repos, "ordinary").allows_anonymous_ui()); | ||
| 1029 | } | ||
| 1030 | |||
| 1031 | /// A server that cannot read its own rules must not guess at them. | ||
| 1032 | #[test] | ||
| 1033 | fn an_unreadable_configuration_closes_everything() { | ||
| 1034 | let tmp = TempDir::new().unwrap(); | ||
| 1035 | init_bare(tmp.path(), "ordinary.git"); | ||
| 1036 | init_governance(tmp.path(), "repo ordinary\n RWD = alex\n"); | ||
| 1037 | |||
| 1038 | let repos = discover(tmp.path()).unwrap(); | ||
| 1039 | let entry = named(&repos, "ordinary"); | ||
| 1040 | assert_eq!(entry.exposure, Exposure::CLOSED); | ||
| 1041 | assert!(!entry.allows_anonymous_ui() && !entry.is_listed()); | ||
| 906 | } | 1042 | } |
| 907 | 1043 | ||
| 908 | #[test] | 1044 | #[test] |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1005,7 +1005,13 @@ impl ServerHarness { | |||
| 1005 | /// stdin). Also enforces a 60s watchdog: if the server hangs, the ssh | 1005 | /// stdin). Also enforces a 60s watchdog: if the server hangs, the ssh |
| 1006 | /// child is killed rather than hanging the test/CI forever. | 1006 | /// child is killed rather than hanging the test/CI forever. |
| 1007 | pub fn ssh_exec_with_stdin(&self, remote_cmd: &str, stdin: &[u8]) -> Output { | 1007 | pub fn ssh_exec_with_stdin(&self, remote_cmd: &str, stdin: &[u8]) -> Output { |
| 1008 | let key = self.ssh_client_key(); | 1008 | self.ssh_exec_as(&self.ssh_client_key(), remote_cmd, stdin) |
| 1009 | } | ||
| 1010 | |||
| 1011 | /// Like `ssh_exec_with_stdin`, but as a named key from `keydir/` rather | ||
| 1012 | /// than the `authorized_keys` one — the only kind that authenticates on a | ||
| 1013 | /// governed server. | ||
| 1014 | pub fn ssh_exec_as(&self, key: &Path, remote_cmd: &str, stdin: &[u8]) -> Output { | ||
| 1009 | let mut child = Command::new("ssh") | 1015 | let mut child = Command::new("ssh") |
| 1010 | .args([ | 1016 | .args([ |
| 1011 | "-p", | 1017 | "-p", |
tests/governance_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -32,6 +32,19 @@ repo agents/[a-z-]+ | |||
| 32 | RW+ = CREATOR | 32 | RW+ = CREATOR |
| 33 | "; | 33 | "; |
| 34 | 34 | ||
| 35 | /// `ACCESS_CONF` with extra lines appended to the `governed` block, which is | ||
| 36 | /// where every exposure rule in these tests goes. | ||
| 37 | fn access_conf_with(governed_extra: &str) -> String { | ||
| 38 | ACCESS_CONF.replace("\nrepo agents/", &format!("{governed_extra}\nrepo agents/")) | ||
| 39 | } | ||
| 40 | |||
| 41 | /// The two enrolled keys every exposure test uses. | ||
| 42 | const KEYS: &[(&str, &str)] = &[("alex.pub", "alex"), ("claude-a.pub", "claude-a")]; | ||
| 43 | |||
| 44 | /// The two exposure lines, spelled the way the spec's example spells them. | ||
| 45 | const ANON_READ: &str = " R = @anonymous\n"; | ||
| 46 | const LISTED: &str = " option listed = yes\n"; | ||
| 47 | |||
| 35 | fn stderr(output: &Output) -> String { | 48 | fn stderr(output: &Output) -> String { |
| 36 | String::from_utf8_lossy(&output.stderr).to_string() | 49 | String::from_utf8_lossy(&output.stderr).to_string() |
| 37 | } | 50 | } |
| @@ -354,9 +367,230 @@ fn an_agent_creates_its_own_wild_repo_and_another_agent_cannot_write_it() { | |||
| 354 | ); | 367 | ); |
| 355 | } | 368 | } |
| 356 | 369 | ||
| 370 | // ---- Exposure: unlisted by default, published by rule ------------------- | ||
| 371 | |||
| 372 | /// Whether an anonymous git client can clone over HTTP, asked of the real | ||
| 373 | /// smart-HTTP endpoint rather than of a rendered page. | ||
| 374 | fn clonable_anonymously(harness: &ServerHarness, repo: &str) -> bool { | ||
| 375 | harness | ||
| 376 | .get(&format!("/{repo}.git/info/refs?service=git-upload-pack")) | ||
| 377 | .status_line | ||
| 378 | .contains("200") | ||
| 379 | } | ||
| 380 | |||
| 381 | fn readable_anonymously(harness: &ServerHarness, repo: &str) -> bool { | ||
| 382 | harness.get(&format!("/{repo}")).status_line.contains("200") | ||
| 383 | } | ||
| 384 | |||
| 385 | fn listed_anonymously(harness: &ServerHarness, repo: &str) -> bool { | ||
| 386 | harness.get_ok("/").body.contains(repo) | ||
| 387 | } | ||
| 388 | |||
| 389 | /// The model, end to end: a governed repository is unlisted and unreadable | ||
| 390 | /// without authentication, `R = @anonymous` makes it reachable by name, and | ||
| 391 | /// `option listed = yes` advertises it. The two are separate states because | ||
| 392 | /// "not in the list" and "404 to a direct URL" are different things. | ||
| 393 | #[test] | ||
| 394 | fn a_governed_repository_is_unlisted_and_unreadable_until_a_rule_says_so() { | ||
| 395 | let harness = ServerHarness::new("governed"); | ||
| 396 | harness.push_head(); | ||
| 397 | harness.bootstrap_settings(ACCESS_CONF, KEYS); | ||
| 398 | |||
| 399 | // The config grants `R = @all` — every enrolled key — and that is not | ||
| 400 | // the same set as "anybody at all", so nothing is open to HTTP. | ||
| 401 | assert!( | ||
| 402 | !listed_anonymously(&harness, "governed"), | ||
| 403 | "a repository with no anonymous grant must not be advertised" | ||
| 404 | ); | ||
| 405 | assert!( | ||
| 406 | !readable_anonymously(&harness, "governed"), | ||
| 407 | "nor reachable by name" | ||
| 408 | ); | ||
| 409 | assert!( | ||
| 410 | !clonable_anonymously(&harness, "governed"), | ||
| 411 | "nor clonable over HTTP" | ||
| 412 | ); | ||
| 413 | |||
| 414 | // `R = @anonymous`: reachable by name, still not advertised. | ||
| 415 | harness.bootstrap_settings(&access_conf_with(ANON_READ), KEYS); | ||
| 416 | assert!( | ||
| 417 | readable_anonymously(&harness, "governed"), | ||
| 418 | "an anonymous read grant must make the repository reachable" | ||
| 419 | ); | ||
| 420 | assert!( | ||
| 421 | clonable_anonymously(&harness, "governed"), | ||
| 422 | "and clonable: a clone is the same read" | ||
| 423 | ); | ||
| 424 | assert!( | ||
| 425 | !listed_anonymously(&harness, "governed"), | ||
| 426 | "but reachable by name is not advertised" | ||
| 427 | ); | ||
| 428 | |||
| 429 | // And the option that advertises it. | ||
| 430 | harness.bootstrap_settings(&access_conf_with(&format!("{ANON_READ}{LISTED}")), KEYS); | ||
| 431 | assert!( | ||
| 432 | listed_anonymously(&harness, "governed"), | ||
| 433 | "option listed = yes must put the repository in the list" | ||
| 434 | ); | ||
| 435 | assert!(readable_anonymously(&harness, "governed")); | ||
| 436 | } | ||
| 437 | |||
| 438 | /// The inverted default is a property of the governed world, not of the | ||
| 439 | /// binary. A server with no `settings.git` keeps today's behaviour exactly — | ||
| 440 | /// otherwise every existing deployment would hide every repository it has the | ||
| 441 | /// moment it upgraded. | ||
| 442 | #[test] | ||
| 443 | fn an_ungoverned_server_still_lists_and_serves_every_repository() { | ||
| 444 | let harness = ServerHarness::new("ungoverned"); | ||
| 445 | harness.push_head(); | ||
| 446 | |||
| 447 | assert!( | ||
| 448 | listed_anonymously(&harness, "ungoverned"), | ||
| 449 | "an ungoverned server must go on listing its repositories" | ||
| 450 | ); | ||
| 451 | assert!(readable_anonymously(&harness, "ungoverned")); | ||
| 452 | assert!(clonable_anonymously(&harness, "ungoverned")); | ||
| 453 | } | ||
| 454 | |||
| 455 | /// Web UI authentication is out of scope, so an HTTP request has no identity: | ||
| 456 | /// a listed repository nobody may read would advertise a name that 404s. The | ||
| 457 | /// pair is rejected on push, and the config that was live stays live. | ||
| 458 | #[test] | ||
| 459 | fn listing_a_repository_nobody_may_read_is_rejected_on_push() { | ||
| 460 | let harness = ServerHarness::new("governed"); | ||
| 461 | harness.push_head(); | ||
| 462 | harness.bootstrap_settings(&access_conf_with(ANON_READ), KEYS); | ||
| 463 | let admin = harness.named_key("alex"); | ||
| 464 | |||
| 465 | // Keep the option, drop the grant. | ||
| 466 | harness.stage_settings(&access_conf_with(LISTED), KEYS); | ||
| 467 | let refused = harness.push_settings_over_ssh(&admin); | ||
| 468 | assert_refused(&refused, "listed without an anonymous read grant"); | ||
| 469 | let message = stderr(&refused); | ||
| 470 | assert!( | ||
| 471 | message.contains("@anonymous") && message.contains("the previous one stays live"), | ||
| 472 | "the rejection must say what is wrong and what happened; got:\n{message}" | ||
| 473 | ); | ||
| 474 | |||
| 475 | // The previously-live config is still the one in force. | ||
| 476 | assert!(harness.live_access_conf().contains("@anonymous")); | ||
| 477 | assert!(readable_anonymously(&harness, "governed")); | ||
| 478 | assert!(!listed_anonymously(&harness, "governed")); | ||
| 479 | } | ||
| 480 | |||
| 481 | /// An unauthenticated request cannot be held responsible for a write, so a | ||
| 482 | /// permission that grants it one is a mistake — rejected, not ignored. | ||
| 483 | #[test] | ||
| 484 | fn a_write_grant_to_the_anonymous_reader_is_rejected_on_push() { | ||
| 485 | let harness = ServerHarness::new("governed"); | ||
| 486 | harness.push_head(); | ||
| 487 | harness.bootstrap_settings(&access_conf_with(ANON_READ), KEYS); | ||
| 488 | let admin = harness.named_key("alex"); | ||
| 489 | |||
| 490 | harness.stage_settings(&access_conf_with(" RW = @anonymous\n"), KEYS); | ||
| 491 | let refused = harness.push_settings_over_ssh(&admin); | ||
| 492 | assert_refused(&refused, "a write grant to @anonymous"); | ||
| 493 | assert!( | ||
| 494 | stderr(&refused).contains("@anonymous"), | ||
| 495 | "got:\n{}", | ||
| 496 | stderr(&refused) | ||
| 497 | ); | ||
| 498 | |||
| 499 | // Still governed by the config that was live before the attempt. | ||
| 500 | assert!(!harness.live_access_conf().contains("RW = @anonymous")); | ||
| 501 | assert!(readable_anonymously(&harness, "governed")); | ||
| 502 | let agent = harness.named_key("claude-a"); | ||
| 503 | harness.work_repo().issue_open("Still governed"); | ||
| 504 | assert_accepted( | ||
| 505 | &harness.ssh_push(&agent, "refs/collab/*:refs/collab/*"), | ||
| 506 | "the previous config still granting the agent its collab refs", | ||
| 507 | ); | ||
| 508 | } | ||
| 509 | |||
| 510 | /// A wild repo is governed like any other, and `agents/settings` is a | ||
| 511 | /// repository whose name happens to end in `settings` — not the repository | ||
| 512 | /// that governs the server. | ||
| 513 | #[test] | ||
| 514 | fn a_wild_repo_obeys_the_exposure_rules_and_is_not_the_governance_repo() { | ||
| 515 | let harness = ServerHarness::new("governed"); | ||
| 516 | harness.bootstrap_settings(ACCESS_CONF, KEYS); | ||
| 517 | let agent = harness.named_key("claude-a"); | ||
| 518 | |||
| 519 | harness.work_repo().commit_file("mine.txt", "1", "mine"); | ||
| 520 | assert_accepted( | ||
| 521 | &harness.ssh_push_from( | ||
| 522 | harness.work_repo().dir.path(), | ||
| 523 | &agent, | ||
| 524 | "agents/settings", | ||
| 525 | "main:main", | ||
| 526 | ), | ||
| 527 | "an agent creating a wild repo whose last segment is `settings`", | ||
| 528 | ); | ||
| 529 | |||
| 530 | // Created, and closed by default like everything else. | ||
| 531 | assert!(harness | ||
| 532 | .repos_dir() | ||
| 533 | .join("agents") | ||
| 534 | .join("settings.git") | ||
| 535 | .exists()); | ||
| 536 | assert!(!listed_anonymously(&harness, "agents/settings")); | ||
| 537 | assert!(!readable_anonymously(&harness, "agents/settings")); | ||
| 538 | |||
| 539 | // Publish the wild namespace. The governance repository is a different | ||
| 540 | // repository and stays shut. | ||
| 541 | harness.bootstrap_settings( | ||
| 542 | &ACCESS_CONF.replace( | ||
| 543 | " RW+ = CREATOR\n", | ||
| 544 | " RW+ = CREATOR\n R = @anonymous\n option listed = yes\n", | ||
| 545 | ), | ||
| 546 | KEYS, | ||
| 547 | ); | ||
| 548 | assert!( | ||
| 549 | readable_anonymously(&harness, "agents/settings"), | ||
| 550 | "a wild repo follows the rule that matches it" | ||
| 551 | ); | ||
| 552 | assert!(listed_anonymously(&harness, "agents/settings")); | ||
| 553 | assert!( | ||
| 554 | !readable_anonymously(&harness, "settings"), | ||
| 555 | "the governance repository is not what `agents/settings` names" | ||
| 556 | ); | ||
| 557 | } | ||
| 558 | |||
| 559 | /// Downloads are data distribution, like a clone, so they follow the same | ||
| 560 | /// grant. Publishing stays `RW+` and SSH-only. | ||
| 561 | #[test] | ||
| 562 | fn release_downloads_follow_the_anonymous_read_grant() { | ||
| 563 | let harness = ServerHarness::new("governed"); | ||
| 564 | harness.push_head(); | ||
| 565 | harness.bootstrap_settings(ACCESS_CONF, KEYS); | ||
| 566 | let admin = harness.named_key("alex"); | ||
| 567 | |||
| 568 | let upload = harness.ssh_exec_as( | ||
| 569 | &admin, | ||
| 570 | "collab-release upload 'governed.git' 'v1' 'a.tar.gz'", | ||
| 571 | b"payload", | ||
| 572 | ); | ||
| 573 | assert!( | ||
| 574 | upload.status.success(), | ||
| 575 | "an admin with RW+ must be able to publish: {}", | ||
| 576 | stderr(&upload) | ||
| 577 | ); | ||
| 578 | |||
| 579 | let (head, _) = harness.get_bytes("/governed/releases/v1/a.tar.gz"); | ||
| 580 | assert!( | ||
| 581 | head.contains("404"), | ||
| 582 | "a repository with no anonymous grant must not serve its artifacts: {head}" | ||
| 583 | ); | ||
| 584 | |||
| 585 | harness.bootstrap_settings(&access_conf_with(ANON_READ), KEYS); | ||
| 586 | let (head, body) = harness.get_bytes("/governed/releases/v1/a.tar.gz"); | ||
| 587 | assert!(head.contains("200"), "got {head}"); | ||
| 588 | assert_eq!(body, b"payload"); | ||
| 589 | } | ||
| 590 | |||
| 357 | /// Creating `settings.git` must not silently publish the key roster and the | 591 | /// Creating `settings.git` must not silently publish the key roster and the |
| 358 | /// access rules to an internet-facing web UI. A contributor reads the rules it | 592 | /// access rules to an internet-facing web UI. This used to be a hand-coded |
| 359 | /// is subject to over SSH, where it is authenticated. | 593 | /// special case; it is now just the default with no rule to lift it. |
| 360 | #[test] | 594 | #[test] |
| 361 | fn the_settings_repository_is_not_on_the_anonymous_http_surface_by_default() { | 595 | fn the_settings_repository_is_not_on_the_anonymous_http_surface_by_default() { |
| 362 | let harness = ServerHarness::new("governed"); | 596 | let harness = ServerHarness::new("governed"); |
| @@ -391,17 +625,16 @@ fn the_settings_repository_is_not_on_the_anonymous_http_surface_by_default() { | |||
| 391 | ); | 625 | ); |
| 392 | } | 626 | } |
| 393 | 627 | ||
| 394 | /// The documented split between the two config systems, asserted rather than | 628 | /// The documented split, asserted rather than left to the doc comment: where |
| 395 | /// left to the doc comment: `settings.git` supersedes `server.toml` on the | 629 | /// governance is in force, `settings.git` supersedes `server.toml` on *every* |
| 396 | /// authenticated-principal axis, and `server.toml` keeps the anonymous one. | 630 | /// axis, the anonymous surface included. Exactly one file answers the |
| 631 | /// question, so a `server.toml` nobody remembers editing can neither subtract | ||
| 632 | /// access that `access.conf` grants nor add exposure it withholds. | ||
| 397 | #[test] | 633 | #[test] |
| 398 | fn settings_supersedes_server_toml_for_principals_and_leaves_it_the_anonymous_surface() { | 634 | fn settings_supersedes_server_toml_on_the_anonymous_axis_in_both_directions() { |
| 399 | let harness = ServerHarness::new("governed"); | 635 | let harness = ServerHarness::new("governed"); |
| 400 | harness.push_head(); | 636 | harness.push_head(); |
| 401 | harness.bootstrap_settings( | 637 | harness.bootstrap_settings(ACCESS_CONF, KEYS); |
| 402 | ACCESS_CONF, | ||
| 403 | &[("alex.pub", "alex"), ("claude-a.pub", "claude-a")], | ||
| 404 | ); | ||
| 405 | 638 | ||
| 406 | // A server.toml that, under the old regime, would deny every principal | 639 | // A server.toml that, under the old regime, would deny every principal |
| 407 | // and hide the repository from anonymous HTTP. | 640 | // and hide the repository from anonymous HTTP. |
| @@ -423,13 +656,25 @@ fn settings_supersedes_server_toml_for_principals_and_leaves_it_the_anonymous_su | |||
| 423 | "access.conf superseding an empty server.toml access list", | 656 | "access.conf superseding an empty server.toml access list", |
| 424 | ); | 657 | ); |
| 425 | 658 | ||
| 426 | // The anonymous axis is still server.toml's: access.conf has no vocabulary | 659 | // The anonymous axis is access.conf's too, and it grants nothing here. |
| 427 | // for a request with no principal, so `visibility` still hides the repo. | 660 | assert!(!readable_anonymously(&harness, "governed")); |
| 428 | let page = harness.get("/governed"); | 661 | |
| 662 | // Now the direction that proves supersession rather than agreement: the | ||
| 663 | // same private server.toml, and a rule that publishes the repository. | ||
| 664 | harness.bootstrap_settings(&access_conf_with(ANON_READ), KEYS); | ||
| 665 | assert!( | ||
| 666 | readable_anonymously(&harness, "governed"), | ||
| 667 | "a private server.toml must not subtract what access.conf grants" | ||
| 668 | ); | ||
| 669 | |||
| 670 | // And the reverse: the most permissive server.toml there is cannot | ||
| 671 | // publish a repository access.conf has not published. | ||
| 672 | harness.write_repo_server_policy( | ||
| 673 | "visibility = \"public\"\n[ui]\nanonymous = true\n[http]\nanonymous_clone = true\n", | ||
| 674 | ); | ||
| 675 | harness.bootstrap_settings(ACCESS_CONF, KEYS); | ||
| 429 | assert!( | 676 | assert!( |
| 430 | !page.status_line.contains("200"), | 677 | !readable_anonymously(&harness, "governed"), |
| 431 | "server.toml must still govern the anonymous surface; got {} \n{}", | 678 | "server.toml must not be able to publish what access.conf withholds" |
| 432 | page.status_line, | ||
| 433 | page.body | ||
| 434 | ); | 679 | ); |
| 435 | } | 680 | } |
tests/nested_repo_http_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -17,6 +17,11 @@ use std::process::{Command, Output}; | |||
| 17 | 17 | ||
| 18 | /// The access rules used by the wild-repo test: one prefix per agent, with | 18 | /// The access rules used by the wild-repo test: one prefix per agent, with |
| 19 | /// the creating key owning what it creates. | 19 | /// the creating key owning what it creates. |
| 20 | /// | ||
| 21 | /// The wild block publishes what it creates, because this file is about | ||
| 22 | /// *reachability* over HTTP — whether a repository that exists can be found | ||
| 23 | /// under its name — and a governed repository is closed to HTTP until a rule | ||
| 24 | /// opens it. `governance_test.rs` owns the exposure model itself. | ||
| 20 | const ACCESS_CONF: &str = "\ | 25 | const ACCESS_CONF: &str = "\ |
| 21 | @admins = alex | 26 | @admins = alex |
| 22 | @agents = claude-a | 27 | @agents = claude-a |
| @@ -31,6 +36,8 @@ repo governed | |||
| 31 | repo agents/[a-z-]+ | 36 | repo agents/[a-z-]+ |
| 32 | C = @agents | 37 | C = @agents |
| 33 | RW+ = CREATOR | 38 | RW+ = CREATOR |
| 39 | R = @anonymous | ||
| 40 | option listed = yes | ||
| 34 | "; | 41 | "; |
| 35 | 42 | ||
| 36 | /// Create a bare repository at `repos_dir/<relative>`, making parents as | 43 | /// Create a bare repository at `repos_dir/<relative>`, making parents as |