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 /// A key identifying everything a filtered response depends on:
491 /// the reader and the grants they hold, rendered canonically.
492 ///
493 /// Two requests with the same key produce the same filtered payload,
494 /// which is what makes the browser page cacheable per reader. Editing
495 /// the ACL file changes the key, so a hot reload invalidates the
496 /// cached page without anything having to notice the reload happened.
497 ///
498 /// The username is part of the key rather than the grants alone,
499 /// because [`filter_response`] also keeps reviews the reader is
500 /// assigned to. Two readers holding identical grants can therefore
501 /// see different pages, and a key covering only the grants would
502 /// serve one of them the other's assignments.
503 #[must_use]
504 pub fn cache_key(&self, user: &str) -> String {
505 let mut held: Vec<String> = self
506 .grants
507 .get(user)
508 .map(|grants| {
509 grants
510 .iter()
511 .map(|(scope, level)| {
512 let target = match scope {
513 Scope::Repo(repo) => repo.as_str(),
514 Scope::AllRepos => "*",
515 Scope::Node => "@node",
516 };
517 let level = match level {
518 Level::Read => "r",
519 Level::Propose => "p",
520 Level::Write => "w",
521 Level::Own => "o",
522 };
523 format!("{target}={level}")
524 })
525 .collect()
526 })
527 .unwrap_or_default();
528 held.sort();
529 held.dedup();
530 // A unit separator cannot occur in a username the auth file can
531 // express, so the two halves of the key cannot be confused for
532 // one another however they are spelled.
533 format!("{user}\u{1f}{}", held.join(","))
534 }
535
536 /// The [`Denial`] for `user` over `scope` at `level`, or `None` when
537 /// the request is allowed.
538 #[must_use]
539 pub fn check(&self, user: &str, scope: &Scope, level: Level) -> Option<Denial> {
540 if self.allows(user, scope, level) {
541 return None;
542 }
543 // Withholding read from a repository means withholding the fact
544 // that it exists, so an unreadable one is "not found" rather than
545 // "forbidden" — a `403` would confirm the name. A readable one
546 // has already been disclosed, so the honest answer is `403`.
547 //
548 // The node is not hidden this way: the caller is authenticated to
549 // it and is looking straight at it, so pretending its endpoints
550 // do not exist buys nothing and only obscures the fix.
551 if matches!(scope, Scope::Node) || self.allows(user, scope, Level::Read) {
552 Some(Denial {
553 status: 403,
554 reason: match scope {
555 Scope::Node => "requires a node-wide grant".to_string(),
556 other => format!("no write grant for {}", describe(other)),
557 },
558 })
559 } else {
560 Some(Denial {
561 status: 404,
562 reason: "no such repository".to_string(),
563 })
564 }
565 }
566}
567
568/// Whether a granted scope covers a requested one.
569fn covers(granted: &Scope, requested: &Scope) -> bool {
570 match (granted, requested) {
571 (Scope::Node, Scope::Node) => true,
572 (Scope::AllRepos, Scope::Repo(_)) => true,
573 (Scope::Repo(held), Scope::Repo(want)) => held == want,
574 _ => false,
575 }
576}
577
578/// Phrase naming a scope in a denial message.
579fn describe(scope: &Scope) -> String {
580 match scope {
581 Scope::Repo(repo) => format!("repository {repo}"),
582 Scope::AllRepos => "every repository".to_string(),
583 Scope::Node => "this node".to_string(),
584 }
585}
586
587/// Canonical ACL spelling of a repository name: one trailing `.git`
588/// removed, so `owner/repo` and `owner/repo.git` are the same grant.
589#[must_use]
590pub fn normalize_repo(repo: &str) -> String {
591 repo.strip_suffix(".git").unwrap_or(repo).to_string()
592}
593
594/// Parses the repository column.
595fn parse_scope(target: &str) -> Result<Scope, String> {
596 if target == "*" {
597 return Ok(Scope::AllRepos);
598 }
599 if target == "@node" {
600 return Ok(Scope::Node);
601 }
602 if target.starts_with('@') {
603 return Err(format!(
604 "`{target}` is not a pseudo-repository; the only one is `@node`"
605 ));
606 }
607 let repo = normalize_repo(target);
608 let mut segments = repo.split('/');
609 let (Some(owner), Some(name), None) = (segments.next(), segments.next(), segments.next())
610 else {
611 return Err(format!(
612 "`{target}` is not a repository name; write `owner/repo`"
613 ));
614 };
615 if owner.is_empty() || name.is_empty() {
616 return Err(format!(
617 "`{target}` is not a repository name; write `owner/repo`"
618 ));
619 }
620 Ok(Scope::Repo(repo))
621}
622
623/// Parses the optional fourth column, `until=<unix seconds>`.
624///
625/// A `key=value` shape rather than a bare number so the column says what
626/// it means in the file itself, and so a fifth thing to say about a grant
627/// does not have to be positional. An unknown key is an error rather than
628/// something ignored: a grant is the wrong place to be generous about
629/// what a line might have meant.
630///
631/// # Errors
632///
633/// Returns a message when the key is not `until`, or when the value is
634/// not a unix-seconds number.
635fn parse_deadline(column: &str) -> Result<u64, String> {
636 let Some(value) = column.strip_prefix("until=") else {
637 let key = column.split('=').next().unwrap_or(column);
638 return Err(format!(
639 "`{key}` is not a grant option; the only fourth column is `until=<unix seconds>`"
640 ));
641 };
642 value.parse().map_err(|_| {
643 format!("`{value}` is not a unix-seconds deadline; `until=` takes a whole number")
644 })
645}
646
647/// Parses the level column, which is spelled differently on `@node`
648/// because the node-wide read grant is a named role rather than a
649/// repository permission.
650fn parse_level(scope: &Scope, level: &str) -> Result<Level, String> {
651 match (scope, level) {
652 (_, "write") => Ok(Level::Write),
653 (Scope::Node, "auditor") => Ok(Level::Read),
654 (Scope::Node, "read") => {
655 Err("the node-wide read grant is spelled `auditor`, not `read`".to_string())
656 }
657 (_, "auditor") => {
658 Err("`auditor` is a node-wide role; on a repository write `read`".to_string())
659 }
660 (_, "read") => Ok(Level::Read),
661 // `propose` names a right over one repository's review surface.
662 // `@node` is the log and the attestation, which hold no reviews,
663 // so the spelling is refused there rather than quietly granted.
664 (Scope::Node, "propose") => {
665 Err("`propose` is a repository grant; `@node` takes `auditor` or `write`".to_string())
666 }
667 (_, "propose") => Ok(Level::Propose),
668 // `own` names an owner *of a repository*. `@node` is the log and
669 // the attestation, which no repository owns, so the spelling is
670 // refused there rather than quietly granted over everything.
671 (Scope::Node, "own") => {
672 Err("`own` is a repository grant; `@node` takes `auditor` or `write`".to_string())
673 }
674 (_, "own") => Ok(Level::Own),
675 (Scope::Node, other) => Err(format!(
676 "`{other}` is not a level; write `auditor` or `write`"
677 )),
678 (_, other) => Err(format!(
679 "`{other}` is not a level; write `read`, `propose`, `write` or `own`"
680 )),
681 }
682}
683
684/// Repository and level a git smart-HTTP request needs.
685///
686/// `None` means the request names no repository, or names an operation
687/// outside the smart-HTTP surface. Both are denied when an ACL is
688/// configured, rather than falling through to the CGI handler.
689#[must_use]
690pub fn git_requirement(method: &str, url: &str) -> Option<(String, Level)> {
691 let path = url.split('?').next().unwrap_or(url);
692 let query = url.split_once('?').map_or("", |(_, q)| q);
693 // A traversal segment would let a path authorized against the
694 // repository named first reach a different one inside the CGI, since
695 // `git http-backend` resolves PATH_INFO itself. Refusing it here does
696 // not depend on what that resolution happens to do. Percent-encoded
697 // dots need no separate rule: the path is handed to the CGI
698 // undecoded, so `%2e%2e` is a literal directory name, not a segment.
699 if path.split('/').any(|segment| segment == "..") {
700 return None;
701 }
702 let repo = crate::repo_from_path(url)?;
703 // Everything after `/<repo>`; a non-char-boundary index cannot
704 // happen for an ASCII repo name, and yields a denial if it somehow
705 // does.
706 let tail = path
707 .get(1 + repo.len()..)
708 .unwrap_or("")
709 .trim_start_matches('/');
710 let level = match (method, tail) {
711 // `propose`, not `write`: no refname exists yet (see
712 // [`Level::Propose`]). A pusher who reaches here holding only
713 // `propose` has their refs checked when the hook reports them,
714 // and git applies none of them before that.
715 ("POST", "git-receive-pack") => Level::Propose,
716 ("POST", "git-upload-pack") => Level::Read,
717 // The ref advertisement is the first request of both directions,
718 // and the service parameter is the only thing distinguishing a
719 // clone from a push.
720 ("GET", t) if t.starts_with("info/refs") => {
721 if query.split('&').any(|p| p == "service=git-receive-pack") {
722 Level::Propose
723 } else {
724 Level::Read
725 }
726 }
727 ("GET", "HEAD") => Level::Read,
728 ("GET", t) if t.starts_with("info/") || t.starts_with("objects/") => Level::Read,
729 _ => return None,
730 };
731 Some((normalize_repo(&repo), level))
732}
733
734/// Repository a workspace name or provenance subject belongs to: its
735/// first two `/`-separated segments.
736///
737/// `None` for anything with fewer than two segments — a submission
738/// channel like `git/<user>` has two, so it resolves to a repository
739/// name that will simply not be granted, while a bare subject resolves
740/// to nothing and falls to [`Scope::Node`].
741fn subject_repo(subject: &str) -> Option<String> {
742 let mut segments = subject.split('/');
743 let (owner, name) = (segments.next()?, segments.next()?);
744 if owner.is_empty() || name.is_empty() {
745 return None;
746 }
747 Some(normalize_repo(&format!("{owner}/{name}")))
748}
749
750/// Repository named by a view ref key in its `<repo>:<refname>` form.
751pub(crate) fn ref_repo(name: &str) -> Option<String> {
752 name.split_once(':').map(|(repo, _)| normalize_repo(repo))
753}
754
755/// Scopes an op must be authorized against, never empty.
756///
757/// `review_repo` resolves a review id to the repository its target ref
758/// names, so posting a verdict needs write on the repository under
759/// review rather than a node-wide grant. An op that resolves to no
760/// repository is authorized against [`Scope::Node`]: that is the
761/// fail-closed rule, and being an exhaustive match, a new [`OpKind`]
762/// variant will not compile until somebody classifies it.
763#[must_use]
764pub fn op_scopes(kind: &OpKind, review_repo: impl Fn(&str) -> Option<String>) -> Vec<Scope> {
765 let repos: Vec<String> = match kind {
766 // A landing authorizes against the repository whose ref it
767 // moves, exactly as the bare ref move does. It needs no scope of
768 // its own: the extra authority a `Submit` carries is the landing
769 // gate's, and that gate is not this grant.
770 OpKind::SetRef { name, .. }
771 | OpKind::DeleteRef { name, .. }
772 | OpKind::Submit { name, .. } => ref_repo(name).into_iter().collect(),
773 OpKind::SetWorkspaceHead { workspace, .. } | OpKind::DeleteWorkspace { workspace } => {
774 subject_repo(workspace).into_iter().collect()
775 }
776 // Every change op names the workspace it is bound to, and a
777 // workspace's leading two segments are its repository. So the
778 // lifecycle authorizes against the same repository a legacy
779 // workspace move does, rather than falling to a node-wide grant.
780 OpKind::CreateChange { workspace, .. }
781 | OpKind::CheckpointChange { workspace, .. }
782 | OpKind::ArchiveChange { workspace, .. } => subject_repo(workspace).into_iter().collect(),
783 // A check reports on a commit, and a commit belongs to no
784 // repository, so the destination ref is the only thing that can
785 // scope it — the same reasoning, and the same fail-closed
786 // fallback, as the review request above.
787 OpKind::RequestReview { target_ref, .. } | OpKind::RecordCheck { target_ref, .. } => {
788 target_ref
789 .as_deref()
790 .and_then(ref_repo)
791 .into_iter()
792 .collect()
793 }
794 // A comment authorizes against the repository under review, the
795 // same as a verdict on the same review: discussion is part of the
796 // review surface, not a node-wide fact.
797 OpKind::PostVerdict { id, .. }
798 | OpKind::ArchiveReview { id, .. }
799 | OpKind::SlashApproval { id, .. }
800 | OpKind::PostComment { id, .. }
801 | OpKind::ViewedReview { id, .. }
802 | OpKind::AssignReviewers { id, .. } => review_repo(id).into_iter().collect(),
803 OpKind::RecordProvenance { subject, .. } => subject_repo(subject).into_iter().collect(),
804 // Node-scoped by nature: these name keys, operators, or the
805 // whole ref state, and never one repository. A vouch is about
806 // who somebody is, which is not a fact about any repository even
807 // when the only place the reader met them was one.
808 OpKind::BindKey { .. }
809 | OpKind::RevokeKey { .. }
810 | OpKind::Vouch { .. }
811 | OpKind::WithdrawVouch { .. }
812 | OpKind::RecordRefSnapshot { .. }
813 | OpKind::CountersignSnapshot { .. } => Vec::new(),
814 };
815 if repos.is_empty() {
816 vec![Scope::Node]
817 } else {
818 repos.into_iter().map(Scope::Repo).collect()
819 }
820}
821
822/// The grant strength an op needs over the scopes [`op_scopes`] names.
823///
824/// Write for everything that moves a ref or changes a review's shape,
825/// and read for the ops that only report what their own signer thinks:
826/// a verdict, a comment, a viewing receipt (D55), and a vouch or its
827/// withdrawal (D65).
828///
829/// Those are exactly the ops admission binds to the signing channel — a
830/// claimed attribution other than the channel is `reviewer_mismatch` —
831/// and the fold refuses a verdict from anybody the review does not list.
832/// Read is therefore the whole authority they need, and requiring write
833/// would mean handing push rights to every reviewer drawn onto a
834/// repository, which is the opposite of what asking for a review is for.
835///
836/// A vouch joins them for the same reason and one more. It is node-
837/// scoped, so `write` here would mean node-wide write: the web of trust
838/// would be authorable only by the handful of identities who can already
839/// move any ref on the node, which is not a web. What stops it being
840/// free is not this level but [`View::is_bound_operator`] — both ends of
841/// an edge need a binding, and only the node authors those.
842///
843/// [`View::is_bound_operator`]: choir_view::View::is_bound_operator
844///
845/// Exhaustive on purpose, like [`op_scopes`]: a new [`OpKind`] variant
846/// will not compile until somebody says which side of this line it
847/// falls on, and the safe answer is write.
848#[must_use]
849pub fn op_level(kind: &OpKind) -> Level {
850 match kind {
851 OpKind::PostVerdict { .. }
852 | OpKind::PostComment { .. }
853 | OpKind::ViewedReview { .. }
854 | OpKind::Vouch { .. }
855 | OpKind::WithdrawVouch { .. }
856 | OpKind::CountersignSnapshot { .. } => Level::Read,
857 OpKind::SetRef { .. }
858 | OpKind::DeleteRef { .. }
859 | OpKind::Submit { .. }
860 | OpKind::SetWorkspaceHead { .. }
861 | OpKind::DeleteWorkspace { .. }
862 | OpKind::CreateChange { .. }
863 | OpKind::CheckpointChange { .. }
864 | OpKind::ArchiveChange { .. }
865 | OpKind::RequestReview { .. }
866 | OpKind::RecordCheck { .. }
867 | OpKind::ArchiveReview { .. }
868 | OpKind::SlashApproval { .. }
869 | OpKind::AssignReviewers { .. }
870 | OpKind::RecordProvenance { .. }
871 | OpKind::BindKey { .. }
872 | OpKind::RevokeKey { .. }
873 | OpKind::RecordRefSnapshot { .. } => Level::Write,
874 }
875}
876
877/// Scopes a `/api/submit` body must be authorized against, each with the
878/// level that op needs over it.
879///
880/// A body that cannot be decoded far enough to name a repository falls
881/// to [`Scope::Node`] at [`Level::Write`] rather than being waved
882/// through, so a caller without a node-wide grant gets a denial and one
883/// with it gets the handler's own `400`.
884fn submission_scopes(
885 body: &serde_json::Value,
886 review_repo: &impl Fn(&str) -> Option<String>,
887) -> Vec<(Scope, Level)> {
888 let decoded = body
889 .get("payload_hex")
890 .and_then(serde_json::Value::as_str)
891 .and_then(crate::platform::hex_decode)
892 .and_then(|bytes| ViewOp::from_payload(&bytes).ok());
893 match decoded {
894 Some(op) => {
895 let level = op_level(&op.kind);
896 op_scopes(&op.kind, review_repo)
897 .into_iter()
898 .map(|scope| (scope, level))
899 .collect()
900 }
901 None => vec![(Scope::Node, Level::Write)],
902 }
903}
904
905/// The ACL decision for one platform-API request, or `None` when it is
906/// allowed.
907///
908/// Unknown `/api/` paths are denied. They reach no handler today, so the
909/// only behaviour this changes is for an endpoint added later without a
910/// row here — which should fail closed rather than ship unauthorized.
911#[must_use]
912pub fn api_denial(
913 acl: &Effective,
914 user: &str,
915 method: &str,
916 path: &str,
917 body: &[u8],
918 review_repo: impl Fn(&str) -> Option<String>,
919) -> Option<Denial> {
920 let json = || serde_json::from_slice::<serde_json::Value>(body).ok();
921 let required: Vec<(Scope, Level)> = match (method, path) {
922 ("POST", "/api/workspace") => {
923 let repo = json()
924 .as_ref()
925 .and_then(|v| v.get("repo"))
926 .and_then(serde_json::Value::as_str)
927 .map(normalize_repo);
928 match repo {
929 Some(repo) => vec![(Scope::Repo(repo), Level::Write)],
930 None => vec![(Scope::Node, Level::Write)],
931 }
932 }
933 // Archiving detaches a repository's workspace, so it needs the
934 // same repo-scoped write as creating one. Without an arm here the
935 // fail-closed default below would answer 404 and the workspace
936 // lifecycle would end at create.
937 ("POST", "/api/workspace/archive") => {
938 let repo = json()
939 .as_ref()
940 .and_then(|v| v.get("repo"))
941 .and_then(serde_json::Value::as_str)
942 .map(normalize_repo);
943 match repo {
944 Some(repo) => vec![(Scope::Repo(repo), Level::Write)],
945 None => vec![(Scope::Node, Level::Write)],
946 }
947 }
948 // D68. A round moves a branch on other people's behalf and
949 // spends CI per open proposal, so it sits with whoever owns the
950 // repository. A `write` holder can already move the branch, but
951 // only by pushing their own work to it; asking the queue to
952 // land everybody else's is a different thing to be trusted with.
953 ("POST", "/api/queue/run") => {
954 let repo = json()
955 .as_ref()
956 .and_then(|v| v.get("repo"))
957 .and_then(serde_json::Value::as_str)
958 .map(normalize_repo);
959 match repo {
960 Some(repo) => vec![(Scope::Repo(repo), Level::Own)],
961 None => vec![(Scope::Node, Level::Own)],
962 }
963 }
964 ("POST", "/api/submit") => match json() {
965 Some(value) => submission_scopes(&value, &review_repo),
966 None => vec![(Scope::Node, Level::Write)],
967 },
968 ("POST", "/api/submit-batch") => {
969 let ops = json();
970 let entries = ops
971 .as_ref()
972 .and_then(|v| v.get("ops"))
973 .and_then(serde_json::Value::as_array);
974 match entries {
975 Some(entries) => {
976 let mut scopes: Vec<(Scope, Level)> = Vec::new();
977 for entry in entries {
978 for required in submission_scopes(entry, &review_repo) {
979 if !scopes.contains(&required) {
980 scopes.push(required);
981 }
982 }
983 }
984 if scopes.is_empty() {
985 scopes.push((Scope::Node, Level::Write));
986 }
987 scopes
988 }
989 None => vec![(Scope::Node, Level::Write)],
990 }
991 }
992 // Whole-node reads: gated rather than filtered, because the log
993 // is a hash chain and the attestation covers the complete ref
994 // state. Narrowing either would destroy what it is for.
995 ("GET", p) if p.starts_with("/api/log") => vec![(Scope::Node, Level::Read)],
996 ("GET", "/api/ref-agreement") => vec![(Scope::Node, Level::Read)],
997 // Phase A leaves the aggregate view readable by any authenticated
998 // actor: filtering it, and the page rendered from it, is phase B.
999 // Until then a credential can enumerate ref names and oids of
1000 // repositories it cannot clone.
1001 ("GET", p) if p.starts_with("/api/view") || p.starts_with("/api/reviews") => Vec::new(),
1002 // An appeal names an attempt id the caller was handed by their own
1003 // rejection, not a repository, so there is nothing repo-scoped to
1004 // check here.
1005 ("POST", "/api/appeal") => Vec::new(),
1006 // Credential self-service (D36). Issuing and revoking are
1007 // node-wide writes and reading the roster is the node-wide read,
1008 // because an account is a fact about the node rather than about
1009 // one repository — and because `@node` is the grant this store is
1010 // forbidden to issue, so the authority to issue can only have
1011 // come from the operator's own file.
1012 ("POST", "/api/accounts/invite" | "/api/accounts/revoke") => {
1013 vec![(Scope::Node, Level::Write)]
1014 }
1015 // D72. Answering a request is issuing an invite or refusing to,
1016 // so it sits at exactly the authority issuing one does. Asking is
1017 // not here at all: `POST /api/access` is pre-auth and never
1018 // reaches this table.
1019 ("POST", "/api/accounts/request/grant" | "/api/accounts/request/decline") => {
1020 vec![(Scope::Node, Level::Write)]
1021 }
1022 ("GET", "/api/accounts") => vec![(Scope::Node, Level::Read)],
1023 // Nothing required, because this one narrows itself: the handler
1024 // filters the list to the repositories the caller can read, so a
1025 // grant requirement here would be a second, coarser answer to
1026 // the same question. A `@node read` requirement in particular
1027 // would be exactly wrong — it would hide the listing from every
1028 // reader who holds one repository, which is who it is for.
1029 ("GET", "/api/repos") => Vec::new(),
1030 // Creating a repository is a write against the node itself:
1031 // there is no repository yet for it to be scoped to. The handler
1032 // has said so since it was written, and this row is what makes
1033 // that true — without it the path falls through to the `_` arm
1034 // below and every node with an ACL answers 404 to
1035 // `choir repo create`, which is every node that has issued a
1036 // second credential.
1037 ("POST", "/api/repo") => vec![(Scope::Node, Level::Write)],
1038 // Enrolling and removing a passkey act on the caller's own
1039 // account and no one else's (D39): the handler never reads a
1040 // user from the body, so there is no scope here to check that
1041 // would not simply be "you are authenticated". A grant
1042 // requirement would be worse than none — `@node write` would
1043 // mean only operators could enrol, which is the opposite of the
1044 // point, and a repo scope would tie a fact about a person to a
1045 // repository they may not have.
1046 ("POST", "/api/accounts/passkey" | "/api/accounts/passkey/remove") => Vec::new(),
1047 // Preparing an op for a browser to sign grants nothing (D39):
1048 // the response is bytes the caller could have assembled
1049 // themselves, and what makes an op admissible is the signature
1050 // over them plus this same table applied at `/api/submit`. A
1051 // grant requirement here would gate a serializer, and would have
1052 // to be kept in agreement with the one that gates the write --
1053 // two places to answer one question, which is how they drift.
1054 ("POST", "/api/prepare") => Vec::new(),
1055 // Redemption is reached by a principal that holds no grant at
1056 // all — an unredeemed invite — so there is nothing here to check.
1057 // What keeps it from being an open door is that the invite is
1058 // itself a credential, and that an invite principal is refused
1059 // every other route before this table is consulted.
1060 ("POST", "/api/accounts/redeem") => Vec::new(),
1061 _ => {
1062 return Some(Denial {
1063 status: 404,
1064 reason: "no such endpoint".to_string(),
1065 })
1066 }
1067 };
1068 required
1069 .into_iter()
1070 .find_map(|(scope, level)| acl.check(user, &scope, level))
1071}
1072
1073/// How one top-level section of a read response may be disclosed.
1074#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1075pub enum Disclosure {
1076 /// Served to anyone who reaches the endpoint. It names no repository,
1077 /// and a writer cannot bind a scoped submission without it.
1078 Public,
1079 /// Served whole to a reader holding [`Scope::Node`] and omitted from
1080 /// everyone else. This is the "gate, never filter" rule `/api/log`
1081 /// and `/api/ref-agreement` follow, applied one level down: the
1082 /// section counts, attributes or times events across every
1083 /// repository at once, so there is no honest way to narrow it.
1084 NodeWide,
1085 /// Narrowed entry by entry to the reader's repository grants.
1086 PerRepo,
1087}
1088
1089/// Every top-level section `/api/view` and `/api/reviews` serve.
1090///
1091/// [`filter_response`] drives off this table and drops any section that
1092/// has no row here, so a section added to the view without being
1093/// classified is withheld from readers lacking a node-wide grant rather
1094/// than served to all of them.
1095///
1096/// The omission is the failure mode worth designing against, because it
1097/// has already happened once: `changes` was added to the view while the
1098/// filter's section list was maintained by hand, and every authenticated
1099/// reader received every change record — owner channel, workspace and
1100/// revisions — for repositories they held no grant on. Failing closed
1101/// keeps that quiet, so
1102/// `every_section_the_view_serves_is_classified` in `tests/it/acl.rs`
1103/// makes it loud, comparing this table against a view a real node
1104/// served rather than against a sample written from memory.
1105pub const SECTIONS: [(&str, Disclosure); 29] = [
1106 ("log", Disclosure::Public),
1107 ("build", Disclosure::Public),
1108 // [`crate::bound`]'s marks. Public because of *when* they are
1109 // computed, not because a row count is harmless: bounding runs after
1110 // this filter, so each count describes the reader's own narrowed
1111 // slice. Were the order ever reversed, these would be the worst kind
1112 // of NodeWide — a measurement of the node handed to someone granted
1113 // one corner of it — and the row would be a lie rather than a leak.
1114 ("paging", Disclosure::Public),
1115 ("refs_omitted", Disclosure::Public),
1116 ("workspaces_omitted", Disclosure::Public),
1117 ("provenance_omitted", Disclosure::Public),
1118 ("reviews_omitted", Disclosure::Public),
1119 ("changes_omitted", Disclosure::Public),
1120 ("bindings_omitted", Disclosure::Public),
1121 ("vouches_omitted", Disclosure::Public),
1122 ("witnessed_omitted", Disclosure::Public),
1123 ("pending_omitted", Disclosure::Public),
1124 ("checks_omitted", Disclosure::Public),
1125 ("snapshot", Disclosure::NodeWide),
1126 ("bindings", Disclosure::NodeWide),
1127 // Node-wide for the same reason `bindings` is, and it needs saying
1128 // because a vouch reads like public reputation: the graph is a map
1129 // of who the node's operators are and who stands behind whom, which
1130 // is exactly the document a reader granted one repository was not
1131 // given. A profile derives from the narrowed view, so such a reader
1132 // is told there are no vouches to see rather than shown somebody
1133 // else's.
1134 ("vouches", Disclosure::NodeWide),
1135 ("witnessed", Disclosure::NodeWide),
1136 ("concentration", Disclosure::NodeWide),
1137 ("view_growth", Disclosure::NodeWide),
1138 ("newcomer_harm", Disclosure::NodeWide),
1139 ("new_actor_review_outcomes", Disclosure::NodeWide),
1140 ("sequencer_lag", Disclosure::NodeWide),
1141 ("refs", Disclosure::PerRepo),
1142 ("workspaces", Disclosure::PerRepo),
1143 ("provenance", Disclosure::PerRepo),
1144 ("reviews", Disclosure::PerRepo),
1145 ("changes", Disclosure::PerRepo),
1146 // Narrowed on the destination ref a report named, which is the only
1147 // repository a commit id can be attributed to. A check reported
1148 // without one stays node-wide, which is the fail-closed direction.
1149 ("checks", Disclosure::PerRepo),
1150 // `/api/reviews` rather than `/api/view`, narrowed by the same rule.
1151 ("pending", Disclosure::PerRepo),
1152];
1153
1154/// The disclosure rule for `section`, or `None` when it has no row and
1155/// must therefore be withheld.
1156#[must_use]
1157pub fn disclosure(section: &str) -> Option<Disclosure> {
1158 SECTIONS
1159 .iter()
1160 .find(|(name, _)| *name == section)
1161 .map(|(_, rule)| *rule)
1162}
1163
1164/// A read response narrowed to what `user` may see (D29 phase B).
1165///
1166/// `/api/view` and `/api/reviews` are the two endpoints that answer with
1167/// other repositories' contents, so they are the two this rewrites; every
1168/// other path is already gated by [`api_denial`] and passes through. A
1169/// body that is not the JSON object this expects is returned untouched
1170/// rather than emptied, because a filter that silently blanks an
1171/// unrecognized payload hides the mismatch instead of showing it.
1172///
1173/// `log` and `build` survive for every reader: they name the node, its
1174/// log head and the binary serving them. A writer needs the head to bind
1175/// a scoped submission, and neither says anything about a repository.
1176#[must_use]
1177pub fn filter_response(acl: &Effective, user: &str, path: &str, body: &str) -> String {
1178 if !(path.starts_with("/api/view") || path.starts_with("/api/reviews")) {
1179 return body.to_string();
1180 }
1181 let Ok(mut value) = serde_json::from_str::<serde_json::Value>(body) else {
1182 return body.to_string();
1183 };
1184 let Some(object) = value.as_object_mut() else {
1185 return body.to_string();
1186 };
1187 let node_wide = acl.allows(user, &Scope::Node, Level::Read);
1188 let readable = |repo: Option<String>| -> bool {
1189 // A key naming no repository is node-scoped by the same rule that
1190 // sends a repo-less op to `Scope::Node`: fail closed, and let a
1191 // node-wide grant see it.
1192 match repo {
1193 Some(repo) => acl.allows(user, &Scope::Repo(repo), Level::Read),
1194 None => node_wide,
1195 }
1196 };
1197 if !node_wide {
1198 // Unclassified sections leave with the node-wide ones. A section
1199 // this build does not know about cannot be narrowed, and serving
1200 // it whole is the disclosure this table exists to prevent.
1201 object.retain(|section, _| {
1202 matches!(
1203 disclosure(section),
1204 Some(Disclosure::Public | Disclosure::PerRepo)
1205 )
1206 });
1207 }
1208 retain_keys(object.get_mut("refs"), |key| readable(ref_repo(key)));
1209 for section in ["workspaces", "provenance"] {
1210 retain_keys(object.get_mut(section), |key| readable(subject_repo(key)));
1211 }
1212 for section in ["reviews", "pending"] {
1213 retain_entries(object.get_mut(section), |_, review| {
1214 readable(review_repo_of(review)) || assigned_to(user, review)
1215 });
1216 }
1217 // A change is keyed by its own stable id rather than by a repository,
1218 // so it narrows on the workspace it is bound to. `workspace_id` and
1219 // not `active_workspace`: the binding outlives archival, and reading
1220 // the cleared field would send every archived change to the repo-less
1221 // branch, where a node-wide reader would still see it but the record
1222 // would no longer be attributable to the repository it came from.
1223 retain_entries(object.get_mut("checks"), |_, check| {
1224 readable(
1225 check
1226 .get("target_ref")
1227 .and_then(serde_json::Value::as_str)
1228 .and_then(ref_repo),
1229 )
1230 });
1231 retain_entries(object.get_mut("changes"), |_, change| {
1232 readable(
1233 change
1234 .get("workspace_id")
1235 .and_then(serde_json::Value::as_str)
1236 .and_then(subject_repo),
1237 )
1238 });
1239 value.to_string()
1240}
1241
1242/// Repository a serialized review names through its target ref, if any.
1243fn review_repo_of(review: &serde_json::Value) -> Option<String> {
1244 review
1245 .get("target_ref")
1246 .and_then(serde_json::Value::as_str)
1247 .and_then(ref_repo)
1248}
1249
1250/// Whether `user` is one of a review's assigned reviewers.
1251///
1252/// A reviewer is a channel name, so both the bare name and the operator
1253/// half of `operator/agent` count: `reviewer_operator` is what the review
1254/// rules themselves treat as one actor, and matching only the full string
1255/// would drop a reader's own assignments the moment they run two agents.
1256/// Without this the ACL would silently break the review fan-out — the
1257/// reviews a reader most needs are exactly the ones on repositories they
1258/// were invited into rather than granted.
1259fn assigned_to(user: &str, review: &serde_json::Value) -> bool {
1260 review
1261 .get("reviewers")
1262 .and_then(serde_json::Value::as_array)
1263 .is_some_and(|reviewers| {
1264 reviewers
1265 .iter()
1266 .filter_map(serde_json::Value::as_str)
1267 .any(|name| name == user || choir_view::reviewer_operator(name) == user)
1268 })
1269}
1270
1271/// Drops map entries whose key fails `keep`. A non-map is left alone.
1272fn retain_keys(section: Option<&mut serde_json::Value>, keep: impl Fn(&str) -> bool) {
1273 retain_entries(section, |key, _| keep(key));
1274}
1275
1276/// Drops map entries whose key and value fail `keep`. A non-map is left
1277/// alone: every section this is applied to is a JSON object, and one that
1278/// is not has already stopped meaning what the filter thinks it means.
1279fn retain_entries(
1280 section: Option<&mut serde_json::Value>,
1281 keep: impl Fn(&str, &serde_json::Value) -> bool,
1282) {
1283 if let Some(map) = section.and_then(serde_json::Value::as_object_mut) {
1284 map.retain(|key, value| keep(key, value));
1285 }
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290 use super::*;
1291
1292 /// A fixed instant for every test that says nothing about deadlines,
1293 /// so those tests read exactly as they did before D66.
1294 const NOW: u64 = 1_700_000_000;
1295
1296 /// Parses and dates in one step (D66). Tests that are about the
1297 /// grammar failing still call [`Acl::parse`] directly, because a file
1298 /// that does not parse never reaches a clock.
1299 fn parse(text: &str) -> Effective {
1300 Acl::parse(text).expect("parses").at(NOW)
1301 }
1302
1303 /// The sections gated whole on a node-wide grant.
1304 fn node_wide_sections() -> impl Iterator<Item = &'static str> {
1305 SECTIONS
1306 .iter()
1307 .filter(|(_, rule)| *rule == Disclosure::NodeWide)
1308 .map(|(name, _)| *name)
1309 }
1310
1311 /// D60. The level is only useful if it sits *between* read and
1312 /// write, and the enum's own note says a variant declared out of
1313 /// strength order silently changes every existing `>=` check. So the
1314 /// ordering is asserted directly rather than inferred from behaviour.
1315 #[test]
1316 fn propose_sits_between_read_and_write() {
1317 assert!(Level::Read < Level::Propose);
1318 assert!(Level::Propose < Level::Write);
1319 assert!(Level::Write < Level::Own);
1320
1321 let acl = parse("carol owner/p propose\n");
1322 assert!(
1323 acl.allows_repo("carol", "owner/p", Level::Read),
1324 "propose must imply read"
1325 );
1326 assert!(acl.allows_repo("carol", "owner/p", Level::Propose));
1327 assert!(!acl.allows_repo("carol", "owner/p", Level::Write));
1328 assert!(!acl.allows_repo("carol", "owner/p", Level::Own));
1329 }
1330
1331 /// `@node` is the log and the attestation, which hold no reviews, so
1332 /// the spelling is refused there rather than quietly granted over
1333 /// everything -- the same rule `own` follows.
1334 #[test]
1335 fn propose_is_not_a_node_wide_spelling() {
1336 let error = Acl::parse("carol @node propose\n").expect_err("`@node propose` must refuse");
1337 assert!(
1338 error.contains("repository grant"),
1339 "unhelpful refusal: {error}"
1340 );
1341 }
1342
1343 /// The parity claim D66 rests on: three columns mean today exactly
1344 /// what they meant before the fourth existed, at any instant.
1345 #[test]
1346 fn a_grant_with_no_deadline_is_the_grant_it_always_was() {
1347 let text = "alice owner/p write\nbob @node auditor\n";
1348 for now in [0, NOW, u64::MAX] {
1349 let acl = Acl::parse(text).expect("parses").at(now);
1350 assert!(
1351 acl.allows_repo("alice", "owner/p", Level::Write),
1352 "a deadline-free grant lapsed at {now}"
1353 );
1354 assert!(acl.allows("bob", &Scope::Node, Level::Read));
1355 }
1356 assert_eq!(Acl::parse(text).expect("parses").expired(u64::MAX), 0);
1357 }
1358
1359 /// The boundary, stated on both sides: `until=N` is in force through
1360 /// `N - 1` and gone at `N`, which is the comparison an invite makes.
1361 #[test]
1362 fn a_deadline_ends_the_grant_in_the_second_it_names() {
1363 let table = Acl::parse("alice owner/p write until=1000\n").expect("parses");
1364 assert!(table.at(999).allows_repo("alice", "owner/p", Level::Write));
1365 assert!(!table.at(1000).allows_repo("alice", "owner/p", Level::Write));
1366 assert!(!table.at(1001).allows_repo("alice", "owner/p", Level::Write));
1367 assert_eq!(table.expired(999), 0);
1368 assert_eq!(table.expired(1000), 1);
1369 }
1370
1371 /// A lapse is a downgrade, not a lockout. This is what makes a
1372 /// time-locked privilege lendable: the account survives it.
1373 #[test]
1374 fn a_lapsed_write_falls_back_to_a_permanent_read() {
1375 let table =
1376 Acl::parse("alice owner/p read\nalice owner/p write until=1000\n").expect("parses");
1377 let after = table.at(2000);
1378 assert!(
1379 after.allows_repo("alice", "owner/p", Level::Read),
1380 "the permanent grant went with the expiring one"
1381 );
1382 assert!(!after.allows_repo("alice", "owner/p", Level::Write));
1383 // And the denial is the readable-repository one, not the
1384 // does-not-exist one: withholding a level is not withholding the
1385 // repository's existence from somebody who can still read it.
1386 let denial = after
1387 .check("alice", &Scope::Repo("owner/p".into()), Level::Write)
1388 .expect("denied");
1389 assert_eq!(denial.status, 403);
1390 }
1391
1392 /// An expiring `own` returns the repository to the approval-weight
1393 /// rule (D42) rather than leaving it owned by nobody-in-particular.
1394 #[test]
1395 fn an_expired_owner_is_not_an_owner() {
1396 let table = Acl::parse("alice owner/p own until=1000\n").expect("parses");
1397 assert!(table.at(999).has_owner("owner/p"));
1398 assert!(!table.at(1000).has_owner("owner/p"));
1399 }
1400
1401 /// The page cache is keyed on what the reader may see, so a lapse has
1402 /// to move the key or a cached page outlives the grant that filtered
1403 /// it.
1404 #[test]
1405 fn a_lapse_moves_the_cache_key() {
1406 let table =
1407 Acl::parse("alice owner/p read\nalice owner/q read until=1000\n").expect("parses");
1408 assert_ne!(
1409 table.at(999).cache_key("alice"),
1410 table.at(1000).cache_key("alice"),
1411 "a reader who lost a repository kept the key that cached it"
1412 );
1413 assert_eq!(
1414 table.at(1000).cache_key("alice"),
1415 Acl::parse("alice owner/p read\n")
1416 .expect("parses")
1417 .at(1000)
1418 .cache_key("alice"),
1419 "a lapsed grant left a trace in the key of a reader who no longer holds it"
1420 );
1421 }
1422
1423 /// Both sources keep their deadlines through the merge (D36).
1424 #[test]
1425 fn merging_keeps_each_side_of_the_deadline() {
1426 let file = Acl::parse("alice owner/p read\n").expect("parses");
1427 let store = Acl::parse("alice owner/q write until=1000\n").expect("parses");
1428 let merged = file.merged(&store);
1429 assert!(merged.at(999).allows_repo("alice", "owner/q", Level::Write));
1430 assert!(!merged
1431 .at(1000)
1432 .allows_repo("alice", "owner/q", Level::Write));
1433 assert!(merged.at(1000).allows_repo("alice", "owner/p", Level::Read));
1434 }
1435
1436 /// D36's rule is about what may be *written*, so an expired node
1437 /// grant is still a node grant and still refused.
1438 #[test]
1439 fn an_expired_node_grant_is_still_a_node_grant() {
1440 let table = Acl::parse("mallory @node auditor until=1000\n").expect("parses");
1441 assert!(
1442 table.grants_node("mallory"),
1443 "a deadline in the past made node scope look un-granted"
1444 );
1445 assert!(!table.at(2000).allows("mallory", &Scope::Node, Level::Read));
1446 }
1447
1448 /// The fourth column is checked, not guessed at: a key that is not
1449 /// `until`, a value that is not a number, and a fifth column are all
1450 /// refusals naming the line.
1451 #[test]
1452 fn the_fourth_column_is_only_a_deadline() {
1453 for (bad, why) in [
1454 ("alice o/r read expires=1000\n", "a key that is not `until`"),
1455 ("alice o/r read 1000\n", "a bare number with no key"),
1456 ("alice o/r read until=soon\n", "a value that is not seconds"),
1457 ("alice o/r read until=-1\n", "a negative deadline"),
1458 ("alice o/r read until=1000 extra\n", "a fifth column"),
1459 ] {
1460 let error = Acl::parse(bad).expect_err(why);
1461 assert!(error.starts_with("line 1: "), "{why}: {error}");
1462 }
1463 // A deadline already in the past is not a parse error: one stale
1464 // line must not be a node-wide lockout.
1465 let stale = Acl::parse("alice o/r read until=1\n").expect("a past deadline parses");
1466 assert_eq!(stale.expired(NOW), 1);
1467 assert!(!stale.at(NOW).allows_repo("alice", "o/r", Level::Read));
1468 }
1469
1470 #[test]
1471 fn the_grammar_accepts_the_documented_file_and_nothing_else() {
1472 // Counted on the file's own table rather than a dated one: this
1473 // test is about the grammar, and `len` is a fact about what was
1474 // written, not about what holds now.
1475 let acl = Acl::parse(
1476 "# a comment\n\
1477 alice owner/project write\n\
1478 \n\
1479 bob * read # trailing comment\n\
1480 carol @node auditor\n",
1481 )
1482 .expect("the documented example must parse");
1483 assert_eq!(acl.len(), 3);
1484
1485 for (bad, why) in [
1486 ("alice owner/project", "two columns"),
1487 ("alice owner/project write extra", "four columns"),
1488 ("alice owner/project admin", "a level that does not exist"),
1489 ("alice @nope write", "an invented pseudo-repository"),
1490 ("alice owner write", "a repo name with no owner"),
1491 ("alice owner/a/b write", "a three-segment repo name"),
1492 (
1493 "alice @node read",
1494 "`read` where the role is spelled `auditor`",
1495 ),
1496 ("alice owner/project auditor", "a node role on a repository"),
1497 ] {
1498 assert!(Acl::parse(bad).is_err(), "accepted {why}: {bad:?}");
1499 }
1500 }
1501
1502 /// The error has to name the line, or an operator with a 40-line file
1503 /// is bisecting it by hand while the node refuses to start.
1504 #[test]
1505 fn a_parse_error_names_its_line() {
1506 let error = Acl::parse("alice o/r read\nbob o/r sideways\n").expect_err("line 2 is bad");
1507 assert!(error.contains("line 2"), "{error}");
1508 }
1509
1510 #[test]
1511 fn both_spellings_of_a_repository_are_one_grant() {
1512 let acl = parse("alice owner/project.git write");
1513 assert!(acl.allows_repo("alice", "owner/project", Level::Write));
1514 assert!(acl.allows_repo("alice", "owner/project.git", Level::Write));
1515 }
1516
1517 #[test]
1518 fn write_implies_read_and_read_does_not_imply_write() {
1519 let acl = parse("alice o/r write\nbob o/r read");
1520 assert!(acl.allows_repo("alice", "o/r", Level::Read));
1521 assert!(acl.allows_repo("bob", "o/r", Level::Read));
1522 assert!(!acl.allows_repo("bob", "o/r", Level::Write));
1523 }
1524
1525 /// The implication is the derived [`Ord`], which follows declaration
1526 /// order, so this pins the order rather than the spelling. Declaring
1527 /// `Own` before `Write` would compile, pass every existing test, and
1528 /// quietly turn every `allows(.., Write)` check in the daemon into a
1529 /// check for something weaker.
1530 #[test]
1531 fn own_implies_write_and_write_does_not_imply_own() {
1532 let acl = parse("alice o/r own\nbob o/r write");
1533 assert!(acl.allows_repo("alice", "o/r", Level::Read));
1534 assert!(acl.allows_repo("alice", "o/r", Level::Write));
1535 assert!(acl.allows_repo("alice", "o/r", Level::Own));
1536 assert!(acl.allows_repo("bob", "o/r", Level::Write));
1537 assert!(
1538 !acl.allows_repo("bob", "o/r", Level::Own),
1539 "write must not confer ownership: the landing gate asks for \
1540 Own precisely because push permission is not assent"
1541 );
1542 assert!(Level::Read < Level::Write && Level::Write < Level::Own);
1543 }
1544
1545 /// `own` names an owner of a repository. `@node` is the op log and the
1546 /// attestation, which no repository owns; granting it there would be a
1547 /// node-wide authority nobody asked for.
1548 #[test]
1549 fn own_is_a_repository_grant_and_the_node_refuses_it() {
1550 assert!(Acl::parse("alice o/r own").is_ok());
1551 assert!(
1552 Acl::parse("alice * own").is_ok(),
1553 "owning every repo is sayable"
1554 );
1555 let error = Acl::parse("alice @node own").expect_err("@node cannot be owned");
1556 assert!(error.contains("repository grant"), "{error}");
1557 }
1558
1559 /// `*` is a statement about repositories. If it also covered the node
1560 /// it would hand every repository-granted actor the op log and the
1561 /// key-management ops, which is the escalation this separation exists
1562 /// to prevent.
1563 #[test]
1564 fn the_wildcard_does_not_reach_the_node() {
1565 let acl = parse("bob * write");
1566 assert!(acl.allows_repo("bob", "anything/at-all", Level::Write));
1567 assert!(!acl.allows("bob", &Scope::Node, Level::Read));
1568 }
1569
1570 /// The three grants `@anon` must not parse into holding.
1571 ///
1572 /// Refused in the parser rather than at the point of use, so the
1573 /// failure is a node that will not start on a file somebody typed
1574 /// wrong, instead of a node that starts and serves more than the
1575 /// operator meant. Each line here is a different way to say "the
1576 /// internet", and only the narrow one is a sentence the file has.
1577 #[test]
1578 fn the_anonymous_principal_cannot_be_granted_more_than_one_repository_to_read() {
1579 // The op log and the audit surface.
1580 let node = Acl::parse("@anon @node auditor").expect_err("refused");
1581 assert!(node.contains("@node"), "{node}");
1582 // Every repository, including the ones added next month.
1583 let all = Acl::parse("@anon * read").expect_err("refused");
1584 assert!(all.contains('*'), "{all}");
1585 // A write path for a caller that presented nothing.
1586 let write = Acl::parse("@anon o/r write").expect_err("refused");
1587 assert!(write.contains("read"), "{write}");
1588
1589 // The one thing it may hold, so the three refusals above are
1590 // about what they name rather than about the principal.
1591 let ok = parse("@anon o/r read");
1592 assert!(ok.allows_repo(ANON, "o/r", Level::Read));
1593 assert!(!ok.allows_repo(ANON, "o/other", Level::Read));
1594 assert!(!ok.allows_repo(ANON, "o/r", Level::Propose));
1595 assert!(ok.holds_anything(ANON));
1596 assert!(!ok.holds_anything("nobody"));
1597 }
1598
1599 /// Every other principal keeps the grammar it had.
1600 ///
1601 /// The guards above are keyed on one exact name. A rule that leaked
1602 /// onto ordinary users would take `*` and `@node` away from the
1603 /// operator, which is most of what the file is for.
1604 #[test]
1605 fn the_restriction_is_the_reserved_name_and_not_the_grammar() {
1606 let acl = parse("bob * write\ncarol @node auditor\nanon o/r write");
1607 assert!(acl.allows_repo("bob", "any/thing", Level::Write));
1608 assert!(acl.allows("carol", &Scope::Node, Level::Read));
1609 // A user literally called `anon`, with no `@`, is an ordinary
1610 // account name and is not this principal.
1611 assert!(acl.allows_repo("anon", "o/r", Level::Write));
1612 assert!(!acl.holds_anything(ANON));
1613 }
1614
1615 #[test]
1616 fn an_unreadable_repository_is_not_found_and_a_readable_one_is_forbidden() {
1617 let acl = parse("bob o/r read");
1618 let unreadable = acl
1619 .check("bob", &Scope::Repo("o/other".into()), Level::Read)
1620 .expect("denied");
1621 assert_eq!(unreadable.status, 404);
1622 let unwritable = acl
1623 .check("bob", &Scope::Repo("o/r".into()), Level::Write)
1624 .expect("denied");
1625 assert_eq!(unwritable.status, 403);
1626 // The node is never hidden: the caller is authenticated to it.
1627 let no_role = acl.check("bob", &Scope::Node, Level::Read).expect("denied");
1628 assert_eq!(no_role.status, 403);
1629 }
1630
1631 #[test]
1632 fn the_smart_http_surface_maps_to_the_level_it_actually_needs() {
1633 let cases = [
1634 (
1635 "GET",
1636 "/o/r.git/info/refs?service=git-upload-pack",
1637 Some(Level::Read),
1638 ),
1639 (
1640 "GET",
1641 "/o/r.git/info/refs?service=git-receive-pack",
1642 Some(Level::Propose),
1643 ),
1644 ("GET", "/o/r.git/info/refs", Some(Level::Read)),
1645 ("GET", "/o/r.git/HEAD", Some(Level::Read)),
1646 ("GET", "/o/r.git/objects/info/packs", Some(Level::Read)),
1647 ("POST", "/o/r.git/git-upload-pack", Some(Level::Read)),
1648 // `propose`, not `write`, since D60: this boundary has no
1649 // refname to judge, so it admits the push and the hook
1650 // decides which refs the grant actually reaches.
1651 ("POST", "/o/r.git/git-receive-pack", Some(Level::Propose)),
1652 // Not a repository path, and not a smart-HTTP operation:
1653 // both refused rather than handed to the CGI.
1654 ("GET", "/not-a-repo/file", None),
1655 ("DELETE", "/o/r.git/git-receive-pack", None),
1656 // Authorized against `o/r`, resolved by the CGI against
1657 // `o/other`: refused before it can be either.
1658 ("GET", "/o/r.git/objects/../../o/other.git/info/refs", None),
1659 ];
1660 for (method, url, want) in cases {
1661 let got = git_requirement(method, url).map(|(_, level)| level);
1662 assert_eq!(got, want, "{method} {url}");
1663 }
1664 assert_eq!(
1665 git_requirement("POST", "/o/r.git/git-receive-pack").map(|(repo, _)| repo),
1666 Some("o/r".to_string()),
1667 "the repository must reach the ACL in its canonical spelling"
1668 );
1669 }
1670
1671 /// A push whose advertisement was read-gated would fail late and
1672 /// confusingly. This is the arm that gets that right, so it is worth
1673 /// its own assertion rather than one row in the table above.
1674 ///
1675 /// Both directions since D60, because the level moved down and a
1676 /// one-sided assertion would no longer notice it moving further: a
1677 /// `read` grant is still refused, and a `propose` grant is admitted.
1678 #[test]
1679 fn a_push_advertisement_needs_propose_not_read() {
1680 let (repo, level) =
1681 git_requirement("GET", "/o/r.git/info/refs?service=git-receive-pack").expect("maps");
1682
1683 let reader = parse("bob o/r read");
1684 assert!(reader
1685 .check("bob", &Scope::Repo(repo.clone()), level)
1686 .is_some());
1687
1688 let proposer = parse("carol o/r propose");
1689 assert!(
1690 proposer.check("carol", &Scope::Repo(repo), level).is_none(),
1691 "a propose grant must reach the advertisement, or the level is unusable"
1692 );
1693 }
1694
1695 #[test]
1696 fn an_op_naming_no_repository_falls_to_the_node() {
1697 let none = |_: &str| None;
1698 let bound = OpKind::SetRef {
1699 name: "owner/project.git:refs/heads/main".into(),
1700 commit: choir_oplog::ContentHash::blake3(b"c"),
1701 prev: None,
1702 };
1703 assert_eq!(
1704 op_scopes(&bound, none),
1705 vec![Scope::Repo("owner/project".into())]
1706 );
1707
1708 let unbound = OpKind::RequestReview {
1709 id: "r1".into(),
1710 target: choir_oplog::ContentHash::blake3(b"c"),
1711 reviewers: Vec::new(),
1712 target_ref: None,
1713 };
1714 assert_eq!(op_scopes(&unbound, none), vec![Scope::Node]);
1715
1716 // A verdict resolves through the review it settles, so a reviewer
1717 // needs write on the repository under review — not the node-wide
1718 // grant that would also hand them key management.
1719 let verdict = OpKind::PostVerdict {
1720 id: "r1".into(),
1721 reviewer: "bob".into(),
1722 verdict: choir_view::Verdict::Approve,
1723 note: String::new(),
1724 };
1725 let resolves = |_: &str| Some("owner/project".to_string());
1726 assert_eq!(
1727 op_scopes(&verdict, resolves),
1728 vec![Scope::Repo("owner/project".into())]
1729 );
1730 assert_eq!(op_scopes(&verdict, none), vec![Scope::Node]);
1731
1732 // A comment resolves the same way (D38). Scoping it to the node
1733 // instead would mean a reader with write on one repository could
1734 // not answer a review there without a grant over everything.
1735 let comment = OpKind::PostComment {
1736 id: "r1".into(),
1737 comment: "c1".into(),
1738 author: "bob".into(),
1739 body: "why this base?".into(),
1740 };
1741 assert_eq!(
1742 op_scopes(&comment, resolves),
1743 vec![Scope::Repo("owner/project".into())]
1744 );
1745 assert_eq!(op_scopes(&comment, none), vec![Scope::Node]);
1746 }
1747
1748 #[test]
1749 fn a_workspace_op_is_scoped_to_the_repository_it_sits_in() {
1750 let none = |_: &str| None;
1751 let op = OpKind::SetWorkspaceHead {
1752 workspace: "owner/project/feature-x".into(),
1753 commit: choir_oplog::ContentHash::blake3(b"c"),
1754 prev: None,
1755 };
1756 assert_eq!(
1757 op_scopes(&op, none),
1758 vec![Scope::Repo("owner/project".into())]
1759 );
1760 }
1761
1762 /// A view payload shaped like the one `/api/view` serves: two
1763 /// repositories in every per-repository section, plus the node-wide
1764 /// sections and the two that survive for everybody.
1765 fn sample_view() -> String {
1766 serde_json::json!({
1767 "log": { "node": "b3-node", "head": "b3-head", "scope_required": false },
1768 "build": { "commit": "abc" },
1769 "refs": {
1770 "owner/mine.git:refs/heads/main": "git-1111",
1771 "owner/theirs.git:refs/heads/main": "git-2222",
1772 },
1773 "workspaces": {
1774 "owner/mine/feature-a": "git-3333",
1775 "owner/theirs/feature-b": "git-4444",
1776 },
1777 "provenance": {
1778 "owner/mine/feature-a": { "plan": "mine" },
1779 "owner/theirs/feature-b": { "plan": "theirs" },
1780 },
1781 "reviews": {
1782 "r-mine": { "target_ref": "owner/mine.git:refs/heads/main", "reviewers": [] },
1783 "r-theirs": { "target_ref": "owner/theirs.git:refs/heads/main", "reviewers": [] },
1784 "r-invited": {
1785 "target_ref": "owner/theirs.git:refs/heads/main",
1786 "reviewers": ["alice/bot"],
1787 },
1788 "r-unbound": { "target_ref": null, "reviewers": [] },
1789 },
1790 "changes": {
1791 "c-mine": {
1792 "owner": "owner/agent",
1793 "workspace_id": "owner/mine/feature-a",
1794 "active_workspace": "owner/mine/feature-a",
1795 },
1796 "c-theirs": {
1797 "owner": "other/agent",
1798 "workspace_id": "owner/theirs/feature-b",
1799 "active_workspace": "owner/theirs/feature-b",
1800 },
1801 // Archived: the active workspace is gone, but the binding
1802 // it was created under still names the repository.
1803 "c-theirs-archived": {
1804 "owner": "other/agent",
1805 "workspace_id": "owner/theirs/feature-c",
1806 "active_workspace": null,
1807 },
1808 },
1809 "snapshot": { "id": "b3-snap" },
1810 "bindings": { "k1": { "operator": "someone" } },
1811 "vouches": { "someone": { "another": { "at": 4, "note": "" } } },
1812 "witnessed": { "someone": { "snapshot": { "codec": 30, "digest": [] }, "at": 5 } },
1813 "concentration": { "as_of_seq": 9 },
1814 "view_growth": { "entries": 9 },
1815 "newcomer_harm": {},
1816 "new_actor_review_outcomes": {},
1817 "sequencer_lag": {},
1818 })
1819 .to_string()
1820 }
1821
1822 /// The whole point of phase B: a reader granted one repository sees
1823 /// that repository, and cannot enumerate the other one through any
1824 /// of the four sections that name it.
1825 #[test]
1826 fn a_reader_granted_one_repository_sees_only_that_repository() {
1827 let acl = parse("alice owner/mine read");
1828 let filtered = filter_response(&acl, "alice", "/api/view", &sample_view());
1829 let value: serde_json::Value = serde_json::from_str(&filtered).expect("json");
1830 let keys = |section: &str| -> Vec<String> {
1831 value[section]
1832 .as_object()
1833 .unwrap_or_else(|| panic!("{section} object"))
1834 .keys()
1835 .cloned()
1836 .collect()
1837 };
1838 assert_eq!(keys("refs"), ["owner/mine.git:refs/heads/main"]);
1839 assert_eq!(keys("workspaces"), ["owner/mine/feature-a"]);
1840 assert_eq!(keys("provenance"), ["owner/mine/feature-a"]);
1841 // `r-invited` targets the ungranted repository and still reaches
1842 // this reader, because they were asked to review it: an invitation
1843 // discloses the ref it is about, and withholding it would silently
1844 // break the fan-out. `r-unbound` names no repository at all, so it
1845 // is node-scoped and fails closed like every repo-less subject.
1846 assert_eq!(keys("reviews"), ["r-invited", "r-mine"]);
1847 // A change record names its owner channel, its workspace and its
1848 // revisions, so leaking one enumerates both the repository and who
1849 // is working in it. The archived change narrows on the binding it
1850 // kept, not on the workspace it no longer has.
1851 assert_eq!(keys("changes"), ["c-mine"]);
1852 }
1853
1854 /// Node-wide sections are gated, not narrowed, and the two a writer
1855 /// needs to keep working are not gated at all.
1856 #[test]
1857 fn the_node_sections_need_the_node_grant_and_the_log_head_never_does() {
1858 let repo_only = parse("alice owner/mine write");
1859 let narrowed = filter_response(&repo_only, "alice", "/api/view", &sample_view());
1860 for section in node_wide_sections() {
1861 assert!(
1862 !narrowed.contains(section),
1863 "{section} reached a reader with no node grant"
1864 );
1865 }
1866 // Without these a writer cannot bind a scoped submission, and
1867 // neither says anything about a repository.
1868 assert!(narrowed.contains("b3-head") && narrowed.contains("\"build\""));
1869
1870 let auditor = parse("carol @node auditor");
1871 let whole = filter_response(&auditor, "carol", "/api/view", &sample_view());
1872 for section in node_wide_sections() {
1873 assert!(
1874 whole.contains(section),
1875 "{section} was withheld from an auditor"
1876 );
1877 }
1878 // An auditor holds no repository grant, so the repository
1879 // sections are empty for them — `@node` is not a way around `*`.
1880 let value: serde_json::Value = serde_json::from_str(&whole).expect("json");
1881 assert!(value["refs"].as_object().expect("refs").is_empty());
1882 }
1883
1884 /// The pending-review queue is the same data reached by a different
1885 /// path, so it is filtered by the same rule. Asking for somebody
1886 /// else's queue must not become a way to read the reviews the view
1887 /// would have withheld.
1888 #[test]
1889 fn the_pending_queue_is_filtered_like_the_view() {
1890 let acl = parse("alice owner/mine read");
1891 let body = serde_json::json!({
1892 "pending": {
1893 "r-mine": { "target_ref": "owner/mine.git:refs/heads/main", "reviewers": ["x"] },
1894 "r-theirs": { "target_ref": "owner/theirs.git:refs/heads/main", "reviewers": ["x"] },
1895 }
1896 })
1897 .to_string();
1898 let filtered = filter_response(&acl, "alice", "/api/reviews?reviewer=x", &body);
1899 assert!(filtered.contains("r-mine"));
1900 assert!(!filtered.contains("r-theirs"), "{filtered}");
1901 }
1902
1903 /// Two readers with the same grants share a rendered page only if
1904 /// their keys match; a grant edit and a different reader must both
1905 /// change the key, or a stale page outlives the reload that should
1906 /// have invalidated it.
1907 #[test]
1908 fn the_cache_key_moves_with_the_reader_and_with_their_grants() {
1909 let before = parse("alice owner/mine read\nbob owner/mine read");
1910 assert_ne!(before.cache_key("alice"), before.cache_key("bob"));
1911 let after = parse("alice owner/mine write\nbob owner/mine read");
1912 assert_ne!(
1913 before.cache_key("alice"),
1914 after.cache_key("alice"),
1915 "an ACL edit left the cache key unchanged"
1916 );
1917 // Order in the file is not identity: the same grants written the
1918 // other way round are the same key.
1919 let reordered = parse("alice owner/b read\nalice owner/a read");
1920 let forward = parse("alice owner/a read\nalice owner/b read");
1921 assert_eq!(reordered.cache_key("alice"), forward.cache_key("alice"));
1922 }
1923
1924 /// A body the filter does not recognize is passed through rather than
1925 /// blanked: an empty response would read as "nothing here" and hide
1926 /// the mismatch that produced it.
1927 #[test]
1928 fn an_unrecognized_body_or_path_is_left_alone() {
1929 let acl = parse("alice owner/mine read");
1930 assert_eq!(
1931 filter_response(&acl, "alice", "/api/view", "not json"),
1932 "not json"
1933 );
1934 assert_eq!(
1935 filter_response(&acl, "alice", "/api/view", "[1,2]"),
1936 "[1,2]"
1937 );
1938 let other = r#"{"refs":{"owner/theirs.git:refs/heads/main":"git-2222"}}"#;
1939 assert_eq!(filter_response(&acl, "alice", "/api/submit", other), other);
1940 }
1941}