Skip to main content

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}