a73x

8bbdad3e

Enrol the keys that already have access when governance is turned on

a73x   2026-08-14 06:27

Commit message
Enrol the keys that already have access when governance is turned on

`keydir/` supersedes `authorized_keys` the instant `settings.git` exists —
`auth_publickey` reads one roster or the other, never both — so `setup`,
being a one-key bootstrap, revoked SSH for everybody but the administrator
at the moment it ran. On the live deployment that is one of two keys.

The supersession is right; two rosters that can disagree is worse. What was
wrong is that the rest of `setup` is built on the opposite principle. The
file goes to considerable lengths — an end-to-end exposure test, a run-time
`verify()` — to guarantee that enabling governance changes nothing, and auth
was the one axis where it did.

So setup now copies every well-formed `authorized_keys` entry into `keydir/`,
leaving the file itself untouched, and translates the `server.toml` rosters
that named those keys by fingerprint — previously only the admin's own key
could be translated, because it was the only one with a name.

Enrolling is the default and `--no-enrol-existing` is the flag, because the
two mistakes are not symmetric: an over-broad roster is a `git rm` and a
push, a lockout may need the filesystem. On a fresh server the default and
the flag do the same thing, so the default costs nothing where there is
nothing to lose. Declining says how many keys it just locked out.

Naming is the real work, since an `authorized_keys` entry has no identity and
the keydir basename *is* the principal. The entry's trailing comment names it
where the comment can be a principal name, used verbatim and never cut down
to `alex@laptop`'s first component: that truncation is the guess that merges,
turning two entries the operator listed separately into one principal so a
rule written for one silently covers the other. A name two entries both claim
goes to neither. Everything else gets `key1`, `key2` — obviously provisional
rather than plausibly wrong — reported with the key's fingerprint. Every
derived name is printed, by `--dry-run` too, while renaming is still free.

`verify()` grows the matching half: the generated rules are asked, through
the real evaluator, whether each enrolled principal gets exactly the read,
write and rewind its `server.toml` gives it today. A mismatch aborts before
anything is written.

Two tests carry the claim, both against a live server over real SSH: three
keys with three different reaches, measured before and after; and a file
holding a comment-less entry, an unusable comment, a duplicate comment and a
line that is not a key, none of which may abort the setup. Both were checked
by mutation — dropping an enrolled key fails the first, and translating only
the admin's roster entry is caught by `verify()` before the seed is written.

Also fixes a latent harness bug that this test was the first to reach: the
server's stdout and stderr are pipes nothing ever read, so a server blocked
on its next log line partway through any test making enough requests. It
presented as a hang on whichever operation crossed 64 KiB.

README.md
Old New
@@ -428,9 +428,18 @@ The seed reproduces the policy already in force: it reads each repository's
428 `R = @anonymous` and `option listed = yes` for a repository that is public 428 `R = @anonymous` and `option listed = yes` for a repository that is public
429 today. So enabling governance changes nothing about what the server exposes, 429 today. So enabling governance changes nothing about what the server exposes,
430 which is what makes it safe to run on a live server — `--dry-run` prints the 430 which is what makes it safe to run on a live server — `--dry-run` prints the
431 rules first. Setup refuses if `settings.git` already exists, and leaves 431 rules first. Setup refuses if `settings.git` already exists.
432 `authorized_keys` alone (though once governance is on, only keys in `keydir/` 432
433 authenticate; enrol the rest before anyone needs them). 433 It changes nothing about who can *connect*, either. Because `keydir/`
434 supersedes `authorized_keys` the moment `settings.git` exists, setup copies
435 every key in that file into `keydir/` — leaving the file itself untouched — and
436 translates the `server.toml` rosters that named those keys by fingerprint. An
437 entry's trailing comment names it where the comment can be a principal name,
438 used verbatim and never cut down to `alex@laptop`'s first component; otherwise
439 it gets an obviously provisional `key1`, `key2`, reported with the key's
440 fingerprint so you can rename it. Every derived name is printed, by `--dry-run`
441 too. Pass `--no-enrol-existing` for a clean single-administrator bootstrap,
442 which says how many keys that locks out.
434 443
435 ```text 444 ```text
436 settings.git 445 settings.git
src/server/main.rs
Old New
@@ -73,8 +73,8 @@ enum Command {
73 /// running this changes nothing about what the server exposes. That is 73 /// running this changes nothing about what the server exposes. That is
74 /// what makes it safe on a live server; `--dry-run` shows the rules first. 74 /// what makes it safe on a live server; `--dry-run` shows the rules first.
75 Setup { 75 Setup {
76 /// The same config the server runs with; `repos_dir` is read, and 76 /// The same config the server runs with. `repos_dir` is read, and
77 /// `authorized_keys` is left alone. 77 /// `authorized_keys` is read but never written.
78 #[arg(short, long)] 78 #[arg(short, long)]
79 config: PathBuf, 79 config: PathBuf,
80 80
@@ -90,7 +90,17 @@ enum Command {
90 #[arg(long, value_name = "NAME")] 90 #[arg(long, value_name = "NAME")]
91 admin_name: Option<String>, 91 admin_name: Option<String>,
92 92
93 /// Print the `conf/access.conf` this would write, and write nothing. 93 /// Do not enrol the keys already in `authorized_keys`.
94 ///
95 /// By default every key in that file is copied into `keydir/`, because
96 /// `keydir/` supersedes it the moment governance is on and the keys
97 /// would otherwise be refused. Pass this for a clean single-admin
98 /// bootstrap, knowing it locks everybody else out.
99 #[arg(long)]
100 no_enrol_existing: bool,
101
102 /// Print the `conf/access.conf` this would write and the names it
103 /// would enrol, and write nothing.
94 #[arg(long)] 104 #[arg(long)]
95 dry_run: bool, 105 dry_run: bool,
96 }, 106 },
@@ -136,11 +146,14 @@ async fn main() {
136 Command::Setup { 146 Command::Setup {
137 admin_key, 147 admin_key,
138 admin_name, 148 admin_name,
149 no_enrol_existing,
139 .. 150 ..
140 } => setup::run( 151 } => setup::run(
141 &config.repos_dir, 152 &config.repos_dir,
153 &config.authorized_keys,
142 &admin_key, 154 &admin_key,
143 admin_name.as_deref(), 155 admin_name.as_deref(),
156 !no_enrol_existing,
144 dry_run, 157 dry_run,
145 ), 158 ),
146 }); 159 });
src/server/setup.rs
Old New
@@ -32,6 +32,23 @@
32 //! bug in this file, and it aborts rather than governing a server with rules 32 //! bug in this file, and it aborts rather than governing a server with rules
33 //! that mean something other than what it was told they mean. 33 //! that mean something other than what it was told they mean.
34 //! 34 //!
35 //! # Why the seed enrols the keys that already have access
36 //!
37 //! The same principle again, on the axis where it is easiest to get wrong.
38 //! `keydir/` supersedes `authorized_keys` the instant `settings.git` exists —
39 //! `auth_publickey` reads one roster or the other, never both — so a bootstrap
40 //! that enrolled only the administrator would revoke SSH for everybody else at
41 //! the moment it ran. That is not a smaller change than hiding every
42 //! repository; it is a larger one, and it is the change that locks the
43 //! operator out of the server they are configuring.
44 //!
45 //! So every well-formed entry in `authorized_keys` is enrolled, and the rules
46 //! translate the `server.toml` rosters that named those keys by fingerprint.
47 //! `--no-enrol-existing` asks for the clean single-administrator bootstrap
48 //! instead, and says how many keys that costs. The default is the safe one
49 //! because the two mistakes are not symmetric: an over-broad roster is a
50 //! `git rm` and a push, and a lockout may need the filesystem.
51 //!
35 //! # The one thing governance cannot say 52 //! # The one thing governance cannot say
36 //! 53 //!
37 //! `server.toml` has two anonymous switches — `[ui] anonymous` and 54 //! `server.toml` has two anonymous switches — `[ui] anonymous` and
@@ -41,11 +58,16 @@
41 //! and by name, in the report and in a comment in the file: a bootstrap may 58 //! and by name, in the report and in a comment in the file: a bootstrap may
42 //! narrow exposure where it must, and must never widen it by guessing. 59 //! narrow exposure where it must, and must never widen it by guessing.
43 60
61 use std::collections::HashSet;
44 use std::path::{Path, PathBuf}; 62 use std::path::{Path, PathBuf};
45 63
46 use russh::keys::PublicKey; 64 use russh::keys::PublicKey;
47 65
48 use crate::governance::{self, conf::AccessConf, keydir, SETTINGS_REPO}; 66 use crate::governance::{
67 self,
68 conf::{Access, AccessConf, Subject},
69 keydir, SETTINGS_REPO,
70 };
49 use crate::repos::{self, RepoPolicy}; 71 use crate::repos::{self, RepoPolicy};
50 72
51 /// The branch the seed lands on. `governance::load` follows HEAD, so this is 73 /// The branch the seed lands on. `governance::load` follows HEAD, so this is
@@ -62,8 +84,22 @@ enum NameSource {
62 } 84 }
63 85
64 /// Run the command. Returns the process exit code. 86 /// Run the command. Returns the process exit code.
65 pub fn run(repos_dir: &Path, admin_key: &str, admin_name: Option<&str>, dry_run: bool) -> i32 { 87 pub fn run(
66 match try_run(repos_dir, admin_key, admin_name, dry_run) { 88 repos_dir: &Path,
89 authorized_keys: &Path,
90 admin_key: &str,
91 admin_name: Option<&str>,
92 enrol_existing: bool,
93 dry_run: bool,
94 ) -> i32 {
95 match try_run(
96 repos_dir,
97 authorized_keys,
98 admin_key,
99 admin_name,
100 enrol_existing,
101 dry_run,
102 ) {
67 Ok(()) => 0, 103 Ok(()) => 0,
68 Err(reason) => { 104 Err(reason) => {
69 eprintln!("error: {reason}"); 105 eprintln!("error: {reason}");
@@ -74,8 +110,10 @@ pub fn run(repos_dir: &Path, admin_key: &str, admin_name: Option<&str>, dry_run:
74 110
75 fn try_run( 111 fn try_run(
76 repos_dir: &Path, 112 repos_dir: &Path,
113 authorized_keys: &Path,
77 admin_key: &str, 114 admin_key: &str,
78 admin_name: Option<&str>, 115 admin_name: Option<&str>,
116 enrol_existing: bool,
79 dry_run: bool, 117 dry_run: bool,
80 ) -> Result<(), String> { 118 ) -> Result<(), String> {
81 if !repos_dir.is_dir() { 119 if !repos_dir.is_dir() {
@@ -98,6 +136,12 @@ fn try_run(
98 let (key_text, key, name, name_source) = read_admin_key(admin_key, admin_name)?; 136 let (key_text, key, name, name_source) = read_admin_key(admin_key, admin_name)?;
99 let admin_principal = crate::ssh::session::ssh_key_principal(&key); 137 let admin_principal = crate::ssh::session::ssh_key_principal(&key);
100 138
139 // Read `authorized_keys` whether or not we are enrolling from it: the
140 // count is what makes declining an informed choice rather than a silent
141 // one.
142 let enrolment = Enrolment::plan(authorized_keys, &admin_principal, &name, enrol_existing);
143 let roster = enrolment.roster(&admin_principal, &name);
144
101 let entries = repos::discover(repos_dir) 145 let entries = repos::discover(repos_dir)
102 .map_err(|e| format!("cannot read {}: {e}", repos_dir.display()))?; 146 .map_err(|e| format!("cannot read {}: {e}", repos_dir.display()))?;
103 147
@@ -111,22 +155,30 @@ fn try_run(
111 )); 155 ));
112 } 156 }
113 157
114 let plan = Plan::build(repos_dir, &entries, &name, &admin_principal); 158 let plan = Plan::build(repos_dir, &entries, &name, &roster);
115 let conf_text = plan.render(); 159 let conf_text = plan.render();
116 160
117 // Machine-check the safety claim before anything is written: the rules 161 // Machine-check the safety claim before anything is written: the rules
118 // just generated must give every repository the decision it already has. 162 // just generated must give every repository the decision it already has,
119 verify(&conf_text, &plan)?; 163 // to an anonymous request and to each key being enrolled.
164 verify(&conf_text, &plan, &roster)?;
120 165
121 if dry_run { 166 if dry_run {
122 print!("{conf_text}"); 167 print!("{conf_text}");
123 println!(); 168 println!();
124 report(&plan, &name, &name_source, &settings_path, true); 169 report(&plan, &name, &name_source, &enrolment, &settings_path, true);
125 return Ok(()); 170 return Ok(());
126 } 171 }
127 172
128 seed(&settings_path, &conf_text, &name, &key_text)?; 173 seed(&settings_path, &conf_text, &name, &key_text, &enrolment)?;
129 report(&plan, &name, &name_source, &settings_path, false); 174 report(
175 &plan,
176 &name,
177 &name_source,
178 &enrolment,
179 &settings_path,
180 false,
181 );
130 Ok(()) 182 Ok(())
131 } 183 }
132 184
@@ -204,6 +256,284 @@ fn read_admin_key(
204 } 256 }
205 257
206 // --------------------------------------------------------------------------- 258 // ---------------------------------------------------------------------------
259 // The keys that already have access
260 // ---------------------------------------------------------------------------
261
262 /// How an enrolled key got the name it got.
263 ///
264 /// Reported for every key, because the basename *is* the principal: it goes
265 /// into `conf/access.conf`, and a name that is merely plausible is worse than
266 /// one that is obviously provisional. `key3` invites a rename; `alex` does not.
267 enum Naming {
268 /// The entry's trailing comment, used verbatim.
269 Comment,
270 /// A placeholder. The string says why the comment could not be used.
271 Placeholder(&'static str),
272 }
273
274 /// One `authorized_keys` entry, on its way into `keydir/`.
275 struct EnrolledKey {
276 name: String,
277 /// The OpenSSH line, written as `keydir/<name>.pub`. Kept verbatim so the
278 /// file in git is recognisably the line the operator had.
279 text: String,
280 fingerprint: String,
281 /// Which line of `authorized_keys` it came from, 1-based.
282 line: usize,
283 naming: Naming,
284 }
285
286 /// What `authorized_keys` yielded.
287 struct Enrolment {
288 path: PathBuf,
289 keys: Vec<EnrolledKey>,
290 /// Entries that were read but not enrolled, each with the reason. Never
291 /// fatal: one unusable line must not cost everybody else their access.
292 notes: Vec<String>,
293 /// Well-formed entries left out because `--no-enrol-existing` was given.
294 /// This is the size of the lockout the operator asked for.
295 declined: usize,
296 }
297
298 /// A candidate entry, before names are settled.
299 struct Candidate {
300 line: usize,
301 text: String,
302 fingerprint: String,
303 /// The trailing comment, if the entry has one that could be a name.
304 wanted: Option<String>,
305 /// Why it could not be, if it could not.
306 refusal: Option<&'static str>,
307 }
308
309 impl Enrolment {
310 /// Read `authorized_keys` and decide who is enrolled under what name.
311 ///
312 /// Nothing here is fatal. A file that does not exist, a line that is not a
313 /// key, an entry carrying `command=` restrictions — each is reported and
314 /// skipped, because the purpose of this pass is to *keep* access, and
315 /// aborting over one bad line would keep none of it.
316 fn plan(path: &Path, admin_fingerprint: &str, admin_name: &str, enrol: bool) -> Enrolment {
317 let mut enrolment = Enrolment {
318 path: path.to_path_buf(),
319 keys: Vec::new(),
320 notes: Vec::new(),
321 declined: 0,
322 };
323
324 let text = match std::fs::read_to_string(path) {
325 Ok(text) => text,
326 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
327 // Not an error: a server that has never had one has nobody to
328 // lock out.
329 return enrolment;
330 }
331 Err(e) => {
332 enrolment.notes.push(format!(
333 "{} could not be read ({e}), so no existing key was enrolled. \
334 Every key it holds will be refused SSH once governance is on.",
335 path.display()
336 ));
337 return enrolment;
338 }
339 };
340
341 let candidates = enrolment.read_entries(&text, admin_fingerprint);
342 if !enrol {
343 enrolment.declined = candidates.len();
344 return enrolment;
345 }
346 enrolment.keys = assign_names(candidates, admin_name);
347 enrolment
348 }
349
350 /// Parse the file into candidates, recording every entry it had to drop.
351 fn read_entries(&mut self, text: &str, admin_fingerprint: &str) -> Vec<Candidate> {
352 let mut candidates: Vec<Candidate> = Vec::new();
353 let mut seen: HashSet<String> = HashSet::new();
354
355 for (index, raw) in text.lines().enumerate() {
356 let line = index + 1;
357 let entry = raw.trim();
358 if entry.is_empty() || entry.starts_with('#') {
359 continue;
360 }
361
362 // An entry with options (`command="…",no-pty ssh-ed25519 …`) does
363 // not parse here — and does not authenticate today either, because
364 // `is_authorized` compares the first field against the key type.
365 // Skipping it is therefore faithful, not a narrowing.
366 let key = match PublicKey::from_openssh(entry) {
367 Ok(key) => key,
368 Err(e) => {
369 self.notes.push(format!(
370 "{}:{line} is not a well-formed OpenSSH public key ({e}), so it was not \
371 enrolled. An entry carrying `command=` or other options looks like this \
372 too, and does not authenticate today either.",
373 self.path.display()
374 ));
375 continue;
376 }
377 };
378
379 let fingerprint = crate::ssh::session::ssh_key_principal(&key);
380 if fingerprint == admin_fingerprint {
381 self.notes.push(format!(
382 "{}:{line} is the administrator's own key, already enrolled under the name \
383 --admin-key gave it. One key cannot be two principals, so it was not \
384 enrolled a second time.",
385 self.path.display()
386 ));
387 continue;
388 }
389 if !seen.insert(fingerprint.clone()) {
390 self.notes.push(format!(
391 "{}:{line} repeats a key that appears earlier in the file; enrolled once.",
392 self.path.display()
393 ));
394 continue;
395 }
396
397 let comment = comment_of(entry).to_string();
398 let (wanted, refusal) = if comment.is_empty() {
399 (None, Some("the entry has no comment to take a name from"))
400 } else if keydir::validate_name(&comment).is_err() {
401 (
402 None,
403 Some("its comment cannot be written as a principal name"),
404 )
405 } else {
406 (Some(comment), None)
407 };
408
409 candidates.push(Candidate {
410 line,
411 text: entry.to_string(),
412 fingerprint,
413 wanted,
414 refusal,
415 });
416 }
417 candidates
418 }
419
420 /// Fingerprint-to-name for every identity these rules may mention: the
421 /// administrator, and each key being enrolled.
422 ///
423 /// This is exactly the set of `server.toml` roster entries that can be
424 /// translated at all — a fingerprint outside it has no name to write.
425 fn roster(&self, admin_fingerprint: &str, admin_name: &str) -> Roster {
426 let mut roster = vec![(admin_fingerprint.to_string(), admin_name.to_string())];
427 for key in &self.keys {
428 roster.push((key.fingerprint.clone(), key.name.clone()));
429 }
430 Roster(roster)
431 }
432 }
433
434 /// The trailing comment of an `authorized_keys` entry: everything after the
435 /// key type and the key data.
436 ///
437 /// Taken from the line rather than from the parsed key, so that "the comment"
438 /// means here what it means in `ssh::auth`, which splits the same way. A
439 /// comment is free text and may hold spaces; whether it can be a name is a
440 /// separate question, asked next.
441 fn comment_of(entry: &str) -> &str {
442 let after_type = match entry.trim().split_once(char::is_whitespace) {
443 Some((_, rest)) => rest.trim_start(),
444 None => return "",
445 };
446 match after_type.split_once(char::is_whitespace) {
447 Some((_, comment)) => comment.trim(),
448 None => "",
449 }
450 }
451
452 /// Settle a name on every candidate.
453 ///
454 /// The comment is used **verbatim** when it can be a principal name, and never
455 /// cut down to a first component. Turning `alex@laptop` into `alex` would be a
456 /// guess, and the way that guess fails is the expensive way: two entries the
457 /// operator listed separately — `alex@laptop`, `alex@desktop` — collapse into
458 /// one principal, and a rule written for one silently covers the other. A
459 /// bootstrap may narrow access where it must; it must never widen it by
460 /// inference.
461 ///
462 /// For the same reason a name two entries both claim is given to neither. One
463 /// of them winning by file order would put a plausible name on the wrong key,
464 /// which is the mistake that survives review; `key1` and `key2` do not.
465 fn assign_names(candidates: Vec<Candidate>, admin_name: &str) -> Vec<EnrolledKey> {
466 let mut claimed: HashSet<&str> = HashSet::new();
467 let mut contested: HashSet<&str> = HashSet::new();
468 for candidate in &candidates {
469 if let Some(name) = candidate.wanted.as_deref() {
470 if !claimed.insert(name) {
471 contested.insert(name);
472 }
473 }
474 }
475
476 let mut taken: HashSet<String> = HashSet::new();
477 taken.insert(admin_name.to_string());
478 let mut next_placeholder = 1usize;
479
480 let mut enrolled = Vec::new();
481 for candidate in &candidates {
482 let refusal = match candidate.wanted.as_deref() {
483 Some(name) if contested.contains(name) => {
484 Some("the same comment appears on more than one entry")
485 }
486 Some(name) if name == admin_name => {
487 Some("its comment is the name the administrator already holds")
488 }
489 Some(_) => None,
490 None => candidate.refusal,
491 };
492
493 let (name, naming) = match (candidate.wanted.as_deref(), refusal) {
494 (Some(name), None) => (name.to_string(), Naming::Comment),
495 (_, reason) => {
496 let mut placeholder = format!("key{next_placeholder}");
497 while taken.contains(&placeholder) {
498 next_placeholder += 1;
499 placeholder = format!("key{next_placeholder}");
500 }
501 next_placeholder += 1;
502 (
503 placeholder,
504 Naming::Placeholder(reason.unwrap_or("its comment could not name a principal")),
505 )
506 }
507 };
508 taken.insert(name.clone());
509 enrolled.push(EnrolledKey {
510 name,
511 text: candidate.text.clone(),
512 fingerprint: candidate.fingerprint.clone(),
513 line: candidate.line,
514 naming,
515 });
516 }
517 enrolled
518 }
519
520 /// Fingerprint-to-name for the identities this seed enrols.
521 struct Roster(Vec<(String, String)>);
522
523 impl Roster {
524 fn name_for(&self, fingerprint: &str) -> Option<&str> {
525 self.0
526 .iter()
527 .find(|(f, _)| f == fingerprint)
528 .map(|(_, name)| name.as_str())
529 }
530
531 fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
532 self.0.iter().map(|(f, n)| (f.as_str(), n.as_str()))
533 }
534 }
535
536 // ---------------------------------------------------------------------------
207 // Translating the policy already in force 537 // Translating the policy already in force
208 // --------------------------------------------------------------------------- 538 // ---------------------------------------------------------------------------
209 539
@@ -220,6 +550,9 @@ struct RepoPlan {
220 anonymous_read: bool, 550 anonymous_read: bool,
221 /// Notes to write into the file above the block, and to report. 551 /// Notes to write into the file above the block, and to report.
222 notes: Vec<String>, 552 notes: Vec<String>,
553 /// The policy these rules were translated from, kept so `verify` can ask
554 /// it the same questions it asks the rule engine.
555 policy: RepoPolicy,
223 } 556 }
224 557
225 struct Plan { 558 struct Plan {
@@ -234,12 +567,7 @@ struct Plan {
234 } 567 }
235 568
236 impl Plan { 569 impl Plan {
237 fn build( 570 fn build(repos_dir: &Path, entries: &[repos::RepoEntry], admin: &str, roster: &Roster) -> Plan {
238 repos_dir: &Path,
239 entries: &[repos::RepoEntry],
240 admin: &str,
241 admin_principal: &str,
242 ) -> Plan {
243 let mut plan = Plan { 571 let mut plan = Plan {
244 admin: admin.to_string(), 572 admin: admin.to_string(),
245 repos: Vec::new(), 573 repos: Vec::new(),
@@ -276,7 +604,7 @@ impl Plan {
276 continue; 604 continue;
277 } 605 }
278 plan.repos 606 plan.repos
279 .push(RepoPlan::build(&key, &entry.policy, admin, admin_principal)); 607 .push(RepoPlan::build(&key, &entry.policy, roster));
280 } 608 }
281 609
282 for repo in &plan.repos { 610 for repo in &plan.repos {
@@ -337,7 +665,7 @@ fn rule(lhs: &str, rhs: &str) -> String {
337 } 665 }
338 666
339 impl RepoPlan { 667 impl RepoPlan {
340 fn build(key: &str, policy: &RepoPolicy, admin: &str, admin_principal: &str) -> RepoPlan { 668 fn build(key: &str, policy: &RepoPolicy, roster: &Roster) -> RepoPlan {
341 let mut rules = Vec::new(); 669 let mut rules = Vec::new();
342 let mut notes = Vec::new(); 670 let mut notes = Vec::new();
343 671
@@ -345,31 +673,36 @@ impl RepoPlan {
345 // 673 //
346 // `*` in server.toml means "any principal that authenticated at all", 674 // `*` in server.toml means "any principal that authenticated at all",
347 // and `@all` means "every key enrolled in keydir/", which is the same 675 // and `@all` means "every key enrolled in keydir/", which is the same
348 // sentence in the new language. A force-push was never separately 676 // sentence in the new language *because* every key that could
677 // authenticate is being enrolled. A force-push was never separately
349 // gated before governance, so a writer maps to RW+. 678 // gated before governance, so a writer maps to RW+.
350 let read_all = policy.access.read.iter().any(|entry| entry == "*"); 679 let read_all = policy.access.read.iter().any(|entry| entry == "*");
351 let write_all = policy.access.write.iter().any(|entry| entry == "*"); 680 let write_all = policy.access.write.iter().any(|entry| entry == "*");
352 681
353 // An explicit roster names fingerprints, and rules name keydir 682 // An explicit roster names fingerprints, and rules name keydir
354 // identities. Only the one key this command enrols can be translated; 683 // identities. The ones this command enrols can be translated; a
355 // the rest are recorded so the operator can enrol and grant them. 684 // fingerprint belonging to no enrolled key has no name to write, and
685 // is recorded so the operator can enrol and grant it.
356 let mut untranslatable: Vec<&str> = policy 686 let mut untranslatable: Vec<&str> = policy
357 .access 687 .access
358 .read 688 .read
359 .iter() 689 .iter()
360 .chain(policy.access.write.iter()) 690 .chain(policy.access.write.iter())
361 .map(String::as_str) 691 .map(String::as_str)
362 .filter(|entry| *entry != "*" && *entry != admin_principal) 692 .filter(|entry| *entry != "*" && roster.name_for(entry).is_none())
363 .collect(); 693 .collect();
364 untranslatable.sort(); 694 untranslatable.sort();
365 untranslatable.dedup(); 695 untranslatable.dedup();
366 696
367 // A fingerprint cannot appear in a rule, so the one roster entry that 697 // A fingerprint cannot appear in a rule, so each roster entry named by
368 // can be translated is the admin's own key, under the admin's name. 698 // `server.toml` is written under the name it is being enrolled with.
369 if !write_all && policy.allows_write(admin_principal) { 699 // Nothing is emitted where `@all` below already says the same thing.
370 rules.push(("RW+".to_string(), admin.to_string())); 700 for (fingerprint, name) in roster.iter() {
371 } else if !read_all && policy.allows_read(admin_principal) { 701 if !write_all && policy.allows_write(fingerprint) {
372 rules.push(("R".to_string(), admin.to_string())); 702 rules.push(("RW+".to_string(), name.to_string()));
703 } else if !read_all && policy.allows_read(fingerprint) {
704 rules.push(("R".to_string(), name.to_string()));
705 }
373 } 706 }
374 if write_all { 707 if write_all {
375 rules.push(("RW+".to_string(), "@all".to_string())); 708 rules.push(("RW+".to_string(), "@all".to_string()));
@@ -419,6 +752,7 @@ impl RepoPlan {
419 listed: anonymous_read, 752 listed: anonymous_read,
420 anonymous_read, 753 anonymous_read,
421 notes, 754 notes,
755 policy: policy.clone(),
422 } 756 }
423 } 757 }
424 } 758 }
@@ -428,12 +762,13 @@ impl RepoPlan {
428 // --------------------------------------------------------------------------- 762 // ---------------------------------------------------------------------------
429 763
430 /// Parse what was generated and confirm it decides every repository the way 764 /// Parse what was generated and confirm it decides every repository the way
431 /// the plan says it should. 765 /// the plan says it should — for an anonymous request, and for every key being
766 /// enrolled.
432 /// 767 ///
433 /// This is the safety claim, checked against the real evaluator rather than 768 /// This is the safety claim, checked against the real evaluator rather than
434 /// against a second copy of the intent. If it ever fails, this file has a bug 769 /// against a second copy of the intent. If it ever fails, this file has a bug
435 /// and the right thing to do is refuse to govern the server with it. 770 /// and the right thing to do is refuse to govern the server with it.
436 fn verify(conf_text: &str, plan: &Plan) -> Result<(), String> { 771 fn verify(conf_text: &str, plan: &Plan, roster: &Roster) -> Result<(), String> {
437 let conf = AccessConf::parse(conf_text).map_err(|e| { 772 let conf = AccessConf::parse(conf_text).map_err(|e| {
438 format!( 773 format!(
439 "the generated rules do not parse ({e}); refusing to write them. \ 774 "the generated rules do not parse ({e}); refusing to write them. \
@@ -454,6 +789,35 @@ fn verify(conf_text: &str, plan: &Plan) -> Result<(), String> {
454 } 789 }
455 } 790 }
456 791
792 // The other half of the same claim: every key that could authenticate
793 // before must reach exactly the repositories it reached before. Asked of
794 // the rule engine, against the `server.toml` decision it is replacing.
795 //
796 // Rewind is compared against the write decision deliberately. Nothing
797 // before governance gated a force-push separately from a push, so a
798 // writer maps to RW+, and this is where that equivalence is checked
799 // rather than assumed.
800 for repo in &plan.repos {
801 for (fingerprint, name) in roster.iter() {
802 let subject = Subject::new(name);
803 for (access, expected) in [
804 (Access::Read, repo.policy.allows_read(fingerprint)),
805 (Access::Write, repo.policy.allows_write(fingerprint)),
806 (Access::Rewind, repo.policy.allows_write(fingerprint)),
807 ] {
808 let granted = conf.allows_repo(&repo.key, &subject, access);
809 if granted != expected {
810 return Err(format!(
811 "the generated rules would give {name} {access:?} on {} where its \
812 server.toml gives {expected} (rules say {granted}); refusing to write \
813 them. This is a bug in `setup`.",
814 repo.key
815 ));
816 }
817 }
818 }
819 }
820
457 // The settings repository must stay off the anonymous surface: the whole 821 // The settings repository must stay off the anonymous surface: the whole
458 // key roster is in it. 822 // key roster is in it.
459 if conf.anonymous_may_read(SETTINGS_REPO) || conf.is_listed(SETTINGS_REPO) { 823 if conf.anonymous_may_read(SETTINGS_REPO) || conf.is_listed(SETTINGS_REPO) {
@@ -481,6 +845,7 @@ fn seed(
481 conf_text: &str, 845 conf_text: &str,
482 admin_name: &str, 846 admin_name: &str,
483 key_text: &str, 847 key_text: &str,
848 enrolment: &Enrolment,
484 ) -> Result<(), String> { 849 ) -> Result<(), String> {
485 let mut options = git2::RepositoryInitOptions::new(); 850 let mut options = git2::RepositoryInitOptions::new();
486 options.bare(true).initial_head(SEED_BRANCH); 851 options.bare(true).initial_head(SEED_BRANCH);
@@ -489,7 +854,7 @@ fn seed(
489 854
490 // From here on a failure leaves a half-made repository, which would make 855 // From here on a failure leaves a half-made repository, which would make
491 // the server think it is governed by nothing. Clean it up on every exit. 856 // the server think it is governed by nothing. Clean it up on every exit.
492 match build_and_commit(&repo, conf_text, admin_name, key_text) { 857 match build_and_commit(&repo, conf_text, admin_name, key_text, enrolment) {
493 Ok(()) => Ok(()), 858 Ok(()) => Ok(()),
494 Err(reason) => { 859 Err(reason) => {
495 drop(repo); 860 drop(repo);
@@ -504,13 +869,11 @@ fn build_and_commit(
504 conf_text: &str, 869 conf_text: &str,
505 admin_name: &str, 870 admin_name: &str,
506 key_text: &str, 871 key_text: &str,
872 enrolment: &Enrolment,
507 ) -> Result<(), String> { 873 ) -> Result<(), String> {
508 let git = |e: git2::Error| format!("building the seed commit: {e}"); 874 let git = |e: git2::Error| format!("building the seed commit: {e}");
509 875
510 let conf_blob = repo.blob(conf_text.as_bytes()).map_err(git)?; 876 let conf_blob = repo.blob(conf_text.as_bytes()).map_err(git)?;
511 let key_blob = repo
512 .blob(format!("{}\n", key_text.trim()).as_bytes())
513 .map_err(git)?;
514 877
515 let mut conf_dir = repo.treebuilder(None).map_err(git)?; 878 let mut conf_dir = repo.treebuilder(None).map_err(git)?;
516 conf_dir 879 conf_dir
@@ -519,9 +882,19 @@ fn build_and_commit(
519 let conf_tree = conf_dir.write().map_err(git)?; 882 let conf_tree = conf_dir.write().map_err(git)?;
520 883
521 let mut keydir_tree = repo.treebuilder(None).map_err(git)?; 884 let mut keydir_tree = repo.treebuilder(None).map_err(git)?;
522 keydir_tree 885 for (name, text) in std::iter::once((admin_name, key_text)).chain(
523 .insert(format!("{admin_name}.pub"), key_blob, 0o100644) 886 enrolment
524 .map_err(git)?; 887 .keys
888 .iter()
889 .map(|key| (key.name.as_str(), key.text.as_str())),
890 ) {
891 let blob = repo
892 .blob(format!("{}\n", text.trim()).as_bytes())
893 .map_err(git)?;
894 keydir_tree
895 .insert(format!("{name}.pub"), blob, 0o100644)
896 .map_err(git)?;
897 }
525 let keydir_tree = keydir_tree.write().map_err(git)?; 898 let keydir_tree = keydir_tree.write().map_err(git)?;
526 899
527 let mut root = repo.treebuilder(None).map_err(git)?; 900 let mut root = repo.treebuilder(None).map_err(git)?;
@@ -557,7 +930,14 @@ fn build_and_commit(
557 // The report 930 // The report
558 // --------------------------------------------------------------------------- 931 // ---------------------------------------------------------------------------
559 932
560 fn report(plan: &Plan, name: &str, source: &NameSource, settings_path: &Path, dry_run: bool) { 933 fn report(
934 plan: &Plan,
935 name: &str,
936 source: &NameSource,
937 enrolment: &Enrolment,
938 settings_path: &Path,
939 dry_run: bool,
940 ) {
561 let derived = match source { 941 let derived = match source {
562 NameSource::Explicit => "from --admin-name", 942 NameSource::Explicit => "from --admin-name",
563 NameSource::Filename => "from the key file's name", 943 NameSource::Filename => "from the key file's name",
@@ -583,6 +963,8 @@ fn report(plan: &Plan, name: &str, source: &NameSource, settings_path: &Path, dr
583 } 963 }
584 ); 964 );
585 965
966 report_enrolment(enrolment, dry_run);
967
586 for note in plan.narrowed.iter().chain(plan.skipped.iter()) { 968 for note in plan.narrowed.iter().chain(plan.skipped.iter()) {
587 let mut lines = note.lines(); 969 let mut lines = note.lines();
588 if let Some(first) = lines.next() { 970 if let Some(first) = lines.next() {
@@ -595,12 +977,87 @@ fn report(plan: &Plan, name: &str, source: &NameSource, settings_path: &Path, dr
595 977
596 if !dry_run { 978 if !dry_run {
597 println!(); 979 println!();
598 println!("Governance is now in force. `authorized_keys` was not touched, but it is"); 980 println!("Governance is now in force. `authorized_keys` was not touched, and is");
599 println!("no longer consulted: only keys in keydir/ authenticate. Enrol the rest"); 981 println!("no longer consulted: only keys in keydir/ authenticate. Add anyone else by");
600 println!( 982 println!(
601 "by cloning {}, adding keydir/<name>.pub", 983 "cloning {}, adding keydir/<name>.pub",
602 settings_path.display() 984 settings_path.display()
603 ); 985 );
604 println!("and a grant in conf/access.conf, and pushing both together."); 986 println!("and a grant in conf/access.conf, and pushing both together.");
605 } 987 }
606 } 988 }
989
990 /// Say what happened to `authorized_keys`, by name.
991 ///
992 /// Every derived name is printed, not just the surprising ones. The operator
993 /// is being handed a set of principals they did not choose, at the one moment
994 /// renaming them is free — a file rename and a push — and before anything in
995 /// `conf/access.conf` has come to depend on them.
996 fn report_enrolment(enrolment: &Enrolment, dry_run: bool) {
997 let verb = if dry_run { "Would enrol" } else { "Enrolled" };
998
999 if enrolment.declined > 0 {
1000 println!();
1001 println!(
1002 "--no-enrol-existing: {} {} in {} {} NOT enrolled, and will be refused SSH",
1003 enrolment.declined,
1004 if enrolment.declined == 1 {
1005 "key"
1006 } else {
1007 "keys"
1008 },
1009 enrolment.path.display(),
1010 if enrolment.declined == 1 {
1011 "was"
1012 } else {
1013 "were"
1014 },
1015 );
1016 println!("as soon as governance is in force. Only the admin identity above can connect.");
1017 } else if !enrolment.keys.is_empty() {
1018 println!();
1019 println!(
1020 "{verb} {} {} from {}, so nobody loses access:",
1021 enrolment.keys.len(),
1022 if enrolment.keys.len() == 1 {
1023 "key"
1024 } else {
1025 "keys"
1026 },
1027 enrolment.path.display()
1028 );
1029 for key in &enrolment.keys {
1030 match key.naming {
1031 Naming::Comment => println!(
1032 " keydir/{}.pub (line {}, named after the entry's comment)",
1033 key.name, key.line
1034 ),
1035 Naming::Placeholder(reason) => {
1036 println!(
1037 " keydir/{}.pub (line {}, PROVISIONAL: {reason})",
1038 key.name, key.line
1039 );
1040 println!(" {}", key.fingerprint);
1041 }
1042 }
1043 }
1044 if enrolment
1045 .keys
1046 .iter()
1047 .any(|key| matches!(key.naming, Naming::Placeholder(_)))
1048 {
1049 println!(
1050 " A provisional name is a working principal, not a right one. Rename the file \
1051 and the"
1052 );
1053 println!(
1054 " grants that use it before anyone comes to rely on it; match the key by the \
1055 fingerprint above."
1056 );
1057 }
1058 }
1059
1060 for note in &enrolment.notes {
1061 eprintln!("warning: {note}");
1062 }
1063 }
tests/common/mod.rs
Old New
@@ -1030,6 +1030,16 @@ pub struct ServerHarness {
1030 repo_name: String, 1030 repo_name: String,
1031 work_repo: TestRepo, 1031 work_repo: TestRepo,
1032 server: Child, 1032 server: Child,
1033 /// Everything the server has logged, drained continuously.
1034 ///
1035 /// Draining is not a convenience. The server's stdout and stderr are
1036 /// pipes, and a pipe holds ~64 KiB before a write to it blocks — so a
1037 /// server nobody reads stops answering partway through a test that makes
1038 /// enough requests, and does it at whatever operation happens to cross the
1039 /// line. That looks like the server hanging on *that* request, which is
1040 /// the wrong thing to go and debug. (Same failure as `Drain`'s pty case,
1041 /// at a different buffer size.)
1042 server_log: Arc<Mutex<Vec<u8>>>,
1033 http_addr: SocketAddr, 1043 http_addr: SocketAddr,
1034 ssh_addr: SocketAddr, 1044 ssh_addr: SocketAddr,
1035 } 1045 }
@@ -1087,13 +1097,21 @@ impl ServerHarness {
1087 .spawn() 1097 .spawn()
1088 .expect("failed to start git-collab-server"); 1098 .expect("failed to start git-collab-server");
1089 1099
1090 match wait_until_ready(&mut server, http_addr, ssh_addr) { 1100 // Both streams, from the moment it starts: see `server_log`. The
1101 // `Drain` handles are dropped; each one's reader thread owns its
1102 // pipe and keeps draining until the server exits.
1103 Drain::start(server.stdout.take().expect("server stdout is piped"));
1104 let server_log =
1105 Drain::start(server.stderr.take().expect("server stderr is piped")).live();
1106
1107 match wait_until_ready(&mut server, &server_log, http_addr, ssh_addr) {
1091 Ok(()) => { 1108 Ok(()) => {
1092 return Self { 1109 return Self {
1093 root, 1110 root,
1094 repo_name: repo_name.to_string(), 1111 repo_name: repo_name.to_string(),
1095 work_repo, 1112 work_repo,
1096 server, 1113 server,
1114 server_log,
1097 http_addr, 1115 http_addr,
1098 ssh_addr, 1116 ssh_addr,
1099 }; 1117 };
@@ -1124,6 +1142,12 @@ impl ServerHarness {
1124 self.root.path().join("server.toml") 1142 self.root.path().join("server.toml")
1125 } 1143 }
1126 1144
1145 /// Everything the running server has logged to stderr so far, for a
1146 /// failure message that would otherwise say only "the push was refused".
1147 pub fn server_log(&self) -> String {
1148 String::from_utf8_lossy(&self.server_log.lock().unwrap()).to_string()
1149 }
1150
1127 /// A scratch directory under the harness root, for a test that needs 1151 /// A scratch directory under the harness root, for a test that needs
1128 /// somewhere to clone to or a file to feed a command. 1152 /// somewhere to clone to or a file to feed a command.
1129 pub fn scratch(&self, name: &str) -> PathBuf { 1153 pub fn scratch(&self, name: &str) -> PathBuf {
@@ -1195,6 +1219,26 @@ impl ServerHarness {
1195 key_path 1219 key_path
1196 } 1220 }
1197 1221
1222 /// The `authorized_keys` file the running server authenticates against
1223 /// while it is ungoverned, and that `setup` reads to decide who to enrol.
1224 pub fn authorized_keys_path(&self) -> PathBuf {
1225 self.root.path().join("authorized_keys")
1226 }
1227
1228 /// Put the given `named_key`s into `authorized_keys`, replacing whatever
1229 /// was there. Each entry keeps the key's own `ssh-keygen -C` comment, so a
1230 /// test sees the same trailing comment an operator's file would carry.
1231 pub fn authorize_named_keys(&self, names: &[&str]) {
1232 let mut content = String::new();
1233 for name in names {
1234 let pubkey =
1235 std::fs::read_to_string(self.named_key(name).with_extension("pub")).unwrap();
1236 content.push_str(pubkey.trim());
1237 content.push('\n');
1238 }
1239 std::fs::write(self.authorized_keys_path(), content).unwrap();
1240 }
1241
1198 fn settings_bare(&self) -> PathBuf { 1242 fn settings_bare(&self) -> PathBuf {
1199 self.repos_dir().join("settings.git") 1243 self.repos_dir().join("settings.git")
1200 } 1244 }
@@ -1567,6 +1611,7 @@ impl ServerHarness {
1567 /// instead of just reporting an exit code. 1611 /// instead of just reporting an exit code.
1568 fn wait_until_ready( 1612 fn wait_until_ready(
1569 server: &mut Child, 1613 server: &mut Child,
1614 log: &Arc<Mutex<Vec<u8>>>,
1570 http_addr: SocketAddr, 1615 http_addr: SocketAddr,
1571 ssh_addr: SocketAddr, 1616 ssh_addr: SocketAddr,
1572 ) -> Result<(), String> { 1617 ) -> Result<(), String> {
@@ -1576,7 +1621,7 @@ fn wait_until_ready(
1576 return Err(format!( 1621 return Err(format!(
1577 "git-collab-server exited before becoming ready: exit status {:?}{}", 1622 "git-collab-server exited before becoming ready: exit status {:?}{}",
1578 status.code(), 1623 status.code(),
1579 server_stderr(server) 1624 server_stderr(log)
1580 )); 1625 ));
1581 } 1626 }
1582 1627
@@ -1591,7 +1636,7 @@ fn wait_until_ready(
1591 let _ = server.wait(); 1636 let _ = server.wait();
1592 return Err(format!( 1637 return Err(format!(
1593 "timed out waiting for git-collab-server on http {http_addr} / ssh {ssh_addr}{}", 1638 "timed out waiting for git-collab-server on http {http_addr} / ssh {ssh_addr}{}",
1594 server_stderr(server) 1639 server_stderr(log)
1595 )); 1640 ));
1596 } 1641 }
1597 1642
@@ -1599,19 +1644,13 @@ fn wait_until_ready(
1599 } 1644 }
1600 } 1645 }
1601 1646
1602 /// Drain the (already exited or killed) server's stderr for error messages. 1647 /// What the server has logged to stderr so far, for an error message.
1603 fn server_stderr(server: &mut Child) -> String { 1648 fn server_stderr(log: &Arc<Mutex<Vec<u8>>>) -> String {
1604 match server.stderr.take() { 1649 let buf = String::from_utf8_lossy(&log.lock().unwrap()).to_string();
1605 Some(mut stderr) => { 1650 if buf.trim().is_empty() {
1606 let mut buf = String::new(); 1651 "\nserver stderr: <empty>".to_string()
1607 let _ = stderr.read_to_string(&mut buf); 1652 } else {
1608 if buf.trim().is_empty() { 1653 format!("\nserver stderr:\n{buf}")
1609 "\nserver stderr: <empty>".to_string()
1610 } else {
1611 format!("\nserver stderr:\n{buf}")
1612 }
1613 }
1614 None => "\nserver stderr: <unavailable>".to_string(),
1615 } 1654 }
1616 } 1655 }
1617 1656
tests/server_setup_test.rs
Old New
@@ -593,6 +593,372 @@ fn a_key_on_stdin_with_no_name_is_refused() {
593 ); 593 );
594 } 594 }
595 595
596 // ---------------------------------------------------------------------------
597 // Enrolling the keys that already have access
598 // ---------------------------------------------------------------------------
599 //
600 // `keydir/` supersedes `authorized_keys` the instant `settings.git` exists, so
601 // a one-key bootstrap would revoke SSH for everybody but the admin. Setup
602 // therefore enrols the file's entries, and these tests hold it to the same
603 // standard as the exposure test above: what each key could do before, it can
604 // do after.
605
606 /// The principal string the server derives from a public key, taken from
607 /// OpenSSH's own fingerprinting rather than from ours — an external oracle,
608 /// so a change to how we compute principals cannot quietly bless itself.
609 fn principal_of(pubkey: &Path) -> String {
610 let output = Command::new("ssh-keygen")
611 .args(["-lf", pubkey.to_str().unwrap()])
612 .output()
613 .expect("failed to run ssh-keygen");
614 assert!(
615 output.status.success(),
616 "ssh-keygen -lf failed: {}",
617 stderr(&output)
618 );
619 let text = String::from_utf8(output.stdout).unwrap();
620 let fingerprint = text
621 .split_whitespace()
622 .nth(1)
623 .expect("ssh-keygen -lf prints the fingerprint second");
624 format!("key:{fingerprint}")
625 }
626
627 /// What one key can do to one repository, asked of the running server over
628 /// real SSH rather than of the code that decides it.
629 #[derive(Debug, PartialEq, Eq)]
630 struct Reach {
631 read: bool,
632 write: bool,
633 }
634
635 fn reach_of(harness: &ServerHarness, key_name: &str, repo: &str, phase: &str) -> Reach {
636 let key = harness.named_key(key_name);
637 let dir = harness.work_repo().dir.path();
638 Reach {
639 read: harness.ssh_fetch(dir, &key, repo).status.success(),
640 write: harness
641 .ssh_push_from(
642 dir,
643 &key,
644 repo,
645 &format!("HEAD:refs/heads/probe-{phase}-{key_name}"),
646 )
647 .status
648 .success(),
649 }
650 }
651
652 /// The whole who-can-do-what matrix, in a fixed order so before and after are
653 /// comparable as one value.
654 fn reach_matrix(
655 harness: &ServerHarness,
656 keys: &[&str],
657 repos: &[&str],
658 phase: &str,
659 ) -> Vec<(String, Reach)> {
660 let mut matrix = Vec::new();
661 for key in keys {
662 for repo in repos {
663 matrix.push((
664 format!("{key} -> {repo}"),
665 reach_of(harness, key, repo, phase),
666 ));
667 }
668 }
669 matrix
670 }
671
672 /// A server that is already in use: three keys in `authorized_keys`, and a
673 /// repository set that gives each of them a *different* reach.
674 ///
675 /// The differences are the point. A fixture where everyone could do everything
676 /// would be satisfied by a seed that granted everyone everything, which is the
677 /// widening the no-op principle forbids just as much as the lockout.
678 fn populated_server(keys: &[&str]) -> ServerHarness {
679 let harness = ServerHarness::new("hosted");
680 let repos_dir = harness.repos_dir();
681
682 // Generate the keys first: the rosters below name them by fingerprint,
683 // exactly as an operator's `server.toml` would.
684 let principals: Vec<String> = keys
685 .iter()
686 .map(|name| principal_of(&harness.named_key(name).with_extension("pub")))
687 .collect();
688 harness.authorize_named_keys(keys);
689
690 // Anyone who authenticates: the default, and the common case.
691 make_repo(&repos_dir, "open", "");
692 // An explicit roster of one: only the second key, read and write.
693 make_repo(
694 &repos_dir,
695 "bob-only",
696 &format!(
697 "visibility = \"private\"\n[access]\nread = [{0:?}]\nwrite = [{0:?}]\n",
698 principals[1]
699 ),
700 );
701 // Read for the third key, write for nobody at all.
702 make_repo(
703 &repos_dir,
704 "carol-reads",
705 &format!(
706 "visibility = \"private\"\n[access]\nread = [{:?}]\nwrite = []\n",
707 principals[2]
708 ),
709 );
710
711 harness.work_repo().commit_file("a.txt", "one", "first");
712 harness
713 }
714
715 /// **The test that matters.** Enabling governance does not change who can do
716 /// what over SSH, any more than it changes what is exposed over HTTP.
717 ///
718 /// Every key in `authorized_keys` is measured against every repository before
719 /// setup runs and again afterwards, through the real server. The two matrices
720 /// must be identical: no key loses access it had, and no key gains access it
721 /// did not.
722 #[test]
723 fn setup_preserves_exactly_what_every_authorized_key_could_already_do() {
724 let keys = ["alex", "bob", "carol"];
725 let repos = ["open", "bob-only", "carol-reads"];
726 let harness = populated_server(&keys);
727
728 let before = reach_matrix(&harness, &keys, &repos, "before");
729
730 // The fixture is only worth anything if it is genuinely mixed: a matrix
731 // that was all-true or all-false would be preserved by a seed that got
732 // the whole question wrong.
733 assert!(
734 before.iter().any(|(_, r)| r.read) && before.iter().any(|(_, r)| !r.read),
735 "the fixture must contain both readable and unreadable pairs, got {before:?}"
736 );
737 assert!(
738 before.iter().any(|(_, r)| r.write) && before.iter().any(|(_, r)| !r.write),
739 "the fixture must contain both writable and unwritable pairs, got {before:?}"
740 );
741
742 let output = setup(
743 &harness.config_path(),
744 &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()],
745 );
746 assert_ok(&output, "setup on a server with keys already in use");
747
748 // Governance really is in force, or everything below is vacuous: an
749 // unenrolled key must now be refused outright.
750 assert!(
751 harness.repos_dir().join("settings.git").exists(),
752 "setup must create settings.git"
753 );
754 let stranger = harness.named_key("stranger");
755 assert!(
756 !harness
757 .ssh_fetch(harness.work_repo().dir.path(), &stranger, "open")
758 .status
759 .success(),
760 "a key that was never in authorized_keys must not authenticate under governance"
761 );
762
763 let after = reach_matrix(&harness, &keys, &repos, "after");
764 assert_eq!(
765 before, after,
766 "enabling governance changed what an already-authorized key can do"
767 );
768 }
769
770 /// The admin's own key is normally already in `authorized_keys`, and one key
771 /// cannot be two principals: enrolling it again under a derived name would
772 /// make the roster ambiguous and abort the seed.
773 #[test]
774 fn the_admin_key_already_in_authorized_keys_is_enrolled_once_under_the_admin_name() {
775 let harness = populated_server(&["alex", "bob", "carol"]);
776 let output = setup(
777 &harness.config_path(),
778 &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()],
779 );
780 assert_ok(&output, "setup");
781
782 let files = git_in(
783 &harness.repos_dir().join("settings.git"),
784 &["show", "--name-only", "--format=", "HEAD"],
785 );
786 let enrolled: Vec<&str> = files
787 .lines()
788 .filter(|line| line.starts_with("keydir/"))
789 .collect();
790 assert!(
791 enrolled.contains(&"keydir/alex.pub"),
792 "the admin keeps the name it was given; got {enrolled:?}"
793 );
794 assert_eq!(
795 enrolled.iter().filter(|path| path.contains("alex")).count(),
796 1,
797 "the admin's key must be enrolled exactly once; got {enrolled:?}"
798 );
799 assert_eq!(
800 enrolled.len(),
801 3,
802 "one file per distinct key; got {enrolled:?}"
803 );
804 }
805
806 /// An entry with no usable comment still gets in, and the operator is told
807 /// what it was called. A malformed entry is reported and skipped rather than
808 /// aborting a setup that would otherwise preserve everyone else's access.
809 #[test]
810 fn an_unnameable_or_malformed_entry_does_not_abort_the_setup_and_is_reported() {
811 let harness = ServerHarness::new("hosted");
812 make_repo(&harness.repos_dir(), "open", "");
813
814 // Built by hand, because these are the shapes a real file grows: a key
815 // with no comment at all, a comment that cannot be a principal name, and
816 // a line that is not a key.
817 let read = |name: &str| {
818 std::fs::read_to_string(harness.named_key(name).with_extension("pub"))
819 .unwrap()
820 .trim()
821 .to_string()
822 };
823 let strip_comment = |line: &str| {
824 line.split_whitespace()
825 .take(2)
826 .collect::<Vec<_>>()
827 .join(" ")
828 };
829 let bob = read("bob");
830 let dave = strip_comment(&read("dave"));
831 let erin = format!("{} erin's spare laptop", strip_comment(&read("erin")));
832 std::fs::write(
833 harness.authorized_keys_path(),
834 format!(
835 "# the operator's own notes\n\n{bob}\n{dave}\n{erin}\n\
836 ssh-ed25519 not-actually-base64 broken@host\n"
837 ),
838 )
839 .unwrap();
840
841 let output = setup(
842 &harness.config_path(),
843 &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()],
844 );
845 assert_ok(&output, "setup with an unnameable and a malformed entry");
846
847 let files = git_in(
848 &harness.repos_dir().join("settings.git"),
849 &["show", "--name-only", "--format=", "HEAD"],
850 );
851 let enrolled: Vec<&str> = files
852 .lines()
853 .filter(|line| line.starts_with("keydir/"))
854 .collect();
855 assert!(
856 enrolled.contains(&"keydir/bob@test.pub"),
857 "a comment that can name a principal should name it; got {enrolled:?}"
858 );
859 assert_eq!(
860 enrolled.len(),
861 4,
862 "the admin and the three well-formed entries; got {enrolled:?}"
863 );
864
865 // The two that could not be named got provisional names, and the operator
866 // was told which key wears which.
867 let report = format!("{}{}", stdout(&output), stderr(&output));
868 assert!(
869 enrolled.contains(&"keydir/key1.pub") && enrolled.contains(&"keydir/key2.pub"),
870 "an entry with no usable comment needs an obviously provisional name; got {enrolled:?}"
871 );
872 for name in ["bob@test", "key1", "key2"] {
873 assert!(
874 report.contains(name),
875 "setup must report the name it derived for every enrolled key; {name} missing from:\n{report}"
876 );
877 }
878 assert!(
879 report.contains(&principal_of(
880 &harness.named_key("dave").with_extension("pub")
881 )),
882 "a provisional name is only usable if the report says which key it is:\n{report}"
883 );
884 assert!(
885 report.to_lowercase().contains("not a well-formed")
886 || report.to_lowercase().contains("malformed")
887 || report.to_lowercase().contains("could not be read"),
888 "the malformed entry must be reported, not silently dropped:\n{report}"
889 );
890
891 // And the malformed line did not become a principal.
892 assert!(
893 !files.contains("broken"),
894 "a line that is not a key must not be enrolled; got {files}"
895 );
896 }
897
898 /// The derived names are the thing an operator most wants to check before it
899 /// is too late to change them cheaply, so the dry run has to show them.
900 #[test]
901 fn the_dry_run_shows_the_derived_names_before_anything_is_written() {
902 let harness = populated_server(&["alex", "bob", "carol"]);
903 let output = setup(
904 &harness.config_path(),
905 &[
906 "--admin-key",
907 admin_key(&harness, "alex").to_str().unwrap(),
908 "--dry-run",
909 ],
910 );
911 assert_ok(&output, "a dry run");
912
913 let report = format!("{}{}", stdout(&output), stderr(&output));
914 for name in ["bob@test", "carol@test"] {
915 assert!(
916 report.contains(name),
917 "the dry run must show the name {name} it would enrol; got:\n{report}"
918 );
919 }
920 assert!(
921 !harness.repos_dir().join("settings.git").exists(),
922 "a dry run must not create anything"
923 );
924 }
925
926 /// The opt-out. Enrolment is the default because the safe answer should be,
927 /// but an operator who wants the clean single-admin bootstrap can say so.
928 #[test]
929 fn no_enrol_existing_gives_a_single_admin_bootstrap() {
930 let harness = populated_server(&["alex", "bob", "carol"]);
931 let output = setup(
932 &harness.config_path(),
933 &[
934 "--admin-key",
935 admin_key(&harness, "alex").to_str().unwrap(),
936 "--no-enrol-existing",
937 ],
938 );
939 assert_ok(&output, "setup with enrolment declined");
940
941 let files = git_in(
942 &harness.repos_dir().join("settings.git"),
943 &["show", "--name-only", "--format=", "HEAD"],
944 );
945 let mut paths: Vec<&str> = files.lines().filter(|l| !l.is_empty()).collect();
946 paths.sort();
947 assert_eq!(
948 paths,
949 vec!["conf/access.conf", "keydir/alex.pub"],
950 "declining enrolment must leave the admin alone in keydir/; got {files}"
951 );
952
953 // And it says what that costs, because it is the lockout the default
954 // exists to avoid.
955 let report = format!("{}{}", stdout(&output), stderr(&output));
956 assert!(
957 report.contains("2") && report.to_lowercase().contains("authorized_keys"),
958 "declining must say how many keys it just locked out; got:\n{report}"
959 );
960 }
961
596 /// `setup` must never touch `authorized_keys`: leaving it alone is what lets 962 /// `setup` must never touch `authorized_keys`: leaving it alone is what lets
597 /// an operator turn governance back off by removing `settings.git` and find 963 /// an operator turn governance back off by removing `settings.git` and find
598 /// the server exactly as they left it. 964 /// the server exactly as they left it.