choir_node/acl.rs
1//! Per-repository authorization (D29).
2//!
3//! The node's basic-auth check answers *who is this* and returns a
4//! username. This module answers the other question — *may this actor do
5//! this to this repository* — which until D29 nothing asked. Without it
6//! any valid credential reaches every repository on the node: safe while
7//! one operator holds the only token, and the first thing that matters
8//! when a second one is issued.
9//!
10//! The grammar is three whitespace-separated columns, `#` comments, blank
11//! lines ignored — the same shape as `--auth-file`, so the operator
12//! learns one file format rather than two — and an optional fourth column
13//! carrying a deadline (D66):
14//!
15//! ```text
16//! # <user|@anon> <repo|*|@node> <level> [until=<unix seconds>]
17//! alice owner/project own
18//! alice owner/notes read
19//! bob owner/project write until=1788000000
20//! carol @node auditor
21//! @anon owner/project read
22//! ```
23//!
24//! The last line is the one that publishes something. [`ANON`] is the
25//! reader who presented no credential, and naming a repository beside it
26//! is how that repository stops being behind the wall. It may hold only
27//! `read`, only on repositories written out one per line, and never
28//! `@node`; those three are refused by [`Acl::parse`], so a file that
29//! would publish more than the operator typed does not load at all.
30//!
31//! **A grant with a deadline stops mattering when the deadline passes,**
32//! and nothing sweeps: the table is dated on every request, so a lapse
33//! takes effect on the next one. It lapses *downward*, not to nothing —
34//! `bob` above keeps whatever weaker grant another line gives him, which
35//! is what makes a time-locked `write` over a permanent `read` a usable
36//! way to lend a privilege rather than an account.
37//!
38//! This is the mechanism D24's T1 response names ("time-locks + bonds
39//! only") and did not have. It is deliberately **not** an answer to T1's
40//! *measurement* problem: a grant lives in this file and in the D36
41//! store, never in the op log, so a replayer still cannot see one. That
42//! is D29's design and [`choir_view::View::validate_submit`] says so —
43//! the ACL grant is the single element replay cannot rederive.
44//!
45//! Four levels, `read` < `propose` < `write` < `own`. `propose` arrived
46//! with D60 and is the one that lets a repository take contributions
47//! from someone who is not trusted with its branches: it admits a push
48//! to `refs/for/<branch>/<user>/<topic>`, where the pusher's own name is
49//! what keeps two of them apart, and refuses every other ref. `own`
50//! arrived with D42, which
51//! is the first repository-scoped administrative action to exist: on a
52//! protected ref an owner's assent authorizes the landing, and `write`
53//! alone does not. Before that there was deliberately no `admin`, because
54//! a level with no operation behind it only invites a meaningless grant.
55//!
56//! **`own` is granted in this file only.** Self-service (D36) renders its
57//! issued grants in the same grammar, but the landing gate reads the
58//! operator's file directly rather than the merged table, so ownership
59//! cannot be self-issued.
60//!
61//! The file is not the only source of grants. Credential self-service
62//! (D36) renders what it issued in this same grammar, and the node
63//! enforces the two as one table ([`Acl::merged`]) — so an issued grant
64//! and a hand-written one are the same kind of fact, checked in the same
65//! place. What self-service may not issue is [`Scope::Node`]: node-wide
66//! authority stays in the file the operator writes.
67//!
68//! `*` covers every repository and never covers [`Scope::Node`]: the op
69//! log and the ref-state attestation describe the whole node, not a
70//! repository, and are gated rather than filtered because filtering a
71//! hash chain or a complete-ref-state snapshot destroys the property each
72//! exists to provide.
73//!
74//! The operator's guide to every authorization question this module
75//! and its neighbours answer:
76//!
77#![doc = include_str!("../../../docs/operating/authorization.md")]
78
79use std::collections::HashMap;
80use std::path::Path;
81
82use choir_view::{OpKind, ViewOp};
83
84/// What a grant applies to.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum Scope {
87 /// One repository, in the canonical spelling produced by
88 /// [`normalize_repo`].
89 Repo(String),
90 /// Every repository on the node, written `*` in the file.
91 ///
92 /// Never matches [`Scope::Node`]. `*` is a statement about
93 /// repositories, and the node's own log, attestation and
94 /// repository-less ops are not a repository.
95 AllRepos,
96 /// The node itself, written `@node` in the file: the op log, the
97 /// ref-state attestation, and ops that name no repository.
98 Node,
99}
100
101/// Grant strength. [`Level::Read`] is implied by [`Level::Write`], which
102/// is implied by [`Level::Own`].
103///
104/// The implication is the derived [`Ord`], which follows declaration
105/// order, and [`Effective::allows`] compares with `>=`. A new level must
106/// therefore be declared in strength order or every existing check
107/// silently changes meaning.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
109pub enum Level {
110 /// Clone and fetch a repository; read the node-scoped endpoints.
111 /// Spelled `read` on a repository and `auditor` on `@node`.
112 Read,
113 /// Everything [`Level::Read`] allows, plus opening a proposal: a
114 /// push to `refs/for/<branch>/<user>/<topic>` (D53) and no other
115 /// ref. Spelled `propose` (D60).
116 ///
117 /// The pusher's own name is a required segment, and that is what
118 /// keeps two holders of this level apart. Several people hold
119 /// `propose` at once, by construction -- it is the grant given to
120 /// contributors a repository does not trust -- so without it whoever
121 /// pushed second would take over or delete the first one's proposal,
122 /// and the log would record the takeover as an ordinary update by an
123 /// authorized pusher. A `write` holder is not held to the rule,
124 /// because a `write` holder can already reach every ref anyway.
125 ///
126 /// This is the grant for a contributor the operator does not trust
127 /// with the repository's branches, which until it existed was not
128 /// expressible: opening a review needed `write`, and `write` also
129 /// reaches every unprotected ref. Taking a contribution from a
130 /// stranger meant handing them the repository.
131 ///
132 /// **It is enforced in two places because it has to be.** The
133 /// smart-HTTP boundary admits the push at this level, and cannot do
134 /// better: git sends the ref list only after the server agrees to
135 /// receive the pack, so no refname exists when [`git_requirement`]
136 /// runs. The refname first exists when the `pre-receive` hook
137 /// reports it, and that is where a proposal-only grant is held to
138 /// proposals. Nothing is applied in between -- git applies no ref
139 /// until the hook exits zero.
140 Propose,
141 /// Everything [`Level::Propose`] allows, plus pushing any other ref,
142 /// provisioning a workspace, and submitting ops.
143 Write,
144 /// Everything [`Level::Write`] allows, plus authorizing a landing on
145 /// a protected ref of this repository (D42). Spelled `own`.
146 ///
147 /// This is the repository-scoped administrative action the module
148 /// documentation used to say did not exist. It is not a *stronger
149 /// push*: on a protected ref an owner's assent is what the gate asks
150 /// for, and `write` alone no longer answers it.
151 Own,
152}
153
154/// A refused request: the status to answer with, and the reason.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct Denial {
157 /// HTTP status. `404` when the actor may not even read the target,
158 /// so a missing grant does not confirm that the repository exists;
159 /// `403` when they can read it but not perform this operation.
160 pub status: u16,
161 /// Human-readable reason, safe to return to the caller.
162 pub reason: String,
163}
164
165/// One grant: what it covers, how strong it is, and when it stops (D66).
166///
167/// `until` is unix seconds and `None` means forever, which is every
168/// grant written before D66 and every one written since without the
169/// fourth column. It is absolute rather than a duration because a
170/// duration has to be measured from something, and a file that is read
171/// again on every reload has no issue time to measure from.
172#[derive(Debug, Clone)]
173struct Grant {
174 scope: Scope,
175 level: Level,
176 until: Option<u64>,
177}
178
179impl Grant {
180 /// Whether this grant is still in force at `now`, in unix seconds.
181 ///
182 /// Strict, so the grant is already dead in the second it names rather
183 /// than in the one after. That is the comparison an invite's expiry
184 /// makes in [`crate::accounts`], and the two are the same kind of
185 /// statement about the same timeline; a deadline that meant one
186 /// second more here than there would be a bug nobody could see.
187 fn live_at(&self, now: u64) -> bool {
188 self.until.is_none_or(|until| now < until)
189 }
190}
191
192/// The reader who presented no credential.
193///
194/// Granting this principal `read` on a repository is what makes that
195/// repository public: the node stops refusing an unauthenticated
196/// browse or fetch of it and evaluates the ACL under this name instead,
197/// so one table answers "may this caller read this" for strangers and
198/// account holders alike rather than a second rule existing beside it.
199///
200/// It cannot be authenticated as. Account names are ASCII alphanumerics
201/// with `-`, `_` and `.`, so the leading `@` is unspellable in the one
202/// place a name is chosen -- the same property that makes `@node`
203/// unforgeable as a repository name. Nothing here relies on a check
204/// somewhere else refusing to issue it.
205///
206/// What it may hold is deliberately narrow, and enforced in
207/// [`Acl::parse`] rather than at the point of use: never `@node`, never
208/// `*`, and never a level above [`Level::Read`]. A grant that would let
209/// a stranger write is not refused later, it does not parse.
210pub const ANON: &str = "@anon";
211
212/// A parsed ACL file: which users hold which grants, and until when.
213///
214/// Empty means nobody holds anything, which under a configured ACL denies
215/// every request. That is the intended failure mode, and the reason a
216/// malformed file is never partially applied.
217///
218/// **This type answers no authorization question.** It is what the file
219/// says; [`Acl::at`] turns it into the [`Effective`] table that holds at
220/// one instant, and that is the only type with `allows` on it. The split
221/// is the whole D66 mechanism: a grant with a deadline is only safe if
222/// forgetting the deadline is impossible, and here forgetting it does not
223/// compile.
224#[derive(Debug, Default, Clone)]
225pub struct Acl {
226 grants: HashMap<String, Vec<Grant>>,
227}
228
229impl Acl {
230 /// Parses the ACL grammar described in the module documentation.
231 ///
232 /// # Errors
233 ///
234 /// Returns a message naming the offending line number. A file with
235 /// one bad line does not parse at all: a partially applied ACL would
236 /// silently revoke somebody's access.
237 ///
238 /// A deadline already in the past is **not** an error. It parses, and
239 /// then never matches: refusing the file would turn one stale line
240 /// into a node-wide lockout, which is a worse failure than the one it
241 /// would be reporting. [`Acl::expired`] is how the operator sees it.
242 pub fn parse(text: &str) -> Result<Self, String> {
243 let mut grants: HashMap<String, Vec<Grant>> = HashMap::new();
244 for (index, raw) in text.lines().enumerate() {
245 let number = index + 1;
246 let line = raw.split('#').next().unwrap_or("").trim();
247 if line.is_empty() {
248 continue;
249 }
250 let mut columns = line.split_whitespace();
251 let (Some(user), Some(target), Some(level), deadline, None) = (
252 columns.next(),
253 columns.next(),
254 columns.next(),
255 columns.next(),
256 columns.next(),
257 ) else {
258 return Err(format!(
259 "line {number}: expected three columns, `<user> <repo|*|@node> <read|write>`, \
260 and optionally a fourth, `until=<unix seconds>`"
261 ));
262 };
263 let scope = parse_scope(target).map_err(|e| format!("line {number}: {e}"))?;
264 let level = parse_level(&scope, level).map_err(|e| format!("line {number}: {e}"))?;
265 if user == ANON {
266 // The reserved principal, and the only one nobody
267 // authenticates as. Three refusals rather than one,
268 // because each is a different way to hand the internet
269 // more than "read this repository".
270 if matches!(scope, Scope::Node) {
271 return Err(format!(
272 "line {number}: `{ANON}` may not hold `@node` — that scope is the op \
273 log and the audit surface, and this principal is every stranger"
274 ));
275 }
276 if matches!(scope, Scope::AllRepos) {
277 return Err(format!(
278 "line {number}: `{ANON}` may not hold `*` — name each repository that \
279 is meant to be public, so adding a private one later is not a \
280 publication nobody typed"
281 ));
282 }
283 if level > Level::Read {
284 return Err(format!(
285 "line {number}: `{ANON}` may hold only `read` — a level above it is a \
286 write path for a caller that presented no credential"
287 ));
288 }
289 }
290 let until = deadline
291 .map(parse_deadline)
292 .transpose()
293 .map_err(|e| format!("line {number}: {e}"))?;
294 grants.entry(user.to_string()).or_default().push(Grant {
295 scope,
296 level,
297 until,
298 });
299 }
300 Ok(Self { grants })
301 }
302
303 /// Whether `user` is granted anything at [`Scope::Node`] here, at any
304 /// level and whatever its deadline says.
305 ///
306 /// Deliberately **not** an [`Effective`] question. D36 forbids
307 /// self-service from issuing node-wide authority at all, and a
308 /// deadline must never be the thing that enforces that: a grant
309 /// dated into the past would answer "no" today and "yes" to anyone
310 /// who reads the same table with a different clock. The rule is
311 /// about what may be written, so it is asked of what is written.
312 #[must_use]
313 pub fn grants_node(&self, user: &str) -> bool {
314 self.grants
315 .get(user)
316 .is_some_and(|held| held.iter().any(|grant| matches!(grant.scope, Scope::Node)))
317 }
318
319 /// The grants that hold at `now`, in unix seconds — the only table
320 /// that answers an authorization question.
321 ///
322 /// Evaluated per request rather than cached across one, so a caller
323 /// holding this decides every question at a single instant. A grant
324 /// that lapses mid-request therefore lapses at the next request, not
325 /// between two checks of the same one.
326 #[must_use]
327 pub fn at(&self, now: u64) -> Effective {
328 let mut grants: HashMap<String, Vec<(Scope, Level)>> = HashMap::new();
329 for (user, held) in &self.grants {
330 let live: Vec<(Scope, Level)> = held
331 .iter()
332 .filter(|grant| grant.live_at(now))
333 .map(|grant| (grant.scope.clone(), grant.level))
334 .collect();
335 if !live.is_empty() {
336 grants.insert(user.clone(), live);
337 }
338 }
339 Effective { grants }
340 }
341
342 /// How many grants have a deadline that has already passed at `now`.
343 ///
344 /// Reported at startup and on reload so a line that is dead on
345 /// arrival — a typo in the deadline, or a file that outlived what it
346 /// was granting — is visible without an operator diffing behaviour
347 /// against intent.
348 #[must_use]
349 pub fn expired(&self, now: u64) -> usize {
350 self.grants
351 .values()
352 .flatten()
353 .filter(|grant| !grant.live_at(now))
354 .count()
355 }
356
357 /// Reads and parses the file at `path`.
358 ///
359 /// # Errors
360 ///
361 /// Returns a message when the file cannot be read, or when it does
362 /// not parse.
363 pub fn load(path: &Path) -> Result<Self, String> {
364 let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
365 Self::parse(&text)
366 }
367
368 /// Number of grants across all users.
369 #[must_use]
370 pub fn len(&self) -> usize {
371 self.grants.values().map(Vec::len).sum()
372 }
373
374 /// Whether the table holds no grants at all, in which case a
375 /// configured ACL denies everyone.
376 #[must_use]
377 pub fn is_empty(&self) -> bool {
378 self.len() == 0
379 }
380
381 /// This table plus `other`'s grants, as one table.
382 ///
383 /// The union, never an intersection: the operator's file and the
384 /// self-service store (D36) each answer for the grants they issued,
385 /// and neither can withdraw the other's. Written so that every
386 /// enforcement point keeps consulting exactly one [`Acl`] — the two
387 /// sources are a detail of where grants come from, not a second
388 /// decision anybody has to remember to make.
389 #[must_use]
390 pub fn merged(&self, other: &Self) -> Self {
391 let mut grants = self.grants.clone();
392 for (user, held) in &other.grants {
393 grants.entry(user.clone()).or_default().extend(held.clone());
394 }
395 Self { grants }
396 }
397}
398
399/// The grants that hold right now: an [`Acl`] with every lapsed deadline
400/// already dropped (D66).
401///
402/// Produced only by [`Acl::at`], which is what makes the deadline
403/// impossible to skip — there is no way to reach `allows` holding a
404/// table nobody has dated. Every field of every method below behaves
405/// exactly as it did before D66 for a grant with no deadline, which is
406/// still most of them.
407#[derive(Debug, Default, Clone)]
408pub struct Effective {
409 grants: HashMap<String, Vec<(Scope, Level)>>,
410}
411
412impl Effective {
413 /// Whether `user` holds any live grant at all.
414 ///
415 /// Asked of [`ANON`] before an unauthenticated request is evaluated
416 /// under that name, so a node whose table never mentions it keeps
417 /// refusing strangers at the gate rather than walking the whole
418 /// request to reach the same answer. It is a question about the
419 /// table and not an authorization: what the caller may actually
420 /// reach is still [`Effective::allows`], repository by repository.
421 #[must_use]
422 pub fn holds_anything(&self, user: &str) -> bool {
423 self.grants.get(user).is_some_and(|held| !held.is_empty())
424 }
425
426 /// Whether `user` holds at least `level` over `scope`.
427 #[must_use]
428 pub fn allows(&self, user: &str, scope: &Scope, level: Level) -> bool {
429 let Some(held) = self.grants.get(user) else {
430 return false;
431 };
432 held.iter()
433 .any(|(granted, at)| *at >= level && covers(granted, scope))
434 }
435
436 /// Whether `user` holds at least `level` over repository `repo`,
437 /// given in either spelling (`owner/repo` or `owner/repo.git`).
438 #[must_use]
439 pub fn allows_repo(&self, user: &str, repo: &str, level: Level) -> bool {
440 self.allows(user, &Scope::Repo(normalize_repo(repo)), level)
441 }
442
443 /// Whether anybody at all holds [`Level::Own`] over `repo` (D42).
444 ///
445 /// This is the switch between the two landing rules, not an
446 /// authorization check: a repository with no owner keeps the
447 /// approval-weight gate, and one with an owner asks for owner assent
448 /// instead. It is deliberately a question about the repository rather
449 /// than about a user, because the gate has to choose which rule
450 /// applies before it knows whether the actor satisfies it.
451 #[must_use]
452 pub fn has_owner(&self, repo: &str) -> bool {
453 let scope = Scope::Repo(normalize_repo(repo));
454 self.grants.values().any(|held| {
455 held.iter()
456 .any(|(granted, at)| *at >= Level::Own && covers(granted, &scope))
457 })
458 }
459
460 /// Every subject holding [`Level::Own`] over `repo`, sorted.
461 ///
462 /// [`Self::has_owner`] answers the question the *gate* asks — which
463 /// of the two landing rules applies — and deliberately answers it
464 /// without naming anybody, because the gate does not need a name.
465 /// A review page does: "an owner's assent lands this" is a rule, and
466 /// "`alice` or `bob` can land this" is an answer. Sorted so the
467 /// sentence a page renders is the same on two nodes holding the same
468 /// grants.
469 ///
470 /// This is a description of the ACL, never a decision about a
471 /// landing. The one function that admits a landing is
472 /// `Platform::authorization_for`, and nothing here may become a
473 /// second opinion beside it.
474 #[must_use]
475 pub fn owners(&self, repo: &str) -> Vec<String> {
476 let scope = Scope::Repo(normalize_repo(repo));
477 let mut names: Vec<String> = self
478 .grants
479 .iter()
480 .filter(|(_, held)| {
481 held.iter()
482 .any(|(granted, at)| *at >= Level::Own && covers(granted, &scope))
483 })
484 .map(|(who, _)| who.clone())
485 .collect();
486 names.sort();
487 names
488 }
489
490 /// Every repository `user` may read, by name, sorted.
491 ///
492 /// For writing a crawl policy and a sitemap out of the table that
493 /// already decides what is public (D78), rather than out of a second
494 /// list somebody has to remember to edit. `owners` above is the
495 /// shape this follows: a description of the ACL, never a decision.
496 ///
497 /// **[`Scope::AllRepos`] contributes nothing**, because this returns
498 /// names and `*` is not one. That is not a gap for the caller this
499 /// exists for: D78 refuses `*` to [`ANON`] at parse time, so the
500 /// anonymous reader's grants are always explicit repositories. A
501 /// caller asking about somebody who does hold `*` gets the repos
502 /// they were named on and must not read the empty tail as "nothing
503 /// else"; [`Effective::allows_repo`] answers that question.
504 #[must_use]
505 pub fn readable_repos(&self, user: &str) -> Vec<String> {
506 let Some(held) = self.grants.get(user) else {
507 return Vec::new();
508 };
509 let mut names: Vec<String> = held
510 .iter()
511 .filter_map(|(granted, at)| match granted {
512 Scope::Repo(name) if *at >= Level::Read => Some(name.clone()),
513 _ => None,
514 })
515 .collect();
516 names.sort();
517 names.dedup();
518 names
519 }
520
521 /// A key identifying everything a filtered response depends on:
522 /// the reader and the grants they hold, rendered canonically.
523 ///
524 /// Two requests with the same key produce the same filtered payload,
525 /// which is what makes the browser page cacheable per reader. Editing
526 /// the ACL file changes the key, so a hot reload invalidates the
527 /// cached page without anything having to notice the reload happened.
528 ///
529 /// The username is part of the key rather than the grants alone,
530 /// because [`filter_response`] also keeps reviews the reader is
531 /// assigned to. Two readers holding identical grants can therefore
532 /// see different pages, and a key covering only the grants would
533 /// serve one of them the other's assignments.
534 #[must_use]
535 pub fn cache_key(&self, user: &str) -> String {
536 let mut held: Vec<String> = self
537 .grants
538 .get(user)
539 .map(|grants| {
540 grants
541 .iter()
542 .map(|(scope, level)| {
543 let target = match scope {
544 Scope::Repo(repo) => repo.as_str(),
545 Scope::AllRepos => "*",
546 Scope::Node => "@node",
547 };
548 let level = match level {
549 Level::Read => "r",
550 Level::Propose => "p",
551 Level::Write => "w",
552 Level::Own => "o",
553 };
554 format!("{target}={level}")
555 })
556 .collect()
557 })
558 .unwrap_or_default();
559 held.sort();
560 held.dedup();
561 // A unit separator cannot occur in a username the auth file can
562 // express, so the two halves of the key cannot be confused for
563 // one another however they are spelled.
564 format!("{user}\u{1f}{}", held.join(","))
565 }
566
567 /// The [`Denial`] for `user` over `scope` at `level`, or `None` when
568 /// the request is allowed.
569 #[must_use]
570 pub fn check(&self, user: &str, scope: &Scope, level: Level) -> Option<Denial> {
571 if self.allows(user, scope, level) {
572 return None;
573 }
574 // Withholding read from a repository means withholding the fact
575 // that it exists, so an unreadable one is "not found" rather than
576 // "forbidden" — a `403` would confirm the name. A readable one
577 // has already been disclosed, so the honest answer is `403`.
578 //
579 // The node is not hidden this way: the caller is authenticated to
580 // it and is looking straight at it, so pretending its endpoints
581 // do not exist buys nothing and only obscures the fix.
582 if matches!(scope, Scope::Node) || self.allows(user, scope, Level::Read) {
583 Some(Denial {
584 status: 403,
585 reason: match scope {
586 Scope::Node => "requires a node-wide grant".to_string(),
587 other => format!("no write grant for {}", describe(other)),
588 },
589 })
590 } else {
591 Some(Denial {
592 status: 404,
593 reason: "no such repository".to_string(),
594 })
595 }
596 }
597}
598
599/// Whether a granted scope covers a requested one.
600fn covers(granted: &Scope, requested: &Scope) -> bool {
601 match (granted, requested) {
602 (Scope::Node, Scope::Node) => true,
603 (Scope::AllRepos, Scope::Repo(_)) => true,
604 (Scope::Repo(held), Scope::Repo(want)) => held == want,
605 _ => false,
606 }
607}
608
609/// Phrase naming a scope in a denial message.
610fn describe(scope: &Scope) -> String {
611 match scope {
612 Scope::Repo(repo) => format!("repository {repo}"),
613 Scope::AllRepos => "every repository".to_string(),
614 Scope::Node => "this node".to_string(),
615 }
616}
617
618/// Canonical ACL spelling of a repository name: one trailing `.git`
619/// removed, so `owner/repo` and `owner/repo.git` are the same grant.
620#[must_use]
621pub fn normalize_repo(repo: &str) -> String {
622 repo.strip_suffix(".git").unwrap_or(repo).to_string()
623}
624
625/// Parses the repository column.
626fn parse_scope(target: &str) -> Result<Scope, String> {
627 if target == "*" {
628 return Ok(Scope::AllRepos);
629 }
630 if target == "@node" {
631 return Ok(Scope::Node);
632 }
633 if target.starts_with('@') {
634 return Err(format!(
635 "`{target}` is not a pseudo-repository; the only one is `@node`"
636 ));
637 }
638 let repo = normalize_repo(target);
639 let mut segments = repo.split('/');
640 let (Some(owner), Some(name), None) = (segments.next(), segments.next(), segments.next())
641 else {
642 return Err(format!(
643 "`{target}` is not a repository name; write `owner/repo`"
644 ));
645 };
646 if owner.is_empty() || name.is_empty() {
647 return Err(format!(
648 "`{target}` is not a repository name; write `owner/repo`"
649 ));
650 }
651 Ok(Scope::Repo(repo))
652}
653
654/// Parses the optional fourth column, `until=<unix seconds>`.
655///
656/// A `key=value` shape rather than a bare number so the column says what
657/// it means in the file itself, and so a fifth thing to say about a grant
658/// does not have to be positional. An unknown key is an error rather than
659/// something ignored: a grant is the wrong place to be generous about
660/// what a line might have meant.
661///
662/// # Errors
663///
664/// Returns a message when the key is not `until`, or when the value is
665/// not a unix-seconds number.
666fn parse_deadline(column: &str) -> Result<u64, String> {
667 let Some(value) = column.strip_prefix("until=") else {
668 let key = column.split('=').next().unwrap_or(column);
669 return Err(format!(
670 "`{key}` is not a grant option; the only fourth column is `until=<unix seconds>`"
671 ));
672 };
673 value.parse().map_err(|_| {
674 format!("`{value}` is not a unix-seconds deadline; `until=` takes a whole number")
675 })
676}
677
678/// Parses the level column, which is spelled differently on `@node`
679/// because the node-wide read grant is a named role rather than a
680/// repository permission.
681fn parse_level(scope: &Scope, level: &str) -> Result<Level, String> {
682 match (scope, level) {
683 (_, "write") => Ok(Level::Write),
684 (Scope::Node, "auditor") => Ok(Level::Read),
685 (Scope::Node, "read") => {
686 Err("the node-wide read grant is spelled `auditor`, not `read`".to_string())
687 }
688 (_, "auditor") => {
689 Err("`auditor` is a node-wide role; on a repository write `read`".to_string())
690 }
691 (_, "read") => Ok(Level::Read),
692 // `propose` names a right over one repository's review surface.
693 // `@node` is the log and the attestation, which hold no reviews,
694 // so the spelling is refused there rather than quietly granted.
695 (Scope::Node, "propose") => {
696 Err("`propose` is a repository grant; `@node` takes `auditor` or `write`".to_string())
697 }
698 (_, "propose") => Ok(Level::Propose),
699 // `own` names an owner *of a repository*. `@node` is the log and
700 // the attestation, which no repository owns, so the spelling is
701 // refused there rather than quietly granted over everything.
702 (Scope::Node, "own") => {
703 Err("`own` is a repository grant; `@node` takes `auditor` or `write`".to_string())
704 }
705 (_, "own") => Ok(Level::Own),
706 (Scope::Node, other) => Err(format!(
707 "`{other}` is not a level; write `auditor` or `write`"
708 )),
709 (_, other) => Err(format!(
710 "`{other}` is not a level; write `read`, `propose`, `write` or `own`"
711 )),
712 }
713}
714
715/// Repository and level a git smart-HTTP request needs.
716///
717/// `None` means the request names no repository, or names an operation
718/// outside the smart-HTTP surface. Both are denied when an ACL is
719/// configured, rather than falling through to the CGI handler.
720#[must_use]
721pub fn git_requirement(method: &str, url: &str) -> Option<(String, Level)> {
722 let path = url.split('?').next().unwrap_or(url);
723 let query = url.split_once('?').map_or("", |(_, q)| q);
724 // A traversal segment would let a path authorized against the
725 // repository named first reach a different one inside the CGI, since
726 // `git http-backend` resolves PATH_INFO itself. Refusing it here does
727 // not depend on what that resolution happens to do. Percent-encoded
728 // dots need no separate rule: the path is handed to the CGI
729 // undecoded, so `%2e%2e` is a literal directory name, not a segment.
730 if path.split('/').any(|segment| segment == "..") {
731 return None;
732 }
733 let repo = crate::repo_from_path(url)?;
734 // Everything after `/<repo>`; a non-char-boundary index cannot
735 // happen for an ASCII repo name, and yields a denial if it somehow
736 // does.
737 let tail = path
738 .get(1 + repo.len()..)
739 .unwrap_or("")
740 .trim_start_matches('/');
741 let level = match (method, tail) {
742 // `propose`, not `write`: no refname exists yet (see
743 // [`Level::Propose`]). A pusher who reaches here holding only
744 // `propose` has their refs checked when the hook reports them,
745 // and git applies none of them before that.
746 ("POST", "git-receive-pack") => Level::Propose,
747 ("POST", "git-upload-pack") => Level::Read,
748 // The ref advertisement is the first request of both directions,
749 // and the service parameter is the only thing distinguishing a
750 // clone from a push.
751 ("GET", t) if t.starts_with("info/refs") => {
752 if query.split('&').any(|p| p == "service=git-receive-pack") {
753 Level::Propose
754 } else {
755 Level::Read
756 }
757 }
758 ("GET", "HEAD") => Level::Read,
759 ("GET", t) if t.starts_with("info/") || t.starts_with("objects/") => Level::Read,
760 _ => return None,
761 };
762 Some((normalize_repo(&repo), level))
763}
764
765/// Whether a request is a write, in the sense a seed refuses (D80).
766///
767/// Two halves, one rule each. **git**: whatever [`git_requirement`] grades
768/// at [`Level::Propose`] or above, which is every step of a push and no
769/// step of a clone, and is decided before `git http-backend` is spawned.
770/// **The API**: every `POST` under `/api/`. That one is by method rather
771/// than by grant level on purpose, because level grades *authority* and
772/// not *mutation*: a verdict or a comment needs only `read` over the
773/// repository (see [`op_level`]) and still appends to the log. Every
774/// `/api/` read is a `GET`, and every `/api/` `POST` changes something,
775/// the log or a store beside it, so the method is the classification that
776/// cannot let a write through as a read.
777///
778/// The pre-authentication routes (signing in, asking for access) are not
779/// asked, because they never reach the point where this is.
780#[must_use]
781pub fn is_write(method: &str, url: &str) -> bool {
782 let path = url.split('?').next().unwrap_or(url);
783 if path.starts_with("/api/") {
784 return method == "POST";
785 }
786 git_requirement(method, url).is_some_and(|(_, level)| level >= Level::Propose)
787}
788
789/// Repository a workspace name or provenance subject belongs to: its
790/// first two `/`-separated segments.
791///
792/// `None` for anything with fewer than two segments — a submission
793/// channel like `git/<user>` has two, so it resolves to a repository
794/// name that will simply not be granted, while a bare subject resolves
795/// to nothing and falls to [`Scope::Node`].
796fn subject_repo(subject: &str) -> Option<String> {
797 let mut segments = subject.split('/');
798 let (owner, name) = (segments.next()?, segments.next()?);
799 if owner.is_empty() || name.is_empty() {
800 return None;
801 }
802 Some(normalize_repo(&format!("{owner}/{name}")))
803}
804
805/// Repository named by a view ref key in its `<repo>:<refname>` form.
806pub(crate) fn ref_repo(name: &str) -> Option<String> {
807 name.split_once(':').map(|(repo, _)| normalize_repo(repo))
808}
809
810/// Scopes an op must be authorized against, never empty.
811///
812/// `review_repo` resolves a review id to the repository its target ref
813/// names, so posting a verdict needs write on the repository under
814/// review rather than a node-wide grant. An op that resolves to no
815/// repository is authorized against [`Scope::Node`]: that is the
816/// fail-closed rule, and being an exhaustive match, a new [`OpKind`]
817/// variant will not compile until somebody classifies it.
818#[must_use]
819pub fn op_scopes(kind: &OpKind, review_repo: impl Fn(&str) -> Option<String>) -> Vec<Scope> {
820 let repos: Vec<String> = match kind {
821 // A landing authorizes against the repository whose ref it
822 // moves, exactly as the bare ref move does. It needs no scope of
823 // its own: the extra authority a `Submit` carries is the landing
824 // gate's, and that gate is not this grant.
825 OpKind::SetRef { name, .. }
826 | OpKind::DeleteRef { name, .. }
827 | OpKind::Submit { name, .. } => ref_repo(name).into_iter().collect(),
828 OpKind::SetWorkspaceHead { workspace, .. } | OpKind::DeleteWorkspace { workspace } => {
829 subject_repo(workspace).into_iter().collect()
830 }
831 // Every change op names the workspace it is bound to, and a
832 // workspace's leading two segments are its repository. So the
833 // lifecycle authorizes against the same repository a legacy
834 // workspace move does, rather than falling to a node-wide grant.
835 OpKind::CreateChange { workspace, .. }
836 | OpKind::CheckpointChange { workspace, .. }
837 | OpKind::ArchiveChange { workspace, .. } => subject_repo(workspace).into_iter().collect(),
838 // A check reports on a commit, and a commit belongs to no
839 // repository, so the destination ref is the only thing that can
840 // scope it — the same reasoning, and the same fail-closed
841 // fallback, as the review request above.
842 OpKind::RequestReview { target_ref, .. } | OpKind::RecordCheck { target_ref, .. } => {
843 target_ref
844 .as_deref()
845 .and_then(ref_repo)
846 .into_iter()
847 .collect()
848 }
849 // A comment authorizes against the repository under review, the
850 // same as a verdict on the same review: discussion is part of the
851 // review surface, not a node-wide fact.
852 OpKind::PostVerdict { id, .. }
853 | OpKind::ArchiveReview { id, .. }
854 | OpKind::SlashApproval { id, .. }
855 | OpKind::PostComment { id, .. }
856 | OpKind::ViewedReview { id, .. }
857 | OpKind::AssignReviewers { id, .. } => review_repo(id).into_iter().collect(),
858 OpKind::RecordProvenance { subject, .. } => subject_repo(subject).into_iter().collect(),
859 // Node-scoped by nature: these name keys, operators, or the
860 // whole ref state, and never one repository. A vouch is about
861 // who somebody is, which is not a fact about any repository even
862 // when the only place the reader met them was one.
863 OpKind::BindKey { .. }
864 | OpKind::RevokeKey { .. }
865 | OpKind::Vouch { .. }
866 | OpKind::WithdrawVouch { .. }
867 | OpKind::RecordRefSnapshot { .. }
868 | OpKind::CountersignSnapshot { .. } => Vec::new(),
869 };
870 if repos.is_empty() {
871 vec![Scope::Node]
872 } else {
873 repos.into_iter().map(Scope::Repo).collect()
874 }
875}
876
877/// The grant strength an op needs over the scopes [`op_scopes`] names.
878///
879/// Write for everything that moves a ref or changes a review's shape,
880/// and read for the ops that only report what their own signer thinks:
881/// a verdict, a comment, a viewing receipt (D55), and a vouch or its
882/// withdrawal (D65).
883///
884/// Those are exactly the ops admission binds to the signing channel — a
885/// claimed attribution other than the channel is `reviewer_mismatch` —
886/// and the fold refuses a verdict from anybody the review does not list.
887/// Read is therefore the whole authority they need, and requiring write
888/// would mean handing push rights to every reviewer drawn onto a
889/// repository, which is the opposite of what asking for a review is for.
890///
891/// A vouch joins them for the same reason and one more. It is node-
892/// scoped, so `write` here would mean node-wide write: the web of trust
893/// would be authorable only by the handful of identities who can already
894/// move any ref on the node, which is not a web. What stops it being
895/// free is not this level but [`View::is_bound_operator`] — both ends of
896/// an edge need a binding, and only the node authors those.
897///
898/// [`View::is_bound_operator`]: choir_view::View::is_bound_operator
899///
900/// Exhaustive on purpose, like [`op_scopes`]: a new [`OpKind`] variant
901/// will not compile until somebody says which side of this line it
902/// falls on, and the safe answer is write.
903#[must_use]
904pub fn op_level(kind: &OpKind) -> Level {
905 match kind {
906 OpKind::PostVerdict { .. }
907 | OpKind::PostComment { .. }
908 | OpKind::ViewedReview { .. }
909 | OpKind::Vouch { .. }
910 | OpKind::WithdrawVouch { .. }
911 | OpKind::CountersignSnapshot { .. } => Level::Read,
912 OpKind::SetRef { .. }
913 | OpKind::DeleteRef { .. }
914 | OpKind::Submit { .. }
915 | OpKind::SetWorkspaceHead { .. }
916 | OpKind::DeleteWorkspace { .. }
917 | OpKind::CreateChange { .. }
918 | OpKind::CheckpointChange { .. }
919 | OpKind::ArchiveChange { .. }
920 | OpKind::RequestReview { .. }
921 | OpKind::RecordCheck { .. }
922 | OpKind::ArchiveReview { .. }
923 | OpKind::SlashApproval { .. }
924 | OpKind::AssignReviewers { .. }
925 | OpKind::RecordProvenance { .. }
926 | OpKind::BindKey { .. }
927 | OpKind::RevokeKey { .. }
928 | OpKind::RecordRefSnapshot { .. } => Level::Write,
929 }
930}
931
932/// Scopes a `/api/submit` body must be authorized against, each with the
933/// level that op needs over it.
934///
935/// A body that cannot be decoded far enough to name a repository falls
936/// to [`Scope::Node`] at [`Level::Write`] rather than being waved
937/// through, so a caller without a node-wide grant gets a denial and one
938/// with it gets the handler's own `400`.
939fn submission_scopes(
940 body: &serde_json::Value,
941 review_repo: &impl Fn(&str) -> Option<String>,
942) -> Vec<(Scope, Level)> {
943 let decoded = body
944 .get("payload_hex")
945 .and_then(serde_json::Value::as_str)
946 .and_then(crate::platform::hex_decode)
947 .and_then(|bytes| ViewOp::from_payload(&bytes).ok());
948 match decoded {
949 Some(op) => {
950 let level = op_level(&op.kind);
951 op_scopes(&op.kind, review_repo)
952 .into_iter()
953 .map(|scope| (scope, level))
954 .collect()
955 }
956 None => vec![(Scope::Node, Level::Write)],
957 }
958}
959
960/// The ACL decision for one platform-API request, or `None` when it is
961/// allowed.
962///
963/// Unknown `/api/` paths are denied. They reach no handler today, so the
964/// only behaviour this changes is for an endpoint added later without a
965/// row here — which should fail closed rather than ship unauthorized.
966#[must_use]
967pub fn api_denial(
968 acl: &Effective,
969 user: &str,
970 method: &str,
971 path: &str,
972 body: &[u8],
973 review_repo: impl Fn(&str) -> Option<String>,
974) -> Option<Denial> {
975 let json = || serde_json::from_slice::<serde_json::Value>(body).ok();
976 let required: Vec<(Scope, Level)> = match (method, path) {
977 ("POST", "/api/workspace") => {
978 let repo = json()
979 .as_ref()
980 .and_then(|v| v.get("repo"))
981 .and_then(serde_json::Value::as_str)
982 .map(normalize_repo);
983 match repo {
984 Some(repo) => vec![(Scope::Repo(repo), Level::Write)],
985 None => vec![(Scope::Node, Level::Write)],
986 }
987 }
988 // Archiving detaches a repository's workspace, so it needs the
989 // same repo-scoped write as creating one. Without an arm here the
990 // fail-closed default below would answer 404 and the workspace
991 // lifecycle would end at create.
992 ("POST", "/api/workspace/archive") => {
993 let repo = json()
994 .as_ref()
995 .and_then(|v| v.get("repo"))
996 .and_then(serde_json::Value::as_str)
997 .map(normalize_repo);
998 match repo {
999 Some(repo) => vec![(Scope::Repo(repo), Level::Write)],
1000 None => vec![(Scope::Node, Level::Write)],
1001 }
1002 }
1003 // D68. A round moves a branch on other people's behalf and
1004 // spends CI per open proposal, so it sits with whoever owns the
1005 // repository. A `write` holder can already move the branch, but
1006 // only by pushing their own work to it; asking the queue to
1007 // land everybody else's is a different thing to be trusted with.
1008 ("POST", "/api/queue/run") => {
1009 let repo = json()
1010 .as_ref()
1011 .and_then(|v| v.get("repo"))
1012 .and_then(serde_json::Value::as_str)
1013 .map(normalize_repo);
1014 match repo {
1015 Some(repo) => vec![(Scope::Repo(repo), Level::Own)],
1016 None => vec![(Scope::Node, Level::Own)],
1017 }
1018 }
1019 ("POST", "/api/submit") => match json() {
1020 Some(value) => submission_scopes(&value, &review_repo),
1021 None => vec![(Scope::Node, Level::Write)],
1022 },
1023 ("POST", "/api/submit-batch") => {
1024 let ops = json();
1025 let entries = ops
1026 .as_ref()
1027 .and_then(|v| v.get("ops"))
1028 .and_then(serde_json::Value::as_array);
1029 match entries {
1030 Some(entries) => {
1031 let mut scopes: Vec<(Scope, Level)> = Vec::new();
1032 for entry in entries {
1033 for required in submission_scopes(entry, &review_repo) {
1034 if !scopes.contains(&required) {
1035 scopes.push(required);
1036 }
1037 }
1038 }
1039 if scopes.is_empty() {
1040 scopes.push((Scope::Node, Level::Write));
1041 }
1042 scopes
1043 }
1044 None => vec![(Scope::Node, Level::Write)],
1045 }
1046 }
1047 // Whole-node reads: gated rather than filtered, because the log
1048 // is a hash chain and the attestation covers the complete ref
1049 // state. Narrowing either would destroy what it is for.
1050 ("GET", p) if p.starts_with("/api/log") => vec![(Scope::Node, Level::Read)],
1051 // The keys that verify the log's signatures, behind the log's own
1052 // grant: the reader who needs them is the one replaying the log,
1053 // and a second grant for the half of the same check would be a
1054 // second answer to one question. Public keys are public; the gate
1055 // is about not publishing a node's roster to strangers.
1056 ("GET", "/api/signers") => vec![(Scope::Node, Level::Read)],
1057 ("GET", "/api/ref-agreement") => vec![(Scope::Node, Level::Read)],
1058 // A seed's signed statements (D80). No grant beyond being
1059 // authenticated, the same as `/api/view`, whose `snapshot` already
1060 // tells every authenticated reader an attestation's id and
1061 // position; a statement says only that the seed folded one.
1062 ("GET", "/api/witness") => Vec::new(),
1063 // Phase A leaves the aggregate view readable by any authenticated
1064 // actor: filtering it, and the page rendered from it, is phase B.
1065 // Until then a credential can enumerate ref names and oids of
1066 // repositories it cannot clone.
1067 ("GET", p) if p.starts_with("/api/view") || p.starts_with("/api/reviews") => Vec::new(),
1068 // An appeal names an attempt id the caller was handed by their own
1069 // rejection, not a repository, so there is nothing repo-scoped to
1070 // check here.
1071 ("POST", "/api/appeal") => Vec::new(),
1072 // Credential self-service (D36). Issuing and revoking are
1073 // node-wide writes and reading the roster is the node-wide read,
1074 // because an account is a fact about the node rather than about
1075 // one repository — and because `@node` is the grant this store is
1076 // forbidden to issue, so the authority to issue can only have
1077 // come from the operator's own file.
1078 ("POST", "/api/accounts/invite" | "/api/accounts/revoke") => {
1079 vec![(Scope::Node, Level::Write)]
1080 }
1081 // D72. Answering a request is issuing an invite or refusing to,
1082 // so it sits at exactly the authority issuing one does. Asking is
1083 // not here at all: `POST /api/access` is pre-auth and never
1084 // reaches this table.
1085 ("POST", "/api/accounts/request/grant" | "/api/accounts/request/decline") => {
1086 vec![(Scope::Node, Level::Write)]
1087 }
1088 ("GET", "/api/accounts") => vec![(Scope::Node, Level::Read)],
1089 // Nothing required, because this one narrows itself: the handler
1090 // filters the list to the repositories the caller can read, so a
1091 // grant requirement here would be a second, coarser answer to
1092 // the same question. A `@node read` requirement in particular
1093 // would be exactly wrong — it would hide the listing from every
1094 // reader who holds one repository, which is who it is for.
1095 ("GET", "/api/repos") => Vec::new(),
1096 // Creating a repository is a write against the node itself:
1097 // there is no repository yet for it to be scoped to. The handler
1098 // has said so since it was written, and this row is what makes
1099 // that true — without it the path falls through to the `_` arm
1100 // below and every node with an ACL answers 404 to
1101 // `choir repo create`, which is every node that has issued a
1102 // second credential.
1103 ("POST", "/api/repo") => vec![(Scope::Node, Level::Write)],
1104 // Enrolling and removing a passkey act on the caller's own
1105 // account and no one else's (D39): the handler never reads a
1106 // user from the body, so there is no scope here to check that
1107 // would not simply be "you are authenticated". A grant
1108 // requirement would be worse than none — `@node write` would
1109 // mean only operators could enrol, which is the opposite of the
1110 // point, and a repo scope would tie a fact about a person to a
1111 // repository they may not have.
1112 ("POST", "/api/accounts/passkey" | "/api/accounts/passkey/remove") => Vec::new(),
1113 // Preparing an op for a browser to sign grants nothing (D39):
1114 // the response is bytes the caller could have assembled
1115 // themselves, and what makes an op admissible is the signature
1116 // over them plus this same table applied at `/api/submit`. A
1117 // grant requirement here would gate a serializer, and would have
1118 // to be kept in agreement with the one that gates the write --
1119 // two places to answer one question, which is how they drift.
1120 ("POST", "/api/prepare") => Vec::new(),
1121 // Redemption is reached by a principal that holds no grant at
1122 // all — an unredeemed invite — so there is nothing here to check.
1123 // What keeps it from being an open door is that the invite is
1124 // itself a credential, and that an invite principal is refused
1125 // every other route before this table is consulted.
1126 ("POST", "/api/accounts/redeem") => Vec::new(),
1127 _ => {
1128 return Some(Denial {
1129 status: 404,
1130 reason: "no such endpoint".to_string(),
1131 })
1132 }
1133 };
1134 required
1135 .into_iter()
1136 .find_map(|(scope, level)| acl.check(user, &scope, level))
1137}
1138
1139/// How one top-level section of a read response may be disclosed.
1140#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1141pub enum Disclosure {
1142 /// Served to anyone who reaches the endpoint. It names no repository,
1143 /// and a writer cannot bind a scoped submission without it.
1144 Public,
1145 /// Served whole to a reader holding [`Scope::Node`] and omitted from
1146 /// everyone else. This is the "gate, never filter" rule `/api/log`
1147 /// and `/api/ref-agreement` follow, applied one level down: the
1148 /// section counts, attributes or times events across every
1149 /// repository at once, so there is no honest way to narrow it.
1150 NodeWide,
1151 /// Narrowed entry by entry to the reader's repository grants.
1152 PerRepo,
1153}
1154
1155/// Every top-level section `/api/view` and `/api/reviews` serve.
1156///
1157/// [`filter_response`] drives off this table and drops any section that
1158/// has no row here, so a section added to the view without being
1159/// classified is withheld from readers lacking a node-wide grant rather
1160/// than served to all of them.
1161///
1162/// The omission is the failure mode worth designing against, because it
1163/// has already happened once: `changes` was added to the view while the
1164/// filter's section list was maintained by hand, and every authenticated
1165/// reader received every change record — owner channel, workspace and
1166/// revisions — for repositories they held no grant on. Failing closed
1167/// keeps that quiet, so
1168/// `every_section_the_view_serves_is_classified` in `tests/it/acl.rs`
1169/// makes it loud, comparing this table against a view a real node
1170/// served rather than against a sample written from memory.
1171pub const SECTIONS: [(&str, Disclosure); 30] = [
1172 ("log", Disclosure::Public),
1173 ("build", Disclosure::Public),
1174 // Where a seed stands against its home (D80): the home's address and
1175 // key, positions and counts. Public for the reason `log` is: it names
1176 // no repository, and a reader granted one needs to know whether the
1177 // copy they are reading is current.
1178 ("replica", Disclosure::Public),
1179 // [`crate::bound`]'s marks. Public because of *when* they are
1180 // computed, not because a row count is harmless: bounding runs after
1181 // this filter, so each count describes the reader's own narrowed
1182 // slice. Were the order ever reversed, these would be the worst kind
1183 // of NodeWide — a measurement of the node handed to someone granted
1184 // one corner of it — and the row would be a lie rather than a leak.
1185 ("paging", Disclosure::Public),
1186 ("refs_omitted", Disclosure::Public),
1187 ("workspaces_omitted", Disclosure::Public),
1188 ("provenance_omitted", Disclosure::Public),
1189 ("reviews_omitted", Disclosure::Public),
1190 ("changes_omitted", Disclosure::Public),
1191 ("bindings_omitted", Disclosure::Public),
1192 ("vouches_omitted", Disclosure::Public),
1193 ("witnessed_omitted", Disclosure::Public),
1194 ("pending_omitted", Disclosure::Public),
1195 ("checks_omitted", Disclosure::Public),
1196 ("snapshot", Disclosure::NodeWide),
1197 ("bindings", Disclosure::NodeWide),
1198 // Node-wide for the same reason `bindings` is, and it needs saying
1199 // because a vouch reads like public reputation: the graph is a map
1200 // of who the node's operators are and who stands behind whom, which
1201 // is exactly the document a reader granted one repository was not
1202 // given. A profile derives from the narrowed view, so such a reader
1203 // is told there are no vouches to see rather than shown somebody
1204 // else's.
1205 ("vouches", Disclosure::NodeWide),
1206 ("witnessed", Disclosure::NodeWide),
1207 ("concentration", Disclosure::NodeWide),
1208 ("view_growth", Disclosure::NodeWide),
1209 ("newcomer_harm", Disclosure::NodeWide),
1210 ("new_actor_review_outcomes", Disclosure::NodeWide),
1211 ("sequencer_lag", Disclosure::NodeWide),
1212 ("refs", Disclosure::PerRepo),
1213 ("workspaces", Disclosure::PerRepo),
1214 ("provenance", Disclosure::PerRepo),
1215 ("reviews", Disclosure::PerRepo),
1216 ("changes", Disclosure::PerRepo),
1217 // Narrowed on the destination ref a report named, which is the only
1218 // repository a commit id can be attributed to. A check reported
1219 // without one stays node-wide, which is the fail-closed direction.
1220 ("checks", Disclosure::PerRepo),
1221 // `/api/reviews` rather than `/api/view`, narrowed by the same rule.
1222 ("pending", Disclosure::PerRepo),
1223];
1224
1225/// The disclosure rule for `section`, or `None` when it has no row and
1226/// must therefore be withheld.
1227#[must_use]
1228pub fn disclosure(section: &str) -> Option<Disclosure> {
1229 SECTIONS
1230 .iter()
1231 .find(|(name, _)| *name == section)
1232 .map(|(_, rule)| *rule)
1233}
1234
1235/// A read response narrowed to what `user` may see (D29 phase B).
1236///
1237/// `/api/view` and `/api/reviews` are the two endpoints that answer with
1238/// other repositories' contents, so they are the two this rewrites; every
1239/// other path is already gated by [`api_denial`] and passes through. A
1240/// body that is not the JSON object this expects is returned untouched
1241/// rather than emptied, because a filter that silently blanks an
1242/// unrecognized payload hides the mismatch instead of showing it.
1243///
1244/// `log` and `build` survive for every reader: they name the node, its
1245/// log head and the binary serving them. A writer needs the head to bind
1246/// a scoped submission, and neither says anything about a repository.
1247#[must_use]
1248pub fn filter_response(acl: &Effective, user: &str, path: &str, body: &str) -> String {
1249 if !(path.starts_with("/api/view") || path.starts_with("/api/reviews")) {
1250 return body.to_string();
1251 }
1252 let Ok(mut value) = serde_json::from_str::<serde_json::Value>(body) else {
1253 return body.to_string();
1254 };
1255 let Some(object) = value.as_object_mut() else {
1256 return body.to_string();
1257 };
1258 let node_wide = acl.allows(user, &Scope::Node, Level::Read);
1259 let readable = |repo: Option<String>| -> bool {
1260 // A key naming no repository is node-scoped by the same rule that
1261 // sends a repo-less op to `Scope::Node`: fail closed, and let a
1262 // node-wide grant see it.
1263 match repo {
1264 Some(repo) => acl.allows(user, &Scope::Repo(repo), Level::Read),
1265 None => node_wide,
1266 }
1267 };
1268 if !node_wide {
1269 // Unclassified sections leave with the node-wide ones. A section
1270 // this build does not know about cannot be narrowed, and serving
1271 // it whole is the disclosure this table exists to prevent.
1272 object.retain(|section, _| {
1273 matches!(
1274 disclosure(section),
1275 Some(Disclosure::Public | Disclosure::PerRepo)
1276 )
1277 });
1278 }
1279 retain_keys(object.get_mut("refs"), |key| readable(ref_repo(key)));
1280 for section in ["workspaces", "provenance"] {
1281 retain_keys(object.get_mut(section), |key| readable(subject_repo(key)));
1282 }
1283 for section in ["reviews", "pending"] {
1284 retain_entries(object.get_mut(section), |_, review| {
1285 readable(review_repo_of(review)) || assigned_to(user, review)
1286 });
1287 }
1288 // A change is keyed by its own stable id rather than by a repository,
1289 // so it narrows on the workspace it is bound to. `workspace_id` and
1290 // not `active_workspace`: the binding outlives archival, and reading
1291 // the cleared field would send every archived change to the repo-less
1292 // branch, where a node-wide reader would still see it but the record
1293 // would no longer be attributable to the repository it came from.
1294 retain_entries(object.get_mut("checks"), |_, check| {
1295 readable(
1296 check
1297 .get("target_ref")
1298 .and_then(serde_json::Value::as_str)
1299 .and_then(ref_repo),
1300 )
1301 });
1302 retain_entries(object.get_mut("changes"), |_, change| {
1303 readable(
1304 change
1305 .get("workspace_id")
1306 .and_then(serde_json::Value::as_str)
1307 .and_then(subject_repo),
1308 )
1309 });
1310 value.to_string()
1311}
1312
1313/// Repository a serialized review names through its target ref, if any.
1314fn review_repo_of(review: &serde_json::Value) -> Option<String> {
1315 review
1316 .get("target_ref")
1317 .and_then(serde_json::Value::as_str)
1318 .and_then(ref_repo)
1319}
1320
1321/// Whether `user` is one of a review's assigned reviewers.
1322///
1323/// A reviewer is a channel name, so both the bare name and the operator
1324/// half of `operator/agent` count: `reviewer_operator` is what the review
1325/// rules themselves treat as one actor, and matching only the full string
1326/// would drop a reader's own assignments the moment they run two agents.
1327/// Without this the ACL would silently break the review fan-out — the
1328/// reviews a reader most needs are exactly the ones on repositories they
1329/// were invited into rather than granted.
1330fn assigned_to(user: &str, review: &serde_json::Value) -> bool {
1331 review
1332 .get("reviewers")
1333 .and_then(serde_json::Value::as_array)
1334 .is_some_and(|reviewers| {
1335 reviewers
1336 .iter()
1337 .filter_map(serde_json::Value::as_str)
1338 .any(|name| name == user || choir_view::reviewer_operator(name) == user)
1339 })
1340}
1341
1342/// Drops map entries whose key fails `keep`. A non-map is left alone.
1343fn retain_keys(section: Option<&mut serde_json::Value>, keep: impl Fn(&str) -> bool) {
1344 retain_entries(section, |key, _| keep(key));
1345}
1346
1347/// Drops map entries whose key and value fail `keep`. A non-map is left
1348/// alone: every section this is applied to is a JSON object, and one that
1349/// is not has already stopped meaning what the filter thinks it means.
1350fn retain_entries(
1351 section: Option<&mut serde_json::Value>,
1352 keep: impl Fn(&str, &serde_json::Value) -> bool,
1353) {
1354 if let Some(map) = section.and_then(serde_json::Value::as_object_mut) {
1355 map.retain(|key, value| keep(key, value));
1356 }
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361 use super::*;
1362
1363 /// A fixed instant for every test that says nothing about deadlines,
1364 /// so those tests read exactly as they did before D66.
1365 const NOW: u64 = 1_700_000_000;
1366
1367 /// Parses and dates in one step (D66). Tests that are about the
1368 /// grammar failing still call [`Acl::parse`] directly, because a file
1369 /// that does not parse never reaches a clock.
1370 fn parse(text: &str) -> Effective {
1371 Acl::parse(text).expect("parses").at(NOW)
1372 }
1373
1374 /// The sections gated whole on a node-wide grant.
1375 fn node_wide_sections() -> impl Iterator<Item = &'static str> {
1376 SECTIONS
1377 .iter()
1378 .filter(|(_, rule)| *rule == Disclosure::NodeWide)
1379 .map(|(name, _)| *name)
1380 }
1381
1382 /// D60. The level is only useful if it sits *between* read and
1383 /// write, and the enum's own note says a variant declared out of
1384 /// strength order silently changes every existing `>=` check. So the
1385 /// ordering is asserted directly rather than inferred from behaviour.
1386 #[test]
1387 fn propose_sits_between_read_and_write() {
1388 assert!(Level::Read < Level::Propose);
1389 assert!(Level::Propose < Level::Write);
1390 assert!(Level::Write < Level::Own);
1391
1392 let acl = parse("carol owner/p propose\n");
1393 assert!(
1394 acl.allows_repo("carol", "owner/p", Level::Read),
1395 "propose must imply read"
1396 );
1397 assert!(acl.allows_repo("carol", "owner/p", Level::Propose));
1398 assert!(!acl.allows_repo("carol", "owner/p", Level::Write));
1399 assert!(!acl.allows_repo("carol", "owner/p", Level::Own));
1400 }
1401
1402 /// `@node` is the log and the attestation, which hold no reviews, so
1403 /// the spelling is refused there rather than quietly granted over
1404 /// everything -- the same rule `own` follows.
1405 #[test]
1406 fn propose_is_not_a_node_wide_spelling() {
1407 let error = Acl::parse("carol @node propose\n").expect_err("`@node propose` must refuse");
1408 assert!(
1409 error.contains("repository grant"),
1410 "unhelpful refusal: {error}"
1411 );
1412 }
1413
1414 /// The parity claim D66 rests on: three columns mean today exactly
1415 /// what they meant before the fourth existed, at any instant.
1416 #[test]
1417 fn a_grant_with_no_deadline_is_the_grant_it_always_was() {
1418 let text = "alice owner/p write\nbob @node auditor\n";
1419 for now in [0, NOW, u64::MAX] {
1420 let acl = Acl::parse(text).expect("parses").at(now);
1421 assert!(
1422 acl.allows_repo("alice", "owner/p", Level::Write),
1423 "a deadline-free grant lapsed at {now}"
1424 );
1425 assert!(acl.allows("bob", &Scope::Node, Level::Read));
1426 }
1427 assert_eq!(Acl::parse(text).expect("parses").expired(u64::MAX), 0);
1428 }
1429
1430 /// The boundary, stated on both sides: `until=N` is in force through
1431 /// `N - 1` and gone at `N`, which is the comparison an invite makes.
1432 #[test]
1433 fn a_deadline_ends_the_grant_in_the_second_it_names() {
1434 let table = Acl::parse("alice owner/p write until=1000\n").expect("parses");
1435 assert!(table.at(999).allows_repo("alice", "owner/p", Level::Write));
1436 assert!(!table.at(1000).allows_repo("alice", "owner/p", Level::Write));
1437 assert!(!table.at(1001).allows_repo("alice", "owner/p", Level::Write));
1438 assert_eq!(table.expired(999), 0);
1439 assert_eq!(table.expired(1000), 1);
1440 }
1441
1442 /// A lapse is a downgrade, not a lockout. This is what makes a
1443 /// time-locked privilege lendable: the account survives it.
1444 #[test]
1445 fn a_lapsed_write_falls_back_to_a_permanent_read() {
1446 let table =
1447 Acl::parse("alice owner/p read\nalice owner/p write until=1000\n").expect("parses");
1448 let after = table.at(2000);
1449 assert!(
1450 after.allows_repo("alice", "owner/p", Level::Read),
1451 "the permanent grant went with the expiring one"
1452 );
1453 assert!(!after.allows_repo("alice", "owner/p", Level::Write));
1454 // And the denial is the readable-repository one, not the
1455 // does-not-exist one: withholding a level is not withholding the
1456 // repository's existence from somebody who can still read it.
1457 let denial = after
1458 .check("alice", &Scope::Repo("owner/p".into()), Level::Write)
1459 .expect("denied");
1460 assert_eq!(denial.status, 403);
1461 }
1462
1463 /// An expiring `own` returns the repository to the approval-weight
1464 /// rule (D42) rather than leaving it owned by nobody-in-particular.
1465 #[test]
1466 fn an_expired_owner_is_not_an_owner() {
1467 let table = Acl::parse("alice owner/p own until=1000\n").expect("parses");
1468 assert!(table.at(999).has_owner("owner/p"));
1469 assert!(!table.at(1000).has_owner("owner/p"));
1470 }
1471
1472 /// The page cache is keyed on what the reader may see, so a lapse has
1473 /// to move the key or a cached page outlives the grant that filtered
1474 /// it.
1475 #[test]
1476 fn a_lapse_moves_the_cache_key() {
1477 let table =
1478 Acl::parse("alice owner/p read\nalice owner/q read until=1000\n").expect("parses");
1479 assert_ne!(
1480 table.at(999).cache_key("alice"),
1481 table.at(1000).cache_key("alice"),
1482 "a reader who lost a repository kept the key that cached it"
1483 );
1484 assert_eq!(
1485 table.at(1000).cache_key("alice"),
1486 Acl::parse("alice owner/p read\n")
1487 .expect("parses")
1488 .at(1000)
1489 .cache_key("alice"),
1490 "a lapsed grant left a trace in the key of a reader who no longer holds it"
1491 );
1492 }
1493
1494 /// Both sources keep their deadlines through the merge (D36).
1495 #[test]
1496 fn merging_keeps_each_side_of_the_deadline() {
1497 let file = Acl::parse("alice owner/p read\n").expect("parses");
1498 let store = Acl::parse("alice owner/q write until=1000\n").expect("parses");
1499 let merged = file.merged(&store);
1500 assert!(merged.at(999).allows_repo("alice", "owner/q", Level::Write));
1501 assert!(!merged
1502 .at(1000)
1503 .allows_repo("alice", "owner/q", Level::Write));
1504 assert!(merged.at(1000).allows_repo("alice", "owner/p", Level::Read));
1505 }
1506
1507 /// D36's rule is about what may be *written*, so an expired node
1508 /// grant is still a node grant and still refused.
1509 #[test]
1510 fn an_expired_node_grant_is_still_a_node_grant() {
1511 let table = Acl::parse("mallory @node auditor until=1000\n").expect("parses");
1512 assert!(
1513 table.grants_node("mallory"),
1514 "a deadline in the past made node scope look un-granted"
1515 );
1516 assert!(!table.at(2000).allows("mallory", &Scope::Node, Level::Read));
1517 }
1518
1519 /// The fourth column is checked, not guessed at: a key that is not
1520 /// `until`, a value that is not a number, and a fifth column are all
1521 /// refusals naming the line.
1522 #[test]
1523 fn the_fourth_column_is_only_a_deadline() {
1524 for (bad, why) in [
1525 ("alice o/r read expires=1000\n", "a key that is not `until`"),
1526 ("alice o/r read 1000\n", "a bare number with no key"),
1527 ("alice o/r read until=soon\n", "a value that is not seconds"),
1528 ("alice o/r read until=-1\n", "a negative deadline"),
1529 ("alice o/r read until=1000 extra\n", "a fifth column"),
1530 ] {
1531 let error = Acl::parse(bad).expect_err(why);
1532 assert!(error.starts_with("line 1: "), "{why}: {error}");
1533 }
1534 // A deadline already in the past is not a parse error: one stale
1535 // line must not be a node-wide lockout.
1536 let stale = Acl::parse("alice o/r read until=1\n").expect("a past deadline parses");
1537 assert_eq!(stale.expired(NOW), 1);
1538 assert!(!stale.at(NOW).allows_repo("alice", "o/r", Level::Read));
1539 }
1540
1541 #[test]
1542 fn the_grammar_accepts_the_documented_file_and_nothing_else() {
1543 // Counted on the file's own table rather than a dated one: this
1544 // test is about the grammar, and `len` is a fact about what was
1545 // written, not about what holds now.
1546 let acl = Acl::parse(
1547 "# a comment\n\
1548 alice owner/project write\n\
1549 \n\
1550 bob * read # trailing comment\n\
1551 carol @node auditor\n",
1552 )
1553 .expect("the documented example must parse");
1554 assert_eq!(acl.len(), 3);
1555
1556 for (bad, why) in [
1557 ("alice owner/project", "two columns"),
1558 ("alice owner/project write extra", "four columns"),
1559 ("alice owner/project admin", "a level that does not exist"),
1560 ("alice @nope write", "an invented pseudo-repository"),
1561 ("alice owner write", "a repo name with no owner"),
1562 ("alice owner/a/b write", "a three-segment repo name"),
1563 (
1564 "alice @node read",
1565 "`read` where the role is spelled `auditor`",
1566 ),
1567 ("alice owner/project auditor", "a node role on a repository"),
1568 ] {
1569 assert!(Acl::parse(bad).is_err(), "accepted {why}: {bad:?}");
1570 }
1571 }
1572
1573 /// The error has to name the line, or an operator with a 40-line file
1574 /// is bisecting it by hand while the node refuses to start.
1575 #[test]
1576 fn a_parse_error_names_its_line() {
1577 let error = Acl::parse("alice o/r read\nbob o/r sideways\n").expect_err("line 2 is bad");
1578 assert!(error.contains("line 2"), "{error}");
1579 }
1580
1581 #[test]
1582 fn both_spellings_of_a_repository_are_one_grant() {
1583 let acl = parse("alice owner/project.git write");
1584 assert!(acl.allows_repo("alice", "owner/project", Level::Write));
1585 assert!(acl.allows_repo("alice", "owner/project.git", Level::Write));
1586 }
1587
1588 #[test]
1589 fn write_implies_read_and_read_does_not_imply_write() {
1590 let acl = parse("alice o/r write\nbob o/r read");
1591 assert!(acl.allows_repo("alice", "o/r", Level::Read));
1592 assert!(acl.allows_repo("bob", "o/r", Level::Read));
1593 assert!(!acl.allows_repo("bob", "o/r", Level::Write));
1594 }
1595
1596 /// The implication is the derived [`Ord`], which follows declaration
1597 /// order, so this pins the order rather than the spelling. Declaring
1598 /// `Own` before `Write` would compile, pass every existing test, and
1599 /// quietly turn every `allows(.., Write)` check in the daemon into a
1600 /// check for something weaker.
1601 #[test]
1602 fn own_implies_write_and_write_does_not_imply_own() {
1603 let acl = parse("alice o/r own\nbob o/r write");
1604 assert!(acl.allows_repo("alice", "o/r", Level::Read));
1605 assert!(acl.allows_repo("alice", "o/r", Level::Write));
1606 assert!(acl.allows_repo("alice", "o/r", Level::Own));
1607 assert!(acl.allows_repo("bob", "o/r", Level::Write));
1608 assert!(
1609 !acl.allows_repo("bob", "o/r", Level::Own),
1610 "write must not confer ownership: the landing gate asks for \
1611 Own precisely because push permission is not assent"
1612 );
1613 assert!(Level::Read < Level::Write && Level::Write < Level::Own);
1614 }
1615
1616 /// `own` names an owner of a repository. `@node` is the op log and the
1617 /// attestation, which no repository owns; granting it there would be a
1618 /// node-wide authority nobody asked for.
1619 #[test]
1620 fn own_is_a_repository_grant_and_the_node_refuses_it() {
1621 assert!(Acl::parse("alice o/r own").is_ok());
1622 assert!(
1623 Acl::parse("alice * own").is_ok(),
1624 "owning every repo is sayable"
1625 );
1626 let error = Acl::parse("alice @node own").expect_err("@node cannot be owned");
1627 assert!(error.contains("repository grant"), "{error}");
1628 }
1629
1630 /// `*` is a statement about repositories. If it also covered the node
1631 /// it would hand every repository-granted actor the op log and the
1632 /// key-management ops, which is the escalation this separation exists
1633 /// to prevent.
1634 #[test]
1635 fn the_wildcard_does_not_reach_the_node() {
1636 let acl = parse("bob * write");
1637 assert!(acl.allows_repo("bob", "anything/at-all", Level::Write));
1638 assert!(!acl.allows("bob", &Scope::Node, Level::Read));
1639 }
1640
1641 /// The three grants `@anon` must not parse into holding.
1642 ///
1643 /// Refused in the parser rather than at the point of use, so the
1644 /// failure is a node that will not start on a file somebody typed
1645 /// wrong, instead of a node that starts and serves more than the
1646 /// operator meant. Each line here is a different way to say "the
1647 /// internet", and only the narrow one is a sentence the file has.
1648 #[test]
1649 fn the_anonymous_principal_cannot_be_granted_more_than_one_repository_to_read() {
1650 // The op log and the audit surface.
1651 let node = Acl::parse("@anon @node auditor").expect_err("refused");
1652 assert!(node.contains("@node"), "{node}");
1653 // Every repository, including the ones added next month.
1654 let all = Acl::parse("@anon * read").expect_err("refused");
1655 assert!(all.contains('*'), "{all}");
1656 // A write path for a caller that presented nothing.
1657 let write = Acl::parse("@anon o/r write").expect_err("refused");
1658 assert!(write.contains("read"), "{write}");
1659
1660 // The one thing it may hold, so the three refusals above are
1661 // about what they name rather than about the principal.
1662 let ok = parse("@anon o/r read");
1663 assert!(ok.allows_repo(ANON, "o/r", Level::Read));
1664 assert!(!ok.allows_repo(ANON, "o/other", Level::Read));
1665 assert!(!ok.allows_repo(ANON, "o/r", Level::Propose));
1666 assert!(ok.holds_anything(ANON));
1667 assert!(!ok.holds_anything("nobody"));
1668 }
1669
1670 /// The enumeration the crawl policy and the sitemap are built from.
1671 #[test]
1672 fn readable_repos_names_what_a_principal_may_read_and_nothing_else() {
1673 let acl = Acl::parse(
1674 "@anon open/source read\n\
1675 @anon another/one read\n\
1676 alice closed/thing write\n\
1677 bob * write\n",
1678 )
1679 .expect("a table");
1680 let table = acl.at(0);
1681
1682 assert_eq!(
1683 table.readable_repos(ANON),
1684 vec!["another/one".to_string(), "open/source".to_string()],
1685 "sorted, so two nodes with the same grants write the same file"
1686 );
1687 assert!(
1688 table.readable_repos("nobody").is_empty(),
1689 "a principal with no grants named a repository"
1690 );
1691 // `write` implies `read`, so a grant above the level still lists.
1692 assert_eq!(
1693 table.readable_repos("alice"),
1694 vec!["closed/thing".to_string()]
1695 );
1696 // `*` is not a name. The caller that matters cannot hold it --
1697 // D78 refuses it to `@anon` at parse time -- and a caller that
1698 // does must not read the empty tail as "nothing else".
1699 assert!(
1700 table.readable_repos("bob").is_empty(),
1701 "a wildcard grant was expanded into names it does not have"
1702 );
1703 }
1704
1705 /// Every other principal keeps the grammar it had.
1706 ///
1707 /// The guards above are keyed on one exact name. A rule that leaked
1708 /// onto ordinary users would take `*` and `@node` away from the
1709 /// operator, which is most of what the file is for.
1710 #[test]
1711 fn the_restriction_is_the_reserved_name_and_not_the_grammar() {
1712 let acl = parse("bob * write\ncarol @node auditor\nanon o/r write");
1713 assert!(acl.allows_repo("bob", "any/thing", Level::Write));
1714 assert!(acl.allows("carol", &Scope::Node, Level::Read));
1715 // A user literally called `anon`, with no `@`, is an ordinary
1716 // account name and is not this principal.
1717 assert!(acl.allows_repo("anon", "o/r", Level::Write));
1718 assert!(!acl.holds_anything(ANON));
1719 }
1720
1721 #[test]
1722 fn an_unreadable_repository_is_not_found_and_a_readable_one_is_forbidden() {
1723 let acl = parse("bob o/r read");
1724 let unreadable = acl
1725 .check("bob", &Scope::Repo("o/other".into()), Level::Read)
1726 .expect("denied");
1727 assert_eq!(unreadable.status, 404);
1728 let unwritable = acl
1729 .check("bob", &Scope::Repo("o/r".into()), Level::Write)
1730 .expect("denied");
1731 assert_eq!(unwritable.status, 403);
1732 // The node is never hidden: the caller is authenticated to it.
1733 let no_role = acl.check("bob", &Scope::Node, Level::Read).expect("denied");
1734 assert_eq!(no_role.status, 403);
1735 }
1736
1737 #[test]
1738 fn the_smart_http_surface_maps_to_the_level_it_actually_needs() {
1739 let cases = [
1740 (
1741 "GET",
1742 "/o/r.git/info/refs?service=git-upload-pack",
1743 Some(Level::Read),
1744 ),
1745 (
1746 "GET",
1747 "/o/r.git/info/refs?service=git-receive-pack",
1748 Some(Level::Propose),
1749 ),
1750 ("GET", "/o/r.git/info/refs", Some(Level::Read)),
1751 ("GET", "/o/r.git/HEAD", Some(Level::Read)),
1752 ("GET", "/o/r.git/objects/info/packs", Some(Level::Read)),
1753 ("POST", "/o/r.git/git-upload-pack", Some(Level::Read)),
1754 // `propose`, not `write`, since D60: this boundary has no
1755 // refname to judge, so it admits the push and the hook
1756 // decides which refs the grant actually reaches.
1757 ("POST", "/o/r.git/git-receive-pack", Some(Level::Propose)),
1758 // Not a repository path, and not a smart-HTTP operation:
1759 // both refused rather than handed to the CGI.
1760 ("GET", "/not-a-repo/file", None),
1761 ("DELETE", "/o/r.git/git-receive-pack", None),
1762 // Authorized against `o/r`, resolved by the CGI against
1763 // `o/other`: refused before it can be either.
1764 ("GET", "/o/r.git/objects/../../o/other.git/info/refs", None),
1765 ];
1766 for (method, url, want) in cases {
1767 let got = git_requirement(method, url).map(|(_, level)| level);
1768 assert_eq!(got, want, "{method} {url}");
1769 }
1770 assert_eq!(
1771 git_requirement("POST", "/o/r.git/git-receive-pack").map(|(repo, _)| repo),
1772 Some("o/r".to_string()),
1773 "the repository must reach the ACL in its canonical spelling"
1774 );
1775 }
1776
1777 /// A push whose advertisement was read-gated would fail late and
1778 /// confusingly. This is the arm that gets that right, so it is worth
1779 /// its own assertion rather than one row in the table above.
1780 ///
1781 /// Both directions since D60, because the level moved down and a
1782 /// one-sided assertion would no longer notice it moving further: a
1783 /// `read` grant is still refused, and a `propose` grant is admitted.
1784 #[test]
1785 fn a_push_advertisement_needs_propose_not_read() {
1786 let (repo, level) =
1787 git_requirement("GET", "/o/r.git/info/refs?service=git-receive-pack").expect("maps");
1788
1789 let reader = parse("bob o/r read");
1790 assert!(reader
1791 .check("bob", &Scope::Repo(repo.clone()), level)
1792 .is_some());
1793
1794 let proposer = parse("carol o/r propose");
1795 assert!(
1796 proposer.check("carol", &Scope::Repo(repo), level).is_none(),
1797 "a propose grant must reach the advertisement, or the level is unusable"
1798 );
1799 }
1800
1801 #[test]
1802 fn an_op_naming_no_repository_falls_to_the_node() {
1803 let none = |_: &str| None;
1804 let bound = OpKind::SetRef {
1805 name: "owner/project.git:refs/heads/main".into(),
1806 commit: choir_oplog::ContentHash::blake3(b"c"),
1807 prev: None,
1808 };
1809 assert_eq!(
1810 op_scopes(&bound, none),
1811 vec![Scope::Repo("owner/project".into())]
1812 );
1813
1814 let unbound = OpKind::RequestReview {
1815 id: "r1".into(),
1816 target: choir_oplog::ContentHash::blake3(b"c"),
1817 reviewers: Vec::new(),
1818 target_ref: None,
1819 };
1820 assert_eq!(op_scopes(&unbound, none), vec![Scope::Node]);
1821
1822 // A verdict resolves through the review it settles, so a reviewer
1823 // needs write on the repository under review — not the node-wide
1824 // grant that would also hand them key management.
1825 let verdict = OpKind::PostVerdict {
1826 id: "r1".into(),
1827 reviewer: "bob".into(),
1828 verdict: choir_view::Verdict::Approve,
1829 note: String::new(),
1830 };
1831 let resolves = |_: &str| Some("owner/project".to_string());
1832 assert_eq!(
1833 op_scopes(&verdict, resolves),
1834 vec![Scope::Repo("owner/project".into())]
1835 );
1836 assert_eq!(op_scopes(&verdict, none), vec![Scope::Node]);
1837
1838 // A comment resolves the same way (D38). Scoping it to the node
1839 // instead would mean a reader with write on one repository could
1840 // not answer a review there without a grant over everything.
1841 let comment = OpKind::PostComment {
1842 id: "r1".into(),
1843 comment: "c1".into(),
1844 author: "bob".into(),
1845 body: "why this base?".into(),
1846 };
1847 assert_eq!(
1848 op_scopes(&comment, resolves),
1849 vec![Scope::Repo("owner/project".into())]
1850 );
1851 assert_eq!(op_scopes(&comment, none), vec![Scope::Node]);
1852 }
1853
1854 #[test]
1855 fn a_workspace_op_is_scoped_to_the_repository_it_sits_in() {
1856 let none = |_: &str| None;
1857 let op = OpKind::SetWorkspaceHead {
1858 workspace: "owner/project/feature-x".into(),
1859 commit: choir_oplog::ContentHash::blake3(b"c"),
1860 prev: None,
1861 };
1862 assert_eq!(
1863 op_scopes(&op, none),
1864 vec![Scope::Repo("owner/project".into())]
1865 );
1866 }
1867
1868 /// A view payload shaped like the one `/api/view` serves: two
1869 /// repositories in every per-repository section, plus the node-wide
1870 /// sections and the two that survive for everybody.
1871 fn sample_view() -> String {
1872 serde_json::json!({
1873 "log": { "node": "b3-node", "head": "b3-head", "scope_required": false },
1874 "build": { "commit": "abc" },
1875 "refs": {
1876 "owner/mine.git:refs/heads/main": "git-1111",
1877 "owner/theirs.git:refs/heads/main": "git-2222",
1878 },
1879 "workspaces": {
1880 "owner/mine/feature-a": "git-3333",
1881 "owner/theirs/feature-b": "git-4444",
1882 },
1883 "provenance": {
1884 "owner/mine/feature-a": { "plan": "mine" },
1885 "owner/theirs/feature-b": { "plan": "theirs" },
1886 },
1887 "reviews": {
1888 "r-mine": { "target_ref": "owner/mine.git:refs/heads/main", "reviewers": [] },
1889 "r-theirs": { "target_ref": "owner/theirs.git:refs/heads/main", "reviewers": [] },
1890 "r-invited": {
1891 "target_ref": "owner/theirs.git:refs/heads/main",
1892 "reviewers": ["alice/bot"],
1893 },
1894 "r-unbound": { "target_ref": null, "reviewers": [] },
1895 },
1896 "changes": {
1897 "c-mine": {
1898 "owner": "owner/agent",
1899 "workspace_id": "owner/mine/feature-a",
1900 "active_workspace": "owner/mine/feature-a",
1901 },
1902 "c-theirs": {
1903 "owner": "other/agent",
1904 "workspace_id": "owner/theirs/feature-b",
1905 "active_workspace": "owner/theirs/feature-b",
1906 },
1907 // Archived: the active workspace is gone, but the binding
1908 // it was created under still names the repository.
1909 "c-theirs-archived": {
1910 "owner": "other/agent",
1911 "workspace_id": "owner/theirs/feature-c",
1912 "active_workspace": null,
1913 },
1914 },
1915 "snapshot": { "id": "b3-snap" },
1916 "bindings": { "k1": { "operator": "someone" } },
1917 "vouches": { "someone": { "another": { "at": 4, "note": "" } } },
1918 "witnessed": { "someone": { "snapshot": { "codec": 30, "digest": [] }, "at": 5 } },
1919 "concentration": { "as_of_seq": 9 },
1920 "view_growth": { "entries": 9 },
1921 "newcomer_harm": {},
1922 "new_actor_review_outcomes": {},
1923 "sequencer_lag": {},
1924 })
1925 .to_string()
1926 }
1927
1928 /// The whole point of phase B: a reader granted one repository sees
1929 /// that repository, and cannot enumerate the other one through any
1930 /// of the four sections that name it.
1931 #[test]
1932 fn a_reader_granted_one_repository_sees_only_that_repository() {
1933 let acl = parse("alice owner/mine read");
1934 let filtered = filter_response(&acl, "alice", "/api/view", &sample_view());
1935 let value: serde_json::Value = serde_json::from_str(&filtered).expect("json");
1936 let keys = |section: &str| -> Vec<String> {
1937 value[section]
1938 .as_object()
1939 .unwrap_or_else(|| panic!("{section} object"))
1940 .keys()
1941 .cloned()
1942 .collect()
1943 };
1944 assert_eq!(keys("refs"), ["owner/mine.git:refs/heads/main"]);
1945 assert_eq!(keys("workspaces"), ["owner/mine/feature-a"]);
1946 assert_eq!(keys("provenance"), ["owner/mine/feature-a"]);
1947 // `r-invited` targets the ungranted repository and still reaches
1948 // this reader, because they were asked to review it: an invitation
1949 // discloses the ref it is about, and withholding it would silently
1950 // break the fan-out. `r-unbound` names no repository at all, so it
1951 // is node-scoped and fails closed like every repo-less subject.
1952 assert_eq!(keys("reviews"), ["r-invited", "r-mine"]);
1953 // A change record names its owner channel, its workspace and its
1954 // revisions, so leaking one enumerates both the repository and who
1955 // is working in it. The archived change narrows on the binding it
1956 // kept, not on the workspace it no longer has.
1957 assert_eq!(keys("changes"), ["c-mine"]);
1958 }
1959
1960 /// Node-wide sections are gated, not narrowed, and the two a writer
1961 /// needs to keep working are not gated at all.
1962 #[test]
1963 fn the_node_sections_need_the_node_grant_and_the_log_head_never_does() {
1964 let repo_only = parse("alice owner/mine write");
1965 let narrowed = filter_response(&repo_only, "alice", "/api/view", &sample_view());
1966 for section in node_wide_sections() {
1967 assert!(
1968 !narrowed.contains(section),
1969 "{section} reached a reader with no node grant"
1970 );
1971 }
1972 // Without these a writer cannot bind a scoped submission, and
1973 // neither says anything about a repository.
1974 assert!(narrowed.contains("b3-head") && narrowed.contains("\"build\""));
1975
1976 let auditor = parse("carol @node auditor");
1977 let whole = filter_response(&auditor, "carol", "/api/view", &sample_view());
1978 for section in node_wide_sections() {
1979 assert!(
1980 whole.contains(section),
1981 "{section} was withheld from an auditor"
1982 );
1983 }
1984 // An auditor holds no repository grant, so the repository
1985 // sections are empty for them — `@node` is not a way around `*`.
1986 let value: serde_json::Value = serde_json::from_str(&whole).expect("json");
1987 assert!(value["refs"].as_object().expect("refs").is_empty());
1988 }
1989
1990 /// The pending-review queue is the same data reached by a different
1991 /// path, so it is filtered by the same rule. Asking for somebody
1992 /// else's queue must not become a way to read the reviews the view
1993 /// would have withheld.
1994 #[test]
1995 fn the_pending_queue_is_filtered_like_the_view() {
1996 let acl = parse("alice owner/mine read");
1997 let body = serde_json::json!({
1998 "pending": {
1999 "r-mine": { "target_ref": "owner/mine.git:refs/heads/main", "reviewers": ["x"] },
2000 "r-theirs": { "target_ref": "owner/theirs.git:refs/heads/main", "reviewers": ["x"] },
2001 }
2002 })
2003 .to_string();
2004 let filtered = filter_response(&acl, "alice", "/api/reviews?reviewer=x", &body);
2005 assert!(filtered.contains("r-mine"));
2006 assert!(!filtered.contains("r-theirs"), "{filtered}");
2007 }
2008
2009 /// Two readers with the same grants share a rendered page only if
2010 /// their keys match; a grant edit and a different reader must both
2011 /// change the key, or a stale page outlives the reload that should
2012 /// have invalidated it.
2013 #[test]
2014 fn the_cache_key_moves_with_the_reader_and_with_their_grants() {
2015 let before = parse("alice owner/mine read\nbob owner/mine read");
2016 assert_ne!(before.cache_key("alice"), before.cache_key("bob"));
2017 let after = parse("alice owner/mine write\nbob owner/mine read");
2018 assert_ne!(
2019 before.cache_key("alice"),
2020 after.cache_key("alice"),
2021 "an ACL edit left the cache key unchanged"
2022 );
2023 // Order in the file is not identity: the same grants written the
2024 // other way round are the same key.
2025 let reordered = parse("alice owner/b read\nalice owner/a read");
2026 let forward = parse("alice owner/a read\nalice owner/b read");
2027 assert_eq!(reordered.cache_key("alice"), forward.cache_key("alice"));
2028 }
2029
2030 /// A body the filter does not recognize is passed through rather than
2031 /// blanked: an empty response would read as "nothing here" and hide
2032 /// the mismatch that produced it.
2033 #[test]
2034 fn an_unrecognized_body_or_path_is_left_alone() {
2035 let acl = parse("alice owner/mine read");
2036 assert_eq!(
2037 filter_response(&acl, "alice", "/api/view", "not json"),
2038 "not json"
2039 );
2040 assert_eq!(
2041 filter_response(&acl, "alice", "/api/view", "[1,2]"),
2042 "[1,2]"
2043 );
2044 let other = r#"{"refs":{"owner/theirs.git:refs/heads/main":"git-2222"}}"#;
2045 assert_eq!(filter_response(&acl, "alice", "/api/submit", other), other);
2046 }
2047
2048 /// The seed's write rule (D80): a push is a write from its first
2049 /// request, a clone never is, and every API `POST` is one, including
2050 /// the ones a reader holds only `read` for.
2051 #[test]
2052 fn a_write_is_a_push_step_or_an_api_post() {
2053 for (method, url, write) in [
2054 ("GET", "/o/r.git/info/refs?service=git-receive-pack", true),
2055 ("POST", "/o/r.git/git-receive-pack", true),
2056 ("GET", "/o/r.git/info/refs?service=git-upload-pack", false),
2057 ("POST", "/o/r.git/git-upload-pack", false),
2058 ("GET", "/o/r.git/HEAD", false),
2059 ("POST", "/api/submit", true),
2060 ("POST", "/api/submit-batch", true),
2061 ("POST", "/api/git-update", true),
2062 ("POST", "/api/repo", true),
2063 ("POST", "/api/workspace", true),
2064 ("POST", "/api/accounts/invite", true),
2065 ("POST", "/api/appeal", true),
2066 ("GET", "/api/view", false),
2067 ("GET", "/api/log?from=0", false),
2068 ("GET", "/api/signers", false),
2069 ("GET", "/api/witness", false),
2070 ("GET", "/", false),
2071 ] {
2072 assert_eq!(super::is_write(method, url), write, "{method} {url}");
2073 }
2074 }
2075}