ea980317
Take the server's rules from a settings repository
a73x 2026-08-11 10:15
Commit message
Cargo.lock
| Old | New | ||
|---|---|---|---|
| @@ -1618,6 +1618,7 @@ dependencies = [ | |||
| 1618 | "pulldown-cmark", | 1618 | "pulldown-cmark", |
| 1619 | "rand_core 0.6.4", | 1619 | "rand_core 0.6.4", |
| 1620 | "ratatui", | 1620 | "ratatui", |
| 1621 | "regex", | ||
| 1621 | "russh", | 1622 | "russh", |
| 1622 | "serde", | 1623 | "serde", |
| 1623 | "serde_json", | 1624 | "serde_json", |
Cargo.toml
| Old | New | ||
|---|---|---|---|
| @@ -43,6 +43,10 @@ toml_edit = { version = "0.22", features = ["serde"] } | |||
| 43 | tracing = "0.1" | 43 | tracing = "0.1" |
| 44 | tracing-subscriber = "0.3" | 44 | tracing-subscriber = "0.3" |
| 45 | sha2 = "0.10" | 45 | sha2 = "0.10" |
| 46 | # Access rules and repo patterns in settings.git are regexes written by whoever | ||
| 47 | # can push the config, so the engine must be one with a linear-time guarantee | ||
| 48 | # rather than a backtracker. Already in the tree via tracing-subscriber. | ||
| 49 | regex = "1" | ||
| 46 | tempfile = "3" | 50 | tempfile = "3" |
| 47 | tokio-util = { version = "0.7", features = ["io"] } | 51 | tokio-util = { version = "0.7", features = ["io"] } |
| 48 | 52 | ||
README.md
| Old | New | ||
|---|---|---|---|
| @@ -356,6 +356,80 @@ policy before serving anything you care about. | |||
| 356 | 356 | ||
| 357 | `make docker` builds a container image. | 357 | `make docker` builds a container image. |
| 358 | 358 | ||
| 359 | ### Governance | ||
| 360 | |||
| 361 | Create a repository called `settings` under `repos_dir` and the server takes its | ||
| 362 | rules from there instead. Pushing to it reconfigures the server; there is no | ||
| 363 | reload and no restart. | ||
| 364 | |||
| 365 | ```text | ||
| 366 | settings.git | ||
| 367 | ├── conf/access.conf | ||
| 368 | └── keydir/ | ||
| 369 | ├── laptop/alex.pub | ||
| 370 | ├── desktop/alex.pub | ||
| 371 | └── claude-a.pub | ||
| 372 | ``` | ||
| 373 | |||
| 374 | A principal's name is the **basename** of its key file, directories ignored — so | ||
| 375 | `laptop/alex.pub` and `desktop/alex.pub` are both `alex`, which is how one person | ||
| 376 | adds a second machine. | ||
| 377 | |||
| 378 | ```text | ||
| 379 | @admins = alex | ||
| 380 | @agents = claude-a claude-b | ||
| 381 | |||
| 382 | repo settings | ||
| 383 | RW+ = @admins | ||
| 384 | |||
| 385 | repo tools | ||
| 386 | RW+ = @admins | ||
| 387 | RW refs/collab/ = @agents | ||
| 388 | R = @all | ||
| 389 | |||
| 390 | repo agents/[a-z-]+ | ||
| 391 | C = @agents | ||
| 392 | RW+ = CREATOR | ||
| 393 | ``` | ||
| 394 | |||
| 395 | Rules are ordered and **first-match-wins**, over refexes, with `R` / `RW` / `RW+` | ||
| 396 | to allow and `-` to deny. A refex is a regex anchored at the start; omitted it | ||
| 397 | means every ref, and one that does not begin with `refs/` is read as being under | ||
| 398 | `refs/heads/`. `C` grants creating a repository, which is what makes the wild | ||
| 399 | pattern above work: an agent allocates its own namespace and owns it, with no | ||
| 400 | central allocator. | ||
| 401 | |||
| 402 | Note what the `@agents` line does *not* say. A contributor needs write access to | ||
| 403 | `refs/collab/*` and to nothing else — patches travel as collab refs, so no | ||
| 404 | setting anywhere grants an agent credential the ability to move a branch. | ||
| 405 | |||
| 406 | Every push to `settings` is validated before it is accepted: the rules must | ||
| 407 | parse, the keys must be well-formed, and somebody must still be able to push the | ||
| 408 | config afterwards. A push failing any of those is rejected and the previous | ||
| 409 | config keeps governing, so the live config is only ever one that works. | ||
| 410 | |||
| 411 | **How this interacts with `server.toml`.** The two are split by axis, and the | ||
| 412 | split is total: | ||
| 413 | |||
| 414 | | Question | Governed | Ungoverned | | ||
| 415 | |---|---|---| | ||
| 416 | | What may an authenticated principal do? | `conf/access.conf` | `server.toml`'s `[access]` | | ||
| 417 | | Which keys authenticate at all? | `keydir/` | the `authorized_keys` file | | ||
| 418 | | What may an anonymous HTTP request see? | `server.toml` | `server.toml` | | ||
| 419 | |||
| 420 | When `settings.git` exists, `access.conf` **supersedes** `server.toml`'s | ||
| 421 | `[access]` outright — those lists are not consulted, not intersected, not | ||
| 422 | unioned — and `keydir/` supersedes `authorized_keys`. Superseding rather than | ||
| 423 | layering means exactly one file answers each question, so the two can never | ||
| 424 | disagree in a way that is invisible in the file you are reading. | ||
| 425 | |||
| 426 | `visibility`, `[ui] anonymous` and `[http] anonymous_clone` stay in | ||
| 427 | `server.toml`, because an anonymous request has no principal for a rule to | ||
| 428 | match: `@all` means every enrolled key, not the public. | ||
| 429 | |||
| 430 | With no `settings` repository, none of this is on and the server behaves exactly | ||
| 431 | as it did before. | ||
| 432 | |||
| 359 | ### Releases | 433 | ### Releases |
| 360 | 434 | ||
| 361 | ```console | 435 | ```console |
src/server/governance/conf.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,923 @@ | |||
| 1 | //! Parser and evaluator for `conf/access.conf`, the ordered access rules held | ||
| 2 | //! in `settings.git`. | ||
| 3 | //! | ||
| 4 | //! The syntax and semantics follow gitolite, because the point of the format is | ||
| 5 | //! that an operator who knows gitolite already knows this: | ||
| 6 | //! | ||
| 7 | //! ```text | ||
| 8 | //! @admins = alex | ||
| 9 | //! @agents = claude-a claude-b | ||
| 10 | //! | ||
| 11 | //! repo tools | ||
| 12 | //! RW+ = @admins | ||
| 13 | //! RW refs/collab/ = @agents | ||
| 14 | //! R = @all | ||
| 15 | //! ``` | ||
| 16 | //! | ||
| 17 | //! Evaluation is **ordered, first-match-wins**. Rules are gathered from every | ||
| 18 | //! `repo` block matching the repository, in file order, filtered to the | ||
| 19 | //! accessing principal, and the first rule whose refex matches the ref decides: | ||
| 20 | //! `-` denies, a permission containing the requested access allows, anything | ||
| 21 | //! else falls through to the next rule. Order is therefore load-bearing, which | ||
| 22 | //! is why this is line-oriented rather than TOML. | ||
| 23 | |||
| 24 | use std::collections::HashMap; | ||
| 25 | |||
| 26 | use regex::Regex; | ||
| 27 | |||
| 28 | /// The access being requested. | ||
| 29 | /// | ||
| 30 | /// These are the letters a rule's permission is tested against, so the mapping | ||
| 31 | /// from permission to access is literal containment: `RW+` grants `R`, `W` and | ||
| 32 | /// `+` and nothing else. | ||
| 33 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 34 | pub enum Access { | ||
| 35 | /// Fetch, clone, list releases. | ||
| 36 | Read, | ||
| 37 | /// Fast-forward a ref, create a ref. | ||
| 38 | Write, | ||
| 39 | /// Rewind or delete a ref; upload or delete a release. | ||
| 40 | Rewind, | ||
| 41 | /// Create the repository itself (wild repos). | ||
| 42 | Create, | ||
| 43 | } | ||
| 44 | |||
| 45 | /// A rule's permission. Exactly the set the design names, plus `-` for deny. | ||
| 46 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 47 | pub enum Perm { | ||
| 48 | Deny, | ||
| 49 | R, | ||
| 50 | Rw, | ||
| 51 | RwPlus, | ||
| 52 | C, | ||
| 53 | } | ||
| 54 | |||
| 55 | impl Perm { | ||
| 56 | fn parse(token: &str) -> Option<Perm> { | ||
| 57 | match token { | ||
| 58 | "-" => Some(Perm::Deny), | ||
| 59 | "R" => Some(Perm::R), | ||
| 60 | "RW" => Some(Perm::Rw), | ||
| 61 | "RW+" => Some(Perm::RwPlus), | ||
| 62 | "C" => Some(Perm::C), | ||
| 63 | _ => None, | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | /// Whether this permission grants `access`. | ||
| 68 | /// | ||
| 69 | /// `C` is deliberately disjoint from `RW+`: creating a repository is not | ||
| 70 | /// implied by full control over one that already exists, which is what | ||
| 71 | /// lets `C = @agents` / `RW+ = CREATOR` mean what it says. | ||
| 72 | pub fn grants(self, access: Access) -> bool { | ||
| 73 | match self { | ||
| 74 | Perm::Deny => false, | ||
| 75 | Perm::R => access == Access::Read, | ||
| 76 | Perm::Rw => matches!(access, Access::Read | Access::Write), | ||
| 77 | Perm::RwPlus => matches!(access, Access::Read | Access::Write | Access::Rewind), | ||
| 78 | Perm::C => access == Access::Create, | ||
| 79 | } | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | /// Who is asking, and of which repository. | ||
| 84 | #[derive(Debug, Clone, Copy)] | ||
| 85 | pub struct Subject<'a> { | ||
| 86 | /// The principal's name — the basename of its key file in `keydir/`. | ||
| 87 | pub name: &'a str, | ||
| 88 | /// The recorded creator of the repository being accessed, if it has one. | ||
| 89 | /// `CREATOR` in a rule's user list matches only when this equals `name`. | ||
| 90 | pub creator: Option<&'a str>, | ||
| 91 | } | ||
| 92 | |||
| 93 | impl<'a> Subject<'a> { | ||
| 94 | pub fn new(name: &'a str) -> Self { | ||
| 95 | Self { | ||
| 96 | name, | ||
| 97 | creator: None, | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | pub fn with_creator(name: &'a str, creator: Option<&'a str>) -> Self { | ||
| 102 | Self { name, creator } | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | #[derive(Debug, thiserror::Error)] | ||
| 107 | pub enum ConfError { | ||
| 108 | #[error("line {line}: {message}")] | ||
| 109 | Syntax { line: usize, message: String }, | ||
| 110 | } | ||
| 111 | |||
| 112 | impl ConfError { | ||
| 113 | fn syntax(line: usize, message: impl Into<String>) -> Self { | ||
| 114 | ConfError::Syntax { | ||
| 115 | line, | ||
| 116 | message: message.into(), | ||
| 117 | } | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | #[derive(Debug)] | ||
| 122 | struct Rule { | ||
| 123 | perm: Perm, | ||
| 124 | refex: Regex, | ||
| 125 | users: Vec<String>, | ||
| 126 | } | ||
| 127 | |||
| 128 | #[derive(Debug)] | ||
| 129 | struct Block { | ||
| 130 | patterns: Vec<RepoPattern>, | ||
| 131 | rules: Vec<Rule>, | ||
| 132 | } | ||
| 133 | |||
| 134 | #[derive(Debug)] | ||
| 135 | enum RepoPattern { | ||
| 136 | /// A plain name, matched byte-exactly. | ||
| 137 | Exact(String), | ||
| 138 | /// A pattern with regex metacharacters, anchored at both ends. | ||
| 139 | Wild(Regex), | ||
| 140 | /// `@all`, or a named group whose members are themselves patterns. | ||
| 141 | Group(String), | ||
| 142 | } | ||
| 143 | |||
| 144 | /// A parsed `conf/access.conf`. | ||
| 145 | #[derive(Debug, Default)] | ||
| 146 | pub struct AccessConf { | ||
| 147 | /// Group name (without `@`) to its members, fully expanded. | ||
| 148 | groups: HashMap<String, Vec<String>>, | ||
| 149 | blocks: Vec<Block>, | ||
| 150 | } | ||
| 151 | |||
| 152 | /// Characters allowed in a plain (non-wild) repository name or a principal | ||
| 153 | /// name. Anything else in a repo pattern makes it a regex. | ||
| 154 | fn is_plain_name(s: &str) -> bool { | ||
| 155 | !s.is_empty() | ||
| 156 | && s.chars() | ||
| 157 | .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | '@' | '+')) | ||
| 158 | } | ||
| 159 | |||
| 160 | /// A repo pattern is "wild" if it contains a regex metacharacter. | ||
| 161 | fn is_wild_pattern(s: &str) -> bool { | ||
| 162 | s.chars().any(|c| { | ||
| 163 | matches!( | ||
| 164 | c, | ||
| 165 | '[' | ']' | '*' | '?' | '(' | ')' | '|' | '\\' | '$' | '^' | ||
| 166 | ) | ||
| 167 | }) | ||
| 168 | } | ||
| 169 | |||
| 170 | /// Compile a refex the way gitolite does. | ||
| 171 | /// | ||
| 172 | /// An omitted refex is `refs/.*`. A refex that does not already name a ref | ||
| 173 | /// namespace is implicitly under `refs/heads/`. The result anchors at the | ||
| 174 | /// start but **not** at the end, so `main` also matches `refs/heads/maint`; | ||
| 175 | /// that is gitolite's documented behaviour and the design adopts it verbatim. | ||
| 176 | fn compile_refex(refex: Option<&str>, line: usize) -> Result<Regex, ConfError> { | ||
| 177 | let raw = refex.unwrap_or("refs/.*"); | ||
| 178 | let expanded = if raw.starts_with("refs/") || raw.starts_with("VREF/") { | ||
| 179 | raw.to_string() | ||
| 180 | } else { | ||
| 181 | format!("refs/heads/{raw}") | ||
| 182 | }; | ||
| 183 | Regex::new(&format!("^(?:{expanded})")) | ||
| 184 | .map_err(|e| ConfError::syntax(line, format!("invalid refex {raw:?}: {e}"))) | ||
| 185 | } | ||
| 186 | |||
| 187 | impl AccessConf { | ||
| 188 | /// Parse an access.conf. Every failure carries a line number, because this | ||
| 189 | /// message is what a rejected push shows the person who wrote the file. | ||
| 190 | pub fn parse(source: &str) -> Result<AccessConf, ConfError> { | ||
| 191 | let mut conf = AccessConf::default(); | ||
| 192 | // Raw group definitions, in order, so members may name earlier groups. | ||
| 193 | let mut current: Option<Block> = None; | ||
| 194 | |||
| 195 | for (index, raw_line) in source.lines().enumerate() { | ||
| 196 | let line = index + 1; | ||
| 197 | let text = strip_comment(raw_line).trim(); | ||
| 198 | if text.is_empty() { | ||
| 199 | continue; | ||
| 200 | } | ||
| 201 | |||
| 202 | if let Some(rest) = text.strip_prefix("repo ") { | ||
| 203 | if let Some(block) = current.take() { | ||
| 204 | conf.blocks.push(block); | ||
| 205 | } | ||
| 206 | let patterns = rest | ||
| 207 | .split_whitespace() | ||
| 208 | .map(|p| conf.compile_repo_pattern(p, line)) | ||
| 209 | .collect::<Result<Vec<_>, _>>()?; | ||
| 210 | if patterns.is_empty() { | ||
| 211 | return Err(ConfError::syntax(line, "`repo` needs at least one name")); | ||
| 212 | } | ||
| 213 | current = Some(Block { | ||
| 214 | patterns, | ||
| 215 | rules: Vec::new(), | ||
| 216 | }); | ||
| 217 | continue; | ||
| 218 | } | ||
| 219 | if text == "repo" { | ||
| 220 | return Err(ConfError::syntax(line, "`repo` needs at least one name")); | ||
| 221 | } | ||
| 222 | |||
| 223 | let Some((lhs, rhs)) = text.split_once('=') else { | ||
| 224 | return Err(ConfError::syntax( | ||
| 225 | line, | ||
| 226 | format!("expected a `repo` line or a rule, got {text:?}"), | ||
| 227 | )); | ||
| 228 | }; | ||
| 229 | let lhs = lhs.trim(); | ||
| 230 | let rhs = rhs.trim(); | ||
| 231 | |||
| 232 | if let Some(group) = lhs.strip_prefix('@') { | ||
| 233 | if current.is_some() { | ||
| 234 | return Err(ConfError::syntax( | ||
| 235 | line, | ||
| 236 | "group definitions must come before any `repo` block", | ||
| 237 | )); | ||
| 238 | } | ||
| 239 | if group.is_empty() || !is_plain_name(group) { | ||
| 240 | return Err(ConfError::syntax(line, format!("bad group name @{group}"))); | ||
| 241 | } | ||
| 242 | if group == "all" { | ||
| 243 | return Err(ConfError::syntax( | ||
| 244 | line, | ||
| 245 | "@all is built in and cannot be defined", | ||
| 246 | )); | ||
| 247 | } | ||
| 248 | let members = conf.expand_members(rhs, line)?; | ||
| 249 | conf.groups | ||
| 250 | .entry(group.to_string()) | ||
| 251 | .or_default() | ||
| 252 | .extend(members); | ||
| 253 | continue; | ||
| 254 | } | ||
| 255 | |||
| 256 | let block = current.as_mut().ok_or_else(|| { | ||
| 257 | ConfError::syntax(line, "a rule must appear inside a `repo` block") | ||
| 258 | })?; | ||
| 259 | |||
| 260 | let mut lhs_tokens = lhs.split_whitespace(); | ||
| 261 | let perm_token = lhs_tokens | ||
| 262 | .next() | ||
| 263 | .ok_or_else(|| ConfError::syntax(line, "rule is missing a permission"))?; | ||
| 264 | let perm = Perm::parse(perm_token).ok_or_else(|| { | ||
| 265 | ConfError::syntax( | ||
| 266 | line, | ||
| 267 | format!("unknown permission {perm_token:?}; expected one of -, R, RW, RW+, C"), | ||
| 268 | ) | ||
| 269 | })?; | ||
| 270 | let refex = lhs_tokens.next(); | ||
| 271 | if let Some(extra) = lhs_tokens.next() { | ||
| 272 | return Err(ConfError::syntax( | ||
| 273 | line, | ||
| 274 | format!("unexpected {extra:?} after the refex; a rule takes at most one"), | ||
| 275 | )); | ||
| 276 | } | ||
| 277 | let refex = compile_refex(refex, line)?; | ||
| 278 | |||
| 279 | let users: Vec<String> = rhs.split_whitespace().map(|u| u.to_string()).collect(); | ||
| 280 | if users.is_empty() { | ||
| 281 | return Err(ConfError::syntax(line, "rule names no principals")); | ||
| 282 | } | ||
| 283 | for user in &users { | ||
| 284 | let bare = user.strip_prefix('@').unwrap_or(user); | ||
| 285 | if !is_plain_name(bare) { | ||
| 286 | return Err(ConfError::syntax(line, format!("bad principal {user:?}"))); | ||
| 287 | } | ||
| 288 | } | ||
| 289 | |||
| 290 | block.rules.push(Rule { perm, refex, users }); | ||
| 291 | } | ||
| 292 | |||
| 293 | if let Some(block) = current.take() { | ||
| 294 | conf.blocks.push(block); | ||
| 295 | } | ||
| 296 | Ok(conf) | ||
| 297 | } | ||
| 298 | |||
| 299 | /// Expand a group's member list, resolving `@`-references to groups | ||
| 300 | /// already defined. An unknown group reference is an error rather than an | ||
| 301 | /// empty expansion: a typo'd group name must not silently grant nothing | ||
| 302 | /// (or, on the repo side, match nothing). | ||
| 303 | fn expand_members(&self, rhs: &str, line: usize) -> Result<Vec<String>, ConfError> { | ||
| 304 | let mut out = Vec::new(); | ||
| 305 | for token in rhs.split_whitespace() { | ||
| 306 | if let Some(name) = token.strip_prefix('@') { | ||
| 307 | let members = self | ||
| 308 | .groups | ||
| 309 | .get(name) | ||
| 310 | .ok_or_else(|| ConfError::syntax(line, format!("unknown group @{name}")))?; | ||
| 311 | out.extend(members.iter().cloned()); | ||
| 312 | } else { | ||
| 313 | out.push(token.to_string()); | ||
| 314 | } | ||
| 315 | } | ||
| 316 | if out.is_empty() { | ||
| 317 | return Err(ConfError::syntax(line, "group definition has no members")); | ||
| 318 | } | ||
| 319 | Ok(out) | ||
| 320 | } | ||
| 321 | |||
| 322 | fn compile_repo_pattern(&self, pattern: &str, line: usize) -> Result<RepoPattern, ConfError> { | ||
| 323 | if let Some(group) = pattern.strip_prefix('@') { | ||
| 324 | if group != "all" && !self.groups.contains_key(group) { | ||
| 325 | return Err(ConfError::syntax(line, format!("unknown group @{group}"))); | ||
| 326 | } | ||
| 327 | return Ok(RepoPattern::Group(group.to_string())); | ||
| 328 | } | ||
| 329 | if is_wild_pattern(pattern) { | ||
| 330 | let regex = Regex::new(&format!("^(?:{pattern})$")).map_err(|e| { | ||
| 331 | ConfError::syntax(line, format!("invalid repo pattern {pattern:?}: {e}")) | ||
| 332 | })?; | ||
| 333 | return Ok(RepoPattern::Wild(regex)); | ||
| 334 | } | ||
| 335 | if !is_plain_name(pattern) { | ||
| 336 | return Err(ConfError::syntax( | ||
| 337 | line, | ||
| 338 | format!("bad repo name {pattern:?}"), | ||
| 339 | )); | ||
| 340 | } | ||
| 341 | Ok(RepoPattern::Exact(pattern.to_string())) | ||
| 342 | } | ||
| 343 | |||
| 344 | fn pattern_matches(&self, pattern: &RepoPattern, repo: &str) -> bool { | ||
| 345 | match pattern { | ||
| 346 | RepoPattern::Exact(name) => name == repo, | ||
| 347 | RepoPattern::Wild(regex) => regex.is_match(repo), | ||
| 348 | RepoPattern::Group(name) if name == "all" => true, | ||
| 349 | RepoPattern::Group(name) => self | ||
| 350 | .groups | ||
| 351 | .get(name) | ||
| 352 | .is_some_and(|members| members.iter().any(|m| m == repo)), | ||
| 353 | } | ||
| 354 | } | ||
| 355 | |||
| 356 | fn user_matches(&self, token: &str, subject: &Subject<'_>) -> bool { | ||
| 357 | if token == "@all" { | ||
| 358 | return true; | ||
| 359 | } | ||
| 360 | if token == "CREATOR" { | ||
| 361 | return subject.creator.is_some_and(|c| c == subject.name); | ||
| 362 | } | ||
| 363 | if let Some(group) = token.strip_prefix('@') { | ||
| 364 | return self | ||
| 365 | .groups | ||
| 366 | .get(group) | ||
| 367 | .is_some_and(|members| members.iter().any(|m| m == subject.name)); | ||
| 368 | } | ||
| 369 | token == subject.name | ||
| 370 | } | ||
| 371 | |||
| 372 | /// The one evaluation path. `refname` of `None` asks the repository-level | ||
| 373 | /// question ("may this principal do this to *some* ref here?"), in which | ||
| 374 | /// case refexes are not consulted and `-` rules are skipped rather than | ||
| 375 | /// denying — a deny on one refex must not make the whole repository | ||
| 376 | /// unreachable. | ||
| 377 | fn evaluate( | ||
| 378 | &self, | ||
| 379 | repo: &str, | ||
| 380 | subject: &Subject<'_>, | ||
| 381 | refname: Option<&str>, | ||
| 382 | access: Access, | ||
| 383 | ) -> bool { | ||
| 384 | for block in &self.blocks { | ||
| 385 | if !block | ||
| 386 | .patterns | ||
| 387 | .iter() | ||
| 388 | .any(|pattern| self.pattern_matches(pattern, repo)) | ||
| 389 | { | ||
| 390 | continue; | ||
| 391 | } | ||
| 392 | for rule in &block.rules { | ||
| 393 | if !rule | ||
| 394 | .users | ||
| 395 | .iter() | ||
| 396 | .any(|token| self.user_matches(token, subject)) | ||
| 397 | { | ||
| 398 | continue; | ||
| 399 | } | ||
| 400 | match refname { | ||
| 401 | None => { | ||
| 402 | if rule.perm == Perm::Deny { | ||
| 403 | continue; | ||
| 404 | } | ||
| 405 | } | ||
| 406 | Some(name) => { | ||
| 407 | if !rule.refex.is_match(name) { | ||
| 408 | continue; | ||
| 409 | } | ||
| 410 | if rule.perm == Perm::Deny { | ||
| 411 | return false; | ||
| 412 | } | ||
| 413 | } | ||
| 414 | } | ||
| 415 | if rule.perm.grants(access) { | ||
| 416 | return true; | ||
| 417 | } | ||
| 418 | } | ||
| 419 | } | ||
| 420 | false | ||
| 421 | } | ||
| 422 | |||
| 423 | #[cfg(test)] | ||
| 424 | fn evaluate_order_blind( | ||
| 425 | &self, | ||
| 426 | repo: &str, | ||
| 427 | subject: &Subject<'_>, | ||
| 428 | refname: Option<&str>, | ||
| 429 | access: Access, | ||
| 430 | ) -> bool { | ||
| 431 | let mut allowed = false; | ||
| 432 | let mut denied = false; | ||
| 433 | for block in &self.blocks { | ||
| 434 | if !block | ||
| 435 | .patterns | ||
| 436 | .iter() | ||
| 437 | .any(|pattern| self.pattern_matches(pattern, repo)) | ||
| 438 | { | ||
| 439 | continue; | ||
| 440 | } | ||
| 441 | for rule in &block.rules { | ||
| 442 | if !rule | ||
| 443 | .users | ||
| 444 | .iter() | ||
| 445 | .any(|token| self.user_matches(token, subject)) | ||
| 446 | { | ||
| 447 | continue; | ||
| 448 | } | ||
| 449 | if let Some(name) = refname { | ||
| 450 | if !rule.refex.is_match(name) { | ||
| 451 | continue; | ||
| 452 | } | ||
| 453 | } else if rule.perm == Perm::Deny { | ||
| 454 | continue; | ||
| 455 | } | ||
| 456 | if rule.perm == Perm::Deny { | ||
| 457 | denied = true; | ||
| 458 | } else if rule.perm.grants(access) { | ||
| 459 | allowed = true; | ||
| 460 | } | ||
| 461 | } | ||
| 462 | } | ||
| 463 | allowed && !denied | ||
| 464 | } | ||
| 465 | |||
| 466 | /// May this principal do `access` to *some* ref of this repository? Used | ||
| 467 | /// at dispatch, where the verb is known but the refs are not yet. | ||
| 468 | pub fn allows_repo(&self, repo: &str, subject: &Subject<'_>, access: Access) -> bool { | ||
| 469 | self.evaluate(repo, subject, None, access) | ||
| 470 | } | ||
| 471 | |||
| 472 | /// May this principal do `access` to this specific ref? First-match-wins. | ||
| 473 | pub fn allows_ref( | ||
| 474 | &self, | ||
| 475 | repo: &str, | ||
| 476 | subject: &Subject<'_>, | ||
| 477 | refname: &str, | ||
| 478 | access: Access, | ||
| 479 | ) -> bool { | ||
| 480 | self.evaluate(repo, subject, Some(refname), access) | ||
| 481 | } | ||
| 482 | } | ||
| 483 | |||
| 484 | fn strip_comment(line: &str) -> &str { | ||
| 485 | match line.find('#') { | ||
| 486 | Some(index) => &line[..index], | ||
| 487 | None => line, | ||
| 488 | } | ||
| 489 | } | ||
| 490 | |||
| 491 | #[cfg(test)] | ||
| 492 | mod tests { | ||
| 493 | use super::*; | ||
| 494 | |||
| 495 | fn conf(source: &str) -> AccessConf { | ||
| 496 | AccessConf::parse(source).expect("config should parse") | ||
| 497 | } | ||
| 498 | |||
| 499 | const SPEC_EXAMPLE: &str = "\ | ||
| 500 | @admins = alex | ||
| 501 | @agents = claude-a claude-b | ||
| 502 | |||
| 503 | repo settings | ||
| 504 | RW+ = @admins | ||
| 505 | |||
| 506 | repo tools | ||
| 507 | RW+ = @admins | ||
| 508 | RW refs/collab/ = @agents | ||
| 509 | R = @all | ||
| 510 | |||
| 511 | repo agents/[a-z].* | ||
| 512 | C = @agents | ||
| 513 | RW+ = CREATOR | ||
| 514 | "; | ||
| 515 | |||
| 516 | #[test] | ||
| 517 | fn spec_example_parses() { | ||
| 518 | let conf = conf(SPEC_EXAMPLE); | ||
| 519 | assert!(conf.allows_repo("settings", &Subject::new("alex"), Access::Rewind)); | ||
| 520 | } | ||
| 521 | |||
| 522 | // ---- The point of the whole design: a contributor's grant is one prefix | ||
| 523 | // and refs/heads never appears in it. ---- | ||
| 524 | |||
| 525 | #[test] | ||
| 526 | fn agent_writes_collab_refs_but_not_branches() { | ||
| 527 | let conf = conf(SPEC_EXAMPLE); | ||
| 528 | let agent = Subject::new("claude-a"); | ||
| 529 | |||
| 530 | assert!(conf.allows_ref("tools", &agent, "refs/collab/patches/abc", Access::Write)); | ||
| 531 | assert!(conf.allows_ref("tools", &agent, "refs/collab/issues/abc", Access::Write)); | ||
| 532 | assert!(conf.allows_ref("tools", &agent, "refs/collab/archive/abc", Access::Write)); | ||
| 533 | |||
| 534 | assert!(!conf.allows_ref("tools", &agent, "refs/heads/main", Access::Write)); | ||
| 535 | assert!(!conf.allows_ref("tools", &agent, "refs/tags/v1", Access::Write)); | ||
| 536 | // ...but it can still read the branch it is contributing against. | ||
| 537 | assert!(conf.allows_ref("tools", &agent, "refs/heads/main", Access::Read)); | ||
| 538 | } | ||
| 539 | |||
| 540 | #[test] | ||
| 541 | fn agent_cannot_rewind_the_refs_it_may_write() { | ||
| 542 | let conf = conf(SPEC_EXAMPLE); | ||
| 543 | let agent = Subject::new("claude-a"); | ||
| 544 | // RW, not RW+: fast-forward yes, force-push/delete no. | ||
| 545 | assert!(conf.allows_ref("tools", &agent, "refs/collab/patches/x", Access::Write)); | ||
| 546 | assert!(!conf.allows_ref("tools", &agent, "refs/collab/patches/x", Access::Rewind)); | ||
| 547 | } | ||
| 548 | |||
| 549 | #[test] | ||
| 550 | fn admin_writes_everything() { | ||
| 551 | let conf = conf(SPEC_EXAMPLE); | ||
| 552 | let admin = Subject::new("alex"); | ||
| 553 | assert!(conf.allows_ref("tools", &admin, "refs/heads/main", Access::Rewind)); | ||
| 554 | assert!(conf.allows_ref("settings", &admin, "refs/heads/main", Access::Rewind)); | ||
| 555 | } | ||
| 556 | |||
| 557 | // ---- Ordering. An ordering bug here is a silent authorization bug, so | ||
| 558 | // both directions are asserted against the same two rules. ---- | ||
| 559 | |||
| 560 | #[test] | ||
| 561 | fn deny_before_a_broader_allow_denies() { | ||
| 562 | let conf = conf( | ||
| 563 | "\ | ||
| 564 | @agents = claude-a | ||
| 565 | |||
| 566 | repo tools | ||
| 567 | - refs/heads/main = @agents | ||
| 568 | RW+ = @agents | ||
| 569 | ", | ||
| 570 | ); | ||
| 571 | let agent = Subject::new("claude-a"); | ||
| 572 | assert!( | ||
| 573 | !conf.allows_ref("tools", &agent, "refs/heads/main", Access::Write), | ||
| 574 | "the deny is first, so it must win" | ||
| 575 | ); | ||
| 576 | // The broader allow still governs every other ref. | ||
| 577 | assert!(conf.allows_ref("tools", &agent, "refs/heads/topic", Access::Write)); | ||
| 578 | } | ||
| 579 | |||
| 580 | #[test] | ||
| 581 | fn the_same_two_rules_reversed_allow() { | ||
| 582 | let conf = conf( | ||
| 583 | "\ | ||
| 584 | @agents = claude-a | ||
| 585 | |||
| 586 | repo tools | ||
| 587 | RW+ = @agents | ||
| 588 | - refs/heads/main = @agents | ||
| 589 | ", | ||
| 590 | ); | ||
| 591 | let agent = Subject::new("claude-a"); | ||
| 592 | assert!( | ||
| 593 | conf.allows_ref("tools", &agent, "refs/heads/main", Access::Write), | ||
| 594 | "the allow is first, so the later deny is never reached" | ||
| 595 | ); | ||
| 596 | } | ||
| 597 | |||
| 598 | /// The two fixtures above hold the *same rules in the other order*, so a | ||
| 599 | /// passing pair only proves anything if order is what separates them. | ||
| 600 | /// This asserts exactly that: an evaluator that gathered the same matching | ||
| 601 | /// rules but ignored their order — the plausible way to write this wrong — | ||
| 602 | /// would answer both cases identically and get one of them wrong. | ||
| 603 | /// | ||
| 604 | /// Without this, `deny_before_a_broader_allow_denies` and | ||
| 605 | /// `the_same_two_rules_reversed_allow` could both keep passing under an | ||
| 606 | /// order-blind implementation that simply denied whenever any deny rule | ||
| 607 | /// matched, and the silent authorization bug would go unnoticed. | ||
| 608 | #[test] | ||
| 609 | fn the_ordering_fixtures_actually_discriminate_on_order() { | ||
| 610 | let deny_first = conf( | ||
| 611 | "\ | ||
| 612 | @agents = claude-a | ||
| 613 | |||
| 614 | repo tools | ||
| 615 | - refs/heads/main = @agents | ||
| 616 | RW+ = @agents | ||
| 617 | ", | ||
| 618 | ); | ||
| 619 | let allow_first = conf( | ||
| 620 | "\ | ||
| 621 | @agents = claude-a | ||
| 622 | |||
| 623 | repo tools | ||
| 624 | RW+ = @agents | ||
| 625 | - refs/heads/main = @agents | ||
| 626 | ", | ||
| 627 | ); | ||
| 628 | let agent = Subject::new("claude-a"); | ||
| 629 | let ref_name = Some("refs/heads/main"); | ||
| 630 | |||
| 631 | // What the real evaluator says: order decides, so the answers differ. | ||
| 632 | assert!(!deny_first.evaluate("tools", &agent, ref_name, Access::Write)); | ||
| 633 | assert!(allow_first.evaluate("tools", &agent, ref_name, Access::Write)); | ||
| 634 | |||
| 635 | // What an order-blind evaluator says: the same answer to both, and it | ||
| 636 | // is the wrong answer for the second. | ||
| 637 | assert_eq!( | ||
| 638 | deny_first.evaluate_order_blind("tools", &agent, ref_name, Access::Write), | ||
| 639 | allow_first.evaluate_order_blind("tools", &agent, ref_name, Access::Write), | ||
| 640 | "the order-blind evaluator is supposed to be unable to tell these \ | ||
| 641 | apart; if it can, this test no longer proves the fixtures depend \ | ||
| 642 | on order" | ||
| 643 | ); | ||
| 644 | assert!( | ||
| 645 | !allow_first.evaluate_order_blind("tools", &agent, ref_name, Access::Write), | ||
| 646 | "and it is supposed to get the allow-first case wrong" | ||
| 647 | ); | ||
| 648 | } | ||
| 649 | |||
| 650 | #[test] | ||
| 651 | fn ordering_holds_across_repo_blocks_not_only_within_one() { | ||
| 652 | // Two blocks both matching `tools`; the deny is in the earlier block. | ||
| 653 | let denies = conf( | ||
| 654 | "\ | ||
| 655 | repo @all | ||
| 656 | - refs/heads/main = claude-a | ||
| 657 | |||
| 658 | repo tools | ||
| 659 | RW+ = claude-a | ||
| 660 | ", | ||
| 661 | ); | ||
| 662 | let allows = conf( | ||
| 663 | "\ | ||
| 664 | repo tools | ||
| 665 | RW+ = claude-a | ||
| 666 | |||
| 667 | repo @all | ||
| 668 | - refs/heads/main = claude-a | ||
| 669 | ", | ||
| 670 | ); | ||
| 671 | let agent = Subject::new("claude-a"); | ||
| 672 | assert!(!denies.allows_ref("tools", &agent, "refs/heads/main", Access::Write)); | ||
| 673 | assert!(allows.allows_ref("tools", &agent, "refs/heads/main", Access::Write)); | ||
| 674 | } | ||
| 675 | |||
| 676 | #[test] | ||
| 677 | fn a_rule_that_does_not_grant_enough_falls_through_rather_than_denying() { | ||
| 678 | // The `R` rule matches the ref and the user but does not grant write; | ||
| 679 | // evaluation must continue to the RW rule rather than stopping. | ||
| 680 | let conf = conf( | ||
| 681 | "\ | ||
| 682 | repo tools | ||
| 683 | R = claude-a | ||
| 684 | RW refs/collab/ = claude-a | ||
| 685 | ", | ||
| 686 | ); | ||
| 687 | let agent = Subject::new("claude-a"); | ||
| 688 | assert!(conf.allows_ref("tools", &agent, "refs/collab/x", Access::Write)); | ||
| 689 | } | ||
| 690 | |||
| 691 | #[test] | ||
| 692 | fn deny_short_circuits_even_when_it_grants_nothing_relevant() { | ||
| 693 | let conf = conf( | ||
| 694 | "\ | ||
| 695 | repo tools | ||
| 696 | - = claude-a | ||
| 697 | RW+ = claude-a | ||
| 698 | ", | ||
| 699 | ); | ||
| 700 | let agent = Subject::new("claude-a"); | ||
| 701 | assert!(!conf.allows_ref("tools", &agent, "refs/heads/main", Access::Write)); | ||
| 702 | assert!(!conf.allows_ref("tools", &agent, "refs/heads/main", Access::Read)); | ||
| 703 | } | ||
| 704 | |||
| 705 | #[test] | ||
| 706 | fn a_deny_on_one_refex_does_not_close_the_repository() { | ||
| 707 | // Repo-level questions skip `-` rules: the principal can still open a | ||
| 708 | // connection and push *something*, and the per-ref check does the rest. | ||
| 709 | let conf = conf( | ||
| 710 | "\ | ||
| 711 | repo tools | ||
| 712 | - refs/heads/main = claude-a | ||
| 713 | RW refs/collab/ = claude-a | ||
| 714 | ", | ||
| 715 | ); | ||
| 716 | let agent = Subject::new("claude-a"); | ||
| 717 | assert!(conf.allows_repo("tools", &agent, Access::Write)); | ||
| 718 | assert!(!conf.allows_ref("tools", &agent, "refs/heads/main", Access::Write)); | ||
| 719 | } | ||
| 720 | |||
| 721 | // ---- Refex semantics ---- | ||
| 722 | |||
| 723 | #[test] | ||
| 724 | fn omitted_refex_is_every_ref() { | ||
| 725 | let conf = conf("repo tools\n RW+ = alex\n"); | ||
| 726 | let alex = Subject::new("alex"); | ||
| 727 | assert!(conf.allows_ref("tools", &alex, "refs/heads/main", Access::Write)); | ||
| 728 | assert!(conf.allows_ref("tools", &alex, "refs/tags/v1", Access::Write)); | ||
| 729 | assert!(conf.allows_ref("tools", &alex, "refs/collab/issues/x", Access::Write)); | ||
| 730 | } | ||
| 731 | |||
| 732 | #[test] | ||
| 733 | fn a_bare_refex_is_implicitly_under_refs_heads() { | ||
| 734 | let conf = conf("repo tools\n RW main = alex\n"); | ||
| 735 | let alex = Subject::new("alex"); | ||
| 736 | assert!(conf.allows_ref("tools", &alex, "refs/heads/main", Access::Write)); | ||
| 737 | assert!(!conf.allows_ref("tools", &alex, "refs/tags/main", Access::Write)); | ||
| 738 | } | ||
| 739 | |||
| 740 | #[test] | ||
| 741 | fn refexes_anchor_at_the_start_but_not_the_end() { | ||
| 742 | // gitolite's documented behaviour, adopted verbatim: `main` also | ||
| 743 | // matches `maint`. Asserted so it stays a decision, not an accident. | ||
| 744 | let conf = conf("repo tools\n RW main = alex\n"); | ||
| 745 | let alex = Subject::new("alex"); | ||
| 746 | assert!(conf.allows_ref("tools", &alex, "refs/heads/maint", Access::Write)); | ||
| 747 | assert!(!conf.allows_ref("tools", &alex, "refs/heads/topic/main", Access::Write)); | ||
| 748 | } | ||
| 749 | |||
| 750 | #[test] | ||
| 751 | fn a_refex_may_be_a_regex() { | ||
| 752 | let conf = conf("repo tools\n RW refs/heads/(feature|topic)/ = alex\n"); | ||
| 753 | let alex = Subject::new("alex"); | ||
| 754 | assert!(conf.allows_ref("tools", &alex, "refs/heads/feature/x", Access::Write)); | ||
| 755 | assert!(conf.allows_ref("tools", &alex, "refs/heads/topic/x", Access::Write)); | ||
| 756 | assert!(!conf.allows_ref("tools", &alex, "refs/heads/main", Access::Write)); | ||
| 757 | } | ||
| 758 | |||
| 759 | // ---- Groups ---- | ||
| 760 | |||
| 761 | #[test] | ||
| 762 | fn at_all_matches_any_named_principal() { | ||
| 763 | let conf = conf("repo tools\n R = @all\n"); | ||
| 764 | assert!(conf.allows_ref( | ||
| 765 | "tools", | ||
| 766 | &Subject::new("nobody-in-particular"), | ||
| 767 | "refs/heads/main", | ||
| 768 | Access::Read | ||
| 769 | )); | ||
| 770 | } | ||
| 771 | |||
| 772 | #[test] | ||
| 773 | fn groups_may_reference_earlier_groups() { | ||
| 774 | let conf = conf( | ||
| 775 | "\ | ||
| 776 | @core = alex | ||
| 777 | @agents = claude-a | ||
| 778 | @staff = @core @agents | ||
| 779 | |||
| 780 | repo tools | ||
| 781 | RW+ = @staff | ||
| 782 | ", | ||
| 783 | ); | ||
| 784 | assert!(conf.allows_repo("tools", &Subject::new("alex"), Access::Rewind)); | ||
| 785 | assert!(conf.allows_repo("tools", &Subject::new("claude-a"), Access::Rewind)); | ||
| 786 | assert!(!conf.allows_repo("tools", &Subject::new("mallory"), Access::Rewind)); | ||
| 787 | } | ||
| 788 | |||
| 789 | #[test] | ||
| 790 | fn a_group_may_be_extended_by_a_second_line() { | ||
| 791 | let conf = conf("@agents = claude-a\n@agents = claude-b\nrepo tools\n RW = @agents\n"); | ||
| 792 | assert!(conf.allows_repo("tools", &Subject::new("claude-a"), Access::Write)); | ||
| 793 | assert!(conf.allows_repo("tools", &Subject::new("claude-b"), Access::Write)); | ||
| 794 | } | ||
| 795 | |||
| 796 | #[test] | ||
| 797 | fn an_unknown_group_reference_is_an_error_not_an_empty_set() { | ||
| 798 | assert!(AccessConf::parse("@staff = @nope\nrepo t\n R = @staff\n").is_err()); | ||
| 799 | assert!(AccessConf::parse("repo @nope\n R = alex\n").is_err()); | ||
| 800 | } | ||
| 801 | |||
| 802 | // ---- Repository patterns and wild repos ---- | ||
| 803 | |||
| 804 | #[test] | ||
| 805 | fn plain_repo_names_match_byte_exactly_including_nesting() { | ||
| 806 | let conf = conf( | ||
| 807 | "\ | ||
| 808 | repo tools | ||
| 809 | RW+ = alex | ||
| 810 | repo private/tools | ||
| 811 | RW+ = root | ||
| 812 | ", | ||
| 813 | ); | ||
| 814 | assert!(conf.allows_repo("tools", &Subject::new("alex"), Access::Write)); | ||
| 815 | assert!(!conf.allows_repo("private/tools", &Subject::new("alex"), Access::Write)); | ||
| 816 | assert!(conf.allows_repo("private/tools", &Subject::new("root"), Access::Write)); | ||
| 817 | assert!(!conf.allows_repo("tools", &Subject::new("root"), Access::Write)); | ||
| 818 | } | ||
| 819 | |||
| 820 | #[test] | ||
| 821 | fn wild_repo_patterns_anchor_at_both_ends() { | ||
| 822 | let conf = conf("repo agents/[a-z].*\n C = claude-a\n"); | ||
| 823 | let agent = Subject::new("claude-a"); | ||
| 824 | assert!(conf.allows_repo("agents/claude-a", &agent, Access::Create)); | ||
| 825 | assert!(!conf.allows_repo("teams/agents/claude-a", &agent, Access::Create)); | ||
| 826 | assert!(!conf.allows_repo("agents/Claude", &agent, Access::Create)); | ||
| 827 | } | ||
| 828 | |||
| 829 | #[test] | ||
| 830 | fn creator_owns_what_it_created_and_nothing_else() { | ||
| 831 | let conf = conf(SPEC_EXAMPLE); | ||
| 832 | let repo = "agents/claude-a"; | ||
| 833 | |||
| 834 | let creator = Subject::with_creator("claude-a", Some("claude-a")); | ||
| 835 | assert!(conf.allows_repo(repo, &creator, Access::Create)); | ||
| 836 | assert!(conf.allows_ref(repo, &creator, "refs/heads/main", Access::Rewind)); | ||
| 837 | |||
| 838 | // A second agent may create its own, but cannot write this one. | ||
| 839 | let other = Subject::with_creator("claude-b", Some("claude-a")); | ||
| 840 | assert!(conf.allows_repo(repo, &other, Access::Create)); | ||
| 841 | assert!(!conf.allows_ref(repo, &other, "refs/heads/main", Access::Write)); | ||
| 842 | } | ||
| 843 | |||
| 844 | #[test] | ||
| 845 | fn creator_matches_nothing_when_the_repo_has_no_recorded_creator() { | ||
| 846 | let conf = conf(SPEC_EXAMPLE); | ||
| 847 | let subject = Subject::with_creator("claude-a", None); | ||
| 848 | assert!(!conf.allows_ref( | ||
| 849 | "agents/claude-a", | ||
| 850 | &subject, | ||
| 851 | "refs/heads/main", | ||
| 852 | Access::Write | ||
| 853 | )); | ||
| 854 | } | ||
| 855 | |||
| 856 | #[test] | ||
| 857 | fn create_is_not_implied_by_rw_plus() { | ||
| 858 | let conf = conf("repo agents/[a-z].*\n RW+ = alex\n"); | ||
| 859 | assert!(!conf.allows_repo("agents/new", &Subject::new("alex"), Access::Create)); | ||
| 860 | } | ||
| 861 | |||
| 862 | // ---- Closed by default ---- | ||
| 863 | |||
| 864 | #[test] | ||
| 865 | fn an_empty_config_denies_everything() { | ||
| 866 | let conf = conf(""); | ||
| 867 | let anyone = Subject::new("alex"); | ||
| 868 | assert!(!conf.allows_repo("tools", &anyone, Access::Read)); | ||
| 869 | assert!(!conf.allows_ref("tools", &anyone, "refs/heads/main", Access::Read)); | ||
| 870 | } | ||
| 871 | |||
| 872 | #[test] | ||
| 873 | fn a_repository_no_block_names_is_unreachable() { | ||
| 874 | let conf = conf(SPEC_EXAMPLE); | ||
| 875 | assert!(!conf.allows_repo("secrets", &Subject::new("alex"), Access::Read)); | ||
| 876 | } | ||
| 877 | |||
| 878 | // ---- Parse errors ---- | ||
| 879 | |||
| 880 | #[test] | ||
| 881 | fn comments_and_blank_lines_are_ignored() { | ||
| 882 | let conf = | ||
| 883 | conf("# a comment\n\n # indented\nrepo tools # trailing\n RW+ = alex # here\n"); | ||
| 884 | assert!(conf.allows_repo("tools", &Subject::new("alex"), Access::Write)); | ||
| 885 | } | ||
| 886 | |||
| 887 | #[test] | ||
| 888 | fn unknown_permissions_are_rejected() { | ||
| 889 | let err = AccessConf::parse("repo t\n RWD = alex\n").unwrap_err(); | ||
| 890 | assert!(err.to_string().contains("line 2"), "got {err}"); | ||
| 891 | assert!(err.to_string().contains("RWD"), "got {err}"); | ||
| 892 | } | ||
| 893 | |||
| 894 | #[test] | ||
| 895 | fn a_rule_outside_a_repo_block_is_rejected() { | ||
| 896 | assert!(AccessConf::parse("RW+ = alex\n").is_err()); | ||
| 897 | } | ||
| 898 | |||
| 899 | #[test] | ||
| 900 | fn a_rule_with_two_refexes_is_rejected() { | ||
| 901 | assert!(AccessConf::parse("repo t\n RW a b = alex\n").is_err()); | ||
| 902 | } | ||
| 903 | |||
| 904 | #[test] | ||
| 905 | fn a_rule_with_no_principals_is_rejected() { | ||
| 906 | assert!(AccessConf::parse("repo t\n RW =\n").is_err()); | ||
| 907 | } | ||
| 908 | |||
| 909 | #[test] | ||
| 910 | fn a_bare_word_that_is_neither_repo_nor_rule_is_rejected() { | ||
| 911 | assert!(AccessConf::parse("hello world\n").is_err()); | ||
| 912 | } | ||
| 913 | |||
| 914 | #[test] | ||
| 915 | fn at_all_cannot_be_redefined() { | ||
| 916 | assert!(AccessConf::parse("@all = alex\n").is_err()); | ||
| 917 | } | ||
| 918 | |||
| 919 | #[test] | ||
| 920 | fn group_definitions_must_precede_repo_blocks() { | ||
| 921 | assert!(AccessConf::parse("repo t\n RW = @late\n@late = alex\n").is_err()); | ||
| 922 | } | ||
| 923 | } | ||
src/server/governance/hook.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,369 @@ | |||
| 1 | //! The server-managed `update` hook. | ||
| 2 | //! | ||
| 3 | //! Authorization happens at two moments, because the server learns the | ||
| 4 | //! repository and the verb before it learns which refs are being written. | ||
| 5 | //! Dispatch answers "may this principal push here at all"; this hook answers | ||
| 6 | //! "may it write *this* ref", once per ref, which is what makes | ||
| 7 | //! `RW refs/collab/ = @agents` mean anything. | ||
| 8 | //! | ||
| 9 | //! The hook is the same binary as the server, re-invoked through a two-line | ||
| 10 | //! shell script. It learns who is pushing from the environment | ||
| 11 | //! `git-receive-pack` was spawned with, and it re-reads `settings.git` itself | ||
| 12 | //! rather than trusting anything the parent passed about permissions — the | ||
| 13 | //! parent passes an identity, never a decision. | ||
| 14 | //! | ||
| 15 | //! It also validates pushes to `settings` itself, and that part runs even on a | ||
| 16 | //! server with no governance yet. Otherwise the first config push would be the | ||
| 17 | //! one push nothing checks, and a typo in it would close the server with no | ||
| 18 | //! way back in short of `kubectl exec`. | ||
| 19 | |||
| 20 | use std::path::{Path, PathBuf}; | ||
| 21 | |||
| 22 | use super::conf::{Access, Subject}; | ||
| 23 | use super::{creator_of, load, validate_settings_tree, GovernanceState, SETTINGS_REPO}; | ||
| 24 | |||
| 25 | /// Environment the server sets on `git-receive-pack`, and which the hook reads | ||
| 26 | /// back. Named rather than inherited so that a hook running outside the server | ||
| 27 | /// — by hand, say — fails closed instead of guessing. | ||
| 28 | pub const ENV_PRINCIPAL: &str = "GIT_COLLAB_PRINCIPAL"; | ||
| 29 | pub const ENV_REPOS_DIR: &str = "GIT_COLLAB_REPOS_DIR"; | ||
| 30 | pub const ENV_REPO: &str = "GIT_COLLAB_REPO"; | ||
| 31 | pub const ENV_REPO_PATH: &str = "GIT_COLLAB_REPO_PATH"; | ||
| 32 | |||
| 33 | const HOOK_NAME: &str = "update"; | ||
| 34 | |||
| 35 | /// The hooks directory for a repository, bare or not. | ||
| 36 | fn hooks_dir(repo_path: &Path, bare: bool) -> PathBuf { | ||
| 37 | if bare { | ||
| 38 | repo_path.join("hooks") | ||
| 39 | } else { | ||
| 40 | repo_path.join(".git").join("hooks") | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | /// Install (or refresh) the `update` hook in a repository. | ||
| 45 | /// | ||
| 46 | /// Written unconditionally rather than only when missing, so a hook cannot | ||
| 47 | /// drift from the binary that installs it, and so an operator who deleted it | ||
| 48 | /// gets it back on the next push rather than silently losing enforcement. | ||
| 49 | pub fn install(repo_path: &Path, bare: bool) -> Result<(), String> { | ||
| 50 | let exe = std::env::current_exe().map_err(|e| format!("cannot locate own binary: {e}"))?; | ||
| 51 | let exe = exe.to_str().ok_or("own binary path is not UTF-8")?; | ||
| 52 | // The path is interpolated into a shell script, so a quote in it would | ||
| 53 | // break out of the quoting. Refuse rather than emit a broken hook. | ||
| 54 | if exe.contains('\'') { | ||
| 55 | return Err(format!("own binary path contains a quote: {exe}")); | ||
| 56 | } | ||
| 57 | |||
| 58 | let dir = hooks_dir(repo_path, bare); | ||
| 59 | std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; | ||
| 60 | let path = dir.join(HOOK_NAME); | ||
| 61 | let script = format!( | ||
| 62 | "#!/bin/sh\n\ | ||
| 63 | # Installed and overwritten by git-collab-server. Edits will be lost.\n\ | ||
| 64 | exec '{exe}' --governance-hook \"$1\" \"$2\" \"$3\"\n" | ||
| 65 | ); | ||
| 66 | |||
| 67 | // Written through a temporary file and renamed into place. Two concurrent | ||
| 68 | // pushes both refresh the hook, and a truncate-then-write would leave a | ||
| 69 | // window in which the other push execs a half-written script — which git | ||
| 70 | // would report as a failed hook, i.e. a spurious rejection. | ||
| 71 | let temp = dir.join(format!(".{HOOK_NAME}.{}", std::process::id())); | ||
| 72 | std::fs::write(&temp, script).map_err(|e| format!("{}: {e}", temp.display()))?; | ||
| 73 | #[cfg(unix)] | ||
| 74 | { | ||
| 75 | use std::os::unix::fs::PermissionsExt; | ||
| 76 | // Set before the rename, so the file is never visible non-executable. | ||
| 77 | std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o755)) | ||
| 78 | .map_err(|e| format!("{}: {e}", temp.display()))?; | ||
| 79 | } | ||
| 80 | std::fs::rename(&temp, &path).map_err(|e| { | ||
| 81 | let _ = std::fs::remove_file(&temp); | ||
| 82 | format!("{}: {e}", path.display()) | ||
| 83 | })?; | ||
| 84 | Ok(()) | ||
| 85 | } | ||
| 86 | |||
| 87 | /// Which access a ref update needs. | ||
| 88 | /// | ||
| 89 | /// Deleting or rewinding is `+`; creating or fast-forwarding is `W`. Anything | ||
| 90 | /// this cannot determine — a tag object where a commit was expected, an | ||
| 91 | /// unreadable object — counts as a rewind, because guessing "fast-forward" | ||
| 92 | /// would be guessing in the permissive direction. | ||
| 93 | fn required_access(repo: &git2::Repository, old: git2::Oid, new: git2::Oid) -> Access { | ||
| 94 | if new.is_zero() { | ||
| 95 | return Access::Rewind; | ||
| 96 | } | ||
| 97 | if old.is_zero() { | ||
| 98 | return Access::Write; | ||
| 99 | } | ||
| 100 | match repo.graph_descendant_of(new, old) { | ||
| 101 | Ok(true) => Access::Write, | ||
| 102 | Ok(false) => Access::Rewind, | ||
| 103 | Err(_) => Access::Rewind, | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | fn env(name: &str) -> Result<String, String> { | ||
| 108 | std::env::var(name).map_err(|_| { | ||
| 109 | format!("{name} is not set; this hook is only meaningful under git-collab-server") | ||
| 110 | }) | ||
| 111 | } | ||
| 112 | |||
| 113 | /// The hook body. `Err` rejects the ref update, and its text is what the | ||
| 114 | /// pushing client sees on its `remote:` lines. | ||
| 115 | pub fn run(refname: &str, old: &str, new: &str) -> Result<(), String> { | ||
| 116 | let repos_dir = PathBuf::from(env(ENV_REPOS_DIR)?); | ||
| 117 | let repo_key = env(ENV_REPO)?; | ||
| 118 | let repo_path = PathBuf::from(env(ENV_REPO_PATH)?); | ||
| 119 | // Empty is legitimate: an ungoverned server has no names to pass. | ||
| 120 | let principal = std::env::var(ENV_PRINCIPAL).unwrap_or_default(); | ||
| 121 | |||
| 122 | let old = parse_oid(old)?; | ||
| 123 | let new = parse_oid(new)?; | ||
| 124 | |||
| 125 | // Opened from the environment so the objects still in receive-pack's | ||
| 126 | // quarantine are visible: during a hook they live in GIT_OBJECT_DIRECTORY | ||
| 127 | // with the repository's real object store as an alternate, and neither is | ||
| 128 | // reachable by opening the path directly. | ||
| 129 | let repo = git2::Repository::open_from_env() | ||
| 130 | .map_err(|e| format!("cannot open the receiving repository: {e}"))?; | ||
| 131 | |||
| 132 | let state = load(&repos_dir); | ||
| 133 | |||
| 134 | if let GovernanceState::Active(governance) = &state { | ||
| 135 | if principal.is_empty() { | ||
| 136 | return Err("no authenticated principal on this push".to_string()); | ||
| 137 | } | ||
| 138 | let creator = creator_of(&repo_path); | ||
| 139 | let subject = Subject::with_creator(&principal, creator.as_deref()); | ||
| 140 | let access = required_access(&repo, old, new); | ||
| 141 | if !governance | ||
| 142 | .conf | ||
| 143 | .allows_ref(&repo_key, &subject, refname, access) | ||
| 144 | { | ||
| 145 | return Err(format!( | ||
| 146 | "{principal} may not {} {refname} in {repo_key}", | ||
| 147 | describe(access) | ||
| 148 | )); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | if let GovernanceState::Unreadable(reason) = &state { | ||
| 152 | return Err(format!("the settings repository is unreadable: {reason}")); | ||
| 153 | } | ||
| 154 | |||
| 155 | if repo_key == SETTINGS_REPO { | ||
| 156 | validate_settings_push(&repo, refname, new)?; | ||
| 157 | } | ||
| 158 | |||
| 159 | Ok(()) | ||
| 160 | } | ||
| 161 | |||
| 162 | fn describe(access: Access) -> &'static str { | ||
| 163 | match access { | ||
| 164 | Access::Read => "read", | ||
| 165 | Access::Write => "write", | ||
| 166 | Access::Rewind => "rewind or delete", | ||
| 167 | Access::Create => "create", | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | fn parse_oid(text: &str) -> Result<git2::Oid, String> { | ||
| 172 | git2::Oid::from_str(text).map_err(|e| format!("bad object id {text:?}: {e}")) | ||
| 173 | } | ||
| 174 | |||
| 175 | /// Reject a push to `settings` that would leave the server with a config it | ||
| 176 | /// cannot use. This is what removes the malformed-config failure mode: the | ||
| 177 | /// live config is only ever one that passed this. | ||
| 178 | fn validate_settings_push( | ||
| 179 | repo: &git2::Repository, | ||
| 180 | refname: &str, | ||
| 181 | new: git2::Oid, | ||
| 182 | ) -> Result<(), String> { | ||
| 183 | // Only branches can become the live config; anything else in this | ||
| 184 | // repository is ordinary collaboration data. | ||
| 185 | if !refname.starts_with("refs/heads/") { | ||
| 186 | return Ok(()); | ||
| 187 | } | ||
| 188 | |||
| 189 | // The name `settings` alone does not make a repository the governance | ||
| 190 | // repository — `conf/access.conf` does. Without this, an ungoverned server | ||
| 191 | // that happens to host an unrelated repo called `settings` would start | ||
| 192 | // rejecting its pushes as invalid configuration. | ||
| 193 | let live_is_config = live_tree(repo).is_some_and(|tree| has_access_conf(&tree)); | ||
| 194 | |||
| 195 | if new.is_zero() { | ||
| 196 | return if live_is_config && live_branch(repo).as_deref() == Some(refname) { | ||
| 197 | Err(format!( | ||
| 198 | "refusing to delete {refname}: it is the live configuration of the \ | ||
| 199 | {SETTINGS_REPO} repository" | ||
| 200 | )) | ||
| 201 | } else { | ||
| 202 | Ok(()) | ||
| 203 | }; | ||
| 204 | } | ||
| 205 | |||
| 206 | let commit = repo | ||
| 207 | .find_commit(new) | ||
| 208 | .map_err(|e| format!("{refname}: cannot read the pushed commit: {e}"))?; | ||
| 209 | let tree = commit | ||
| 210 | .tree() | ||
| 211 | .map_err(|e| format!("{refname}: cannot read the pushed tree: {e}"))?; | ||
| 212 | |||
| 213 | if !live_is_config && !has_access_conf(&tree) { | ||
| 214 | return Ok(()); | ||
| 215 | } | ||
| 216 | |||
| 217 | validate_settings_tree(repo, &tree, refname) | ||
| 218 | .map_err(|e| format!("rejecting this configuration; the previous one stays live\n {e}"))?; | ||
| 219 | Ok(()) | ||
| 220 | } | ||
| 221 | |||
| 222 | fn has_access_conf(tree: &git2::Tree<'_>) -> bool { | ||
| 223 | tree.get_path(Path::new(super::ACCESS_CONF_PATH)).is_ok() | ||
| 224 | } | ||
| 225 | |||
| 226 | /// The branch HEAD points at, whether or not it currently resolves. | ||
| 227 | fn live_branch(repo: &git2::Repository) -> Option<String> { | ||
| 228 | repo.find_reference("HEAD") | ||
| 229 | .ok()? | ||
| 230 | .symbolic_target() | ||
| 231 | .map(str::to_string) | ||
| 232 | } | ||
| 233 | |||
| 234 | fn live_tree(repo: &git2::Repository) -> Option<git2::Tree<'_>> { | ||
| 235 | repo.head().ok()?.peel_to_commit().ok()?.tree().ok() | ||
| 236 | } | ||
| 237 | |||
| 238 | #[cfg(test)] | ||
| 239 | mod tests { | ||
| 240 | use super::*; | ||
| 241 | use std::process::Command; | ||
| 242 | use tempfile::TempDir; | ||
| 243 | |||
| 244 | fn init_bare(path: &Path) -> git2::Repository { | ||
| 245 | git2::Repository::init_bare(path).unwrap() | ||
| 246 | } | ||
| 247 | |||
| 248 | #[test] | ||
| 249 | fn hooks_live_beside_the_object_store_for_bare_repos() { | ||
| 250 | assert_eq!( | ||
| 251 | hooks_dir(Path::new("/srv/git/t.git"), true), | ||
| 252 | PathBuf::from("/srv/git/t.git/hooks") | ||
| 253 | ); | ||
| 254 | assert_eq!( | ||
| 255 | hooks_dir(Path::new("/srv/git/t"), false), | ||
| 256 | PathBuf::from("/srv/git/t/.git/hooks") | ||
| 257 | ); | ||
| 258 | } | ||
| 259 | |||
| 260 | #[test] | ||
| 261 | fn installed_hook_is_executable_and_names_this_binary() { | ||
| 262 | let tmp = TempDir::new().unwrap(); | ||
| 263 | let repo = tmp.path().join("t.git"); | ||
| 264 | init_bare(&repo); | ||
| 265 | install(&repo, true).unwrap(); | ||
| 266 | |||
| 267 | let path = repo.join("hooks").join(HOOK_NAME); | ||
| 268 | let script = std::fs::read_to_string(&path).unwrap(); | ||
| 269 | assert!(script.starts_with("#!/bin/sh"), "got {script}"); | ||
| 270 | assert!(script.contains("--governance-hook"), "got {script}"); | ||
| 271 | assert!( | ||
| 272 | script.contains(std::env::current_exe().unwrap().to_str().unwrap()), | ||
| 273 | "got {script}" | ||
| 274 | ); | ||
| 275 | |||
| 276 | #[cfg(unix)] | ||
| 277 | { | ||
| 278 | use std::os::unix::fs::PermissionsExt; | ||
| 279 | let mode = std::fs::metadata(&path).unwrap().permissions().mode(); | ||
| 280 | assert_eq!(mode & 0o111, 0o111, "hook must be executable"); | ||
| 281 | } | ||
| 282 | } | ||
| 283 | |||
| 284 | #[test] | ||
| 285 | fn installing_twice_refreshes_rather_than_appends() { | ||
| 286 | let tmp = TempDir::new().unwrap(); | ||
| 287 | let repo = tmp.path().join("t.git"); | ||
| 288 | init_bare(&repo); | ||
| 289 | install(&repo, true).unwrap(); | ||
| 290 | let first = std::fs::read_to_string(repo.join("hooks").join(HOOK_NAME)).unwrap(); | ||
| 291 | install(&repo, true).unwrap(); | ||
| 292 | let second = std::fs::read_to_string(repo.join("hooks").join(HOOK_NAME)).unwrap(); | ||
| 293 | assert_eq!(first, second); | ||
| 294 | } | ||
| 295 | |||
| 296 | #[test] | ||
| 297 | fn install_replaces_a_hook_that_was_tampered_with() { | ||
| 298 | let tmp = TempDir::new().unwrap(); | ||
| 299 | let repo = tmp.path().join("t.git"); | ||
| 300 | init_bare(&repo); | ||
| 301 | std::fs::create_dir_all(repo.join("hooks")).unwrap(); | ||
| 302 | std::fs::write(repo.join("hooks").join(HOOK_NAME), "#!/bin/sh\nexit 0\n").unwrap(); | ||
| 303 | |||
| 304 | install(&repo, true).unwrap(); | ||
| 305 | |||
| 306 | let script = std::fs::read_to_string(repo.join("hooks").join(HOOK_NAME)).unwrap(); | ||
| 307 | assert!(script.contains("--governance-hook"), "got {script}"); | ||
| 308 | } | ||
| 309 | |||
| 310 | /// A repository with commits, so ancestry questions have an answer. | ||
| 311 | fn repo_with_two_commits() -> (TempDir, git2::Repository, git2::Oid, git2::Oid) { | ||
| 312 | let tmp = TempDir::new().unwrap(); | ||
| 313 | let path = tmp.path().join("work"); | ||
| 314 | std::fs::create_dir_all(&path).unwrap(); | ||
| 315 | for args in [ | ||
| 316 | vec!["init", "-q", "-b", "main"], | ||
| 317 | vec!["config", "user.email", "t@example.com"], | ||
| 318 | vec!["config", "user.name", "T"], | ||
| 319 | ] { | ||
| 320 | Command::new("git") | ||
| 321 | .args(&args) | ||
| 322 | .current_dir(&path) | ||
| 323 | .output() | ||
| 324 | .unwrap(); | ||
| 325 | } | ||
| 326 | std::fs::write(path.join("a"), "1").unwrap(); | ||
| 327 | Command::new("git") | ||
| 328 | .args(["add", "-A"]) | ||
| 329 | .current_dir(&path) | ||
| 330 | .output() | ||
| 331 | .unwrap(); | ||
| 332 | Command::new("git") | ||
| 333 | .args(["commit", "-qm", "one"]) | ||
| 334 | .current_dir(&path) | ||
| 335 | .output() | ||
| 336 | .unwrap(); | ||
| 337 | let repo = git2::Repository::open(&path).unwrap(); | ||
| 338 | let first = repo.head().unwrap().peel_to_commit().unwrap().id(); | ||
| 339 | |||
| 340 | std::fs::write(path.join("a"), "2").unwrap(); | ||
| 341 | Command::new("git") | ||
| 342 | .args(["commit", "-qam", "two"]) | ||
| 343 | .current_dir(&path) | ||
| 344 | .output() | ||
| 345 | .unwrap(); | ||
| 346 | let repo = git2::Repository::open(&path).unwrap(); | ||
| 347 | let second = repo.head().unwrap().peel_to_commit().unwrap().id(); | ||
| 348 | |||
| 349 | (tmp, repo, first, second) | ||
| 350 | } | ||
| 351 | |||
| 352 | #[test] | ||
| 353 | fn creating_and_fast_forwarding_need_write_rewinding_and_deleting_need_plus() { | ||
| 354 | let (_tmp, repo, first, second) = repo_with_two_commits(); | ||
| 355 | let zero = git2::Oid::zero(); | ||
| 356 | |||
| 357 | assert_eq!(required_access(&repo, zero, first), Access::Write); | ||
| 358 | assert_eq!(required_access(&repo, first, second), Access::Write); | ||
| 359 | assert_eq!(required_access(&repo, second, first), Access::Rewind); | ||
| 360 | assert_eq!(required_access(&repo, first, zero), Access::Rewind); | ||
| 361 | } | ||
| 362 | |||
| 363 | #[test] | ||
| 364 | fn an_unreadable_ancestry_is_treated_as_a_rewind_not_a_fast_forward() { | ||
| 365 | let (_tmp, repo, first, _second) = repo_with_two_commits(); | ||
| 366 | let missing = git2::Oid::from_str("0123456789012345678901234567890123456789").unwrap(); | ||
| 367 | assert_eq!(required_access(&repo, first, missing), Access::Rewind); | ||
| 368 | } | ||
| 369 | } | ||
src/server/governance/keydir.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,259 @@ | |||
| 1 | //! `keydir/` — the roster of public keys held in `settings.git`. | ||
| 2 | //! | ||
| 3 | //! The identity is the **basename** of the key file with directories ignored, | ||
| 4 | //! as in gitolite: `keydir/laptop/alex.pub` and `keydir/desktop/alex.pub` are | ||
| 5 | //! both `alex`. That is the whole answer to one operator with several machines | ||
| 6 | //! — adding a device is adding a file, revoking one is `git rm`, and the diff | ||
| 7 | //! is legible in review. | ||
| 8 | //! | ||
| 9 | //! On the wire the principal is still the key fingerprint. This maps | ||
| 10 | //! fingerprint to name, so names appear in `conf/access.conf` and fingerprints | ||
| 11 | //! appear on the connection. A fingerprint with no name here is not a | ||
| 12 | //! principal at all. | ||
| 13 | |||
| 14 | use std::collections::HashMap; | ||
| 15 | |||
| 16 | use russh::keys::PublicKey; | ||
| 17 | |||
| 18 | #[derive(Debug, thiserror::Error)] | ||
| 19 | pub enum KeyDirError { | ||
| 20 | #[error("{path}: not a well-formed OpenSSH public key: {source}")] | ||
| 21 | Malformed { | ||
| 22 | path: String, | ||
| 23 | #[source] | ||
| 24 | source: russh::keys::ssh_key::Error, | ||
| 25 | }, | ||
| 26 | #[error("{path}: {reason}")] | ||
| 27 | BadName { path: String, reason: String }, | ||
| 28 | #[error( | ||
| 29 | "{path}: this key is already enrolled as {existing:?}; one key cannot be two principals" | ||
| 30 | )] | ||
| 31 | Ambiguous { path: String, existing: String }, | ||
| 32 | } | ||
| 33 | |||
| 34 | /// Fingerprint-to-name mapping built from `keydir/`. | ||
| 35 | #[derive(Debug, Default)] | ||
| 36 | pub struct KeyDir { | ||
| 37 | /// Principal string (`key:SHA256:…`) to the name it resolves to. | ||
| 38 | by_fingerprint: HashMap<String, String>, | ||
| 39 | /// Every distinct name, for validation questions like "does anyone still | ||
| 40 | /// hold RW+ on settings". | ||
| 41 | names: Vec<String>, | ||
| 42 | } | ||
| 43 | |||
| 44 | /// The name a key file grants, from its path. | ||
| 45 | /// | ||
| 46 | /// Directories are ignored entirely and one trailing `.pub` is stripped. A | ||
| 47 | /// path that is not a `.pub` file grants nothing — `keydir/README` is not an | ||
| 48 | /// identity. | ||
| 49 | pub fn name_for_path(path: &str) -> Option<&str> { | ||
| 50 | let file = path.rsplit('/').next()?; | ||
| 51 | let name = file.strip_suffix(".pub")?; | ||
| 52 | if name.is_empty() { | ||
| 53 | return None; | ||
| 54 | } | ||
| 55 | Some(name) | ||
| 56 | } | ||
| 57 | |||
| 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 | ||
| 60 | /// reference or the `CREATOR` keyword. | ||
| 61 | fn validate_name(name: &str) -> Result<(), String> { | ||
| 62 | if name == "CREATOR" { | ||
| 63 | return Err("CREATOR is a reserved keyword and cannot name a key".to_string()); | ||
| 64 | } | ||
| 65 | if name.starts_with('@') { | ||
| 66 | return Err("a key name cannot start with @; that is group syntax".to_string()); | ||
| 67 | } | ||
| 68 | if !name | ||
| 69 | .chars() | ||
| 70 | .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@' | '+')) | ||
| 71 | { | ||
| 72 | return Err(format!( | ||
| 73 | "{name:?} contains characters that cannot appear in a principal name" | ||
| 74 | )); | ||
| 75 | } | ||
| 76 | Ok(()) | ||
| 77 | } | ||
| 78 | |||
| 79 | impl KeyDir { | ||
| 80 | pub fn new() -> Self { | ||
| 81 | Self::default() | ||
| 82 | } | ||
| 83 | |||
| 84 | /// Enrol one key file. `path` is the path within the settings repository, | ||
| 85 | /// e.g. `keydir/laptop/alex.pub`; only its basename decides the name. | ||
| 86 | /// | ||
| 87 | /// Enrolling the same key under the same name twice is a no-op (two | ||
| 88 | /// identical files, which is harmless). Enrolling it under two *different* | ||
| 89 | /// names is an error: a fingerprint that resolved to either name depending | ||
| 90 | /// on iteration order would be an authorization coin-flip. | ||
| 91 | pub fn insert(&mut self, path: &str, content: &str) -> Result<(), KeyDirError> { | ||
| 92 | let Some(name) = name_for_path(path) else { | ||
| 93 | // Not a .pub file. Silently ignored, so a README or a .gitkeep in | ||
| 94 | // keydir/ does not fail an otherwise valid config push. | ||
| 95 | return Ok(()); | ||
| 96 | }; | ||
| 97 | validate_name(name).map_err(|reason| KeyDirError::BadName { | ||
| 98 | path: path.to_string(), | ||
| 99 | reason, | ||
| 100 | })?; | ||
| 101 | |||
| 102 | let key = | ||
| 103 | PublicKey::from_openssh(content.trim()).map_err(|source| KeyDirError::Malformed { | ||
| 104 | path: path.to_string(), | ||
| 105 | source, | ||
| 106 | })?; | ||
| 107 | let fingerprint = crate::ssh::session::ssh_key_principal(&key); | ||
| 108 | |||
| 109 | match self.by_fingerprint.get(&fingerprint) { | ||
| 110 | Some(existing) if existing == name => return Ok(()), | ||
| 111 | Some(existing) => { | ||
| 112 | return Err(KeyDirError::Ambiguous { | ||
| 113 | path: path.to_string(), | ||
| 114 | existing: existing.clone(), | ||
| 115 | }) | ||
| 116 | } | ||
| 117 | None => {} | ||
| 118 | } | ||
| 119 | |||
| 120 | self.by_fingerprint.insert(fingerprint, name.to_string()); | ||
| 121 | if !self.names.iter().any(|n| n == name) { | ||
| 122 | self.names.push(name.to_string()); | ||
| 123 | } | ||
| 124 | Ok(()) | ||
| 125 | } | ||
| 126 | |||
| 127 | /// The name this principal string resolves to, or `None` if the key is not | ||
| 128 | /// enrolled — in which case it is not a principal and gets no access. | ||
| 129 | pub fn name_for(&self, principal: &str) -> Option<&str> { | ||
| 130 | self.by_fingerprint.get(principal).map(String::as_str) | ||
| 131 | } | ||
| 132 | |||
| 133 | pub fn names(&self) -> &[String] { | ||
| 134 | &self.names | ||
| 135 | } | ||
| 136 | |||
| 137 | pub fn is_empty(&self) -> bool { | ||
| 138 | self.by_fingerprint.is_empty() | ||
| 139 | } | ||
| 140 | } | ||
| 141 | |||
| 142 | #[cfg(test)] | ||
| 143 | mod tests { | ||
| 144 | use super::*; | ||
| 145 | |||
| 146 | /// Throwaway PUBLIC keys generated for these tests. They guard nothing and | ||
| 147 | /// have no matching private key anywhere in the tree. | ||
| 148 | /// | ||
| 149 | /// The expected fingerprints are OpenSSH's own answers, taken out of band | ||
| 150 | /// with `ssh-keygen -lf`, not values computed the way the code computes | ||
| 151 | /// them — so this asserts agreement with an external oracle rather than | ||
| 152 | /// self-consistency. | ||
| 153 | const KEY_A: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2PlPIF/fKLCQvCHhIUX2FKpQRflVl6CNoQ8aFjIxdG governance-test-a@git-collab"; | ||
| 154 | const FP_A: &str = "key:SHA256:h9V15zrr/EYDfNMPefKR+Gf2PpXdfw8M7Fvu9zLjjqY"; | ||
| 155 | const KEY_B: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaUXZkX8MTYd4ztPAb11azBoq42VDJowOS3Zuj9jZlg governance-test-b@git-collab"; | ||
| 156 | const FP_B: &str = "key:SHA256:GauEGK/qjDwFjqhK9/tOqTzcjmf48HCMgBUX6sddJg4"; | ||
| 157 | |||
| 158 | #[test] | ||
| 159 | fn a_key_at_the_top_level_is_named_by_its_basename() { | ||
| 160 | let mut dir = KeyDir::new(); | ||
| 161 | dir.insert("keydir/alex.pub", KEY_A).unwrap(); | ||
| 162 | assert_eq!(dir.name_for(FP_A), Some("alex")); | ||
| 163 | } | ||
| 164 | |||
| 165 | /// The load-bearing case: one operator, two machines, one identity. | ||
| 166 | #[test] | ||
| 167 | fn two_keys_in_different_directories_are_one_principal() { | ||
| 168 | let mut dir = KeyDir::new(); | ||
| 169 | dir.insert("keydir/laptop/alex.pub", KEY_A).unwrap(); | ||
| 170 | dir.insert("keydir/desktop/alex.pub", KEY_B).unwrap(); | ||
| 171 | |||
| 172 | assert_eq!(dir.name_for(FP_A), Some("alex")); | ||
| 173 | assert_eq!(dir.name_for(FP_B), Some("alex")); | ||
| 174 | assert_eq!(dir.names(), ["alex"], "one identity, not two"); | ||
| 175 | } | ||
| 176 | |||
| 177 | #[test] | ||
| 178 | fn different_basenames_are_different_principals() { | ||
| 179 | let mut dir = KeyDir::new(); | ||
| 180 | dir.insert("keydir/alex.pub", KEY_A).unwrap(); | ||
| 181 | dir.insert("keydir/claude-a.pub", KEY_B).unwrap(); | ||
| 182 | assert_eq!(dir.name_for(FP_A), Some("alex")); | ||
| 183 | assert_eq!(dir.name_for(FP_B), Some("claude-a")); | ||
| 184 | assert_eq!(dir.names().len(), 2); | ||
| 185 | } | ||
| 186 | |||
| 187 | #[test] | ||
| 188 | fn nesting_depth_is_irrelevant() { | ||
| 189 | let mut dir = KeyDir::new(); | ||
| 190 | dir.insert("keydir/a/b/c/alex.pub", KEY_A).unwrap(); | ||
| 191 | assert_eq!(dir.name_for(FP_A), Some("alex")); | ||
| 192 | } | ||
| 193 | |||
| 194 | #[test] | ||
| 195 | fn an_unenrolled_fingerprint_resolves_to_nothing() { | ||
| 196 | let mut dir = KeyDir::new(); | ||
| 197 | dir.insert("keydir/alex.pub", KEY_A).unwrap(); | ||
| 198 | assert_eq!(dir.name_for(FP_B), None); | ||
| 199 | assert_eq!(dir.name_for("key:SHA256:nonsense"), None); | ||
| 200 | } | ||
| 201 | |||
| 202 | #[test] | ||
| 203 | fn the_same_file_twice_is_idempotent() { | ||
| 204 | let mut dir = KeyDir::new(); | ||
| 205 | dir.insert("keydir/alex.pub", KEY_A).unwrap(); | ||
| 206 | dir.insert("keydir/laptop/alex.pub", KEY_A).unwrap(); | ||
| 207 | assert_eq!(dir.names(), ["alex"]); | ||
| 208 | } | ||
| 209 | |||
| 210 | #[test] | ||
| 211 | fn one_key_under_two_names_is_rejected() { | ||
| 212 | let mut dir = KeyDir::new(); | ||
| 213 | dir.insert("keydir/alex.pub", KEY_A).unwrap(); | ||
| 214 | let err = dir.insert("keydir/mallory.pub", KEY_A).unwrap_err(); | ||
| 215 | assert!(err.to_string().contains("alex"), "got {err}"); | ||
| 216 | } | ||
| 217 | |||
| 218 | #[test] | ||
| 219 | fn a_malformed_key_is_rejected() { | ||
| 220 | let mut dir = KeyDir::new(); | ||
| 221 | let err = dir | ||
| 222 | .insert("keydir/alex.pub", "not a key at all") | ||
| 223 | .unwrap_err(); | ||
| 224 | assert!(err.to_string().contains("keydir/alex.pub"), "got {err}"); | ||
| 225 | } | ||
| 226 | |||
| 227 | #[test] | ||
| 228 | fn non_pub_files_are_ignored() { | ||
| 229 | let mut dir = KeyDir::new(); | ||
| 230 | dir.insert("keydir/README", "this is not a key").unwrap(); | ||
| 231 | dir.insert("keydir/.gitkeep", "").unwrap(); | ||
| 232 | assert!(dir.is_empty()); | ||
| 233 | } | ||
| 234 | |||
| 235 | #[test] | ||
| 236 | fn reserved_and_malformed_names_are_rejected() { | ||
| 237 | let mut dir = KeyDir::new(); | ||
| 238 | assert!(dir.insert("keydir/CREATOR.pub", KEY_A).is_err()); | ||
| 239 | assert!(dir.insert("keydir/@admins.pub", KEY_A).is_err()); | ||
| 240 | assert!(dir.insert("keydir/has space.pub", KEY_A).is_err()); | ||
| 241 | } | ||
| 242 | |||
| 243 | #[test] | ||
| 244 | fn trailing_whitespace_in_a_key_file_is_tolerated() { | ||
| 245 | let mut dir = KeyDir::new(); | ||
| 246 | dir.insert("keydir/alex.pub", &format!("{KEY_A}\n\n")) | ||
| 247 | .unwrap(); | ||
| 248 | assert_eq!(dir.name_for(FP_A), Some("alex")); | ||
| 249 | } | ||
| 250 | |||
| 251 | #[test] | ||
| 252 | fn name_for_path_ignores_directories_and_requires_pub() { | ||
| 253 | assert_eq!(name_for_path("keydir/alex.pub"), Some("alex")); | ||
| 254 | assert_eq!(name_for_path("keydir/laptop/alex.pub"), Some("alex")); | ||
| 255 | assert_eq!(name_for_path("alex.pub"), Some("alex")); | ||
| 256 | assert_eq!(name_for_path("keydir/alex"), None); | ||
| 257 | assert_eq!(name_for_path("keydir/.pub"), None); | ||
| 258 | } | ||
| 259 | } | ||
src/server/governance/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,345 @@ | |||
| 1 | //! Governance by a settings repository, in the style of gitolite. | ||
| 2 | //! | ||
| 3 | //! `<repos_dir>/settings.git` holds two things: | ||
| 4 | //! | ||
| 5 | //! ```text | ||
| 6 | //! conf/access.conf ordered access rules (see conf.rs) | ||
| 7 | //! keydir/**/<name>.pub the key roster (see keydir.rs) | ||
| 8 | //! ``` | ||
| 9 | //! | ||
| 10 | //! Pushing to that repository reconfigures the server. Nothing else does: | ||
| 11 | //! there is no reload signal, no restart, and no file on the host to edit. | ||
| 12 | //! | ||
| 13 | //! # Why the config is validated on push | ||
| 14 | //! | ||
| 15 | //! A push to `settings` is validated *before* the ref update is accepted — the | ||
| 16 | //! rules must parse, every key must be well-formed, and at least one principal | ||
| 17 | //! must still be able to push the config afterwards. A push that fails any of | ||
| 18 | //! those is rejected and the previously-live config keeps governing. | ||
| 19 | //! | ||
| 20 | //! That is not a nicety. It is what lets the request path assume its config is | ||
| 21 | //! well-formed, which deletes two whole failure modes: there is no | ||
| 22 | //! malformed-policy branch to decide at request time, and no bounded-staleness | ||
| 23 | //! window in which a superseded config is still in force. The validation runs | ||
| 24 | //! even on a server that is not yet governed, so the *first* config can never | ||
| 25 | //! be a broken one either. | ||
| 26 | //! | ||
| 27 | //! # How this interacts with the per-repo `server.toml` | ||
| 28 | //! | ||
| 29 | //! `<repo>.git/.collab/server.toml` already carries per-repo settings, some of | ||
| 30 | //! which look like access control. The two are split by **axis**, and the split | ||
| 31 | //! is total, so they cannot disagree: | ||
| 32 | //! | ||
| 33 | //! | Axis | Authority when governed | Authority when ungoverned | | ||
| 34 | //! |---|---|---| | ||
| 35 | //! | An authenticated principal's access | `conf/access.conf`, alone | `server.toml`'s `[access]` | | ||
| 36 | //! | Which keys authenticate at all | `keydir/` | the `authorized_keys` file | | ||
| 37 | //! | The anonymous HTTP surface | `server.toml` | `server.toml` | | ||
| 38 | //! | ||
| 39 | //! So when `settings.git` is present, `access.conf` **supersedes** | ||
| 40 | //! `server.toml`'s `[access] read`/`write` outright: those lists are not | ||
| 41 | //! consulted, not intersected, not unioned. `keydir/` likewise supersedes | ||
| 42 | //! `authorized_keys`. | ||
| 43 | //! | ||
| 44 | //! Superseding rather than layering is deliberate. Layering (requiring both to | ||
| 45 | //! allow) would let a `server.toml` nobody remembers editing silently subtract | ||
| 46 | //! access that `access.conf` visibly grants — two authorization systems | ||
| 47 | //! disagreeing, with the disagreement invisible in the file you are reading. | ||
| 48 | //! Superseding means exactly one file answers the question. | ||
| 49 | //! | ||
| 50 | //! `visibility`, `[ui] anonymous` and `[http] anonymous_clone` stay in | ||
| 51 | //! `server.toml` because they are a different question. `access.conf` grants | ||
| 52 | //! access to *named principals*; `@all` means every enrolled key, not the | ||
| 53 | //! public. An anonymous HTTP request has no principal at all, so there is | ||
| 54 | //! nothing for a rule to match and no rule that could express it. That is why | ||
| 55 | //! `server.toml` survives this change rather than being deleted. | ||
| 56 | //! | ||
| 57 | //! When `settings.git` does not exist, none of this is on: authentication, | ||
| 58 | //! authorization and repo creation behave exactly as they did before, and no | ||
| 59 | //! hook is installed on any repository except `settings` itself. | ||
| 60 | |||
| 61 | pub mod conf; | ||
| 62 | pub mod hook; | ||
| 63 | pub mod keydir; | ||
| 64 | |||
| 65 | use std::path::{Path, PathBuf}; | ||
| 66 | |||
| 67 | use conf::{Access, AccessConf, Subject}; | ||
| 68 | use keydir::KeyDir; | ||
| 69 | |||
| 70 | /// The repository that governs the server. Not a name an operator may reuse. | ||
| 71 | pub const SETTINGS_REPO: &str = "settings"; | ||
| 72 | |||
| 73 | pub(crate) const ACCESS_CONF_PATH: &str = "conf/access.conf"; | ||
| 74 | const KEYDIR: &str = "keydir"; | ||
| 75 | |||
| 76 | /// The file inside a repository recording which principal created it, for | ||
| 77 | /// `CREATOR` in wild-repo rules. It lives beside `server.toml` under | ||
| 78 | /// `.collab/`, which is server-owned and outside every ref namespace, so a | ||
| 79 | /// principal cannot rewrite its way into ownership of someone else's repo. | ||
| 80 | const CREATOR_FILE: &str = "creator"; | ||
| 81 | |||
| 82 | /// A loaded, validated configuration. | ||
| 83 | #[derive(Debug)] | ||
| 84 | pub struct Governance { | ||
| 85 | pub conf: AccessConf, | ||
| 86 | pub keys: KeyDir, | ||
| 87 | } | ||
| 88 | |||
| 89 | /// What the server found when it looked for `settings.git`. | ||
| 90 | #[derive(Debug)] | ||
| 91 | pub enum GovernanceState { | ||
| 92 | /// No settings repository, or one that has never been populated. The | ||
| 93 | /// server is ungoverned and behaves exactly as it did before this feature. | ||
| 94 | /// | ||
| 95 | /// An unborn HEAD or a missing `conf/access.conf` counts as absent rather | ||
| 96 | /// than as an error: those are authoritative absences (`NotFound`), and | ||
| 97 | /// treating them as failures would mean a bare `git init settings.git` | ||
| 98 | /// bricked the server with no way in. | ||
| 99 | Absent, | ||
| 100 | Active(Box<Governance>), | ||
| 101 | /// The settings repository exists and should be readable, but reading it | ||
| 102 | /// failed for a reason that is not an absence. Everything is closed: a | ||
| 103 | /// server that cannot read its rules must not guess at them. | ||
| 104 | Unreadable(String), | ||
| 105 | } | ||
| 106 | |||
| 107 | impl Governance { | ||
| 108 | /// Resolve a connection's key fingerprint to the name rules are written | ||
| 109 | /// against. `None` means the key is not enrolled, and so is not a | ||
| 110 | /// principal at all. | ||
| 111 | pub fn name_for(&self, principal_fingerprint: &str) -> Option<&str> { | ||
| 112 | self.keys.name_for(principal_fingerprint) | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | /// The lookup key for a repository: its path relative to the storage | ||
| 117 | /// directory, with one trailing `.git` removed and `/` separators preserved. | ||
| 118 | /// | ||
| 119 | /// `private/tools.git` and `tools.git` are therefore distinct keys, matched | ||
| 120 | /// byte-exactly against the names in `conf/access.conf`. | ||
| 121 | pub fn repo_key(repos_dir: &Path, repo_path: &Path) -> Option<String> { | ||
| 122 | let relative = repo_path.strip_prefix(repos_dir).ok()?; | ||
| 123 | let text = relative.to_str()?; | ||
| 124 | if text.is_empty() { | ||
| 125 | return None; | ||
| 126 | } | ||
| 127 | Some(text.strip_suffix(".git").unwrap_or(text).to_string()) | ||
| 128 | } | ||
| 129 | |||
| 130 | fn settings_repo_path(repos_dir: &Path) -> PathBuf { | ||
| 131 | repos_dir.join(format!("{SETTINGS_REPO}.git")) | ||
| 132 | } | ||
| 133 | |||
| 134 | /// Read the current configuration from `settings.git`. | ||
| 135 | /// | ||
| 136 | /// Called per request rather than cached, which is what makes a config push | ||
| 137 | /// take effect on the next request with no restart. An already-authenticated | ||
| 138 | /// connection is not re-authenticated, but every command it goes on to issue | ||
| 139 | /// is authorized against a freshly read config. | ||
| 140 | pub fn load(repos_dir: &Path) -> GovernanceState { | ||
| 141 | let path = settings_repo_path(repos_dir); | ||
| 142 | if !path.exists() { | ||
| 143 | return GovernanceState::Absent; | ||
| 144 | } | ||
| 145 | |||
| 146 | let repo = match git2::Repository::open_bare(&path) { | ||
| 147 | Ok(repo) => repo, | ||
| 148 | Err(e) if e.code() == git2::ErrorCode::NotFound => return GovernanceState::Absent, | ||
| 149 | Err(e) => { | ||
| 150 | return GovernanceState::Unreadable(format!("cannot open {}: {e}", path.display())) | ||
| 151 | } | ||
| 152 | }; | ||
| 153 | |||
| 154 | let tree = match repo.head().and_then(|head| head.peel_to_commit()) { | ||
| 155 | Ok(commit) => match commit.tree() { | ||
| 156 | Ok(tree) => tree, | ||
| 157 | Err(e) => return GovernanceState::Unreadable(format!("settings tree: {e}")), | ||
| 158 | }, | ||
| 159 | // An unborn or missing HEAD is an absence, not a failure. | ||
| 160 | Err(e) | ||
| 161 | if e.code() == git2::ErrorCode::NotFound | ||
| 162 | || e.code() == git2::ErrorCode::UnbornBranch => | ||
| 163 | { | ||
| 164 | return GovernanceState::Absent | ||
| 165 | } | ||
| 166 | Err(e) => return GovernanceState::Unreadable(format!("settings HEAD: {e}")), | ||
| 167 | }; | ||
| 168 | |||
| 169 | match read_tree(&repo, &tree) { | ||
| 170 | Ok(Some(governance)) => GovernanceState::Active(Box::new(governance)), | ||
| 171 | Ok(None) => GovernanceState::Absent, | ||
| 172 | Err(e) => GovernanceState::Unreadable(e), | ||
| 173 | } | ||
| 174 | } | ||
| 175 | |||
| 176 | /// Build a `Governance` from a settings tree. | ||
| 177 | /// | ||
| 178 | /// `Ok(None)` means there is no `conf/access.conf` in the tree at all, which | ||
| 179 | /// is an absence. `Err` means there is one and it is unusable — which the push | ||
| 180 | /// validation is supposed to have made impossible, so reaching it means | ||
| 181 | /// somebody edited the repository out of band. | ||
| 182 | fn read_tree(repo: &git2::Repository, tree: &git2::Tree<'_>) -> Result<Option<Governance>, String> { | ||
| 183 | let entry = match tree.get_path(Path::new(ACCESS_CONF_PATH)) { | ||
| 184 | Ok(entry) => entry, | ||
| 185 | Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(None), | ||
| 186 | Err(e) => return Err(format!("{ACCESS_CONF_PATH}: {e}")), | ||
| 187 | }; | ||
| 188 | let source = blob_text(repo, entry.id()).map_err(|e| format!("{ACCESS_CONF_PATH}: {e}"))?; | ||
| 189 | let access = AccessConf::parse(&source).map_err(|e| format!("{ACCESS_CONF_PATH}: {e}"))?; | ||
| 190 | let keys = read_keydir(repo, tree)?; | ||
| 191 | Ok(Some(Governance { conf: access, keys })) | ||
| 192 | } | ||
| 193 | |||
| 194 | fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Result<String, String> { | ||
| 195 | let blob = repo.find_blob(oid).map_err(|e| e.to_string())?; | ||
| 196 | String::from_utf8(blob.content().to_vec()).map_err(|_| "not valid UTF-8".to_string()) | ||
| 197 | } | ||
| 198 | |||
| 199 | fn read_keydir(repo: &git2::Repository, tree: &git2::Tree<'_>) -> Result<KeyDir, String> { | ||
| 200 | let mut keys = KeyDir::new(); | ||
| 201 | let keydir = match tree.get_path(Path::new(KEYDIR)) { | ||
| 202 | Ok(entry) => entry, | ||
| 203 | Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(keys), | ||
| 204 | Err(e) => return Err(format!("{KEYDIR}: {e}")), | ||
| 205 | }; | ||
| 206 | let keydir = match keydir.to_object(repo).and_then(|o| o.peel_to_tree()) { | ||
| 207 | Ok(tree) => tree, | ||
| 208 | Err(e) => return Err(format!("{KEYDIR}: {e}")), | ||
| 209 | }; | ||
| 210 | |||
| 211 | // Collect first, insert after: the walk callback cannot return a Rust | ||
| 212 | // error, and swallowing one would enrol a partial roster. | ||
| 213 | let mut blobs: Vec<(String, git2::Oid)> = Vec::new(); | ||
| 214 | keydir | ||
| 215 | .walk(git2::TreeWalkMode::PreOrder, |root, entry| { | ||
| 216 | if entry.kind() == Some(git2::ObjectType::Blob) { | ||
| 217 | if let Some(name) = entry.name() { | ||
| 218 | blobs.push((format!("{KEYDIR}/{root}{name}"), entry.id())); | ||
| 219 | } | ||
| 220 | } | ||
| 221 | git2::TreeWalkResult::Ok | ||
| 222 | }) | ||
| 223 | .map_err(|e| format!("{KEYDIR}: {e}"))?; | ||
| 224 | |||
| 225 | // Sorted so a roster error is reported deterministically rather than | ||
| 226 | // depending on tree iteration order. | ||
| 227 | blobs.sort(); | ||
| 228 | for (path, oid) in blobs { | ||
| 229 | let content = blob_text(repo, oid).map_err(|e| format!("{path}: {e}"))?; | ||
| 230 | keys.insert(&path, &content).map_err(|e| e.to_string())?; | ||
| 231 | } | ||
| 232 | Ok(keys) | ||
| 233 | } | ||
| 234 | |||
| 235 | /// Validate a proposed `settings` tree, as the push hook does. | ||
| 236 | /// | ||
| 237 | /// `landing_ref` is the ref this push would update; the lockout check asks | ||
| 238 | /// whether anyone could still push *that* ref once this config is live, which | ||
| 239 | /// is the precise condition for "the operator can still get back in". | ||
| 240 | pub fn validate_settings_tree( | ||
| 241 | repo: &git2::Repository, | ||
| 242 | tree: &git2::Tree<'_>, | ||
| 243 | landing_ref: &str, | ||
| 244 | ) -> Result<Governance, String> { | ||
| 245 | let governance = match read_tree(repo, tree)? { | ||
| 246 | Some(governance) => governance, | ||
| 247 | None => { | ||
| 248 | return Err(format!( | ||
| 249 | "{ACCESS_CONF_PATH} is missing; removing it would turn governance off entirely" | ||
| 250 | )) | ||
| 251 | } | ||
| 252 | }; | ||
| 253 | |||
| 254 | if governance.keys.is_empty() { | ||
| 255 | return Err(format!( | ||
| 256 | "{KEYDIR}/ enrols no keys; nobody could authenticate afterwards" | ||
| 257 | )); | ||
| 258 | } | ||
| 259 | |||
| 260 | let retains_control = governance.keys.names().iter().any(|name| { | ||
| 261 | governance.conf.allows_ref( | ||
| 262 | SETTINGS_REPO, | ||
| 263 | &Subject::new(name), | ||
| 264 | landing_ref, | ||
| 265 | Access::Rewind, | ||
| 266 | ) | ||
| 267 | }); | ||
| 268 | if !retains_control { | ||
| 269 | return Err(format!( | ||
| 270 | "no principal would retain RW+ on {SETTINGS_REPO} at {landing_ref}; \ | ||
| 271 | this config would lock everyone out" | ||
| 272 | )); | ||
| 273 | } | ||
| 274 | |||
| 275 | Ok(governance) | ||
| 276 | } | ||
| 277 | |||
| 278 | /// Record which principal created a repository, for `CREATOR` rules. | ||
| 279 | pub fn record_creator(repo_path: &Path, name: &str) -> std::io::Result<()> { | ||
| 280 | let dir = repo_path.join(".collab"); | ||
| 281 | std::fs::create_dir_all(&dir)?; | ||
| 282 | std::fs::write(dir.join(CREATOR_FILE), format!("{name}\n")) | ||
| 283 | } | ||
| 284 | |||
| 285 | /// The recorded creator of a repository, if it has one. | ||
| 286 | pub fn creator_of(repo_path: &Path) -> Option<String> { | ||
| 287 | let text = std::fs::read_to_string(repo_path.join(".collab").join(CREATOR_FILE)).ok()?; | ||
| 288 | let name = text.trim(); | ||
| 289 | if name.is_empty() { | ||
| 290 | None | ||
| 291 | } else { | ||
| 292 | Some(name.to_string()) | ||
| 293 | } | ||
| 294 | } | ||
| 295 | |||
| 296 | #[cfg(test)] | ||
| 297 | mod tests { | ||
| 298 | use super::*; | ||
| 299 | |||
| 300 | #[test] | ||
| 301 | fn repo_key_strips_one_dot_git_and_keeps_nesting() { | ||
| 302 | let root = Path::new("/srv/git"); | ||
| 303 | assert_eq!( | ||
| 304 | repo_key(root, Path::new("/srv/git/tools.git")).as_deref(), | ||
| 305 | Some("tools") | ||
| 306 | ); | ||
| 307 | assert_eq!( | ||
| 308 | repo_key(root, Path::new("/srv/git/private/tools.git")).as_deref(), | ||
| 309 | Some("private/tools") | ||
| 310 | ); | ||
| 311 | // A working-tree repo has no .git suffix to strip. | ||
| 312 | assert_eq!( | ||
| 313 | repo_key(root, Path::new("/srv/git/workspace")).as_deref(), | ||
| 314 | Some("workspace") | ||
| 315 | ); | ||
| 316 | // Only one suffix comes off. | ||
| 317 | assert_eq!( | ||
| 318 | repo_key(root, Path::new("/srv/git/odd.git.git")).as_deref(), | ||
| 319 | Some("odd.git") | ||
| 320 | ); | ||
| 321 | } | ||
| 322 | |||
| 323 | #[test] | ||
| 324 | fn repo_key_refuses_paths_outside_the_storage_directory() { | ||
| 325 | assert_eq!( | ||
| 326 | repo_key(Path::new("/srv/git"), Path::new("/etc/passwd")), | ||
| 327 | None | ||
| 328 | ); | ||
| 329 | assert_eq!(repo_key(Path::new("/srv/git"), Path::new("/srv/git")), None); | ||
| 330 | } | ||
| 331 | |||
| 332 | #[test] | ||
| 333 | fn a_missing_settings_repo_is_absent() { | ||
| 334 | let tmp = tempfile::TempDir::new().unwrap(); | ||
| 335 | assert!(matches!(load(tmp.path()), GovernanceState::Absent)); | ||
| 336 | } | ||
| 337 | |||
| 338 | #[test] | ||
| 339 | fn creator_round_trips_and_is_absent_by_default() { | ||
| 340 | let tmp = tempfile::TempDir::new().unwrap(); | ||
| 341 | assert_eq!(creator_of(tmp.path()), None); | ||
| 342 | record_creator(tmp.path(), "claude-a").unwrap(); | ||
| 343 | assert_eq!(creator_of(tmp.path()).as_deref(), Some("claude-a")); | ||
| 344 | } | ||
| 345 | } | ||
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,7 @@ use clap::Parser; | |||
| 4 | use tracing::info; | 4 | use tracing::info; |
| 5 | 5 | ||
| 6 | mod config; | 6 | mod config; |
| 7 | mod governance; | ||
| 7 | mod http; | 8 | mod http; |
| 8 | mod releases; | 9 | mod releases; |
| 9 | mod repos; | 10 | mod repos; |
| @@ -16,8 +17,21 @@ mod ssh; | |||
| 16 | about = "Minimal git hosting server" | 17 | about = "Minimal git hosting server" |
| 17 | )] | 18 | )] |
| 18 | struct Args { | 19 | struct Args { |
| 19 | #[arg(short, long)] | 20 | #[arg(short, long, required_unless_present = "governance_hook")] |
| 20 | config: PathBuf, | 21 | config: Option<PathBuf>, |
| 22 | |||
| 23 | /// Internal: run as the `update` hook this server installs in the | ||
| 24 | /// repositories it serves. Invoked by that hook script, never by hand — | ||
| 25 | /// it reads who is pushing from the environment `git-receive-pack` was | ||
| 26 | /// spawned with, and fails closed when that is absent. | ||
| 27 | #[arg( | ||
| 28 | long, | ||
| 29 | hide = true, | ||
| 30 | num_args = 3, | ||
| 31 | value_names = ["REF", "OLD", "NEW"], | ||
| 32 | conflicts_with = "config" | ||
| 33 | )] | ||
| 34 | governance_hook: Option<Vec<String>>, | ||
| 21 | } | 35 | } |
| 22 | 36 | ||
| 23 | #[tokio::main] | 37 | #[tokio::main] |
| @@ -26,10 +40,24 @@ async fn main() { | |||
| 26 | 40 | ||
| 27 | let args = Args::parse(); | 41 | let args = Args::parse(); |
| 28 | 42 | ||
| 29 | let config = match config::ServerConfig::from_file(&args.config) { | 43 | if let Some(hook_args) = args.governance_hook.as_deref() { |
| 44 | // clap's num_args = 3 guarantees the arity. | ||
| 45 | if let Err(reason) = governance::hook::run(&hook_args[0], &hook_args[1], &hook_args[2]) { | ||
| 46 | eprintln!("git-collab: {reason}"); | ||
| 47 | std::process::exit(1); | ||
| 48 | } | ||
| 49 | return; | ||
| 50 | } | ||
| 51 | |||
| 52 | // `required_unless_present` above leaves exactly one way to get here. | ||
| 53 | let config_path = args | ||
| 54 | .config | ||
| 55 | .expect("--config is required without --governance-hook"); | ||
| 56 | |||
| 57 | let config = match config::ServerConfig::from_file(&config_path) { | ||
| 30 | Ok(c) => c, | 58 | Ok(c) => c, |
| 31 | Err(e) => { | 59 | Err(e) => { |
| 32 | eprintln!("Failed to load config from {:?}: {}", args.config, e); | 60 | eprintln!("Failed to load config from {:?}: {}", config_path, e); |
| 33 | std::process::exit(1); | 61 | std::process::exit(1); |
| 34 | } | 62 | } |
| 35 | }; | 63 | }; |
src/server/repos.rs
| Old | New | ||
|---|---|---|---|
| @@ -108,6 +108,19 @@ impl RepoPolicy { | |||
| 108 | access_allows(&self.access.write, principal) | 108 | access_allows(&self.access.write, principal) |
| 109 | } | 109 | } |
| 110 | 110 | ||
| 111 | /// The same policy with every anonymous door shut, leaving what an | ||
| 112 | /// authenticated principal may do untouched. | ||
| 113 | fn without_anonymous_access(self) -> Self { | ||
| 114 | Self { | ||
| 115 | visibility: RepoVisibility::Private, | ||
| 116 | ui: RepoUiPolicy { anonymous: false }, | ||
| 117 | http: RepoHttpPolicy { | ||
| 118 | anonymous_clone: false, | ||
| 119 | }, | ||
| 120 | ..self | ||
| 121 | } | ||
| 122 | } | ||
| 123 | |||
| 111 | pub fn normalized_description(&self) -> Option<String> { | 124 | pub fn normalized_description(&self) -> Option<String> { |
| 112 | let description = self.description.as_deref()?.trim(); | 125 | let description = self.description.as_deref()?.trim(); |
| 113 | if description.is_empty() { | 126 | if description.is_empty() { |
| @@ -213,7 +226,20 @@ pub fn entry_for_path(path: &Path) -> Option<RepoEntry> { | |||
| 213 | repo_entry_from_path(path, &dir_name) | 226 | repo_entry_from_path(path, &dir_name) |
| 214 | } | 227 | } |
| 215 | 228 | ||
| 229 | /// Whether this entry is the repository that governs the server. | ||
| 230 | fn is_governance_repo(repos_dir: &Path, entry: &RepoEntry) -> bool { | ||
| 231 | entry.path == repos_dir.join(format!("{}.git", crate::governance::SETTINGS_REPO)) | ||
| 232 | } | ||
| 233 | |||
| 216 | /// Scan a directory for git repositories. | 234 | /// Scan a directory for git repositories. |
| 235 | /// | ||
| 236 | /// This is the only route by which the anonymous HTTP surface reaches a | ||
| 237 | /// repository, which is where the governance repository's default is applied: | ||
| 238 | /// creating `settings.git` must not silently publish the key roster and the | ||
| 239 | /// access rules to the internet. An operator who wants them browsable can say | ||
| 240 | /// so in `settings.git/.collab/server.toml`, and then this steps out of the | ||
| 241 | /// way. Authenticated access over SSH is unaffected — it never comes through | ||
| 242 | /// here — so a contributor can still read the rules it is subject to. | ||
| 217 | pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> { | 243 | pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> { |
| 218 | let mut entries = Vec::new(); | 244 | let mut entries = Vec::new(); |
| 219 | let read_dir = std::fs::read_dir(repos_dir)?; | 245 | let read_dir = std::fs::read_dir(repos_dir)?; |
| @@ -226,7 +252,12 @@ pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> { | |||
| 226 | } | 252 | } |
| 227 | 253 | ||
| 228 | let dir_name = entry.file_name().to_string_lossy().to_string(); | 254 | let dir_name = entry.file_name().to_string_lossy().to_string(); |
| 229 | if let Some(repo) = repo_entry_from_path(&path, &dir_name) { | 255 | if let Some(mut repo) = repo_entry_from_path(&path, &dir_name) { |
| 256 | if is_governance_repo(repos_dir, &repo) | ||
| 257 | && !repo_server_config_path(&repo.path, repo.bare).exists() | ||
| 258 | { | ||
| 259 | repo.policy = repo.policy.without_anonymous_access(); | ||
| 260 | } | ||
| 230 | entries.push(repo); | 261 | entries.push(repo); |
| 231 | } | 262 | } |
| 232 | } | 263 | } |
| @@ -413,6 +444,48 @@ mod tests { | |||
| 413 | } | 444 | } |
| 414 | 445 | ||
| 415 | #[test] | 446 | #[test] |
| 447 | fn the_governance_repo_is_not_anonymously_browsable_by_default() { | ||
| 448 | let tmp = TempDir::new().unwrap(); | ||
| 449 | init_bare(tmp.path(), "settings.git"); | ||
| 450 | init_bare(tmp.path(), "ordinary.git"); | ||
| 451 | |||
| 452 | let repos = discover(tmp.path()).unwrap(); | ||
| 453 | let settings = repos.iter().find(|r| r.name == "settings").unwrap(); | ||
| 454 | let ordinary = repos.iter().find(|r| r.name == "ordinary").unwrap(); | ||
| 455 | |||
| 456 | assert!(!settings.policy.allows_anonymous_ui()); | ||
| 457 | assert!(!settings.policy.allows_anonymous_http()); | ||
| 458 | // Only the governance repo; nothing else changes. | ||
| 459 | assert!(ordinary.policy.allows_anonymous_ui()); | ||
| 460 | assert!(ordinary.policy.allows_anonymous_http()); | ||
| 461 | } | ||
| 462 | |||
| 463 | #[test] | ||
| 464 | fn an_explicit_policy_on_the_governance_repo_is_honoured() { | ||
| 465 | let tmp = TempDir::new().unwrap(); | ||
| 466 | let repo_path = tmp.path().join("settings.git"); | ||
| 467 | init_bare(tmp.path(), "settings.git"); | ||
| 468 | write_policy(&repo_path, true, "visibility = \"public\"\n"); | ||
| 469 | |||
| 470 | let repos = discover(tmp.path()).unwrap(); | ||
| 471 | let settings = repos.iter().find(|r| r.name == "settings").unwrap(); | ||
| 472 | assert!(settings.policy.allows_anonymous_ui()); | ||
| 473 | } | ||
| 474 | |||
| 475 | /// A repository called `settings` that is not *the* settings repository — | ||
| 476 | /// nested under another directory — is an ordinary repository. | ||
| 477 | #[test] | ||
| 478 | fn only_the_top_level_settings_repo_gets_the_governance_default() { | ||
| 479 | let tmp = TempDir::new().unwrap(); | ||
| 480 | let nested = tmp.path().join("team"); | ||
| 481 | std::fs::create_dir_all(&nested).unwrap(); | ||
| 482 | init_bare(&nested, "settings.git"); | ||
| 483 | |||
| 484 | let entry = entry_for_path(&nested.join("settings.git")).unwrap(); | ||
| 485 | assert!(!is_governance_repo(tmp.path(), &entry)); | ||
| 486 | } | ||
| 487 | |||
| 488 | #[test] | ||
| 416 | fn malformed_policy_hides_repo() { | 489 | fn malformed_policy_hides_repo() { |
| 417 | let tmp = TempDir::new().unwrap(); | 490 | let tmp = TempDir::new().unwrap(); |
| 418 | let repo_path = tmp.path().join("broken.git"); | 491 | let repo_path = tmp.path().join("broken.git"); |
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,28 @@ use tokio::sync::mpsc; | |||
| 9 | use tracing::{debug, error, info, warn}; | 9 | use tracing::{debug, error, info, warn}; |
| 10 | 10 | ||
| 11 | use super::auth::{is_authorized, load_authorized_keys}; | 11 | use super::auth::{is_authorized, load_authorized_keys}; |
| 12 | use crate::governance::conf::{Access, Subject}; | ||
| 13 | use crate::governance::{self, Governance, GovernanceState}; | ||
| 14 | |||
| 15 | /// Which authorization regime is in force for this request. | ||
| 16 | /// | ||
| 17 | /// Decided per request rather than per connection, so a config push takes | ||
| 18 | /// effect on the next command with no restart. See `governance` for why | ||
| 19 | /// `settings.git` supersedes `server.toml` rather than layering over it. | ||
| 20 | enum Regime { | ||
| 21 | /// No settings repository. `server.toml` and the `authorized_keys` file | ||
| 22 | /// govern, exactly as they did before governance existed. | ||
| 23 | Ungoverned, | ||
| 24 | /// A settings repository, and this connection's key is enrolled in it | ||
| 25 | /// under `name`. | ||
| 26 | Governed { | ||
| 27 | governance: Box<Governance>, | ||
| 28 | name: String, | ||
| 29 | }, | ||
| 30 | /// Either the settings repository could not be read, or this connection's | ||
| 31 | /// key is not enrolled in it. Nothing is permitted. | ||
| 32 | Closed, | ||
| 33 | } | ||
| 12 | 34 | ||
| 13 | /// Configuration shared across all SSH connections. | 35 | /// Configuration shared across all SSH connections. |
| 14 | #[derive(Debug, Clone)] | 36 | #[derive(Debug, Clone)] |
| @@ -54,6 +76,80 @@ impl SshHandler { | |||
| 54 | } | 76 | } |
| 55 | } | 77 | } |
| 56 | 78 | ||
| 79 | /// Read the live configuration and place this connection within it. | ||
| 80 | /// | ||
| 81 | /// Re-read per request: the connection authenticated once, but every | ||
| 82 | /// command it issues afterwards is authorized against the config as it | ||
| 83 | /// stands now, so revoking a key stops the *next* command rather than | ||
| 84 | /// waiting for the connection to drop. | ||
| 85 | fn regime(&self, fingerprint: &str) -> Regime { | ||
| 86 | match governance::load(&self.config.repos_dir) { | ||
| 87 | GovernanceState::Absent => Regime::Ungoverned, | ||
| 88 | GovernanceState::Unreadable(reason) => { | ||
| 89 | error!("Settings repository is unreadable, closing everything: {reason}"); | ||
| 90 | Regime::Closed | ||
| 91 | } | ||
| 92 | GovernanceState::Active(governance) => match governance.name_for(fingerprint) { | ||
| 93 | Some(name) => { | ||
| 94 | let name = name.to_string(); | ||
| 95 | Regime::Governed { governance, name } | ||
| 96 | } | ||
| 97 | None => { | ||
| 98 | warn!("Key {fingerprint} is not enrolled in keydir/"); | ||
| 99 | Regime::Closed | ||
| 100 | } | ||
| 101 | }, | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | /// Environment for a `git-receive-pack` child, and the hook that reads it. | ||
| 106 | /// | ||
| 107 | /// The hook is installed for every repository under governance, and for | ||
| 108 | /// the settings repository whether or not governance is on yet — the first | ||
| 109 | /// config push must be validated too, or a typo in it closes the server. | ||
| 110 | /// | ||
| 111 | /// The three location variables are set on *every* push, including | ||
| 112 | /// ungoverned ones. A hook installed while the server was governed stays on | ||
| 113 | /// disk if governance is later removed, and it has to be able to tell | ||
| 114 | /// "the server ran me on an ungoverned repository" (allow) from "something | ||
| 115 | /// other than the server ran me" (refuse). Only the presence of the | ||
| 116 | /// principal marks the push as governed. | ||
| 117 | fn receive_pack_env( | ||
| 118 | &self, | ||
| 119 | git_cmd: GitCmd, | ||
| 120 | regime: &Regime, | ||
| 121 | repo_key: Option<String>, | ||
| 122 | repo_path: &Path, | ||
| 123 | bare: bool, | ||
| 124 | ) -> Result<Vec<(String, String)>, String> { | ||
| 125 | if git_cmd != GitCmd::ReceivePack { | ||
| 126 | return Ok(Vec::new()); | ||
| 127 | } | ||
| 128 | let Some(key) = repo_key else { | ||
| 129 | return Ok(Vec::new()); | ||
| 130 | }; | ||
| 131 | |||
| 132 | if matches!(regime, Regime::Governed { .. }) || key == governance::SETTINGS_REPO { | ||
| 133 | governance::hook::install(repo_path, bare)?; | ||
| 134 | } | ||
| 135 | |||
| 136 | let mut env = vec![ | ||
| 137 | ( | ||
| 138 | governance::hook::ENV_REPOS_DIR.to_string(), | ||
| 139 | self.config.repos_dir.to_string_lossy().into_owned(), | ||
| 140 | ), | ||
| 141 | (governance::hook::ENV_REPO.to_string(), key), | ||
| 142 | ( | ||
| 143 | governance::hook::ENV_REPO_PATH.to_string(), | ||
| 144 | repo_path.to_string_lossy().into_owned(), | ||
| 145 | ), | ||
| 146 | ]; | ||
| 147 | if let Regime::Governed { name, .. } = regime { | ||
| 148 | env.push((governance::hook::ENV_PRINCIPAL.to_string(), name.clone())); | ||
| 149 | } | ||
| 150 | Ok(env) | ||
| 151 | } | ||
| 152 | |||
| 57 | /// Handle a release verb. | 153 | /// Handle a release verb. |
| 58 | /// | 154 | /// |
| 59 | /// Every filesystem call in the release path is synchronous I/O performed | 155 | /// Every filesystem call in the release path is synchronous I/O performed |
| @@ -75,6 +171,7 @@ impl SshHandler { | |||
| 75 | rel: ReleaseCmd, | 171 | rel: ReleaseCmd, |
| 76 | resolved_path: &Path, | 172 | resolved_path: &Path, |
| 77 | principal: &str, | 173 | principal: &str, |
| 174 | regime: &Regime, | ||
| 78 | ) -> Result<(), russh::Error> { | 175 | ) -> Result<(), russh::Error> { |
| 79 | // Unknown repo and unauthorized repo get the SAME reply, so the error | 176 | // Unknown repo and unauthorized repo get the SAME reply, so the error |
| 80 | // can't be used to probe which private repos exist. This mirrors the | 177 | // can't be used to probe which private repos exist. This mirrors the |
| @@ -90,10 +187,28 @@ impl SshHandler { | |||
| 90 | } | 187 | } |
| 91 | }; | 188 | }; |
| 92 | 189 | ||
| 93 | let authorized = match &rel { | 190 | // Listing needs read; changing what a repository publishes is an |
| 94 | ReleaseCmd::List { .. } => entry.policy.allows_read(principal), | 191 | // administrative act, so it needs RW+ — the same permission that lets |
| 95 | ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => { | 192 | // you rewrite the history the artifacts were built from. |
| 96 | entry.policy.allows_write(principal) | 193 | let needed = match &rel { |
| 194 | ReleaseCmd::List { .. } => Access::Read, | ||
| 195 | ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => Access::Rewind, | ||
| 196 | }; | ||
| 197 | let authorized = match regime { | ||
| 198 | Regime::Closed => false, | ||
| 199 | Regime::Ungoverned => match needed { | ||
| 200 | Access::Read => entry.policy.allows_read(principal), | ||
| 201 | _ => entry.policy.allows_write(principal), | ||
| 202 | }, | ||
| 203 | Regime::Governed { governance, name } => { | ||
| 204 | match governance::repo_key(&self.config.repos_dir, resolved_path) { | ||
| 205 | Some(key) => { | ||
| 206 | let creator = governance::creator_of(resolved_path); | ||
| 207 | let subject = Subject::with_creator(name, creator.as_deref()); | ||
| 208 | governance.conf.allows_repo(&key, &subject, needed) | ||
| 209 | } | ||
| 210 | None => false, | ||
| 211 | } | ||
| 97 | } | 212 | } |
| 98 | }; | 213 | }; |
| 99 | if !authorized { | 214 | if !authorized { |
| @@ -168,7 +283,7 @@ impl SshHandler { | |||
| 168 | /// `key:SHA256:<unpadded-base64>`. `Fingerprint`'s `Display` supplies the | 283 | /// `key:SHA256:<unpadded-base64>`. `Fingerprint`'s `Display` supplies the |
| 169 | /// `SHA256:` prefix itself, so this must not add one — the format is persisted | 284 | /// `SHA256:` prefix itself, so this must not add one — the format is persisted |
| 170 | /// in existing repo configs and has to keep matching them byte for byte. | 285 | /// in existing repo configs and has to keep matching them byte for byte. |
| 171 | fn ssh_key_principal(public_key: &PublicKey) -> String { | 286 | pub fn ssh_key_principal(public_key: &PublicKey) -> String { |
| 172 | format!("key:{}", public_key.fingerprint(HashAlg::Sha256)) | 287 | format!("key:{}", public_key.fingerprint(HashAlg::Sha256)) |
| 173 | } | 288 | } |
| 174 | 289 | ||
| @@ -413,21 +528,39 @@ impl Handler for SshHandler { | |||
| 413 | _user: &str, | 528 | _user: &str, |
| 414 | public_key: &PublicKey, | 529 | public_key: &PublicKey, |
| 415 | ) -> Result<Auth, Self::Error> { | 530 | ) -> Result<Auth, Self::Error> { |
| 416 | // Reload authorized keys from disk each time (changes take effect immediately) | ||
| 417 | let keys = match load_authorized_keys(&self.config.authorized_keys_path) { | ||
| 418 | Ok(keys) => keys, | ||
| 419 | Err(e) => { | ||
| 420 | warn!("Failed to load authorized keys: {}", e); | ||
| 421 | return Ok(Auth::reject()); | ||
| 422 | } | ||
| 423 | }; | ||
| 424 | |||
| 425 | let algorithm = public_key.algorithm(); | 531 | let algorithm = public_key.algorithm(); |
| 426 | let key_type = algorithm.as_str(); | 532 | let key_type = algorithm.as_str(); |
| 427 | let key_data = public_key.public_key_base64(); | 533 | let key_data = public_key.public_key_base64(); |
| 428 | let principal = ssh_key_principal(public_key); | 534 | let principal = ssh_key_principal(public_key); |
| 429 | 535 | ||
| 430 | if is_authorized(&keys, key_type, &key_data) { | 536 | // `keydir/` supersedes the authorized_keys file when a settings |
| 537 | // repository exists: one roster, in git, or the old file — never both, | ||
| 538 | // so a key can never be enrolled in one and revoked in the other. | ||
| 539 | let enrolled = match governance::load(&self.config.repos_dir) { | ||
| 540 | GovernanceState::Active(governance) => { | ||
| 541 | let known = governance.name_for(&principal).is_some(); | ||
| 542 | if !known { | ||
| 543 | debug!("Public key {} is not enrolled in keydir/", principal); | ||
| 544 | } | ||
| 545 | known | ||
| 546 | } | ||
| 547 | GovernanceState::Unreadable(reason) => { | ||
| 548 | error!("Settings repository is unreadable, refusing auth: {reason}"); | ||
| 549 | false | ||
| 550 | } | ||
| 551 | GovernanceState::Absent => { | ||
| 552 | // Reload from disk each time (changes take effect immediately) | ||
| 553 | match load_authorized_keys(&self.config.authorized_keys_path) { | ||
| 554 | Ok(keys) => is_authorized(&keys, key_type, &key_data), | ||
| 555 | Err(e) => { | ||
| 556 | warn!("Failed to load authorized keys: {}", e); | ||
| 557 | return Ok(Auth::reject()); | ||
| 558 | } | ||
| 559 | } | ||
| 560 | } | ||
| 561 | }; | ||
| 562 | |||
| 563 | if enrolled { | ||
| 431 | info!( | 564 | info!( |
| 432 | "Public key auth accepted for key type {} ({})", | 565 | "Public key auth accepted for key type {} ({})", |
| 433 | key_type, principal | 566 | key_type, principal |
| @@ -511,6 +644,8 @@ impl Handler for SshHandler { | |||
| 511 | } | 644 | } |
| 512 | }; | 645 | }; |
| 513 | 646 | ||
| 647 | let regime = self.regime(&principal); | ||
| 648 | |||
| 514 | let git_cmd = match exec_cmd { | 649 | let git_cmd = match exec_cmd { |
| 515 | ExecCommand::Git { cmd, .. } => cmd, | 650 | ExecCommand::Git { cmd, .. } => cmd, |
| 516 | ExecCommand::Release(rel) => { | 651 | ExecCommand::Release(rel) => { |
| @@ -520,10 +655,17 @@ impl Handler for SshHandler { | |||
| 520 | rel, | 655 | rel, |
| 521 | &resolved_path, | 656 | &resolved_path, |
| 522 | &principal, | 657 | &principal, |
| 658 | ®ime, | ||
| 523 | ); | 659 | ); |
| 524 | } | 660 | } |
| 525 | }; | 661 | }; |
| 526 | 662 | ||
| 663 | // The name rules are written against, and the repository they name. | ||
| 664 | // Both are needed before the repository exists, because creating one | ||
| 665 | // is itself a permission (`C`). | ||
| 666 | let repo_key = governance::repo_key(&self.config.repos_dir, &resolved_path); | ||
| 667 | let bare; | ||
| 668 | |||
| 527 | if resolved_path.exists() { | 669 | if resolved_path.exists() { |
| 528 | let entry = match crate::repos::entry_for_path(&resolved_path) { | 670 | let entry = match crate::repos::entry_for_path(&resolved_path) { |
| 529 | Some(entry) => entry, | 671 | Some(entry) => entry, |
| @@ -535,10 +677,25 @@ impl Handler for SshHandler { | |||
| 535 | return reply_and_close(session, channel, "", 1); | 677 | return reply_and_close(session, channel, "", 1); |
| 536 | } | 678 | } |
| 537 | }; | 679 | }; |
| 680 | bare = entry.bare; | ||
| 538 | 681 | ||
| 539 | let authorized = match git_cmd { | 682 | // Pushing is authorized here only as far as "may push something"; |
| 540 | GitCmd::UploadPack => entry.policy.allows_read(&principal), | 683 | // which refs may actually move is the update hook's question. |
| 541 | GitCmd::ReceivePack => entry.policy.allows_write(&principal), | 684 | let needed = match git_cmd { |
| 685 | GitCmd::UploadPack => Access::Read, | ||
| 686 | GitCmd::ReceivePack => Access::Write, | ||
| 687 | }; | ||
| 688 | let authorized = match (®ime, repo_key.as_deref()) { | ||
| 689 | (Regime::Closed, _) | (Regime::Governed { .. }, None) => false, | ||
| 690 | (Regime::Ungoverned, _) => match needed { | ||
| 691 | Access::Read => entry.policy.allows_read(&principal), | ||
| 692 | _ => entry.policy.allows_write(&principal), | ||
| 693 | }, | ||
| 694 | (Regime::Governed { governance, name }, Some(key)) => { | ||
| 695 | let creator = governance::creator_of(&resolved_path); | ||
| 696 | let subject = Subject::with_creator(name, creator.as_deref()); | ||
| 697 | governance.conf.allows_repo(key, &subject, needed) | ||
| 698 | } | ||
| 542 | }; | 699 | }; |
| 543 | 700 | ||
| 544 | if !authorized { | 701 | if !authorized { |
| @@ -551,9 +708,38 @@ impl Handler for SshHandler { | |||
| 551 | return reply_and_close(session, channel, "", 1); | 708 | return reply_and_close(session, channel, "", 1); |
| 552 | } | 709 | } |
| 553 | } else { | 710 | } else { |
| 711 | // Auto-creation is what makes wild repos work without a central | ||
| 712 | // allocator, so under governance it is a permission of its own: | ||
| 713 | // `C` on a pattern the requested name matches. | ||
| 714 | if let Regime::Governed { governance, name } = ®ime { | ||
| 715 | let allowed = repo_key.as_deref().is_some_and(|key| { | ||
| 716 | governance | ||
| 717 | .conf | ||
| 718 | .allows_repo(key, &Subject::new(name), Access::Create) | ||
| 719 | }); | ||
| 720 | if !allowed { | ||
| 721 | warn!( | ||
| 722 | "Rejected exec request: principal {} may not create {:?}", | ||
| 723 | principal, resolved_path | ||
| 724 | ); | ||
| 725 | return reply_and_close(session, channel, "", 1); | ||
| 726 | } | ||
| 727 | } | ||
| 728 | if matches!(regime, Regime::Closed) { | ||
| 729 | return reply_and_close(session, channel, "", 1); | ||
| 730 | } | ||
| 731 | |||
| 554 | match ensure_repo_exists_for_command(git_cmd, &resolved_path) { | 732 | match ensure_repo_exists_for_command(git_cmd, &resolved_path) { |
| 555 | Ok(true) => { | 733 | Ok(true) => { |
| 556 | info!("Created bare repo for receive-pack: {:?}", resolved_path); | 734 | info!("Created bare repo for receive-pack: {:?}", resolved_path); |
| 735 | if let Regime::Governed { name, .. } = ®ime { | ||
| 736 | // Recorded before the push runs, so `RW+ = CREATOR` | ||
| 737 | // already applies to the very first ref update. | ||
| 738 | if let Err(e) = governance::record_creator(&resolved_path, name) { | ||
| 739 | error!("Failed to record creator of {:?}: {}", resolved_path, e); | ||
| 740 | return reply_and_close(session, channel, "", 1); | ||
| 741 | } | ||
| 742 | } | ||
| 557 | } | 743 | } |
| 558 | Ok(false) => { | 744 | Ok(false) => { |
| 559 | warn!( | 745 | warn!( |
| @@ -567,8 +753,18 @@ impl Handler for SshHandler { | |||
| 567 | return reply_and_close(session, channel, "", 1); | 753 | return reply_and_close(session, channel, "", 1); |
| 568 | } | 754 | } |
| 569 | } | 755 | } |
| 756 | bare = true; | ||
| 570 | } | 757 | } |
| 571 | 758 | ||
| 759 | let child_env = | ||
| 760 | match self.receive_pack_env(git_cmd, ®ime, repo_key, &resolved_path, bare) { | ||
| 761 | Ok(env) => env, | ||
| 762 | Err(reason) => { | ||
| 763 | error!("Refusing push to {:?}: {}", resolved_path, reason); | ||
| 764 | return reply_and_close(session, channel, &format!("error: {reason}\n"), 1); | ||
| 765 | } | ||
| 766 | }; | ||
| 767 | |||
| 572 | // Create a channel for forwarding client stdin data to the git | 768 | // Create a channel for forwarding client stdin data to the git |
| 573 | // subprocess. Tagged with the owning SSH channel so data routing and | 769 | // subprocess. Tagged with the owning SSH channel so data routing and |
| 574 | // EOF teardown can't cross-talk between multiplexed channels. | 770 | // EOF teardown can't cross-talk between multiplexed channels. |
| @@ -578,7 +774,9 @@ impl Handler for SshHandler { | |||
| 578 | // Spawn the git subprocess | 774 | // Spawn the git subprocess |
| 579 | let handle = session.handle(); | 775 | let handle = session.handle(); |
| 580 | tokio::spawn(async move { | 776 | tokio::spawn(async move { |
| 581 | if let Err(e) = run_git_command(handle, channel, git_cmd, &resolved_path, rx).await { | 777 | if let Err(e) = |
| 778 | run_git_command(handle, channel, git_cmd, &resolved_path, child_env, rx).await | ||
| 779 | { | ||
| 582 | error!("Git subprocess error: {}", e); | 780 | error!("Git subprocess error: {}", e); |
| 583 | } | 781 | } |
| 584 | }); | 782 | }); |
| @@ -701,19 +899,25 @@ async fn run_git_command( | |||
| 701 | channel: ChannelId, | 899 | channel: ChannelId, |
| 702 | git_cmd: GitCmd, | 900 | git_cmd: GitCmd, |
| 703 | repo_path: &Path, | 901 | repo_path: &Path, |
| 902 | env: Vec<(String, String)>, | ||
| 704 | mut stdin_rx: mpsc::Receiver<Vec<u8>>, | 903 | mut stdin_rx: mpsc::Receiver<Vec<u8>>, |
| 705 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { | 904 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { |
| 706 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | 905 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
| 707 | 906 | ||
| 708 | let mut child = Command::new(git_cmd.as_str()) | 907 | let mut command = Command::new(git_cmd.as_str()); |
| 908 | command | ||
| 709 | .arg(repo_path) | 909 | .arg(repo_path) |
| 710 | .stdin(std::process::Stdio::piped()) | 910 | .stdin(std::process::Stdio::piped()) |
| 711 | .stdout(std::process::Stdio::piped()) | 911 | .stdout(std::process::Stdio::piped()) |
| 712 | .stderr(std::process::Stdio::piped()) | 912 | .stderr(std::process::Stdio::piped()); |
| 713 | .spawn()?; | 913 | for (name, value) in env { |
| 914 | command.env(name, value); | ||
| 915 | } | ||
| 916 | let mut child = command.spawn()?; | ||
| 714 | 917 | ||
| 715 | let mut child_stdin = child.stdin.take().expect("stdin piped"); | 918 | let mut child_stdin = child.stdin.take().expect("stdin piped"); |
| 716 | let mut stdout = child.stdout.take().expect("stdout piped"); | 919 | let mut stdout = child.stdout.take().expect("stdout piped"); |
| 920 | let mut stderr = child.stderr.take().expect("stderr piped"); | ||
| 717 | 921 | ||
| 718 | // Spawn a task to forward client data to the child's stdin | 922 | // Spawn a task to forward client data to the child's stdin |
| 719 | tokio::spawn(async move { | 923 | tokio::spawn(async move { |
| @@ -726,6 +930,34 @@ async fn run_git_command( | |||
| 726 | drop(child_stdin); | 930 | drop(child_stdin); |
| 727 | }); | 931 | }); |
| 728 | 932 | ||
| 933 | // Drain the child's stderr onto the SSH channel's stderr. Previously this | ||
| 934 | // pipe was created and never read, so anything git wrote there was | ||
| 935 | // discarded — and a child chatty enough to fill the pipe buffer would | ||
| 936 | // block forever waiting for a reader that did not exist. | ||
| 937 | // | ||
| 938 | // Hook rejections do not come through here: receive-pack captures its | ||
| 939 | // hooks' stderr and relays it to the client as `remote:` lines on the | ||
| 940 | // sideband, which is part of stdout. This carries git's own diagnostics. | ||
| 941 | let stderr_handle = handle.clone(); | ||
| 942 | tokio::spawn(async move { | ||
| 943 | let mut buf = vec![0u8; 8192]; | ||
| 944 | loop { | ||
| 945 | match stderr.read(&mut buf).await { | ||
| 946 | Ok(0) | Err(_) => break, | ||
| 947 | Ok(n) => { | ||
| 948 | // SSH_EXTENDED_DATA_STDERR | ||
| 949 | if stderr_handle | ||
| 950 | .extended_data(channel, 1, buf[..n].to_vec()) | ||
| 951 | .await | ||
| 952 | .is_err() | ||
| 953 | { | ||
| 954 | break; | ||
| 955 | } | ||
| 956 | } | ||
| 957 | } | ||
| 958 | } | ||
| 959 | }); | ||
| 960 | |||
| 729 | let mut buf = vec![0u8; 32768]; | 961 | let mut buf = vec![0u8; 32768]; |
| 730 | 962 | ||
| 731 | loop { | 963 | loop { |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -824,6 +824,170 @@ impl ServerHarness { | |||
| 824 | key_path | 824 | key_path |
| 825 | } | 825 | } |
| 826 | 826 | ||
| 827 | /// Generate a *named* client keypair under the harness root, or return the | ||
| 828 | /// one already generated for that name. Unlike `ssh_client_key`, this does | ||
| 829 | /// not touch `authorized_keys`: these keys are enrolled through | ||
| 830 | /// `settings.git`'s `keydir/`, which supersedes that file. | ||
| 831 | pub fn named_key(&self, name: &str) -> PathBuf { | ||
| 832 | let dir = self.root.path().join("keys"); | ||
| 833 | std::fs::create_dir_all(&dir).unwrap(); | ||
| 834 | let key_path = dir.join(name); | ||
| 835 | if !key_path.exists() { | ||
| 836 | let output = Command::new("ssh-keygen") | ||
| 837 | .args([ | ||
| 838 | "-t", | ||
| 839 | "ed25519", | ||
| 840 | "-N", | ||
| 841 | "", | ||
| 842 | "-q", | ||
| 843 | "-C", | ||
| 844 | &format!("{name}@test"), | ||
| 845 | "-f", | ||
| 846 | key_path.to_str().unwrap(), | ||
| 847 | ]) | ||
| 848 | .output() | ||
| 849 | .expect("failed to run ssh-keygen"); | ||
| 850 | assert!( | ||
| 851 | output.status.success(), | ||
| 852 | "ssh-keygen failed: {}", | ||
| 853 | String::from_utf8_lossy(&output.stderr) | ||
| 854 | ); | ||
| 855 | } | ||
| 856 | key_path | ||
| 857 | } | ||
| 858 | |||
| 859 | fn settings_bare(&self) -> PathBuf { | ||
| 860 | self.repos_dir().join("settings.git") | ||
| 861 | } | ||
| 862 | |||
| 863 | fn settings_work(&self) -> PathBuf { | ||
| 864 | self.root.path().join("settings-work") | ||
| 865 | } | ||
| 866 | |||
| 867 | /// Create `settings.git` and a working tree for it, the way an init | ||
| 868 | /// container would: over the filesystem, before the server is governed. | ||
| 869 | fn ensure_settings_repos(&self) { | ||
| 870 | let bare = self.settings_bare(); | ||
| 871 | if !bare.exists() { | ||
| 872 | git( | ||
| 873 | self.root.path(), | ||
| 874 | &["init", "--bare", "-b", "main", bare.to_str().unwrap()], | ||
| 875 | ); | ||
| 876 | } | ||
| 877 | let work = self.settings_work(); | ||
| 878 | if !work.exists() { | ||
| 879 | std::fs::create_dir_all(&work).unwrap(); | ||
| 880 | git(&work, &["init", "-q", "-b", "main"]); | ||
| 881 | git(&work, &["config", "user.email", "ops@example.com"]); | ||
| 882 | git(&work, &["config", "user.name", "Ops"]); | ||
| 883 | git(&work, &["remote", "add", "origin", bare.to_str().unwrap()]); | ||
| 884 | } | ||
| 885 | } | ||
| 886 | |||
| 887 | /// Write `conf/access.conf` and `keydir/` entries into the settings | ||
| 888 | /// working tree and commit them, without pushing. | ||
| 889 | /// | ||
| 890 | /// `keys` maps a path *within* `keydir/` (e.g. `laptop/alex.pub`) to the | ||
| 891 | /// name of a `named_key`, so a test can put one key at two paths and see | ||
| 892 | /// them collapse to one identity. | ||
| 893 | pub fn stage_settings(&self, access_conf: &str, keys: &[(&str, &str)]) { | ||
| 894 | self.ensure_settings_repos(); | ||
| 895 | let work = self.settings_work(); | ||
| 896 | |||
| 897 | let conf_path = work.join("conf").join("access.conf"); | ||
| 898 | std::fs::create_dir_all(conf_path.parent().unwrap()).unwrap(); | ||
| 899 | std::fs::write(&conf_path, access_conf).unwrap(); | ||
| 900 | |||
| 901 | for (rel, key_name) in keys { | ||
| 902 | let dest = work.join("keydir").join(rel); | ||
| 903 | std::fs::create_dir_all(dest.parent().unwrap()).unwrap(); | ||
| 904 | let pubkey = | ||
| 905 | std::fs::read_to_string(self.named_key(key_name).with_extension("pub")).unwrap(); | ||
| 906 | std::fs::write(dest, pubkey).unwrap(); | ||
| 907 | } | ||
| 908 | |||
| 909 | git(&work, &["add", "-A"]); | ||
| 910 | git(&work, &["commit", "-q", "--allow-empty", "-m", "settings"]); | ||
| 911 | } | ||
| 912 | |||
| 913 | /// Stage a settings change and land it over the filesystem, bypassing the | ||
| 914 | /// server entirely. This is the bootstrap path; it is deliberately *not* | ||
| 915 | /// how a test exercises push validation. | ||
| 916 | pub fn bootstrap_settings(&self, access_conf: &str, keys: &[(&str, &str)]) { | ||
| 917 | self.stage_settings(access_conf, keys); | ||
| 918 | git( | ||
| 919 | &self.settings_work(), | ||
| 920 | &["push", "-q", "-f", "origin", "main"], | ||
| 921 | ); | ||
| 922 | } | ||
| 923 | |||
| 924 | /// Overwrite `conf/access.conf` with arbitrary text and commit it, so a | ||
| 925 | /// test can stage a config that does not parse. | ||
| 926 | pub fn stage_raw_access_conf(&self, content: &str) { | ||
| 927 | self.ensure_settings_repos(); | ||
| 928 | let work = self.settings_work(); | ||
| 929 | let conf_path = work.join("conf").join("access.conf"); | ||
| 930 | std::fs::create_dir_all(conf_path.parent().unwrap()).unwrap(); | ||
| 931 | std::fs::write(&conf_path, content).unwrap(); | ||
| 932 | git(&work, &["add", "-A"]); | ||
| 933 | git(&work, &["commit", "-q", "--allow-empty", "-m", "settings"]); | ||
| 934 | } | ||
| 935 | |||
| 936 | /// The `conf/access.conf` the server would actually read right now, from | ||
| 937 | /// the tip of `settings.git`'s live branch. | ||
| 938 | pub fn live_access_conf(&self) -> String { | ||
| 939 | let bare = self.settings_bare(); | ||
| 940 | let output = Command::new("git") | ||
| 941 | .args(["show", "HEAD:conf/access.conf"]) | ||
| 942 | .current_dir(&bare) | ||
| 943 | .output() | ||
| 944 | .expect("failed to run git show"); | ||
| 945 | assert!( | ||
| 946 | output.status.success(), | ||
| 947 | "git show failed: {}", | ||
| 948 | String::from_utf8_lossy(&output.stderr) | ||
| 949 | ); | ||
| 950 | String::from_utf8(output.stdout).unwrap() | ||
| 951 | } | ||
| 952 | |||
| 953 | /// Push the settings working tree to the server *over SSH*, as `key`. | ||
| 954 | /// Not asserted: rejection is the interesting outcome. | ||
| 955 | pub fn push_settings_over_ssh(&self, key: &Path) -> Output { | ||
| 956 | self.ssh_push_from(&self.settings_work(), key, "settings", "main:main") | ||
| 957 | } | ||
| 958 | |||
| 959 | /// Run `git push` over SSH from an arbitrary working tree, as `key`, | ||
| 960 | /// returning the raw output rather than asserting success. | ||
| 961 | pub fn ssh_push_from(&self, dir: &Path, key: &Path, repo: &str, refspec: &str) -> Output { | ||
| 962 | let url = format!("ssh://git@127.0.0.1:{}/{}.git", self.ssh_addr.port(), repo); | ||
| 963 | Command::new("git") | ||
| 964 | .args(["push", &url, refspec]) | ||
| 965 | .env("GIT_SSH_COMMAND", ssh_command_for(key)) | ||
| 966 | .env("GIT_TERMINAL_PROMPT", "0") | ||
| 967 | .current_dir(dir) | ||
| 968 | .output() | ||
| 969 | .expect("failed to run git push") | ||
| 970 | } | ||
| 971 | |||
| 972 | /// Push from the harness's own work repo over SSH, as `key`. | ||
| 973 | pub fn ssh_push(&self, key: &Path, refspec: &str) -> Output { | ||
| 974 | let repo = self.repo_name.clone(); | ||
| 975 | self.ssh_push_from(self.work_repo.dir.path(), key, &repo, refspec) | ||
| 976 | } | ||
| 977 | |||
| 978 | /// Fetch over SSH as `key`, returning raw output. Used to assert that an | ||
| 979 | /// unreadable repository is refused rather than merely empty. | ||
| 980 | pub fn ssh_fetch(&self, dir: &Path, key: &Path, repo: &str) -> Output { | ||
| 981 | let url = format!("ssh://git@127.0.0.1:{}/{}.git", self.ssh_addr.port(), repo); | ||
| 982 | Command::new("git") | ||
| 983 | .args(["ls-remote", &url]) | ||
| 984 | .env("GIT_SSH_COMMAND", ssh_command_for(key)) | ||
| 985 | .env("GIT_TERMINAL_PROMPT", "0") | ||
| 986 | .current_dir(dir) | ||
| 987 | .output() | ||
| 988 | .expect("failed to run git ls-remote") | ||
| 989 | } | ||
| 990 | |||
| 827 | /// The ssh client options needed to reach this test server, as a single | 991 | /// The ssh client options needed to reach this test server, as a single |
| 828 | /// command string usable both directly and as GIT_COLLAB_SSH_COMMAND. | 992 | /// command string usable both directly and as GIT_COLLAB_SSH_COMMAND. |
| 829 | pub fn ssh_command_string(&self) -> String { | 993 | pub fn ssh_command_string(&self) -> String { |
| @@ -1170,6 +1334,15 @@ fn git_with_env(dir: &Path, args: &[&str], test_home: &TestHome) { | |||
| 1170 | ); | 1334 | ); |
| 1171 | } | 1335 | } |
| 1172 | 1336 | ||
| 1337 | /// ssh client options for a specific identity, as a single command string. | ||
| 1338 | fn ssh_command_for(key: &Path) -> String { | ||
| 1339 | format!( | ||
| 1340 | "ssh -i {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ | ||
| 1341 | -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=5", | ||
| 1342 | key.display() | ||
| 1343 | ) | ||
| 1344 | } | ||
| 1345 | |||
| 1173 | fn pick_loopback_addr() -> SocketAddr { | 1346 | fn pick_loopback_addr() -> SocketAddr { |
| 1174 | let listener = TcpListener::bind("127.0.0.1:0").unwrap(); | 1347 | let listener = TcpListener::bind("127.0.0.1:0").unwrap(); |
| 1175 | let addr = listener.local_addr().unwrap(); | 1348 | let addr = listener.local_addr().unwrap(); |
tests/governance_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,435 @@ | |||
| 1 | //! Governance by `settings.git`, end to end through the real server. | ||
| 2 | //! | ||
| 3 | //! Every test here drives a live `git-collab-server` over SSH with real | ||
| 4 | //! OpenSSH clients, because the parts most worth checking — that a rejection | ||
| 5 | //! reaches the pushing client, that a rejected config leaves the previous one | ||
| 6 | //! live — are properties of the whole path, not of the rule engine. The rule | ||
| 7 | //! engine's own semantics are unit-tested in `src/server/governance/`. | ||
| 8 | |||
| 9 | mod common; | ||
| 10 | |||
| 11 | use common::ServerHarness; | ||
| 12 | use std::process::Output; | ||
| 13 | |||
| 14 | /// The configuration most of these tests run under. | ||
| 15 | /// | ||
| 16 | /// The shape is the point of the design: an agent's entire grant is one | ||
| 17 | /// prefix, and `refs/heads/` never appears in it. | ||
| 18 | const ACCESS_CONF: &str = "\ | ||
| 19 | @admins = alex | ||
| 20 | @agents = claude-a claude-b | ||
| 21 | |||
| 22 | repo settings | ||
| 23 | RW+ = @admins | ||
| 24 | |||
| 25 | repo governed | ||
| 26 | RW+ = @admins | ||
| 27 | RW refs/collab/ = @agents | ||
| 28 | R = @all | ||
| 29 | |||
| 30 | repo agents/[a-z-]+ | ||
| 31 | C = @agents | ||
| 32 | RW+ = CREATOR | ||
| 33 | "; | ||
| 34 | |||
| 35 | fn stderr(output: &Output) -> String { | ||
| 36 | String::from_utf8_lossy(&output.stderr).to_string() | ||
| 37 | } | ||
| 38 | |||
| 39 | fn assert_refused(output: &Output, context: &str) { | ||
| 40 | assert!( | ||
| 41 | !output.status.success(), | ||
| 42 | "{context}: expected the push to be refused, but it succeeded\n{}", | ||
| 43 | stderr(output) | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | |||
| 47 | fn assert_accepted(output: &Output, context: &str) { | ||
| 48 | assert!( | ||
| 49 | output.status.success(), | ||
| 50 | "{context}: expected the push to be accepted, but it failed\n{}", | ||
| 51 | stderr(output) | ||
| 52 | ); | ||
| 53 | } | ||
| 54 | |||
| 55 | /// A server with no `settings.git` is not governed, and nothing about it | ||
| 56 | /// changes: `authorized_keys` still authenticates, `server.toml` still | ||
| 57 | /// authorizes, pushes still land, and no hook is installed anywhere. | ||
| 58 | #[test] | ||
| 59 | fn a_server_with_no_settings_repository_behaves_exactly_as_before() { | ||
| 60 | let harness = ServerHarness::new("ungoverned"); | ||
| 61 | let key = harness.ssh_client_key(); | ||
| 62 | |||
| 63 | harness.work_repo().commit_file("a.txt", "one", "first"); | ||
| 64 | assert_accepted( | ||
| 65 | &harness.ssh_push(&key, "main:main"), | ||
| 66 | "a branch push on an ungoverned server", | ||
| 67 | ); | ||
| 68 | |||
| 69 | harness.work_repo().issue_open("An issue"); | ||
| 70 | assert_accepted( | ||
| 71 | &harness.ssh_push(&key, "refs/collab/*:refs/collab/*"), | ||
| 72 | "a collab push on an ungoverned server", | ||
| 73 | ); | ||
| 74 | |||
| 75 | // Force-push, which under governance would need RW+. | ||
| 76 | harness.work_repo().commit_file("a.txt", "two", "second"); | ||
| 77 | harness.work_repo().git(&["reset", "--hard", "HEAD~1"]); | ||
| 78 | assert_accepted( | ||
| 79 | &harness.ssh_push(&key, "+main:main"), | ||
| 80 | "a force push on an ungoverned server", | ||
| 81 | ); | ||
| 82 | |||
| 83 | // No hook is installed on a repository the server does not govern. | ||
| 84 | let hook = harness | ||
| 85 | .repos_dir() | ||
| 86 | .join("ungoverned.git") | ||
| 87 | .join("hooks") | ||
| 88 | .join("update"); | ||
| 89 | assert!( | ||
| 90 | !hook.exists(), | ||
| 91 | "an ungoverned server must not install hooks; found {}", | ||
| 92 | hook.display() | ||
| 93 | ); | ||
| 94 | |||
| 95 | // And the HTTP surface is untouched. | ||
| 96 | let page = harness.get_ok("/ungoverned"); | ||
| 97 | assert!(page.body.contains("ungoverned"), "got {}", page.body); | ||
| 98 | } | ||
| 99 | |||
| 100 | /// The consequence the revision-refs work bought: a contributor needs write | ||
| 101 | /// access to `refs/collab/*` and to nothing else, and a compromised agent | ||
| 102 | /// credential cannot move canonical state. | ||
| 103 | #[test] | ||
| 104 | fn an_agent_may_write_collab_refs_and_may_not_write_branches() { | ||
| 105 | let harness = ServerHarness::new("governed"); | ||
| 106 | harness.push_head(); | ||
| 107 | harness.bootstrap_settings( | ||
| 108 | ACCESS_CONF, | ||
| 109 | &[("alex.pub", "alex"), ("claude-a.pub", "claude-a")], | ||
| 110 | ); | ||
| 111 | |||
| 112 | let agent = harness.named_key("claude-a"); | ||
| 113 | |||
| 114 | harness.work_repo().issue_open("Found a bug"); | ||
| 115 | assert_accepted( | ||
| 116 | &harness.ssh_push(&agent, "refs/collab/*:refs/collab/*"), | ||
| 117 | "an agent pushing collab refs", | ||
| 118 | ); | ||
| 119 | |||
| 120 | harness.work_repo().commit_file("evil.txt", "x", "sneak"); | ||
| 121 | let refused = harness.ssh_push(&agent, "main:main"); | ||
| 122 | assert_refused(&refused, "an agent pushing a branch"); | ||
| 123 | |||
| 124 | // The hook's reason has to reach the person pushing, or the refusal is | ||
| 125 | // indistinguishable from a broken server. | ||
| 126 | let message = stderr(&refused); | ||
| 127 | assert!( | ||
| 128 | message.contains("claude-a may not write refs/heads/main"), | ||
| 129 | "the hook's message did not reach the client; got:\n{message}" | ||
| 130 | ); | ||
| 131 | |||
| 132 | // The branch really did not move. | ||
| 133 | let admin = harness.named_key("alex"); | ||
| 134 | let listing = harness.ssh_fetch(harness.work_repo().dir.path(), &admin, "governed"); | ||
| 135 | assert!( | ||
| 136 | !String::from_utf8_lossy(&listing.stdout).contains("sneak"), | ||
| 137 | "the refused commit must not be reachable" | ||
| 138 | ); | ||
| 139 | } | ||
| 140 | |||
| 141 | /// `RW` is not `RW+`: an agent may add to the refs it owns but not rewrite | ||
| 142 | /// them, which is what stops a compromised credential erasing review history. | ||
| 143 | #[test] | ||
| 144 | fn an_agent_may_not_rewind_the_refs_it_may_write() { | ||
| 145 | let harness = ServerHarness::new("governed"); | ||
| 146 | harness.push_head(); | ||
| 147 | harness.bootstrap_settings( | ||
| 148 | ACCESS_CONF, | ||
| 149 | &[("alex.pub", "alex"), ("claude-a.pub", "claude-a")], | ||
| 150 | ); | ||
| 151 | let agent = harness.named_key("claude-a"); | ||
| 152 | |||
| 153 | harness.work_repo().issue_open("First"); | ||
| 154 | assert_accepted( | ||
| 155 | &harness.ssh_push(&agent, "refs/collab/*:refs/collab/*"), | ||
| 156 | "the initial collab push", | ||
| 157 | ); | ||
| 158 | |||
| 159 | // Delete a collab ref: a rewind, and RW does not grant it. | ||
| 160 | let refs = harness | ||
| 161 | .work_repo() | ||
| 162 | .git(&["for-each-ref", "--format=%(refname)", "refs/collab/"]); | ||
| 163 | let victim = refs.lines().next().expect("a collab ref to delete").trim(); | ||
| 164 | let refused = harness.ssh_push(&agent, &format!(":{victim}")); | ||
| 165 | assert_refused(&refused, "an agent deleting a collab ref"); | ||
| 166 | assert!( | ||
| 167 | stderr(&refused).contains("may not rewind or delete"), | ||
| 168 | "got:\n{}", | ||
| 169 | stderr(&refused) | ||
| 170 | ); | ||
| 171 | } | ||
| 172 | |||
| 173 | /// The whole point of validating on push: an unusable config never becomes the | ||
| 174 | /// live one, so there is no malformed-config state for the request path to | ||
| 175 | /// handle. | ||
| 176 | #[test] | ||
| 177 | fn a_config_that_does_not_parse_is_rejected_and_the_previous_one_stays_live() { | ||
| 178 | let harness = ServerHarness::new("governed"); | ||
| 179 | harness.push_head(); | ||
| 180 | harness.bootstrap_settings( | ||
| 181 | ACCESS_CONF, | ||
| 182 | &[("alex.pub", "alex"), ("claude-a.pub", "claude-a")], | ||
| 183 | ); | ||
| 184 | let admin = harness.named_key("alex"); | ||
| 185 | let agent = harness.named_key("claude-a"); | ||
| 186 | |||
| 187 | // A valid config push over SSH lands, proving the path works before we | ||
| 188 | // check that a broken one does not. | ||
| 189 | harness.stage_settings( | ||
| 190 | &format!("{ACCESS_CONF}\nrepo scratch\n RW+ = @admins\n"), | ||
| 191 | &[("alex.pub", "alex"), ("claude-a.pub", "claude-a")], | ||
| 192 | ); | ||
| 193 | assert_accepted( | ||
| 194 | &harness.push_settings_over_ssh(&admin), | ||
| 195 | "an admin pushing a valid config", | ||
| 196 | ); | ||
| 197 | assert!(harness.live_access_conf().contains("repo scratch")); | ||
| 198 | |||
| 199 | // Now a config that does not parse. | ||
| 200 | harness.stage_raw_access_conf("repo governed\n RWD = @agents\n"); | ||
| 201 | let refused = harness.push_settings_over_ssh(&admin); | ||
| 202 | assert_refused(&refused, "an admin pushing a config that does not parse"); | ||
| 203 | |||
| 204 | let message = stderr(&refused); | ||
| 205 | assert!( | ||
| 206 | message.contains("the previous one stays live"), | ||
| 207 | "the rejection must say what happened; got:\n{message}" | ||
| 208 | ); | ||
| 209 | assert!( | ||
| 210 | message.contains("line 2") && message.contains("RWD"), | ||
| 211 | "the rejection must point at the offending line; got:\n{message}" | ||
| 212 | ); | ||
| 213 | |||
| 214 | // The live config is still the previous one, byte for byte... | ||
| 215 | let live = harness.live_access_conf(); | ||
| 216 | assert!(live.contains("repo scratch"), "got:\n{live}"); | ||
| 217 | assert!(!live.contains("RWD"), "got:\n{live}"); | ||
| 218 | |||
| 219 | // ...and, more to the point, it is still the config actually in force. | ||
| 220 | harness.work_repo().issue_open("Still governed"); | ||
| 221 | assert_accepted( | ||
| 222 | &harness.ssh_push(&agent, "refs/collab/*:refs/collab/*"), | ||
| 223 | "the previous config still granting the agent its collab refs", | ||
| 224 | ); | ||
| 225 | harness.work_repo().commit_file("evil.txt", "x", "sneak"); | ||
| 226 | assert_refused( | ||
| 227 | &harness.ssh_push(&agent, "main:main"), | ||
| 228 | "the previous config still denying the agent branches", | ||
| 229 | ); | ||
| 230 | } | ||
| 231 | |||
| 232 | /// The other half of validation: a config that parses but locks everyone out | ||
| 233 | /// is just as unusable, and would need `kubectl exec` to undo. | ||
| 234 | #[test] | ||
| 235 | fn a_config_that_would_lock_everyone_out_is_rejected() { | ||
| 236 | let harness = ServerHarness::new("governed"); | ||
| 237 | harness.bootstrap_settings( | ||
| 238 | ACCESS_CONF, | ||
| 239 | &[("alex.pub", "alex"), ("claude-a.pub", "claude-a")], | ||
| 240 | ); | ||
| 241 | let admin = harness.named_key("alex"); | ||
| 242 | |||
| 243 | // Parses cleanly; nobody retains RW+ on settings. | ||
| 244 | harness.stage_raw_access_conf("repo governed\n RW+ = alex\n"); | ||
| 245 | let refused = harness.push_settings_over_ssh(&admin); | ||
| 246 | assert_refused(&refused, "a config that locks everyone out"); | ||
| 247 | assert!( | ||
| 248 | stderr(&refused).contains("lock everyone out"), | ||
| 249 | "got:\n{}", | ||
| 250 | stderr(&refused) | ||
| 251 | ); | ||
| 252 | |||
| 253 | assert!(harness.live_access_conf().contains("repo settings")); | ||
| 254 | } | ||
| 255 | |||
| 256 | /// Adding a second machine is adding a file. Two keys whose paths differ only | ||
| 257 | /// by directory are one principal, and the rules never mention the directory. | ||
| 258 | #[test] | ||
| 259 | fn two_keys_in_different_keydir_directories_are_the_same_principal() { | ||
| 260 | let harness = ServerHarness::new("governed"); | ||
| 261 | harness.push_head(); | ||
| 262 | harness.bootstrap_settings( | ||
| 263 | ACCESS_CONF, | ||
| 264 | &[ | ||
| 265 | ("laptop/alex.pub", "alex-laptop"), | ||
| 266 | ("desktop/alex.pub", "alex-desktop"), | ||
| 267 | ("claude-a.pub", "claude-a"), | ||
| 268 | ], | ||
| 269 | ); | ||
| 270 | |||
| 271 | // Two distinct keypairs. `conf/access.conf` names `alex` once. | ||
| 272 | let laptop = harness.named_key("alex-laptop"); | ||
| 273 | let desktop = harness.named_key("alex-desktop"); | ||
| 274 | |||
| 275 | harness | ||
| 276 | .work_repo() | ||
| 277 | .commit_file("from-laptop.txt", "1", "laptop"); | ||
| 278 | assert_accepted( | ||
| 279 | &harness.ssh_push(&laptop, "main:main"), | ||
| 280 | "the laptop key acting as alex", | ||
| 281 | ); | ||
| 282 | |||
| 283 | harness | ||
| 284 | .work_repo() | ||
| 285 | .commit_file("from-desktop.txt", "2", "desktop"); | ||
| 286 | assert_accepted( | ||
| 287 | &harness.ssh_push(&desktop, "main:main"), | ||
| 288 | "the desktop key acting as the same alex", | ||
| 289 | ); | ||
| 290 | |||
| 291 | // A third key, generated the same way but never enrolled, is not a | ||
| 292 | // principal at all — it cannot even authenticate. | ||
| 293 | let stranger = harness.named_key("mallory"); | ||
| 294 | let probe = harness.ssh_fetch(harness.work_repo().dir.path(), &stranger, "governed"); | ||
| 295 | assert!( | ||
| 296 | !probe.status.success(), | ||
| 297 | "an unenrolled key must not authenticate\n{}", | ||
| 298 | String::from_utf8_lossy(&probe.stdout) | ||
| 299 | ); | ||
| 300 | } | ||
| 301 | |||
| 302 | /// Wild repos: an agent allocates its own namespace, with no central | ||
| 303 | /// allocator to be a bottleneck or a privilege. | ||
| 304 | #[test] | ||
| 305 | fn an_agent_creates_its_own_wild_repo_and_another_agent_cannot_write_it() { | ||
| 306 | let harness = ServerHarness::new("governed"); | ||
| 307 | harness.bootstrap_settings( | ||
| 308 | ACCESS_CONF, | ||
| 309 | &[ | ||
| 310 | ("alex.pub", "alex"), | ||
| 311 | ("claude-a.pub", "claude-a"), | ||
| 312 | ("claude-b.pub", "claude-b"), | ||
| 313 | ], | ||
| 314 | ); | ||
| 315 | let first = harness.named_key("claude-a"); | ||
| 316 | let second = harness.named_key("claude-b"); | ||
| 317 | |||
| 318 | harness.work_repo().commit_file("mine.txt", "1", "mine"); | ||
| 319 | assert_accepted( | ||
| 320 | &harness.ssh_push_from( | ||
| 321 | harness.work_repo().dir.path(), | ||
| 322 | &first, | ||
| 323 | "agents/claude-a", | ||
| 324 | "main:main", | ||
| 325 | ), | ||
| 326 | "an agent creating its own wild repo", | ||
| 327 | ); | ||
| 328 | assert!(harness | ||
| 329 | .repos_dir() | ||
| 330 | .join("agents") | ||
| 331 | .join("claude-a.git") | ||
| 332 | .exists()); | ||
| 333 | |||
| 334 | harness.work_repo().commit_file("yours.txt", "2", "yours"); | ||
| 335 | assert_refused( | ||
| 336 | &harness.ssh_push_from( | ||
| 337 | harness.work_repo().dir.path(), | ||
| 338 | &second, | ||
| 339 | "agents/claude-a", | ||
| 340 | "main:main", | ||
| 341 | ), | ||
| 342 | "a second agent writing someone else's wild repo", | ||
| 343 | ); | ||
| 344 | |||
| 345 | // But it can create its own. | ||
| 346 | assert_accepted( | ||
| 347 | &harness.ssh_push_from( | ||
| 348 | harness.work_repo().dir.path(), | ||
| 349 | &second, | ||
| 350 | "agents/claude-b", | ||
| 351 | "main:main", | ||
| 352 | ), | ||
| 353 | "the second agent creating its own", | ||
| 354 | ); | ||
| 355 | } | ||
| 356 | |||
| 357 | /// Creating `settings.git` must not silently publish the key roster and the | ||
| 358 | /// access rules to an internet-facing web UI. A contributor reads the rules it | ||
| 359 | /// is subject to over SSH, where it is authenticated. | ||
| 360 | #[test] | ||
| 361 | fn the_settings_repository_is_not_on_the_anonymous_http_surface_by_default() { | ||
| 362 | let harness = ServerHarness::new("governed"); | ||
| 363 | harness.bootstrap_settings( | ||
| 364 | ACCESS_CONF, | ||
| 365 | &[("alex.pub", "alex"), ("claude-a.pub", "claude-a")], | ||
| 366 | ); | ||
| 367 | |||
| 368 | let listing = harness.get_ok("/"); | ||
| 369 | assert!( | ||
| 370 | !listing.body.contains("settings"), | ||
| 371 | "the governance repo must not be listed anonymously; got:\n{}", | ||
| 372 | listing.body | ||
| 373 | ); | ||
| 374 | |||
| 375 | for path in ["/settings", "/settings/tree", "/settings/commits"] { | ||
| 376 | let page = harness.get(path); | ||
| 377 | assert!( | ||
| 378 | !page.status_line.contains("200"), | ||
| 379 | "{path} must not be anonymously readable; got {}", | ||
| 380 | page.status_line | ||
| 381 | ); | ||
| 382 | } | ||
| 383 | |||
| 384 | // An enrolled principal still reads it over SSH. | ||
| 385 | let admin = harness.named_key("alex"); | ||
| 386 | let listed = harness.ssh_fetch(harness.work_repo().dir.path(), &admin, "settings"); | ||
| 387 | assert!( | ||
| 388 | listed.status.success(), | ||
| 389 | "an admin must still be able to read the rules: {}", | ||
| 390 | stderr(&listed) | ||
| 391 | ); | ||
| 392 | } | ||
| 393 | |||
| 394 | /// The documented split between the two config systems, asserted rather than | ||
| 395 | /// left to the doc comment: `settings.git` supersedes `server.toml` on the | ||
| 396 | /// authenticated-principal axis, and `server.toml` keeps the anonymous one. | ||
| 397 | #[test] | ||
| 398 | fn settings_supersedes_server_toml_for_principals_and_leaves_it_the_anonymous_surface() { | ||
| 399 | let harness = ServerHarness::new("governed"); | ||
| 400 | harness.push_head(); | ||
| 401 | harness.bootstrap_settings( | ||
| 402 | ACCESS_CONF, | ||
| 403 | &[("alex.pub", "alex"), ("claude-a.pub", "claude-a")], | ||
| 404 | ); | ||
| 405 | |||
| 406 | // A server.toml that, under the old regime, would deny every principal | ||
| 407 | // and hide the repository from anonymous HTTP. | ||
| 408 | harness.write_repo_server_policy( | ||
| 409 | "visibility = \"private\"\n\ | ||
| 410 | [ui]\nanonymous = false\n\ | ||
| 411 | [http]\nanonymous_clone = false\n\ | ||
| 412 | [access]\nread = []\nwrite = []\n", | ||
| 413 | ); | ||
| 414 | |||
| 415 | // The authenticated axis is access.conf's alone: the empty lists above are | ||
| 416 | // not consulted, so the admin still has RW+. | ||
| 417 | let admin = harness.named_key("alex"); | ||
| 418 | harness | ||
| 419 | .work_repo() | ||
| 420 | .commit_file("b.txt", "1", "still allowed"); | ||
| 421 | assert_accepted( | ||
| 422 | &harness.ssh_push(&admin, "main:main"), | ||
| 423 | "access.conf superseding an empty server.toml access list", | ||
| 424 | ); | ||
| 425 | |||
| 426 | // The anonymous axis is still server.toml's: access.conf has no vocabulary | ||
| 427 | // for a request with no principal, so `visibility` still hides the repo. | ||
| 428 | let page = harness.get("/governed"); | ||
| 429 | assert!( | ||
| 430 | !page.status_line.contains("200"), | ||
| 431 | "server.toml must still govern the anonymous surface; got {} \n{}", | ||
| 432 | page.status_line, | ||
| 433 | page.body | ||
| 434 | ); | ||
| 435 | } | ||