a73x

ea980317

Take the server's rules from a settings repository

a73x   2026-08-11 10:15

Commit message
Take the server's rules from a settings repository

A repository called `settings` under `repos_dir` now holds `conf/access.conf`
and `keydir/`, and pushing to it reconfigures the server. Identity is the
basename of a key file with directories ignored, so `keydir/laptop/alex.pub`
and `keydir/desktop/alex.pub` are one principal — which is how one operator
adds a second machine. Rules are ordered and first-match-wins over refexes,
with `R`/`RW`/`RW+`/`C`, `-` to deny, `@groups`, and wild patterns whose
`C = @agents` / `RW+ = CREATOR` lets an agent allocate its own namespace with
no central allocator to be a bottleneck or a privilege.

The shape this buys is one line: `RW refs/collab/ = @agents`. A contributor
needs write access to `refs/collab/*` and to nothing else, so no setting
anywhere grants an agent credential the ability to move a branch. That only
works because patches travel as collab refs.

Enforcement is at two moments, because the server learns the repository and
the verb before it learns the refs. Dispatch answers "may this principal push
here at all"; a server-managed `update` hook — the same binary, re-invoked
through a two-line script — answers "may it write this ref", which needs the
principal in `git-receive-pack`'s environment. The child's stderr was piped
and never read, discarding git's diagnostics and leaving a chatty child able
to wedge on a full pipe buffer; it is now drained onto the channel.

Config is validated on push: the rules must parse, the keys must be
well-formed, and somebody must still be able to push the config afterwards.
A push failing any of those is rejected and the previous config stays live.
That is load-bearing rather than defensive — it means the request path never
has to decide what a malformed config denies, and there is no staleness window
to bound. The validation runs even on an ungoverned server, so the first
config push cannot be the one nothing checks. The name `settings` alone does
not trigger it; `conf/access.conf` does, so an unrelated repository of that
name is left alone.

`settings.git` supersedes the per-repo `server.toml` on the authenticated axis
and leaves it the anonymous one: `access.conf` alone says what a principal may
do, `keydir/` alone says which keys authenticate, and `visibility` / `ui` /
`http` still govern anonymous HTTP, which has no principal for a rule to
match. Superseding rather than layering means exactly one file answers each
question — layering would let a forgotten `server.toml` silently subtract
access that `access.conf` visibly grants. Creating `settings.git` also does
not publish the key roster to an internet-facing UI: it is off the anonymous
surface unless its own `server.toml` opts in.

With no settings repository none of this is on, no hook is installed, and the
server behaves exactly as it did before.

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

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 &regime,
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 (&regime, 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 } = &regime {
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, .. } = &regime {
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, &regime, 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 }