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