a73x

80539887

Governance: unlisted by default, published by explicit rule

a73x   2026-08-11 18:27

Commit message
Governance: unlisted by default, published by explicit rule

A governed repository is now unlisted and unreadable without
authentication unless a rule says otherwise. Absence of configuration
becomes a decision, and `settings.git` expresses only the exception —
a positive grant, which is what the rule language is already good at.

`@anonymous` is a reserved principal in the rule table, resolved by the
same first-match-wins evaluation as any other, including `-` to deny it
back. It is deliberately not a member of `@all`: that is every *enrolled*
key, a strictly smaller set than "anybody at all", and the whole model
turns on the difference. `listed` is an `option`, gitolite's existing
syntax, because advertising is a display concern rather than an access
one — "not in the list" and "404 to a direct URL" are different states,
and a repository reachable if you know its name is a real configuration.

Four rules an implementer would otherwise have to guess:

- `option listed = yes` without `R = @anonymous` is a config error.
  Web UI auth is out of scope, so HTTP has no identity and the pair
  would advertise a name that 404s. Checked by evaluation rather than
  by looking inside the block, so the grant may come from anywhere in
  the file; a wild pattern names nothing enumerable, so the evaluator
  gates `is_listed` on the same grant and fails closed there.
- `RW = @anonymous` is a config error, not a silently ignored rule.
- The inverted default applies only where governance is in force. A
  server with no `settings.git` behaves exactly as before; flipping the
  default globally would hide every repository on every deployment that
  upgraded.
- Release downloads follow the anonymous read grant, for the same
  reason a clone does. Publish and delete stay RW+ and SSH-only.

Under governance `access.conf` now supersedes `server.toml` on the
anonymous axis too, so exactly one file still answers each question.
The hand-coded special case that forced `settings.git` off the anonymous
surface is gone: no rule grants `@anonymous` anything until an operator
writes one, so the key roster stays unpublished by the default rather
than by its name. The one behaviour change that falls out is that an
*unpopulated* `settings.git` — no `conf/access.conf`, so governing
nothing — is now an ordinary repository on an ungoverned server.

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

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