690c31f4
Bootstrap governance without changing what the server exposes
a73x 2026-08-13 14:55
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -397,9 +397,29 @@ policy before serving anything you care about. | |||
| 397 | 397 | ||
| 398 | ### Governance | 398 | ### Governance |
| 399 | 399 | ||
| 400 | Create a repository called `settings` under `repos_dir` and the server takes its | 400 | A repository called `settings` under `repos_dir` takes over as the server's |
| 401 | rules from there instead. Pushing to it reconfigures the server; there is no | 401 | rules. Pushing to it reconfigures the server; there is no reload and no restart. |
| 402 | reload and no restart. | 402 | |
| 403 | Create it with `setup`, which is deliberately a command rather than something | ||
| 404 | the server does on its own: | ||
| 405 | |||
| 406 | ```console | ||
| 407 | $ git-collab-server setup --config /etc/git-collab/server.toml \ | ||
| 408 | --admin-key ~/.ssh/alex.pub | ||
| 409 | ``` | ||
| 410 | |||
| 411 | The key file's basename becomes the identity — `alex.pub` is the principal | ||
| 412 | `alex` — and `--admin-name` overrides it. That name is enrolled in `keydir/` and | ||
| 413 | granted `RW+` on `settings`, so it can push the rules from then on. | ||
| 414 | |||
| 415 | The seed reproduces the policy already in force: it reads each repository's | ||
| 416 | `server.toml` and writes the rule that means the same thing, including | ||
| 417 | `R = @anonymous` and `option listed = yes` for a repository that is public | ||
| 418 | today. So enabling governance changes nothing about what the server exposes, | ||
| 419 | which is what makes it safe to run on a live server — `--dry-run` prints the | ||
| 420 | rules first. Setup refuses if `settings.git` already exists, and leaves | ||
| 421 | `authorized_keys` alone (though once governance is on, only keys in `keydir/` | ||
| 422 | authenticate; enrol the rest before anyone needs them). | ||
| 403 | 423 | ||
| 404 | ```text | 424 | ```text |
| 405 | settings.git | 425 | settings.git |
src/server/governance/conf.rs
| Old | New | ||
|---|---|---|---|
| @@ -237,6 +237,16 @@ fn compile_refex(refex: Option<&str>, line: usize) -> Result<Regex, ConfError> { | |||
| 237 | } | 237 | } |
| 238 | 238 | ||
| 239 | impl AccessConf { | 239 | impl AccessConf { |
| 240 | /// Whether `name` would parse as a `repo` line naming exactly itself. | ||
| 241 | /// | ||
| 242 | /// A generator writing a block per existing repository needs this: a name | ||
| 243 | /// carrying a regex metacharacter compiles to a *wild* pattern, so the | ||
| 244 | /// block would silently govern repositories other than the one it was | ||
| 245 | /// written for. Better to leave such a repository out and say so. | ||
| 246 | pub fn is_literal_repo_name(name: &str) -> bool { | ||
| 247 | !name.starts_with('@') && !is_wild_pattern(name) && is_plain_name(name) | ||
| 248 | } | ||
| 249 | |||
| 240 | /// Parse an access.conf. Every failure carries a line number, because this | 250 | /// Parse an access.conf. Every failure carries a line number, because this |
| 241 | /// message is what a rejected push shows the person who wrote the file. | 251 | /// message is what a rejected push shows the person who wrote the file. |
| 242 | pub fn parse(source: &str) -> Result<AccessConf, ConfError> { | 252 | pub fn parse(source: &str) -> Result<AccessConf, ConfError> { |
src/server/governance/keydir.rs
| Old | New | ||
|---|---|---|---|
| @@ -58,7 +58,11 @@ pub fn name_for_path(path: &str) -> Option<&str> { | |||
| 58 | /// Names must be usable as literal tokens in `conf/access.conf`, so they carry | 58 | /// Names must be usable as literal tokens in `conf/access.conf`, so they carry |
| 59 | /// the same character set as a principal there and must not look like a group | 59 | /// the same character set as a principal there and must not look like a group |
| 60 | /// reference or the `CREATOR` keyword. | 60 | /// reference or the `CREATOR` keyword. |
| 61 | fn validate_name(name: &str) -> Result<(), String> { | 61 | /// |
| 62 | /// Public to the crate because `setup` derives a name from a filename and has | ||
| 63 | /// to reject a bad one at the point the operator can still fix it, rather than | ||
| 64 | /// enrolling a key nobody can write a rule for. | ||
| 65 | pub(crate) fn validate_name(name: &str) -> Result<(), String> { | ||
| 62 | if name == "CREATOR" { | 66 | if name == "CREATOR" { |
| 63 | return Err("CREATOR is a reserved keyword and cannot name a key".to_string()); | 67 | return Err("CREATOR is a reserved keyword and cannot name a key".to_string()); |
| 64 | } | 68 | } |
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,7 @@ mod migrate; | |||
| 10 | mod refs; | 10 | mod refs; |
| 11 | mod releases; | 11 | mod releases; |
| 12 | mod repos; | 12 | mod repos; |
| 13 | mod setup; | ||
| 13 | mod ssh; | 14 | mod ssh; |
| 14 | 15 | ||
| 15 | #[derive(Parser)] | 16 | #[derive(Parser)] |
| @@ -76,6 +77,40 @@ enum Command { | |||
| 76 | #[arg(long)] | 77 | #[arg(long)] |
| 77 | json: bool, | 78 | json: bool, |
| 78 | }, | 79 | }, |
| 80 | |||
| 81 | /// Create `settings.git` and put governance in force. | ||
| 82 | /// | ||
| 83 | /// Deliberately a command and never a startup step. Creating the settings | ||
| 84 | /// repository is what makes unlisted-and-unreadable the default, so a | ||
| 85 | /// server that seeded one on boot would hide every repository it hosts the | ||
| 86 | /// moment it upgraded. | ||
| 87 | /// | ||
| 88 | /// The seed reproduces the policy already in force — every repository's | ||
| 89 | /// current `server.toml` becomes the rule that means the same thing — so | ||
| 90 | /// running this changes nothing about what the server exposes. That is | ||
| 91 | /// what makes it safe on a live server; `--dry-run` shows the rules first. | ||
| 92 | Setup { | ||
| 93 | /// The same config the server runs with; `repos_dir` is read, and | ||
| 94 | /// `authorized_keys` is left alone. | ||
| 95 | #[arg(short, long)] | ||
| 96 | config: PathBuf, | ||
| 97 | |||
| 98 | /// OpenSSH public key for the first administrator, or `-` for stdin. | ||
| 99 | /// | ||
| 100 | /// The file's basename without `.pub` becomes the identity, as in | ||
| 101 | /// `gitolite setup`: `keydir/alex.pub` is the principal `alex`. | ||
| 102 | #[arg(long, value_name = "FILE|-")] | ||
| 103 | admin_key: String, | ||
| 104 | |||
| 105 | /// Name that identity instead of taking it from the filename. | ||
| 106 | /// Required when the key is read from stdin. | ||
| 107 | #[arg(long, value_name = "NAME")] | ||
| 108 | admin_name: Option<String>, | ||
| 109 | |||
| 110 | /// Print the `conf/access.conf` this would write, and write nothing. | ||
| 111 | #[arg(long)] | ||
| 112 | dry_run: bool, | ||
| 113 | }, | ||
| 79 | } | 114 | } |
| 80 | 115 | ||
| 81 | #[tokio::main] | 116 | #[tokio::main] |
| @@ -103,6 +138,9 @@ async fn main() { | |||
| 103 | let (path, dry_run, json) = match &command { | 138 | let (path, dry_run, json) = match &command { |
| 104 | Command::Migrate { config, dry_run } => (config, *dry_run, false), | 139 | Command::Migrate { config, dry_run } => (config, *dry_run, false), |
| 105 | Command::Refs { config, json } => (config, false, *json), | 140 | Command::Refs { config, json } => (config, false, *json), |
| 141 | Command::Setup { | ||
| 142 | config, dry_run, .. | ||
| 143 | } => (config, *dry_run, false), | ||
| 106 | }; | 144 | }; |
| 107 | let config = match config::ServerConfig::from_file(path) { | 145 | let config = match config::ServerConfig::from_file(path) { |
| 108 | Ok(c) => c, | 146 | Ok(c) => c, |
| @@ -114,6 +152,16 @@ async fn main() { | |||
| 114 | std::process::exit(match command { | 152 | std::process::exit(match command { |
| 115 | Command::Migrate { .. } => migrate::run(&config.repos_dir, dry_run), | 153 | Command::Migrate { .. } => migrate::run(&config.repos_dir, dry_run), |
| 116 | Command::Refs { .. } => refs::run(&config.repos_dir, json), | 154 | Command::Refs { .. } => refs::run(&config.repos_dir, json), |
| 155 | Command::Setup { | ||
| 156 | admin_key, | ||
| 157 | admin_name, | ||
| 158 | .. | ||
| 159 | } => setup::run( | ||
| 160 | &config.repos_dir, | ||
| 161 | &admin_key, | ||
| 162 | admin_name.as_deref(), | ||
| 163 | dry_run, | ||
| 164 | ), | ||
| 117 | }); | 165 | }); |
| 118 | } | 166 | } |
| 119 | 167 | ||
src/server/setup.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,606 @@ | |||
| 1 | //! `git-collab-server setup`: create `settings.git` and put governance in | ||
| 2 | //! force, the way `gitolite setup` creates `gitolite-admin`. | ||
| 3 | //! | ||
| 4 | //! Governance and the exposure model both shipped with nothing able to create | ||
| 5 | //! the repository that turns them on, so a deployment could run the code, host | ||
| 6 | //! repositories, and have no `settings.git` anywhere — governance present and | ||
| 7 | //! entirely inert. This is the missing piece. | ||
| 8 | //! | ||
| 9 | //! # Why this is a command and never a startup step | ||
| 10 | //! | ||
| 11 | //! Creating `settings.git` is precisely what puts the inverted default in | ||
| 12 | //! force: under governance a repository is unlisted and unreadable unless a | ||
| 13 | //! rule says otherwise. A server that seeded one on boot would, at the moment | ||
| 14 | //! it upgraded, make every repository invisible until somebody wrote rules — | ||
| 15 | //! the protection undone by the feature meant to make governance usable. So | ||
| 16 | //! the operator runs this, once, deliberately. | ||
| 17 | //! | ||
| 18 | //! # Why the seed reproduces the policy already in force | ||
| 19 | //! | ||
| 20 | //! The same reason, from the other end. If enabling governance changed what | ||
| 21 | //! the server exposes, no operator would dare enable it on anything live, and | ||
| 22 | //! a bootstrap nobody runs is not a bootstrap. So the seed reads each | ||
| 23 | //! repository's `server.toml` and writes the rule that means what it means | ||
| 24 | //! today, including `R = @anonymous` and `option listed = yes` where the | ||
| 25 | //! repository is public. Enabling governance is then behaviourally a no-op, | ||
| 26 | //! and `tests/server_setup_test.rs` asserts exactly that against a live | ||
| 27 | //! server. | ||
| 28 | //! | ||
| 29 | //! The claim is also checked here, at run time, rather than only in the test | ||
| 30 | //! suite: the generated rules are parsed and every repository's decision is | ||
| 31 | //! compared against the decision `server.toml` gives today. A mismatch is a | ||
| 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. | ||
| 34 | //! | ||
| 35 | //! # The one thing governance cannot say | ||
| 36 | //! | ||
| 37 | //! `server.toml` has two anonymous switches — `[ui] anonymous` and | ||
| 38 | //! `[http] anonymous_clone` — and governance has one grant, `R = @anonymous`, | ||
| 39 | //! covering both. A repository that has them set differently therefore has no | ||
| 40 | //! faithful translation. Setup writes the closed answer and says so, loudly | ||
| 41 | //! 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. | ||
| 43 | |||
| 44 | use std::path::{Path, PathBuf}; | ||
| 45 | |||
| 46 | use russh::keys::PublicKey; | ||
| 47 | |||
| 48 | use crate::governance::{self, conf::AccessConf, keydir, SETTINGS_REPO}; | ||
| 49 | use crate::repos::{self, RepoPolicy}; | ||
| 50 | |||
| 51 | /// The branch the seed lands on. `governance::load` follows HEAD, so this is | ||
| 52 | /// a convention rather than a requirement — but a convention the operator | ||
| 53 | /// clones, so it should be the modern one. | ||
| 54 | const SEED_BRANCH: &str = "main"; | ||
| 55 | |||
| 56 | /// Where the identity in `keydir/` came from. Reported, because the basename | ||
| 57 | /// *is* the principal: a wrong one locks the operator out of the repository | ||
| 58 | /// setup just created, and the failure would not show up until the first push. | ||
| 59 | enum NameSource { | ||
| 60 | Explicit, | ||
| 61 | Filename, | ||
| 62 | } | ||
| 63 | |||
| 64 | /// 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 { | ||
| 66 | match try_run(repos_dir, admin_key, admin_name, dry_run) { | ||
| 67 | Ok(()) => 0, | ||
| 68 | Err(reason) => { | ||
| 69 | eprintln!("error: {reason}"); | ||
| 70 | 1 | ||
| 71 | } | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | fn try_run( | ||
| 76 | repos_dir: &Path, | ||
| 77 | admin_key: &str, | ||
| 78 | admin_name: Option<&str>, | ||
| 79 | dry_run: bool, | ||
| 80 | ) -> Result<(), String> { | ||
| 81 | if !repos_dir.is_dir() { | ||
| 82 | return Err(format!( | ||
| 83 | "repos_dir {} does not exist; nothing to govern", | ||
| 84 | repos_dir.display() | ||
| 85 | )); | ||
| 86 | } | ||
| 87 | |||
| 88 | let settings_path = repos_dir.join(format!("{SETTINGS_REPO}.git")); | ||
| 89 | if settings_path.exists() { | ||
| 90 | return Err(format!( | ||
| 91 | "{} already exists; this server is already set up.\n \ | ||
| 92 | Setup will not overwrite rules an operator wrote. Clone it and edit it, \ | ||
| 93 | or move it aside if you meant to start again.", | ||
| 94 | settings_path.display() | ||
| 95 | )); | ||
| 96 | } | ||
| 97 | |||
| 98 | 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); | ||
| 100 | |||
| 101 | let entries = repos::discover(repos_dir) | ||
| 102 | .map_err(|e| format!("cannot read {}: {e}", repos_dir.display()))?; | ||
| 103 | |||
| 104 | // A directory literally named `settings` is the same name as the | ||
| 105 | // governance repository, and would collide with the block written for it. | ||
| 106 | if let Some(entry) = entries.iter().find(|entry| entry.name == SETTINGS_REPO) { | ||
| 107 | return Err(format!( | ||
| 108 | "{} is already a repository named {SETTINGS_REPO}, which is the name governance \ | ||
| 109 | reserves for itself. Rename it before setting up.", | ||
| 110 | entry.path.display() | ||
| 111 | )); | ||
| 112 | } | ||
| 113 | |||
| 114 | let plan = Plan::build(repos_dir, &entries, &name, &admin_principal); | ||
| 115 | let conf_text = plan.render(); | ||
| 116 | |||
| 117 | // Machine-check the safety claim before anything is written: the rules | ||
| 118 | // just generated must give every repository the decision it already has. | ||
| 119 | verify(&conf_text, &plan)?; | ||
| 120 | |||
| 121 | if dry_run { | ||
| 122 | print!("{conf_text}"); | ||
| 123 | println!(); | ||
| 124 | report(&plan, &name, &name_source, &settings_path, true); | ||
| 125 | return Ok(()); | ||
| 126 | } | ||
| 127 | |||
| 128 | seed(&settings_path, &conf_text, &name, &key_text)?; | ||
| 129 | report(&plan, &name, &name_source, &settings_path, false); | ||
| 130 | Ok(()) | ||
| 131 | } | ||
| 132 | |||
| 133 | // --------------------------------------------------------------------------- | ||
| 134 | // The admin key | ||
| 135 | // --------------------------------------------------------------------------- | ||
| 136 | |||
| 137 | /// Read the admin key from a file or stdin, and work out the identity it | ||
| 138 | /// belongs to. | ||
| 139 | /// | ||
| 140 | /// The name is the `keydir/` basename, which follows `gitolite setup -pk`: | ||
| 141 | /// the file you hand it names the principal. `-` has no filename to read, so | ||
| 142 | /// it needs `--admin-name` and says so rather than inventing something from | ||
| 143 | /// the key comment — a comment is free text an operator never chose as an | ||
| 144 | /// identity, and it is the kind of guess that is wrong exactly once. | ||
| 145 | fn read_admin_key( | ||
| 146 | admin_key: &str, | ||
| 147 | admin_name: Option<&str>, | ||
| 148 | ) -> Result<(String, PublicKey, String, NameSource), String> { | ||
| 149 | let (text, from_filename) = if admin_key == "-" { | ||
| 150 | let mut buffer = String::new(); | ||
| 151 | std::io::Read::read_to_string(&mut std::io::stdin(), &mut buffer) | ||
| 152 | .map_err(|e| format!("cannot read the admin key from stdin: {e}"))?; | ||
| 153 | (buffer, None) | ||
| 154 | } else { | ||
| 155 | let path = PathBuf::from(admin_key); | ||
| 156 | let text = std::fs::read_to_string(&path) | ||
| 157 | .map_err(|e| format!("cannot read the admin key from {}: {e}", path.display()))?; | ||
| 158 | let stem = path | ||
| 159 | .file_name() | ||
| 160 | .and_then(|n| n.to_str()) | ||
| 161 | .map(|n| n.strip_suffix(".pub").unwrap_or(n).to_string()); | ||
| 162 | (text, stem) | ||
| 163 | }; | ||
| 164 | |||
| 165 | let text = text.trim().to_string(); | ||
| 166 | if text.is_empty() { | ||
| 167 | return Err(format!( | ||
| 168 | "the admin key is empty; expected one OpenSSH public key \ | ||
| 169 | (`ssh-ed25519 AAAA… you@host`) from {}", | ||
| 170 | if admin_key == "-" { "stdin" } else { admin_key } | ||
| 171 | )); | ||
| 172 | } | ||
| 173 | |||
| 174 | let key = PublicKey::from_openssh(&text).map_err(|e| { | ||
| 175 | format!( | ||
| 176 | "the admin key is not a well-formed OpenSSH public key: {e}\n \ | ||
| 177 | This is the contents of a `.pub` file, not a private key." | ||
| 178 | ) | ||
| 179 | })?; | ||
| 180 | |||
| 181 | let (name, source) = match (admin_name, from_filename) { | ||
| 182 | (Some(name), _) => (name.to_string(), NameSource::Explicit), | ||
| 183 | (None, Some(stem)) => (stem, NameSource::Filename), | ||
| 184 | (None, None) => { | ||
| 185 | return Err( | ||
| 186 | "a key read from stdin has no filename to take an identity from; \ | ||
| 187 | pass --admin-name <name> to say who it belongs to" | ||
| 188 | .to_string(), | ||
| 189 | ) | ||
| 190 | } | ||
| 191 | }; | ||
| 192 | |||
| 193 | // The basename is written straight into `conf/access.conf` as a | ||
| 194 | // principal, so it has to be a name that file can hold. | ||
| 195 | keydir::validate_name(&name).map_err(|reason| { | ||
| 196 | format!( | ||
| 197 | "{name:?} cannot name a principal: {reason}\n \ | ||
| 198 | The identity is the key file's basename without `.pub`. \ | ||
| 199 | Pass --admin-name <name> to choose it directly." | ||
| 200 | ) | ||
| 201 | })?; | ||
| 202 | |||
| 203 | Ok((text, key, name, source)) | ||
| 204 | } | ||
| 205 | |||
| 206 | // --------------------------------------------------------------------------- | ||
| 207 | // Translating the policy already in force | ||
| 208 | // --------------------------------------------------------------------------- | ||
| 209 | |||
| 210 | /// What one repository's `server.toml` says, and the rules that mean the same. | ||
| 211 | struct RepoPlan { | ||
| 212 | /// The key rules are matched against: the path under `repos_dir` with one | ||
| 213 | /// `.git` removed. The same string `repo_key` gives the request path. | ||
| 214 | key: String, | ||
| 215 | /// Rules, already rendered as `(lhs, rhs)` pairs. | ||
| 216 | rules: Vec<(String, String)>, | ||
| 217 | /// `option listed = yes`. | ||
| 218 | listed: bool, | ||
| 219 | /// What an anonymous request may do today, and so must still do. | ||
| 220 | anonymous_read: bool, | ||
| 221 | /// Notes to write into the file above the block, and to report. | ||
| 222 | notes: Vec<String>, | ||
| 223 | } | ||
| 224 | |||
| 225 | struct Plan { | ||
| 226 | admin: String, | ||
| 227 | repos: Vec<RepoPlan>, | ||
| 228 | /// Repositories no rule could be written for at all, and why. | ||
| 229 | skipped: Vec<String>, | ||
| 230 | /// Every place the seed could not say what `server.toml` says, and so | ||
| 231 | /// said less. Empty is the good answer, and the usual one; anything here | ||
| 232 | /// is a decision the operator now has to make deliberately. | ||
| 233 | narrowed: Vec<String>, | ||
| 234 | } | ||
| 235 | |||
| 236 | impl Plan { | ||
| 237 | fn build( | ||
| 238 | repos_dir: &Path, | ||
| 239 | entries: &[repos::RepoEntry], | ||
| 240 | admin: &str, | ||
| 241 | admin_principal: &str, | ||
| 242 | ) -> Plan { | ||
| 243 | let mut plan = Plan { | ||
| 244 | admin: admin.to_string(), | ||
| 245 | repos: Vec::new(), | ||
| 246 | skipped: Vec::new(), | ||
| 247 | narrowed: Vec::new(), | ||
| 248 | }; | ||
| 249 | |||
| 250 | for entry in entries { | ||
| 251 | let Some(key) = governance::repo_key(repos_dir, &entry.path) else { | ||
| 252 | plan.skipped.push(format!( | ||
| 253 | "{}: not a name that can appear in the rules", | ||
| 254 | entry.path.display() | ||
| 255 | )); | ||
| 256 | continue; | ||
| 257 | }; | ||
| 258 | // A name carrying regex metacharacters would compile to a wild | ||
| 259 | // pattern and quietly govern repositories other than this one. | ||
| 260 | if !AccessConf::is_literal_repo_name(&key) { | ||
| 261 | // Left out, so governance closes it. Say plainly whether that | ||
| 262 | // is a change anyone will notice: this is the one place the | ||
| 263 | // seed cannot keep its no-op promise, and burying it in a | ||
| 264 | // sentence about rule syntax would be the wrong emphasis. | ||
| 265 | let visible = if entry.policy.allows_anonymous_ui() { | ||
| 266 | "It is public today, so it will disappear from the repository list \ | ||
| 267 | until you do." | ||
| 268 | } else { | ||
| 269 | "It is not on the anonymous surface today, so nothing visible changes." | ||
| 270 | }; | ||
| 271 | plan.skipped.push(format!( | ||
| 272 | "{key}: this name cannot be written as a literal rule (it would compile \ | ||
| 273 | to a pattern matching other repositories), so no block was generated \ | ||
| 274 | for it and governance closes it to everyone.\n{visible}" | ||
| 275 | )); | ||
| 276 | continue; | ||
| 277 | } | ||
| 278 | plan.repos | ||
| 279 | .push(RepoPlan::build(&key, &entry.policy, admin, admin_principal)); | ||
| 280 | } | ||
| 281 | |||
| 282 | for repo in &plan.repos { | ||
| 283 | for note in &repo.notes { | ||
| 284 | plan.narrowed.push(format!("{}: {note}", repo.key)); | ||
| 285 | } | ||
| 286 | } | ||
| 287 | plan | ||
| 288 | } | ||
| 289 | |||
| 290 | /// The whole `conf/access.conf`. | ||
| 291 | fn render(&self) -> String { | ||
| 292 | let mut out = String::new(); | ||
| 293 | out.push_str( | ||
| 294 | "# conf/access.conf — the rules that govern this server.\n\ | ||
| 295 | #\n\ | ||
| 296 | # Generated by `git-collab-server setup` from the policy that was already in\n\ | ||
| 297 | # force: every block below reproduces what the repository's server.toml grants\n\ | ||
| 298 | # today, so turning governance on changed nothing. From here on this file is\n\ | ||
| 299 | # the authority on access, and server.toml's [access], visibility, [ui] and\n\ | ||
| 300 | # [http] keys are no longer consulted.\n\ | ||
| 301 | #\n\ | ||
| 302 | # Push to this repository to change it. The push is validated first, and one\n\ | ||
| 303 | # that would lock everyone out is refused.\n\n", | ||
| 304 | ); | ||
| 305 | |||
| 306 | out.push_str(&format!("repo {SETTINGS_REPO}\n")); | ||
| 307 | out.push_str( | ||
| 308 | " # Whoever holds this may rewrite the rules, so it is deliberately one\n\ | ||
| 309 | \x20 # name rather than a group. No anonymous grant: the key roster lives\n\ | ||
| 310 | \x20 # here, and governance is unlisted and unreadable until a rule says\n\ | ||
| 311 | \x20 # otherwise.\n", | ||
| 312 | ); | ||
| 313 | out.push_str(&rule("RW+", &self.admin)); | ||
| 314 | |||
| 315 | for repo in &self.repos { | ||
| 316 | out.push('\n'); | ||
| 317 | out.push_str(&format!("repo {}\n", repo.key)); | ||
| 318 | for note in &repo.notes { | ||
| 319 | for line in note.lines() { | ||
| 320 | out.push_str(&format!(" # {line}\n")); | ||
| 321 | } | ||
| 322 | } | ||
| 323 | for (lhs, rhs) in &repo.rules { | ||
| 324 | out.push_str(&rule(lhs, rhs)); | ||
| 325 | } | ||
| 326 | if repo.listed { | ||
| 327 | out.push_str(&rule("option listed", "yes")); | ||
| 328 | } | ||
| 329 | } | ||
| 330 | out | ||
| 331 | } | ||
| 332 | } | ||
| 333 | |||
| 334 | /// One rule line, in the column layout the design's examples use. | ||
| 335 | fn rule(lhs: &str, rhs: &str) -> String { | ||
| 336 | format!(" {lhs:<21} = {rhs}\n") | ||
| 337 | } | ||
| 338 | |||
| 339 | impl RepoPlan { | ||
| 340 | fn build(key: &str, policy: &RepoPolicy, admin: &str, admin_principal: &str) -> RepoPlan { | ||
| 341 | let mut rules = Vec::new(); | ||
| 342 | let mut notes = Vec::new(); | ||
| 343 | |||
| 344 | // --- The authenticated axis ------------------------------------- | ||
| 345 | // | ||
| 346 | // `*` in server.toml means "any principal that authenticated at all", | ||
| 347 | // and `@all` means "every key enrolled in keydir/", which is the same | ||
| 348 | // sentence in the new language. A force-push was never separately | ||
| 349 | // gated before governance, so a writer maps to RW+. | ||
| 350 | let read_all = policy.access.read.iter().any(|entry| entry == "*"); | ||
| 351 | let write_all = policy.access.write.iter().any(|entry| entry == "*"); | ||
| 352 | |||
| 353 | // An explicit roster names fingerprints, and rules name keydir | ||
| 354 | // identities. Only the one key this command enrols can be translated; | ||
| 355 | // the rest are recorded so the operator can enrol and grant them. | ||
| 356 | let mut untranslatable: Vec<&str> = policy | ||
| 357 | .access | ||
| 358 | .read | ||
| 359 | .iter() | ||
| 360 | .chain(policy.access.write.iter()) | ||
| 361 | .map(String::as_str) | ||
| 362 | .filter(|entry| *entry != "*" && *entry != admin_principal) | ||
| 363 | .collect(); | ||
| 364 | untranslatable.sort(); | ||
| 365 | untranslatable.dedup(); | ||
| 366 | |||
| 367 | // A fingerprint cannot appear in a rule, so the one roster entry that | ||
| 368 | // can be translated is the admin's own key, under the admin's name. | ||
| 369 | if !write_all && policy.allows_write(admin_principal) { | ||
| 370 | rules.push(("RW+".to_string(), admin.to_string())); | ||
| 371 | } else if !read_all && policy.allows_read(admin_principal) { | ||
| 372 | rules.push(("R".to_string(), admin.to_string())); | ||
| 373 | } | ||
| 374 | if write_all { | ||
| 375 | rules.push(("RW+".to_string(), "@all".to_string())); | ||
| 376 | } else if read_all { | ||
| 377 | rules.push(("R".to_string(), "@all".to_string())); | ||
| 378 | } | ||
| 379 | |||
| 380 | if !untranslatable.is_empty() { | ||
| 381 | notes.push(format!( | ||
| 382 | "server.toml granted access to {} by key fingerprint, and rules name\n\ | ||
| 383 | keydir identities instead. Enrol each key as keydir/<name>.pub and add\n\ | ||
| 384 | the grant here; until then those keys cannot reach this repository:\n {}", | ||
| 385 | if untranslatable.len() == 1 { | ||
| 386 | "a key".to_string() | ||
| 387 | } else { | ||
| 388 | format!("{} keys", untranslatable.len()) | ||
| 389 | }, | ||
| 390 | untranslatable.join("\n ") | ||
| 391 | )); | ||
| 392 | } | ||
| 393 | |||
| 394 | // --- The anonymous axis ----------------------------------------- | ||
| 395 | // | ||
| 396 | // Two switches upstream, one grant here. Where they agree the | ||
| 397 | // translation is exact; where they disagree there is no rule that | ||
| 398 | // means what server.toml means, so the closed answer is written and | ||
| 399 | // named. Narrowing is a thing a bootstrap may do; widening is not. | ||
| 400 | let ui = policy.allows_anonymous_ui(); | ||
| 401 | let http = policy.allows_anonymous_http(); | ||
| 402 | let anonymous_read = ui && http; | ||
| 403 | if ui != http { | ||
| 404 | notes.push(format!( | ||
| 405 | "server.toml sets [ui] anonymous = {ui} but [http] anonymous_clone = {http},\n\ | ||
| 406 | and governance has one anonymous read grant covering both. No rule can\n\ | ||
| 407 | mean that, so none was written and this repository is now closed to\n\ | ||
| 408 | anonymous requests. Add `R = @anonymous` (and `option listed = yes`) to\n\ | ||
| 409 | publish it deliberately." | ||
| 410 | )); | ||
| 411 | } | ||
| 412 | if anonymous_read { | ||
| 413 | rules.push(("R".to_string(), "@anonymous".to_string())); | ||
| 414 | } | ||
| 415 | |||
| 416 | RepoPlan { | ||
| 417 | key: key.to_string(), | ||
| 418 | rules, | ||
| 419 | listed: anonymous_read, | ||
| 420 | anonymous_read, | ||
| 421 | notes, | ||
| 422 | } | ||
| 423 | } | ||
| 424 | } | ||
| 425 | |||
| 426 | // --------------------------------------------------------------------------- | ||
| 427 | // The self-check | ||
| 428 | // --------------------------------------------------------------------------- | ||
| 429 | |||
| 430 | /// Parse what was generated and confirm it decides every repository the way | ||
| 431 | /// the plan says it should. | ||
| 432 | /// | ||
| 433 | /// 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 | ||
| 435 | /// and the right thing to do is refuse to govern the server with it. | ||
| 436 | fn verify(conf_text: &str, plan: &Plan) -> Result<(), String> { | ||
| 437 | let conf = AccessConf::parse(conf_text).map_err(|e| { | ||
| 438 | format!( | ||
| 439 | "the generated rules do not parse ({e}); refusing to write them. \ | ||
| 440 | This is a bug in `setup`." | ||
| 441 | ) | ||
| 442 | })?; | ||
| 443 | |||
| 444 | for repo in &plan.repos { | ||
| 445 | let anonymous_read = conf.anonymous_may_read(&repo.key); | ||
| 446 | let listed = conf.is_listed(&repo.key); | ||
| 447 | if anonymous_read != repo.anonymous_read || listed != repo.listed { | ||
| 448 | return Err(format!( | ||
| 449 | "the generated rules would expose {} differently from its server.toml \ | ||
| 450 | (anonymous read {} vs {}, listed {} vs {}); refusing to write them. \ | ||
| 451 | This is a bug in `setup`.", | ||
| 452 | repo.key, anonymous_read, repo.anonymous_read, listed, repo.listed | ||
| 453 | )); | ||
| 454 | } | ||
| 455 | } | ||
| 456 | |||
| 457 | // The settings repository must stay off the anonymous surface: the whole | ||
| 458 | // key roster is in it. | ||
| 459 | if conf.anonymous_may_read(SETTINGS_REPO) || conf.is_listed(SETTINGS_REPO) { | ||
| 460 | return Err(format!( | ||
| 461 | "the generated rules would publish {SETTINGS_REPO}, which holds the key \ | ||
| 462 | roster; refusing to write them. This is a bug in `setup`." | ||
| 463 | )); | ||
| 464 | } | ||
| 465 | Ok(()) | ||
| 466 | } | ||
| 467 | |||
| 468 | // --------------------------------------------------------------------------- | ||
| 469 | // Writing it | ||
| 470 | // --------------------------------------------------------------------------- | ||
| 471 | |||
| 472 | /// Create `settings.git` and land both files in a single commit. | ||
| 473 | /// | ||
| 474 | /// One commit, not two: the push-time guard refuses a roster with no rules, | ||
| 475 | /// because that state governs nothing while publishing who has access to the | ||
| 476 | /// server. Setup must not create by hand the very thing that guard exists to | ||
| 477 | /// prevent — so the tree is assembled whole, put through the same validation a | ||
| 478 | /// push would face, and only then committed. | ||
| 479 | fn seed( | ||
| 480 | settings_path: &Path, | ||
| 481 | conf_text: &str, | ||
| 482 | admin_name: &str, | ||
| 483 | key_text: &str, | ||
| 484 | ) -> Result<(), String> { | ||
| 485 | let mut options = git2::RepositoryInitOptions::new(); | ||
| 486 | options.bare(true).initial_head(SEED_BRANCH); | ||
| 487 | let repo = git2::Repository::init_opts(settings_path, &options) | ||
| 488 | .map_err(|e| format!("cannot create {}: {e}", settings_path.display()))?; | ||
| 489 | |||
| 490 | // 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. | ||
| 492 | match build_and_commit(&repo, conf_text, admin_name, key_text) { | ||
| 493 | Ok(()) => Ok(()), | ||
| 494 | Err(reason) => { | ||
| 495 | drop(repo); | ||
| 496 | let _ = std::fs::remove_dir_all(settings_path); | ||
| 497 | Err(reason) | ||
| 498 | } | ||
| 499 | } | ||
| 500 | } | ||
| 501 | |||
| 502 | fn build_and_commit( | ||
| 503 | repo: &git2::Repository, | ||
| 504 | conf_text: &str, | ||
| 505 | admin_name: &str, | ||
| 506 | key_text: &str, | ||
| 507 | ) -> Result<(), String> { | ||
| 508 | let git = |e: git2::Error| format!("building the seed commit: {e}"); | ||
| 509 | |||
| 510 | 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 | |||
| 515 | let mut conf_dir = repo.treebuilder(None).map_err(git)?; | ||
| 516 | conf_dir | ||
| 517 | .insert("access.conf", conf_blob, 0o100644) | ||
| 518 | .map_err(git)?; | ||
| 519 | let conf_tree = conf_dir.write().map_err(git)?; | ||
| 520 | |||
| 521 | let mut keydir_tree = repo.treebuilder(None).map_err(git)?; | ||
| 522 | keydir_tree | ||
| 523 | .insert(format!("{admin_name}.pub"), key_blob, 0o100644) | ||
| 524 | .map_err(git)?; | ||
| 525 | let keydir_tree = keydir_tree.write().map_err(git)?; | ||
| 526 | |||
| 527 | let mut root = repo.treebuilder(None).map_err(git)?; | ||
| 528 | root.insert("conf", conf_tree, 0o040000).map_err(git)?; | ||
| 529 | root.insert("keydir", keydir_tree, 0o040000).map_err(git)?; | ||
| 530 | let tree = repo.find_tree(root.write().map_err(git)?).map_err(git)?; | ||
| 531 | |||
| 532 | // Exactly the checks a push to this repository would face: the rules | ||
| 533 | // parse, somebody is enrolled, and somebody still holds RW+ on settings. | ||
| 534 | // Passing them is what makes the seed a configuration the server would | ||
| 535 | // have accepted from an operator. | ||
| 536 | let landing_ref = format!("refs/heads/{SEED_BRANCH}"); | ||
| 537 | governance::check_roster_has_rules(&tree)?; | ||
| 538 | governance::validate_settings_tree(repo, &tree, &landing_ref)?; | ||
| 539 | |||
| 540 | let who = git2::Signature::now("git-collab-server setup", "setup@git-collab").map_err(git)?; | ||
| 541 | repo.commit( | ||
| 542 | Some(&landing_ref), | ||
| 543 | &who, | ||
| 544 | &who, | ||
| 545 | "Seed governance from the policy already in force\n\n\ | ||
| 546 | Generated by `git-collab-server setup`. Every block reproduces what the\n\ | ||
| 547 | repository's server.toml granted at this moment, so enabling governance\n\ | ||
| 548 | changed nothing about what this server exposes.\n", | ||
| 549 | &tree, | ||
| 550 | &[], | ||
| 551 | ) | ||
| 552 | .map_err(git)?; | ||
| 553 | Ok(()) | ||
| 554 | } | ||
| 555 | |||
| 556 | // --------------------------------------------------------------------------- | ||
| 557 | // The report | ||
| 558 | // --------------------------------------------------------------------------- | ||
| 559 | |||
| 560 | fn report(plan: &Plan, name: &str, source: &NameSource, settings_path: &Path, dry_run: bool) { | ||
| 561 | let derived = match source { | ||
| 562 | NameSource::Explicit => "from --admin-name", | ||
| 563 | NameSource::Filename => "from the key file's name", | ||
| 564 | }; | ||
| 565 | if dry_run { | ||
| 566 | println!("Dry run: nothing was written."); | ||
| 567 | println!("Would create {}", settings_path.display()); | ||
| 568 | } else { | ||
| 569 | println!("Created {}", settings_path.display()); | ||
| 570 | } | ||
| 571 | println!("Admin identity: {name} ({derived}, enrolled as keydir/{name}.pub)"); | ||
| 572 | println!( | ||
| 573 | " Rules are written against this name, and it holds RW+ on `{SETTINGS_REPO}`. \ | ||
| 574 | If it is wrong, nobody can push the rules." | ||
| 575 | ); | ||
| 576 | println!( | ||
| 577 | "Reproduced the policy of {} {}.", | ||
| 578 | plan.repos.len(), | ||
| 579 | if plan.repos.len() == 1 { | ||
| 580 | "repository" | ||
| 581 | } else { | ||
| 582 | "repositories" | ||
| 583 | } | ||
| 584 | ); | ||
| 585 | |||
| 586 | for note in plan.narrowed.iter().chain(plan.skipped.iter()) { | ||
| 587 | let mut lines = note.lines(); | ||
| 588 | if let Some(first) = lines.next() { | ||
| 589 | eprintln!("warning: {first}"); | ||
| 590 | } | ||
| 591 | for line in lines { | ||
| 592 | eprintln!(" {line}"); | ||
| 593 | } | ||
| 594 | } | ||
| 595 | |||
| 596 | if !dry_run { | ||
| 597 | println!(); | ||
| 598 | println!("Governance is now in force. `authorized_keys` was not touched, but it is"); | ||
| 599 | println!("no longer consulted: only keys in keydir/ authenticate. Enrol the rest"); | ||
| 600 | println!( | ||
| 601 | "by cloning {}, adding keydir/<name>.pub", | ||
| 602 | settings_path.display() | ||
| 603 | ); | ||
| 604 | println!("and a grant in conf/access.conf, and pushing both together."); | ||
| 605 | } | ||
| 606 | } | ||
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1118,6 +1118,20 @@ impl ServerHarness { | |||
| 1118 | self.root.path().join("repos") | 1118 | self.root.path().join("repos") |
| 1119 | } | 1119 | } |
| 1120 | 1120 | ||
| 1121 | /// The config file the running server was started with, for subcommands | ||
| 1122 | /// an operator runs against the same server (`migrate`, `setup`). | ||
| 1123 | pub fn config_path(&self) -> PathBuf { | ||
| 1124 | self.root.path().join("server.toml") | ||
| 1125 | } | ||
| 1126 | |||
| 1127 | /// A scratch directory under the harness root, for a test that needs | ||
| 1128 | /// somewhere to clone to or a file to feed a command. | ||
| 1129 | pub fn scratch(&self, name: &str) -> PathBuf { | ||
| 1130 | let path = self.root.path().join(name); | ||
| 1131 | std::fs::create_dir_all(&path).unwrap(); | ||
| 1132 | path | ||
| 1133 | } | ||
| 1134 | |||
| 1121 | /// Generate a client SSH keypair (once) and authorize it. Returns the key path. | 1135 | /// Generate a client SSH keypair (once) and authorize it. Returns the key path. |
| 1122 | /// | 1136 | /// |
| 1123 | /// Not thread-safe: writes shared state under the harness root | 1137 | /// Not thread-safe: writes shared state under the harness root |
tests/server_setup_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,619 @@ | |||
| 1 | //! `git-collab-server setup`: the bootstrap that creates `settings.git`. | ||
| 2 | //! | ||
| 3 | //! Governance and the exposure model both shipped with no way to create the | ||
| 4 | //! repository that turns them on, so governance was present and inert. This | ||
| 5 | //! is the missing half, and the whole difficulty is in one property: | ||
| 6 | //! | ||
| 7 | //! **Creating `settings.git` is what puts the inverted default in force**, so | ||
| 8 | //! a seed that did not reproduce the server's current effective policy would | ||
| 9 | //! make every repository invisible the moment it ran. The central test here | ||
| 10 | //! therefore captures what a live server exposes, runs `setup` against it, | ||
| 11 | //! and asserts the exposure is byte-for-byte the same decision afterwards. | ||
| 12 | //! Everything else in this file exists to protect that claim. | ||
| 13 | |||
| 14 | mod common; | ||
| 15 | |||
| 16 | use common::ServerHarness; | ||
| 17 | use std::path::Path; | ||
| 18 | use std::process::{Command, Output}; | ||
| 19 | |||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | // Fixtures | ||
| 22 | // --------------------------------------------------------------------------- | ||
| 23 | |||
| 24 | fn git_in(dir: &Path, args: &[&str]) -> String { | ||
| 25 | let output = Command::new("git") | ||
| 26 | .args(args) | ||
| 27 | .current_dir(dir) | ||
| 28 | .output() | ||
| 29 | .expect("failed to run git"); | ||
| 30 | assert!( | ||
| 31 | output.status.success(), | ||
| 32 | "git {:?} in {:?} failed: {}", | ||
| 33 | args, | ||
| 34 | dir, | ||
| 35 | String::from_utf8_lossy(&output.stderr) | ||
| 36 | ); | ||
| 37 | String::from_utf8(output.stdout).unwrap() | ||
| 38 | } | ||
| 39 | |||
| 40 | fn stdout(output: &Output) -> String { | ||
| 41 | String::from_utf8_lossy(&output.stdout).to_string() | ||
| 42 | } | ||
| 43 | |||
| 44 | fn stderr(output: &Output) -> String { | ||
| 45 | String::from_utf8_lossy(&output.stderr).to_string() | ||
| 46 | } | ||
| 47 | |||
| 48 | /// Run `git-collab-server setup` with the given extra arguments. | ||
| 49 | fn setup(config: &Path, args: &[&str]) -> Output { | ||
| 50 | let mut command = Command::new(env!("CARGO_BIN_EXE_git-collab-server")); | ||
| 51 | command.args(["setup", "--config", config.to_str().unwrap()]); | ||
| 52 | command.args(args); | ||
| 53 | command.output().expect("failed to run git-collab-server") | ||
| 54 | } | ||
| 55 | |||
| 56 | fn assert_ok(output: &Output, context: &str) { | ||
| 57 | assert!( | ||
| 58 | output.status.success(), | ||
| 59 | "{context}: expected success, got {:?}\nstdout: {}\nstderr: {}", | ||
| 60 | output.status.code(), | ||
| 61 | stdout(output), | ||
| 62 | stderr(output) | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | |||
| 66 | fn assert_failed(output: &Output, context: &str) { | ||
| 67 | assert!( | ||
| 68 | !output.status.success(), | ||
| 69 | "{context}: expected a refusal, but it succeeded\nstdout: {}", | ||
| 70 | stdout(output) | ||
| 71 | ); | ||
| 72 | } | ||
| 73 | |||
| 74 | /// Create a bare repository under the server's repos dir, with the given | ||
| 75 | /// `server.toml` body (empty for none at all). | ||
| 76 | fn make_repo(repos_dir: &Path, name: &str, server_toml: &str) { | ||
| 77 | let path = repos_dir.join(format!("{name}.git")); | ||
| 78 | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); | ||
| 79 | git_in(repos_dir, &["init", "-q", "--bare", path.to_str().unwrap()]); | ||
| 80 | if !server_toml.is_empty() { | ||
| 81 | let collab = path.join(".collab"); | ||
| 82 | std::fs::create_dir_all(&collab).unwrap(); | ||
| 83 | std::fs::write(collab.join("server.toml"), server_toml).unwrap(); | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | /// What an unauthenticated request may see of a repository, asked of the real | ||
| 88 | /// server rather than of the code that decides it. | ||
| 89 | #[derive(Debug, PartialEq, Eq)] | ||
| 90 | struct Exposed { | ||
| 91 | listed: bool, | ||
| 92 | readable: bool, | ||
| 93 | clonable: bool, | ||
| 94 | } | ||
| 95 | |||
| 96 | fn exposure_of(harness: &ServerHarness, repo: &str) -> Exposed { | ||
| 97 | Exposed { | ||
| 98 | listed: harness.get_ok("/").body.contains(repo), | ||
| 99 | readable: harness.get(&format!("/{repo}")).status_line.contains("200"), | ||
| 100 | clonable: harness | ||
| 101 | .get(&format!("/{repo}.git/info/refs?service=git-upload-pack")) | ||
| 102 | .status_line | ||
| 103 | .contains("200"), | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | /// The repositories the exposure test stands up, and the policy each carries. | ||
| 108 | /// | ||
| 109 | /// Deliberately mixed, and deliberately reaching the same closed state by two | ||
| 110 | /// different routes (`visibility` and the two `anonymous` switches), because | ||
| 111 | /// the seed reads the *effective* policy rather than any one key. | ||
| 112 | const REPOS: &[(&str, &str)] = &[ | ||
| 113 | // No server.toml at all: the default, and public. | ||
| 114 | ("alpha", ""), | ||
| 115 | ("bravo-private", "visibility = \"private\"\n"), | ||
| 116 | ( | ||
| 117 | "charlie-quiet", | ||
| 118 | "visibility = \"public\"\n[ui]\nanonymous = false\n[http]\nanonymous_clone = false\n", | ||
| 119 | ), | ||
| 120 | // Public to anonymous readers, but its authenticated access is an | ||
| 121 | // explicit roster of fingerprints rather than the default `*`. | ||
| 122 | ( | ||
| 123 | "delta-restricted", | ||
| 124 | "visibility = \"public\"\n[access]\nread = [\"key:SHA256:somebody\"]\nwrite = [\"key:SHA256:somebody\"]\n", | ||
| 125 | ), | ||
| 126 | // Nested, because a repo key keeps its `/` and the rule has to match it. | ||
| 127 | ("nested/echo", ""), | ||
| 128 | ]; | ||
| 129 | |||
| 130 | fn names() -> Vec<&'static str> { | ||
| 131 | let mut names: Vec<&'static str> = REPOS.iter().map(|(name, _)| *name).collect(); | ||
| 132 | names.push("hosted"); | ||
| 133 | names | ||
| 134 | } | ||
| 135 | |||
| 136 | /// Stand up a server with the mixed repository set above. | ||
| 137 | fn mixed_server() -> ServerHarness { | ||
| 138 | let harness = ServerHarness::new("hosted"); | ||
| 139 | let repos_dir = harness.repos_dir(); | ||
| 140 | for (name, server_toml) in REPOS { | ||
| 141 | make_repo(&repos_dir, name, server_toml); | ||
| 142 | } | ||
| 143 | harness | ||
| 144 | } | ||
| 145 | |||
| 146 | /// An ed25519 public key file for the admin, under the harness root. | ||
| 147 | fn admin_key(harness: &ServerHarness, name: &str) -> std::path::PathBuf { | ||
| 148 | harness.named_key(name).with_extension("pub") | ||
| 149 | } | ||
| 150 | |||
| 151 | // --------------------------------------------------------------------------- | ||
| 152 | // The test that matters | ||
| 153 | // --------------------------------------------------------------------------- | ||
| 154 | |||
| 155 | /// Enabling governance is behaviourally a no-op. | ||
| 156 | /// | ||
| 157 | /// This is the entire safety claim, and the reason `setup` may be run on a | ||
| 158 | /// live server at all: the seed reproduces the effective policy, so the same | ||
| 159 | /// repositories are listed, the same ones are readable and clonable by an | ||
| 160 | /// anonymous request, and the same ones are refused — before and after. | ||
| 161 | #[test] | ||
| 162 | fn setup_leaves_the_anonymous_exposure_of_every_repository_unchanged() { | ||
| 163 | let harness = mixed_server(); | ||
| 164 | harness.push_head(); | ||
| 165 | |||
| 166 | let before: Vec<(&str, Exposed)> = names() | ||
| 167 | .into_iter() | ||
| 168 | .map(|name| (name, exposure_of(&harness, name))) | ||
| 169 | .collect(); | ||
| 170 | |||
| 171 | // Sanity: the fixture is only worth anything if it is actually mixed. | ||
| 172 | assert!( | ||
| 173 | before.iter().any(|(_, e)| e.readable) && before.iter().any(|(_, e)| !e.readable), | ||
| 174 | "the fixture must contain both exposed and hidden repositories, got {before:?}" | ||
| 175 | ); | ||
| 176 | |||
| 177 | let output = setup( | ||
| 178 | &harness.config_path(), | ||
| 179 | &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()], | ||
| 180 | ); | ||
| 181 | assert_ok(&output, "setup on a mixed server"); | ||
| 182 | |||
| 183 | // Governance really is in force now, or the assertion below is vacuous: | ||
| 184 | // on an ungoverned server `settings` would be listed like anything else. | ||
| 185 | assert!( | ||
| 186 | harness.repos_dir().join("settings.git").exists(), | ||
| 187 | "setup must create settings.git" | ||
| 188 | ); | ||
| 189 | assert_eq!( | ||
| 190 | exposure_of(&harness, "settings"), | ||
| 191 | Exposed { | ||
| 192 | listed: false, | ||
| 193 | readable: false, | ||
| 194 | clonable: false | ||
| 195 | }, | ||
| 196 | "the governance repository must not be on the anonymous surface: \ | ||
| 197 | it holds the key roster" | ||
| 198 | ); | ||
| 199 | |||
| 200 | let after: Vec<(&str, Exposed)> = names() | ||
| 201 | .into_iter() | ||
| 202 | .map(|name| (name, exposure_of(&harness, name))) | ||
| 203 | .collect(); | ||
| 204 | |||
| 205 | assert_eq!( | ||
| 206 | before, after, | ||
| 207 | "enabling governance changed what the server exposes" | ||
| 208 | ); | ||
| 209 | } | ||
| 210 | |||
| 211 | /// Both files land in one commit. | ||
| 212 | /// | ||
| 213 | /// A roster pushed without rules is refused by the push-time guard, and for | ||
| 214 | /// good reason — it governs nothing while publishing who has access. Setup | ||
| 215 | /// must not hand-create by another route the state that guard exists to | ||
| 216 | /// prevent. | ||
| 217 | #[test] | ||
| 218 | fn the_seed_is_a_single_commit_holding_both_the_rules_and_the_roster() { | ||
| 219 | let harness = mixed_server(); | ||
| 220 | let output = setup( | ||
| 221 | &harness.config_path(), | ||
| 222 | &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()], | ||
| 223 | ); | ||
| 224 | assert_ok(&output, "setup"); | ||
| 225 | |||
| 226 | let settings = harness.repos_dir().join("settings.git"); | ||
| 227 | let count = git_in(&settings, &["rev-list", "--count", "HEAD"]); | ||
| 228 | assert_eq!(count.trim(), "1", "the seed must be exactly one commit"); | ||
| 229 | |||
| 230 | let files = git_in(&settings, &["show", "--name-only", "--format=", "HEAD"]); | ||
| 231 | let mut paths: Vec<&str> = files.lines().filter(|l| !l.is_empty()).collect(); | ||
| 232 | paths.sort(); | ||
| 233 | assert_eq!( | ||
| 234 | paths, | ||
| 235 | vec!["conf/access.conf", "keydir/alex.pub"], | ||
| 236 | "one commit must hold both halves; got {files}" | ||
| 237 | ); | ||
| 238 | } | ||
| 239 | |||
| 240 | /// The identity is the keydir basename, and getting it wrong locks the | ||
| 241 | /// operator out of the repository setup just created. So: it is reported, and | ||
| 242 | /// it actually works — the admin can push to `settings` over SSH afterwards, | ||
| 243 | /// authenticating through `keydir/` rather than `authorized_keys`. | ||
| 244 | #[test] | ||
| 245 | fn the_admin_it_names_can_push_the_settings_repository_afterwards() { | ||
| 246 | let harness = mixed_server(); | ||
| 247 | let key = harness.named_key("alex"); | ||
| 248 | let output = setup( | ||
| 249 | &harness.config_path(), | ||
| 250 | &["--admin-key", key.with_extension("pub").to_str().unwrap()], | ||
| 251 | ); | ||
| 252 | assert_ok(&output, "setup"); | ||
| 253 | assert!( | ||
| 254 | stdout(&output).contains("alex"), | ||
| 255 | "setup must report the identity it derived; got {}", | ||
| 256 | stdout(&output) | ||
| 257 | ); | ||
| 258 | |||
| 259 | // Clone over the filesystem, change the rules, push over SSH as the admin. | ||
| 260 | let work = harness.scratch("admin-work"); | ||
| 261 | git_in( | ||
| 262 | &work, | ||
| 263 | &[ | ||
| 264 | "clone", | ||
| 265 | "-q", | ||
| 266 | harness.repos_dir().join("settings.git").to_str().unwrap(), | ||
| 267 | ".", | ||
| 268 | ], | ||
| 269 | ); | ||
| 270 | git_in(&work, &["config", "user.email", "alex@example.com"]); | ||
| 271 | git_in(&work, &["config", "user.name", "Alex"]); | ||
| 272 | let conf = work.join("conf").join("access.conf"); | ||
| 273 | let text = std::fs::read_to_string(&conf).unwrap(); | ||
| 274 | std::fs::write(&conf, format!("{text}\n# an edit by the admin\n")).unwrap(); | ||
| 275 | git_in(&work, &["add", "-A"]); | ||
| 276 | git_in(&work, &["commit", "-q", "-m", "edit"]); | ||
| 277 | |||
| 278 | let push = harness.ssh_push_from(&work, &key, "settings", "HEAD:refs/heads/main"); | ||
| 279 | assert!( | ||
| 280 | push.status.success(), | ||
| 281 | "the admin setup enrolled must be able to push settings; got {}", | ||
| 282 | stderr(&push) | ||
| 283 | ); | ||
| 284 | } | ||
| 285 | |||
| 286 | // --------------------------------------------------------------------------- | ||
| 287 | // Refusals and the dry run | ||
| 288 | // --------------------------------------------------------------------------- | ||
| 289 | |||
| 290 | /// An operator's rules are not something to overwrite. | ||
| 291 | #[test] | ||
| 292 | fn setup_refuses_when_a_settings_repository_already_exists() { | ||
| 293 | let harness = mixed_server(); | ||
| 294 | let key = admin_key(&harness, "alex"); | ||
| 295 | assert_ok( | ||
| 296 | &setup( | ||
| 297 | &harness.config_path(), | ||
| 298 | &["--admin-key", key.to_str().unwrap()], | ||
| 299 | ), | ||
| 300 | "the first setup", | ||
| 301 | ); | ||
| 302 | |||
| 303 | let settings = harness.repos_dir().join("settings.git"); | ||
| 304 | let before = git_in(&settings, &["rev-parse", "HEAD"]); | ||
| 305 | |||
| 306 | let second = setup( | ||
| 307 | &harness.config_path(), | ||
| 308 | &["--admin-key", key.to_str().unwrap()], | ||
| 309 | ); | ||
| 310 | assert_failed(&second, "a second setup"); | ||
| 311 | assert!( | ||
| 312 | stderr(&second).contains("settings.git"), | ||
| 313 | "the refusal must name what is in the way; got {}", | ||
| 314 | stderr(&second) | ||
| 315 | ); | ||
| 316 | assert_eq!( | ||
| 317 | before, | ||
| 318 | git_in(&settings, &["rev-parse", "HEAD"]), | ||
| 319 | "a refused setup must not touch the existing rules" | ||
| 320 | ); | ||
| 321 | } | ||
| 322 | |||
| 323 | /// An operator should be able to read the policy before it governs their | ||
| 324 | /// server. | ||
| 325 | #[test] | ||
| 326 | fn dry_run_prints_the_access_conf_and_changes_nothing() { | ||
| 327 | let harness = mixed_server(); | ||
| 328 | let output = setup( | ||
| 329 | &harness.config_path(), | ||
| 330 | &[ | ||
| 331 | "--admin-key", | ||
| 332 | admin_key(&harness, "alex").to_str().unwrap(), | ||
| 333 | "--dry-run", | ||
| 334 | ], | ||
| 335 | ); | ||
| 336 | assert_ok(&output, "a dry run"); | ||
| 337 | |||
| 338 | let printed = stdout(&output); | ||
| 339 | assert!( | ||
| 340 | printed.contains("repo settings") && printed.contains("RW+"), | ||
| 341 | "the dry run must print the rules that would govern; got {printed}" | ||
| 342 | ); | ||
| 343 | for (name, _) in REPOS { | ||
| 344 | assert!( | ||
| 345 | printed.contains(&format!("repo {name}")), | ||
| 346 | "the dry run must show a block for {name}; got {printed}" | ||
| 347 | ); | ||
| 348 | } | ||
| 349 | assert!( | ||
| 350 | !harness.repos_dir().join("settings.git").exists(), | ||
| 351 | "a dry run must not create anything" | ||
| 352 | ); | ||
| 353 | |||
| 354 | // And the server is still ungoverned: everything it listed, it still | ||
| 355 | // lists. | ||
| 356 | assert!(exposure_of(&harness, "alpha").listed); | ||
| 357 | } | ||
| 358 | |||
| 359 | /// The generated rules reproduce the anonymous axis per repository, in the | ||
| 360 | /// spelling the design uses. Checked as text as well as through the server: | ||
| 361 | /// the operator reads this file, so its contents are part of the contract. | ||
| 362 | #[test] | ||
| 363 | fn the_generated_rules_publish_exactly_the_repositories_that_are_public_today() { | ||
| 364 | let harness = mixed_server(); | ||
| 365 | let output = setup( | ||
| 366 | &harness.config_path(), | ||
| 367 | &[ | ||
| 368 | "--admin-key", | ||
| 369 | admin_key(&harness, "alex").to_str().unwrap(), | ||
| 370 | "--dry-run", | ||
| 371 | ], | ||
| 372 | ); | ||
| 373 | assert_ok(&output, "a dry run"); | ||
| 374 | let printed = stdout(&output); | ||
| 375 | |||
| 376 | /// The lines of the `repo <name>` block, up to the next `repo` line. | ||
| 377 | fn block_of<'a>(conf: &'a str, name: &str) -> Vec<&'a str> { | ||
| 378 | conf.lines() | ||
| 379 | .skip_while(|line| line.trim() != format!("repo {name}")) | ||
| 380 | .skip(1) | ||
| 381 | .take_while(|line| !line.trim_start().starts_with("repo ")) | ||
| 382 | .map(|line| line.trim()) | ||
| 383 | .filter(|line| !line.is_empty() && !line.starts_with('#')) | ||
| 384 | .collect() | ||
| 385 | } | ||
| 386 | |||
| 387 | // Public today: reachable by name, and advertised. | ||
| 388 | let alpha = block_of(&printed, "alpha").join("\n"); | ||
| 389 | assert!( | ||
| 390 | alpha.contains("R = @anonymous") | ||
| 391 | || alpha.contains("= @anonymous") | ||
| 392 | || alpha.contains("= @anonymous"), | ||
| 393 | "a public repository must keep its anonymous read grant; got {alpha}" | ||
| 394 | ); | ||
| 395 | assert!( | ||
| 396 | alpha.contains("listed") && alpha.contains("yes"), | ||
| 397 | "and stay listed; got {alpha}" | ||
| 398 | ); | ||
| 399 | |||
| 400 | // Private today: no anonymous grant at all, in either spelling. | ||
| 401 | let private = block_of(&printed, "bravo-private").join("\n"); | ||
| 402 | assert!( | ||
| 403 | !private.contains("@anonymous"), | ||
| 404 | "a private repository must not gain an anonymous grant; got {private}" | ||
| 405 | ); | ||
| 406 | assert!(!private.contains("listed"), "nor be listed; got {private}"); | ||
| 407 | |||
| 408 | // Public but with both anonymous switches off: same closed result, by a | ||
| 409 | // different route through server.toml. | ||
| 410 | let quiet = block_of(&printed, "charlie-quiet").join("\n"); | ||
| 411 | assert!( | ||
| 412 | !quiet.contains("@anonymous") && !quiet.contains("listed"), | ||
| 413 | "an unlisted repository must stay unlisted; got {quiet}" | ||
| 414 | ); | ||
| 415 | |||
| 416 | // The settings repository grants the admin RW+ and nothing anonymous. | ||
| 417 | let settings = block_of(&printed, "settings").join("\n"); | ||
| 418 | assert!( | ||
| 419 | settings.contains("RW+") && settings.contains("alex"), | ||
| 420 | "the admin must hold RW+ on settings; got {settings}" | ||
| 421 | ); | ||
| 422 | assert!( | ||
| 423 | !settings.contains("@anonymous"), | ||
| 424 | "the key roster must not be published; got {settings}" | ||
| 425 | ); | ||
| 426 | } | ||
| 427 | |||
| 428 | // --------------------------------------------------------------------------- | ||
| 429 | // The admin key | ||
| 430 | // --------------------------------------------------------------------------- | ||
| 431 | |||
| 432 | #[test] | ||
| 433 | fn a_malformed_admin_key_is_refused_and_nothing_is_created() { | ||
| 434 | let harness = mixed_server(); | ||
| 435 | let bad = harness.scratch("keys").join("broken.pub"); | ||
| 436 | std::fs::write(&bad, "ssh-ed25519 this-is-not-base64 alex@laptop\n").unwrap(); | ||
| 437 | |||
| 438 | let output = setup( | ||
| 439 | &harness.config_path(), | ||
| 440 | &["--admin-key", bad.to_str().unwrap()], | ||
| 441 | ); | ||
| 442 | assert_failed(&output, "a malformed key"); | ||
| 443 | assert!( | ||
| 444 | stderr(&output).to_lowercase().contains("key"), | ||
| 445 | "the error must say the key is the problem; got {}", | ||
| 446 | stderr(&output) | ||
| 447 | ); | ||
| 448 | assert!( | ||
| 449 | !harness.repos_dir().join("settings.git").exists(), | ||
| 450 | "a failed setup must leave nothing behind" | ||
| 451 | ); | ||
| 452 | } | ||
| 453 | |||
| 454 | #[test] | ||
| 455 | fn a_missing_admin_key_is_refused_and_nothing_is_created() { | ||
| 456 | let harness = mixed_server(); | ||
| 457 | let missing = harness.repos_dir().join("nowhere").join("absent.pub"); | ||
| 458 | |||
| 459 | let output = setup( | ||
| 460 | &harness.config_path(), | ||
| 461 | &["--admin-key", missing.to_str().unwrap()], | ||
| 462 | ); | ||
| 463 | assert_failed(&output, "a missing key file"); | ||
| 464 | assert!( | ||
| 465 | stderr(&output).contains("absent.pub"), | ||
| 466 | "the error must name the file it could not read; got {}", | ||
| 467 | stderr(&output) | ||
| 468 | ); | ||
| 469 | assert!(!harness.repos_dir().join("settings.git").exists()); | ||
| 470 | } | ||
| 471 | |||
| 472 | /// The keydir basename *is* the principal, so a name that cannot be written | ||
| 473 | /// in `conf/access.conf` has to be refused rather than written and discovered | ||
| 474 | /// later, when it is the reason nobody can push. | ||
| 475 | #[test] | ||
| 476 | fn a_key_whose_filename_cannot_name_a_principal_is_refused() { | ||
| 477 | let harness = mixed_server(); | ||
| 478 | let source = std::fs::read_to_string(admin_key(&harness, "alex")).unwrap(); | ||
| 479 | let reserved = harness.scratch("keys").join("CREATOR.pub"); | ||
| 480 | std::fs::write(&reserved, &source).unwrap(); | ||
| 481 | |||
| 482 | let output = setup( | ||
| 483 | &harness.config_path(), | ||
| 484 | &["--admin-key", reserved.to_str().unwrap()], | ||
| 485 | ); | ||
| 486 | assert_failed(&output, "a key named for a reserved keyword"); | ||
| 487 | assert!( | ||
| 488 | stderr(&output).contains("--admin-name"), | ||
| 489 | "the refusal must point at the way out; got {}", | ||
| 490 | stderr(&output) | ||
| 491 | ); | ||
| 492 | assert!(!harness.repos_dir().join("settings.git").exists()); | ||
| 493 | } | ||
| 494 | |||
| 495 | /// `--admin-name` overrides the filename, which is also the only way to name | ||
| 496 | /// an identity when the key arrives on stdin. | ||
| 497 | #[test] | ||
| 498 | fn the_admin_name_can_be_given_explicitly_and_read_from_stdin() { | ||
| 499 | let harness = mixed_server(); | ||
| 500 | let output = setup( | ||
| 501 | &harness.config_path(), | ||
| 502 | &[ | ||
| 503 | "--admin-key", | ||
| 504 | admin_key(&harness, "alex").to_str().unwrap(), | ||
| 505 | "--admin-name", | ||
| 506 | "ops", | ||
| 507 | ], | ||
| 508 | ); | ||
| 509 | assert_ok(&output, "setup with an explicit name"); | ||
| 510 | assert!( | ||
| 511 | harness.repos_dir().join("settings.git").exists(), | ||
| 512 | "setup must have run" | ||
| 513 | ); | ||
| 514 | let files = git_in( | ||
| 515 | &harness.repos_dir().join("settings.git"), | ||
| 516 | &["show", "--name-only", "--format=", "HEAD"], | ||
| 517 | ); | ||
| 518 | assert!( | ||
| 519 | files.contains("keydir/ops.pub"), | ||
| 520 | "the explicit name must decide the keydir basename; got {files}" | ||
| 521 | ); | ||
| 522 | } | ||
| 523 | |||
| 524 | #[test] | ||
| 525 | fn the_admin_key_may_be_read_from_stdin() { | ||
| 526 | use std::io::Write; | ||
| 527 | use std::process::Stdio; | ||
| 528 | |||
| 529 | let harness = mixed_server(); | ||
| 530 | let key = std::fs::read_to_string(admin_key(&harness, "alex")).unwrap(); | ||
| 531 | |||
| 532 | let mut child = Command::new(env!("CARGO_BIN_EXE_git-collab-server")) | ||
| 533 | .args([ | ||
| 534 | "setup", | ||
| 535 | "--config", | ||
| 536 | harness.config_path().to_str().unwrap(), | ||
| 537 | "--admin-key", | ||
| 538 | "-", | ||
| 539 | "--admin-name", | ||
| 540 | "ops", | ||
| 541 | ]) | ||
| 542 | .stdin(Stdio::piped()) | ||
| 543 | .stdout(Stdio::piped()) | ||
| 544 | .stderr(Stdio::piped()) | ||
| 545 | .spawn() | ||
| 546 | .expect("failed to spawn git-collab-server"); | ||
| 547 | child | ||
| 548 | .stdin | ||
| 549 | .take() | ||
| 550 | .unwrap() | ||
| 551 | .write_all(key.as_bytes()) | ||
| 552 | .unwrap(); | ||
| 553 | let output = child.wait_with_output().unwrap(); | ||
| 554 | assert_ok(&output, "setup reading the key from stdin"); | ||
| 555 | assert!(harness.repos_dir().join("settings.git").exists()); | ||
| 556 | } | ||
| 557 | |||
| 558 | /// Reading a key from stdin with no name to give it cannot guess, and must | ||
| 559 | /// say so rather than inventing one. | ||
| 560 | #[test] | ||
| 561 | fn a_key_on_stdin_with_no_name_is_refused() { | ||
| 562 | use std::io::Write; | ||
| 563 | use std::process::Stdio; | ||
| 564 | |||
| 565 | let harness = mixed_server(); | ||
| 566 | let key = std::fs::read_to_string(admin_key(&harness, "alex")).unwrap(); | ||
| 567 | |||
| 568 | let mut child = Command::new(env!("CARGO_BIN_EXE_git-collab-server")) | ||
| 569 | .args([ | ||
| 570 | "setup", | ||
| 571 | "--config", | ||
| 572 | harness.config_path().to_str().unwrap(), | ||
| 573 | "--admin-key", | ||
| 574 | "-", | ||
| 575 | ]) | ||
| 576 | .stdin(Stdio::piped()) | ||
| 577 | .stdout(Stdio::piped()) | ||
| 578 | .stderr(Stdio::piped()) | ||
| 579 | .spawn() | ||
| 580 | .expect("failed to spawn git-collab-server"); | ||
| 581 | child | ||
| 582 | .stdin | ||
| 583 | .take() | ||
| 584 | .unwrap() | ||
| 585 | .write_all(key.as_bytes()) | ||
| 586 | .unwrap(); | ||
| 587 | let output = child.wait_with_output().unwrap(); | ||
| 588 | assert_failed(&output, "a key on stdin with no name"); | ||
| 589 | assert!( | ||
| 590 | stderr(&output).contains("--admin-name"), | ||
| 591 | "the refusal must name the flag that fixes it; got {}", | ||
| 592 | stderr(&output) | ||
| 593 | ); | ||
| 594 | } | ||
| 595 | |||
| 596 | /// `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 | ||
| 598 | /// the server exactly as they left it. | ||
| 599 | #[test] | ||
| 600 | fn setup_does_not_touch_the_authorized_keys_file() { | ||
| 601 | let harness = mixed_server(); | ||
| 602 | // The harness writes the file when a client key is generated. | ||
| 603 | let key = harness.ssh_client_key(); | ||
| 604 | let authorized = std::fs::read_to_string(key.with_file_name("authorized_keys")).unwrap(); | ||
| 605 | |||
| 606 | assert_ok( | ||
| 607 | &setup( | ||
| 608 | &harness.config_path(), | ||
| 609 | &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()], | ||
| 610 | ), | ||
| 611 | "setup", | ||
| 612 | ); | ||
| 613 | |||
| 614 | assert_eq!( | ||
| 615 | authorized, | ||
| 616 | std::fs::read_to_string(key.with_file_name("authorized_keys")).unwrap(), | ||
| 617 | "setup must leave authorized_keys alone" | ||
| 618 | ); | ||
| 619 | } | ||