Skip to main content

cheers_core/
schema.rs

1//! Relationship schema — per resource kind, the relations a principal can
2//! hold, what each implies, the scopes each unlocks, and which relations a
3//! holder may grant and revoke on the same resource (R731 §D2, §D4).
4//!
5//! Products declare [`ResourceSchema`]s as const data beside their
6//! [`scopes!`](crate::scopes) set; the deployment builds one
7//! [`SchemaRegistry`] at startup against its [`ScopeRegistry`]. The build
8//! refuses a bad schema (unknown target, cycle, unregistered scope,
9//! duplicate) and precomputes each relation's implies-closure, so the mint
10//! path and the authority check are plain lookups.
11//!
12//! Nothing is implied unless declared: `admin` does not unlock `read`'s
13//! scopes unless `admin` lists `read` in `implies`. The closure carries BOTH
14//! scopes and grant rights — a holder of `admin` where `admin` implies
15//! `triager` gets `triager`'s scopes and may grant what `triager` may grant.
16//!
17//! Kind-level relations (the yubaba provisioner case) are tuples on the
18//! reserved resource kind [`KIND_RESOURCE`] with `resource_id` = the kind name,
19//! e.g. `kind/service#provisioner`. A kind relation's grants apply to every
20//! resource of that kind; its scopes unlock as usual.
21//!
22//! A tuple's holder may be a **subject set** ([`Subject::Set`]): everyone who
23//! holds `kind/id#relation`, through the implies-closure, holds the tuple's
24//! relation (noisetable W235 §2.1). The set-aware checks read tuples through
25//! a [`TupleSource`] and follow sets for at most [`MAX_SET_HOPS`] hops with a
26//! visited set, so cycles terminate and the bound refuses rather than errs
27//! (W235 §3 `effective(P, resource, rel)`). There are two walks over the same
28//! rule, and they agree by construction (a test cross-checks them):
29//!
30//! - **Down**, from a resource toward principals: [`SchemaRegistry::holds`],
31//!   [`may_grant`](SchemaRegistry::may_grant) and
32//!   [`members`](SchemaRegistry::members) — the door's point checks and the
33//!   membership enumeration.
34//! - **Up**, from a principal's direct tuples toward the resources sets
35//!   confer: [`SchemaRegistry::held_by`] — mint-time derivation of everything
36//!   one principal holds.
37
38use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
39
40use async_trait::async_trait;
41use serde::{Deserialize, Serialize};
42
43use crate::admission::{AdmissionPolicy, PolicyError};
44use crate::principal::PrincipalId;
45use crate::scope::{Scope, ScopeRegistry};
46use crate::store::StoreError;
47
48/// The reserved `resource_kind` kind-level tuples live under.
49pub const KIND_RESOURCE: &str = "kind";
50
51/// How many subject-set hops a check follows (noisetable W235 §3: depth ≤ 4).
52/// A direct tuple is hop 0; `issue/x#reader ← team/t#member` held through a
53/// direct `team/t#member` is hop 1. A holding reachable only past the bound is
54/// **not held** — never an error. Implies steps are not hops: the closure is
55/// precomputed at [`SchemaRegistry::build`].
56pub const MAX_SET_HOPS: usize = 4;
57
58/// One relation on a resource kind.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct RelationDef {
61    pub name: &'static str,
62    /// Whether holding this relation makes the holder a **member** of the
63    /// resource (R734-B6). Required, with no default: a relation that is not
64    /// marked membership (a name-claim `allow`, a `reserved` hold) is never
65    /// listed in a minted [`SetSnapshot`](crate::SetSnapshot), so forgetting
66    /// to decide fails closed instead of leaking a non-member onto the edge.
67    pub membership: bool,
68    /// Relations (in the same list) a holder of this one also holds.
69    pub implies: &'static [&'static str],
70    /// Scopes this relation unlocks at mint.
71    pub scopes: &'static [Scope],
72    /// Relations on the same resource a holder may grant and revoke. For a
73    /// kind relation these name the kind's per-resource `relations`.
74    pub grants: &'static [&'static str],
75}
76
77/// A product's declaration for one resource kind.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ResourceSchema {
80    pub kind: &'static str,
81    pub relations: &'static [RelationDef],
82    /// Relations held on the kind itself (`kind/<kind>#<name>`).
83    pub kind_relations: &'static [RelationDef],
84}
85
86/// A schema [`SchemaRegistry::build`] refused.
87#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
88pub enum SchemaError {
89    #[error("resource kind '{0}' is reserved for kind-level relations")]
90    ReservedKind(String),
91    #[error("resource kind '{0}' is declared more than once")]
92    DuplicateKind(String),
93    #[error("relation '{relation}' is declared more than once on kind '{kind}'")]
94    DuplicateRelation { kind: String, relation: String },
95    #[error("relation '{relation}' on kind '{kind}' references undeclared relation '{target}'")]
96    UnknownTarget { kind: String, relation: String, target: String },
97    #[error("relation '{relation}' on kind '{kind}' is part of an implies cycle")]
98    Cycle { kind: String, relation: String },
99    #[error("relation '{relation}' on kind '{kind}' unlocks unregistered scope '{scope}'")]
100    UnregisteredScope { kind: String, relation: String, scope: Scope },
101}
102
103/// Who holds an ownership tuple (noisetable W235 §2.1).
104///
105/// Either one principal, or a **subject set** (Zanzibar's "userset"): every
106/// holder of `relation` on the resource `(kind, id)` holds the tuple's
107/// relation. `issues/x#reader ← namespace/n#member` is the tuple
108/// `(issues, x, reader)` with subject `Set { kind: "namespace", id: "n",
109/// relation: "member" }`.
110///
111/// Serialized flat, in the ownership table's column names, so a principal
112/// subject keeps the `principal_id` key it always had on the wire:
113/// `{"principal_id": "user:alice"}` or `{"subject_kind": "namespace",
114/// "subject_id": "n", "subject_relation": "member"}` — exactly one form.
115/// Meant to be `#[serde(flatten)]`ed into the row that carries it.
116#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
117#[serde(try_from = "SubjectWire", into = "SubjectWire")]
118pub enum Subject {
119    Principal(PrincipalId),
120    Set { kind: String, id: String, relation: String },
121}
122
123/// Both subject forms populated, or neither — the one-form rule the
124/// ownership table's CHECK also enforces.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
126#[error(
127    "a subject is either principal_id or all of subject_kind/subject_id/subject_relation, \
128     never both and never neither"
129)]
130pub struct SubjectFormError;
131
132impl Subject {
133    pub fn set(kind: impl Into<String>, id: impl Into<String>, relation: impl Into<String>) -> Self {
134        Self::Set { kind: kind.into(), id: id.into(), relation: relation.into() }
135    }
136
137    /// The holder, when the subject is one principal.
138    pub fn principal(&self) -> Option<&PrincipalId> {
139        match self {
140            Self::Principal(p) => Some(p),
141            Self::Set { .. } => None,
142        }
143    }
144
145    /// `(kind, id, relation)`, when the subject is a set.
146    pub fn as_set(&self) -> Option<(&str, &str, &str)> {
147        match self {
148            Self::Principal(_) => None,
149            Self::Set { kind, id, relation } => Some((kind, id, relation)),
150        }
151    }
152
153    /// Rebuild from the four nullable subject fields (table columns, wire
154    /// keys). Exactly one form must be complete: the principal alone, or all
155    /// three set fields alone.
156    pub fn from_parts(
157        principal_id: Option<PrincipalId>,
158        kind: Option<String>,
159        id: Option<String>,
160        relation: Option<String>,
161    ) -> Result<Self, SubjectFormError> {
162        match (principal_id, kind, id, relation) {
163            (Some(p), None, None, None) => Ok(Self::Principal(p)),
164            (None, Some(kind), Some(id), Some(relation)) => Ok(Self::Set { kind, id, relation }),
165            _ => Err(SubjectFormError),
166        }
167    }
168}
169
170impl From<PrincipalId> for Subject {
171    fn from(p: PrincipalId) -> Self {
172        Self::Principal(p)
173    }
174}
175
176impl std::fmt::Display for Subject {
177    /// `user:alice`, or `namespace/n#member` for a set.
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        match self {
180            Self::Principal(p) => write!(f, "{p}"),
181            Self::Set { kind, id, relation } => write!(f, "{kind}/{id}#{relation}"),
182        }
183    }
184}
185
186/// [`Subject`]'s flat serde shape.
187#[derive(Serialize, Deserialize)]
188struct SubjectWire {
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    principal_id: Option<PrincipalId>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    subject_kind: Option<String>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    subject_id: Option<String>,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    subject_relation: Option<String>,
197}
198
199impl TryFrom<SubjectWire> for Subject {
200    type Error = SubjectFormError;
201    fn try_from(w: SubjectWire) -> Result<Self, SubjectFormError> {
202        Self::from_parts(w.principal_id, w.subject_kind, w.subject_id, w.subject_relation)
203    }
204}
205
206impl From<Subject> for SubjectWire {
207    fn from(s: Subject) -> Self {
208        match s {
209            Subject::Principal(p) => Self {
210                principal_id: Some(p),
211                subject_kind: None,
212                subject_id: None,
213                subject_relation: None,
214            },
215            Subject::Set { kind, id, relation } => Self {
216                principal_id: None,
217                subject_kind: Some(kind),
218                subject_id: Some(id),
219                subject_relation: Some(relation),
220            },
221        }
222    }
223}
224
225/// One ownership tuple as the schema reads it. Implemented by the server's
226/// `OwnershipRow`; kept as a trait so cheers-core stays storage-free.
227pub trait RelationTuple {
228    /// Who holds the tuple — one principal, or a subject set.
229    fn subject(&self) -> &Subject;
230    fn resource_kind(&self) -> &str;
231    fn resource_id(&self) -> &str;
232    fn relation(&self) -> &str;
233    /// `false` once the row is revoked — a dead row confers nothing.
234    fn is_live(&self) -> bool;
235}
236
237/// Where the set-aware checks read tuples. Implemented by cheers-server over
238/// its ownership store; a trait so cheers-core stays storage-free.
239///
240/// Sources return live rows; the walks re-check [`RelationTuple::is_live`]
241/// anyway, so a row a source leaks after revocation confers nothing.
242#[async_trait]
243pub trait TupleSource: Send + Sync {
244    type Tuple: RelationTuple + Send + Sync;
245
246    /// Tuples whose subject is exactly `principal` — no set rows.
247    async fn list_for_principal(
248        &self,
249        principal: &PrincipalId,
250    ) -> Result<Vec<Self::Tuple>, StoreError>;
251
252    /// Tuples on resource `(kind, id)`, either subject form.
253    async fn list_for_resource(&self, kind: &str, id: &str)
254        -> Result<Vec<Self::Tuple>, StoreError>;
255
256    /// Tuples whose subject is a set on `(kind, id)`, any subject relation.
257    async fn list_for_subject_set(
258        &self,
259        kind: &str,
260        id: &str,
261    ) -> Result<Vec<Self::Tuple>, StoreError>;
262}
263
264/// Everything one principal holds ([`SchemaRegistry::held_by`]): direct and
265/// set-derived tuples, implies-closure folded in, by resource.
266#[derive(Debug, Clone, Default, PartialEq, Eq)]
267pub struct Holdings(BTreeMap<(String, String), BTreeSet<String>>);
268
269impl Holdings {
270    pub fn contains(&self, kind: &str, id: &str, relation: &str) -> bool {
271        self.0
272            .get(&(kind.to_owned(), id.to_owned()))
273            .is_some_and(|rels| rels.contains(relation))
274    }
275
276    /// Every held `(kind, id, relation)`, sorted.
277    pub fn iter(&self) -> impl Iterator<Item = (&str, &str, &str)> + '_ {
278        self.0.iter().flat_map(|((kind, id), rels)| {
279            rels.iter().map(move |r| (kind.as_str(), id.as_str(), r.as_str()))
280        })
281    }
282
283    pub fn is_empty(&self) -> bool {
284        self.0.is_empty()
285    }
286
287    /// `true` when the holding is new.
288    fn insert(&mut self, kind: &str, id: &str, relation: &str) -> bool {
289        self.0.entry((kind.to_owned(), id.to_owned())).or_default().insert(relation.to_owned())
290    }
291}
292
293/// One entry of [`SchemaRegistry::members`]: `principal` holds `relation` on
294/// the resource, implies-closure folded in.
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct Member {
297    pub principal: PrincipalId,
298    pub relation: String,
299    /// `(kind, id)` of every resource where one of `principal`'s own direct
300    /// tuples sits from which `relation` derives: the resource itself for a
301    /// direct holder, the set's resource (a parent namespace) for an
302    /// inherited one. Never empty. A membership snapshot's revocation mask
303    /// keys on it (`cheers_core::snapshot`).
304    pub via: BTreeSet<(String, String)>,
305}
306
307/// `(kind, id, relation)` — one node of a set walk: "holds `relation` on
308/// `kind/id`".
309type Node = (String, String, String);
310
311/// A walk's per-check memo of source reads, keyed by `(kind, id)`, so a
312/// resource reached by several paths or for several relations is read once.
313type RowCache<T> = HashMap<(String, String), Vec<T>>;
314
315/// A relation with its implies-closure folded in.
316#[derive(Debug, Clone, Default, PartialEq, Eq)]
317pub struct ResolvedRelation {
318    /// The relation itself and every relation it implies, transitively — what
319    /// a holder of it holds on the same resource.
320    pub relations: BTreeSet<&'static str>,
321    /// Every scope unlocked by the relation or anything it implies.
322    pub scopes: BTreeSet<Scope>,
323    /// Every relation the holder may grant, by the same closure.
324    pub grants: BTreeSet<&'static str>,
325    /// The relation's own [`RelationDef::membership`] (not folded through
326    /// the closure: each implied relation answers for itself).
327    pub membership: bool,
328}
329
330/// How a tuple resolves against the schema.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum Lookup<'a> {
333    /// The tuple's kind has no schema — derivation ignores it (e.g. camp
334    /// `owns` rows that only feed the owns claim).
335    NoSchema,
336    /// The kind is known but the relation is not declared on it.
337    UnknownRelation,
338    Resolved(&'a ResolvedRelation),
339}
340
341#[derive(Debug, Clone, Default)]
342struct KindEntry {
343    relations: HashMap<&'static str, ResolvedRelation>,
344    kind_relations: HashMap<&'static str, ResolvedRelation>,
345}
346
347/// The deployment's relationship schema, built once at startup.
348#[derive(Debug, Clone, Default)]
349pub struct SchemaRegistry {
350    kinds: HashMap<&'static str, KindEntry>,
351}
352
353impl SchemaRegistry {
354    pub fn build(schemas: &[ResourceSchema], scopes: &ScopeRegistry) -> Result<Self, SchemaError> {
355        let mut kinds = HashMap::with_capacity(schemas.len());
356        for s in schemas {
357            if s.kind == KIND_RESOURCE {
358                return Err(SchemaError::ReservedKind(s.kind.to_owned()));
359            }
360            let rel_names: BTreeSet<&str> = s.relations.iter().map(|r| r.name).collect();
361            let entry = KindEntry {
362                relations: resolve_list(s.kind, s.relations, &rel_names, scopes)?,
363                kind_relations: resolve_list(s.kind, s.kind_relations, &rel_names, scopes)?,
364            };
365            if kinds.insert(s.kind, entry).is_some() {
366                return Err(SchemaError::DuplicateKind(s.kind.to_owned()));
367            }
368        }
369        Ok(Self { kinds })
370    }
371
372    /// Resolve one tuple's `(resource_kind, resource_id, relation)`.
373    pub fn lookup(&self, resource_kind: &str, resource_id: &str, relation: &str) -> Lookup<'_> {
374        let list = if resource_kind == KIND_RESOURCE {
375            match self.kinds.get(resource_id) {
376                Some(e) => &e.kind_relations,
377                None => return Lookup::NoSchema,
378            }
379        } else {
380            match self.kinds.get(resource_kind) {
381                Some(e) => &e.relations,
382                None => return Lookup::NoSchema,
383            }
384        };
385        match list.get(relation) {
386            Some(r) => Lookup::Resolved(r),
387            None => Lookup::UnknownRelation,
388        }
389    }
390
391    /// Is `relation` a membership relation on `kind` (R734-B6)? Unknown
392    /// kinds and relations are not: only what the schema marks is minted.
393    pub fn is_membership(&self, kind: &str, relation: &str) -> bool {
394        self.kinds.get(kind).and_then(|e| e.relations.get(relation)).is_some_and(|r| r.membership)
395    }
396
397    /// An [`AdmissionPolicy`] is only usable at the edge when its `floor` and
398    /// every `admitters` entry are membership relations on `kind`: the edge
399    /// sees approvers and floor holders only through the snapshot, which
400    /// lists membership relations alone (R734-B6).
401    pub fn validate_admission_policy(&self, kind: &str, policy: &AdmissionPolicy) -> Result<(), PolicyError> {
402        for rel in std::iter::once(&policy.floor).chain(policy.admitters.iter()) {
403            if !self.is_membership(kind, rel) {
404                return Err(PolicyError::NotMembership { kind: kind.to_owned(), relation: rel.clone() });
405            }
406        }
407        Ok(())
408    }
409
410    /// W235 §3 `effective(P, resource, rel)`: does `principal` hold
411    /// `relation` on `(kind, id)` — by a direct tuple, through the
412    /// implies-closure, or through subject sets within [`MAX_SET_HOPS`]?
413    /// A store error is an error, never "not held".
414    pub async fn holds<S: TupleSource + ?Sized>(
415        &self,
416        source: &S,
417        principal: &PrincipalId,
418        kind: &str,
419        id: &str,
420        relation: &str,
421    ) -> Result<bool, StoreError> {
422        let root = (kind.to_owned(), id.to_owned(), relation.to_owned());
423        self.reaches(source, principal, vec![root]).await
424    }
425
426    /// The D4 authority check: may `principal` grant (or revoke) `relation` on
427    /// `(kind, id)`? True iff it [holds](Self::holds) a relation on
428    /// `(kind, id)` whose closure grants `relation`, or such a kind-level
429    /// relation on `(KIND_RESOURCE, kind)` — directly or through sets.
430    pub async fn may_grant<S: TupleSource + ?Sized>(
431        &self,
432        source: &S,
433        principal: &PrincipalId,
434        kind: &str,
435        id: &str,
436        relation: &str,
437    ) -> Result<bool, StoreError> {
438        let roots = self.grant_roots(kind, id, |g| g.contains(relation));
439        self.reaches(source, principal, roots).await
440    }
441
442    /// The D4 door rule for revoking `tuple`: a holder may always revoke the
443    /// tuple it holds (leaving), else the caller needs grant rights on the
444    /// tuple's relation ([`may_grant`](Self::may_grant)). Only a *direct*
445    /// principal subject counts as holding; a set-subject tuple is not
446    /// revocable by a member of the set on that basis.
447    pub async fn may_revoke<S: TupleSource + ?Sized, T: RelationTuple>(
448        &self,
449        source: &S,
450        principal: &PrincipalId,
451        tuple: &T,
452    ) -> Result<bool, StoreError> {
453        if matches!(tuple.subject(), Subject::Principal(p) if p == principal) {
454            return Ok(true);
455        }
456        self.may_grant(source, principal, tuple.resource_kind(), tuple.resource_id(), tuple.relation())
457            .await
458    }
459
460    /// May `principal` grant *any* relation on `(kind, id)`? The gate on
461    /// reading a resource's rows through the grant door (D4).
462    pub async fn may_grant_any<S: TupleSource + ?Sized>(
463        &self,
464        source: &S,
465        principal: &PrincipalId,
466        kind: &str,
467        id: &str,
468    ) -> Result<bool, StoreError> {
469        let roots = self.grant_roots(kind, id, |g| !g.is_empty());
470        self.reaches(source, principal, roots).await
471    }
472
473    /// Everything `principal` holds — the up-walk. Starts from its direct
474    /// tuples; each holding `kind/id#rel` then confers the relations of every
475    /// tuple whose subject is the set `kind/id#rel`, one hop per level, up to
476    /// [`MAX_SET_HOPS`]. Agrees with [`holds`](Self::holds):
477    /// `held_by(P).contains(k, i, r) == holds(P, k, i, r)`.
478    pub async fn held_by<S: TupleSource + ?Sized>(
479        &self,
480        source: &S,
481        principal: &PrincipalId,
482    ) -> Result<Holdings, StoreError> {
483        let mut held = Holdings::default();
484        let mut frontier = Vec::new();
485        for row in source.list_for_principal(principal).await?.iter().filter(|r| r.is_live()) {
486            self.hold(&mut held, &mut frontier, row);
487        }
488        let mut cache: RowCache<S::Tuple> = HashMap::new();
489        for _hop in 1..=MAX_SET_HOPS {
490            let mut next = Vec::new();
491            for (kind, id, relation) in &frontier {
492                let key = (kind.clone(), id.clone());
493                if !cache.contains_key(&key) {
494                    let rows = source.list_for_subject_set(kind, id).await?;
495                    cache.insert(key.clone(), rows);
496                }
497                let subject = Some((kind.as_str(), id.as_str(), relation.as_str()));
498                for row in cache[&key].iter().filter(|r| r.is_live()) {
499                    if row.subject().as_set() == subject {
500                        self.hold(&mut held, &mut next, row);
501                    }
502                }
503            }
504            if next.is_empty() {
505                break;
506            }
507            frontier = next;
508        }
509        Ok(held)
510    }
511
512    /// Every [`Member`] of `(kind, id)`: each `(principal, relation)` with
513    /// sets flattened and the implies-closure folded in, plus its provenance
514    /// `via`, sorted by `(principal, relation)`. The down-walk enumerated:
515    /// `(P, r)` is listed iff [`holds`](Self::holds)`(P, kind, id, r)`, and
516    /// `via` is every resource where one of P's direct tuples sits that the
517    /// walk reached for `r` — so revoking P's direct tuples on exactly the
518    /// `via` resources is what ends `(P, r)`. This is what a membership
519    /// snapshot (R732-F4) mints from. Kind-level tuples are not members of
520    /// the resources of their kind.
521    pub async fn members<S: TupleSource + ?Sized>(
522        &self,
523        source: &S,
524        kind: &str,
525        id: &str,
526    ) -> Result<Vec<Member>, StoreError> {
527        let mut out: Credits = HashMap::new();
528        // Per node: the relations on (kind, id) its holders were credited with.
529        let mut seen: HashMap<Node, BTreeSet<String>> = HashMap::new();
530        let mut frontier: HashMap<Node, BTreeSet<String>> = HashMap::new();
531        for row in source.list_for_resource(kind, id).await?.iter().filter(|r| r.is_live()) {
532            let conferred: BTreeSet<String> =
533                self.closure(kind, id, row.relation()).into_iter().collect();
534            credit(row.subject(), (kind, id), &conferred, &mut out, &mut seen, &mut frontier, true);
535        }
536        let mut cache: RowCache<S::Tuple> = HashMap::new();
537        for hop in 1..=MAX_SET_HOPS {
538            let mut next = HashMap::new();
539            for ((nk, ni, nrel), conferred) in &frontier {
540                let key = (nk.clone(), ni.clone());
541                if !cache.contains_key(&key) {
542                    let rows = source.list_for_resource(nk, ni).await?;
543                    cache.insert(key.clone(), rows);
544                }
545                for row in cache[&key].iter().filter(|r| r.is_live()) {
546                    if self.confers(nk, ni, row.relation(), nrel) {
547                        let deeper = hop < MAX_SET_HOPS;
548                        let at = (nk.as_str(), ni.as_str());
549                        credit(row.subject(), at, conferred, &mut out, &mut seen, &mut next, deeper);
550                    }
551                }
552            }
553            if next.is_empty() {
554                break;
555            }
556            frontier = next;
557        }
558        let mut out: Vec<Member> = out
559            .into_iter()
560            .map(|((principal, relation), via)| Member { principal, relation, via })
561            .collect();
562        out.sort_by_cached_key(|m| (m.principal.to_string(), m.relation.clone()));
563        Ok(out)
564    }
565
566    /// The down-walk: does `principal` hold any of `roots`? Breadth-first
567    /// through set subjects, one level per hop, so each node is first reached
568    /// at its fewest hops and a single visited set is exact under the bound.
569    async fn reaches<S: TupleSource + ?Sized>(
570        &self,
571        source: &S,
572        principal: &PrincipalId,
573        roots: Vec<Node>,
574    ) -> Result<bool, StoreError> {
575        let mut visited: HashSet<Node> = roots.iter().cloned().collect();
576        let mut frontier = roots;
577        let mut cache: RowCache<S::Tuple> = HashMap::new();
578        for hop in 0..=MAX_SET_HOPS {
579            let mut next = Vec::new();
580            for (kind, id, wanted) in &frontier {
581                let key = (kind.clone(), id.clone());
582                if !cache.contains_key(&key) {
583                    let rows = source.list_for_resource(kind, id).await?;
584                    cache.insert(key.clone(), rows);
585                }
586                for row in cache[&key].iter().filter(|r| r.is_live()) {
587                    if !self.confers(kind, id, row.relation(), wanted) {
588                        continue;
589                    }
590                    match row.subject() {
591                        Subject::Principal(p) if p == principal => return Ok(true),
592                        Subject::Principal(_) => {}
593                        Subject::Set { kind: sk, id: si, relation: srel } => {
594                            let node = (sk.clone(), si.clone(), srel.clone());
595                            if hop < MAX_SET_HOPS && visited.insert(node.clone()) {
596                                next.push(node);
597                            }
598                        }
599                    }
600                }
601            }
602            if next.is_empty() {
603                break;
604            }
605            frontier = next;
606        }
607        Ok(false)
608    }
609
610    /// The relations whose holders may grant per `pred` on `(kind, id)`: those
611    /// on the resource, and the kind-level ones on `(KIND_RESOURCE, kind)`. On
612    /// a kind-level resource itself, its kind's kind relations.
613    fn grant_roots(
614        &self,
615        kind: &str,
616        id: &str,
617        pred: impl Fn(&BTreeSet<&'static str>) -> bool,
618    ) -> Vec<Node> {
619        let mut roots = Vec::new();
620        let mut add = |rk: &str, ri: &str, rels: &HashMap<&'static str, ResolvedRelation>| {
621            roots.extend(
622                rels.iter()
623                    .filter(|(_, r)| pred(&r.grants))
624                    .map(|(name, _)| (rk.to_owned(), ri.to_owned(), (*name).to_owned())),
625            );
626        };
627        if kind == KIND_RESOURCE {
628            if let Some(e) = self.kinds.get(id) {
629                add(KIND_RESOURCE, id, &e.kind_relations);
630            }
631        } else if let Some(e) = self.kinds.get(kind) {
632            add(kind, id, &e.relations);
633            add(KIND_RESOURCE, kind, &e.kind_relations);
634        }
635        roots
636    }
637
638    /// Does holding `held` on `(kind, id)` mean holding `wanted` there — the
639    /// same relation, or one its closure implies?
640    fn confers(&self, kind: &str, id: &str, held: &str, wanted: &str) -> bool {
641        held == wanted
642            || matches!(self.lookup(kind, id, held), Lookup::Resolved(r) if r.relations.contains(wanted))
643    }
644
645    /// `relation` and everything it implies on `(kind, id)`. A relation the
646    /// schema does not resolve (no schema for the kind, or undeclared on it)
647    /// implies only itself.
648    fn closure(&self, kind: &str, id: &str, relation: &str) -> Vec<String> {
649        match self.lookup(kind, id, relation) {
650            Lookup::Resolved(r) => r.relations.iter().map(|r| (*r).to_owned()).collect(),
651            _ => vec![relation.to_owned()],
652        }
653    }
654
655    /// Record that the walking principal holds `row`'s relation, closure
656    /// folded in, on its resource; queue each new holding in `fresh`.
657    fn hold<T: RelationTuple>(&self, held: &mut Holdings, fresh: &mut Vec<Node>, row: &T) {
658        let (kind, id) = (row.resource_kind(), row.resource_id());
659        for rel in self.closure(kind, id, row.relation()) {
660            if held.insert(kind, id, &rel) {
661                fresh.push((kind.to_owned(), id.to_owned(), rel));
662            }
663        }
664    }
665}
666
667/// [`SchemaRegistry::members`]' accumulator: `(principal, relation)` → `via`.
668type Credits = HashMap<(PrincipalId, String), BTreeSet<(String, String)>>;
669
670/// [`SchemaRegistry::members`]' step over one live tuple on resource `at`: a
671/// principal subject is credited with `conferred`, via `at` (where its direct
672/// tuple sits); a set subject passes on what it has not already passed on,
673/// when the walk may still go `deeper`.
674///
675/// Passing a relation through a set node once is enough for `via` too: a
676/// later arrival of the same relation at the same node would credit the same
677/// principals via the same resources.
678fn credit(
679    subject: &Subject,
680    at: (&str, &str),
681    conferred: &BTreeSet<String>,
682    out: &mut Credits,
683    seen: &mut HashMap<Node, BTreeSet<String>>,
684    next: &mut HashMap<Node, BTreeSet<String>>,
685    deeper: bool,
686) {
687    match subject {
688        Subject::Principal(p) => {
689            for r in conferred {
690                out.entry((p.clone(), r.clone()))
691                    .or_default()
692                    .insert((at.0.to_owned(), at.1.to_owned()));
693            }
694        }
695        Subject::Set { kind, id, relation } if deeper => {
696            let node = (kind.clone(), id.clone(), relation.clone());
697            let seen = seen.entry(node.clone()).or_default();
698            let fresh: Vec<String> = conferred.iter().filter(|r| seen.insert((*r).clone())).cloned().collect();
699            if !fresh.is_empty() {
700                next.entry(node).or_default().extend(fresh);
701            }
702        }
703        Subject::Set { .. } => {}
704    }
705}
706
707fn resolve_list(
708    kind: &str,
709    defs: &'static [RelationDef],
710    grant_targets: &BTreeSet<&str>,
711    scopes: &ScopeRegistry,
712) -> Result<HashMap<&'static str, ResolvedRelation>, SchemaError> {
713    let mut by_name: HashMap<&'static str, &'static RelationDef> = HashMap::new();
714    for d in defs {
715        if by_name.insert(d.name, d).is_some() {
716            return Err(SchemaError::DuplicateRelation {
717                kind: kind.to_owned(),
718                relation: d.name.to_owned(),
719            });
720        }
721    }
722    for d in defs {
723        let err = |target: &str| SchemaError::UnknownTarget {
724            kind: kind.to_owned(),
725            relation: d.name.to_owned(),
726            target: target.to_owned(),
727        };
728        if let Some(t) = d.implies.iter().find(|t| !by_name.contains_key(*t)) {
729            return Err(err(t));
730        }
731        if let Some(t) = d.grants.iter().find(|t| !grant_targets.contains(*t)) {
732            return Err(err(t));
733        }
734        if let Some(s) = d.scopes.iter().find(|s| !scopes.contains(s)) {
735            return Err(SchemaError::UnregisteredScope {
736                kind: kind.to_owned(),
737                relation: d.name.to_owned(),
738                scope: s.clone(),
739            });
740        }
741    }
742    // Acyclicity: three-colour DFS over the implies graph.
743    fn visit(
744        name: &'static str,
745        by_name: &HashMap<&'static str, &'static RelationDef>,
746        state: &mut HashMap<&'static str, bool>, // false = on stack, true = done
747    ) -> Result<(), &'static str> {
748        match state.get(name) {
749            Some(true) => return Ok(()),
750            Some(false) => return Err(name),
751            None => {}
752        }
753        state.insert(name, false);
754        for t in by_name[name].implies {
755            visit(t, by_name, state)?;
756        }
757        state.insert(name, true);
758        Ok(())
759    }
760    let mut state = HashMap::new();
761    for d in defs {
762        visit(d.name, &by_name, &mut state).map_err(|r| SchemaError::Cycle {
763            kind: kind.to_owned(),
764            relation: r.to_owned(),
765        })?;
766    }
767    let mut out = HashMap::with_capacity(defs.len());
768    for d in defs {
769        let mut res = ResolvedRelation { membership: d.membership, ..Default::default() };
770        let mut stack = vec![d];
771        let mut seen = BTreeSet::new();
772        while let Some(cur) = stack.pop() {
773            if !seen.insert(cur.name) {
774                continue;
775            }
776            res.relations.insert(cur.name);
777            res.scopes.extend(cur.scopes.iter().cloned());
778            res.grants.extend(cur.grants.iter().copied());
779            stack.extend(cur.implies.iter().map(|t| by_name[t]));
780        }
781        out.insert(d.name, res);
782    }
783    Ok(out)
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use crate::ScopeDef;
790    use pollster::block_on;
791
792    crate::scopes! {
793        // Bound at startup, like yah's.
794        ISSUE_READ = "issue:read" { description: "read" };
795        ISSUE_TRIAGE = "issue:triage" { description: "triage" };
796        ISSUE_ADMIN = "issue:admin" { description: "admin" };
797        SVC_DEPLOY = "svc:deploy" { description: "deploy" };
798    }
799
800    fn scopes() -> ScopeRegistry {
801        ScopeRegistry::builder()
802            .with(DEFS)
803            .bind_audiences("issue", ["https://issues".to_owned()])
804            .bind_audiences("svc", ["https://svc".to_owned()])
805            .build()
806            .unwrap()
807    }
808
809    const ISSUE: ResourceSchema = ResourceSchema {
810        kind: "issue",
811        relations: &[
812            RelationDef { name: "reader", membership: true, implies: &[], scopes: &[ISSUE_READ], grants: &[] },
813            RelationDef {
814                name: "triager",
815                membership: true,
816                implies: &[],
817                scopes: &[ISSUE_TRIAGE],
818                grants: &["reader"],
819            },
820            // admin implies triager, NOT reader.
821            RelationDef {
822                name: "admin",
823                membership: true,
824                implies: &["triager"],
825                scopes: &[ISSUE_ADMIN],
826                grants: &["admin"],
827            },
828        ],
829        kind_relations: &[],
830    };
831
832    const SERVICE: ResourceSchema = ResourceSchema {
833        kind: "service",
834        relations: &[RelationDef { name: "owns", membership: true, implies: &[], scopes: &[SVC_DEPLOY], grants: &[] }],
835        kind_relations: &[RelationDef {
836            name: "provisioner",
837            membership: true,
838            implies: &[],
839            scopes: &[],
840            grants: &["owns"],
841        }],
842    };
843
844    /// `team`: no scopes, only membership. `owner` implies `member`.
845    const TEAM: ResourceSchema = ResourceSchema {
846        kind: "team",
847        relations: &[
848            RelationDef { name: "owner", membership: true, implies: &["member"], scopes: &[], grants: &["member"] },
849            RelationDef { name: "member", membership: true, implies: &[], scopes: &[], grants: &[] },
850        ],
851        kind_relations: &[],
852    };
853
854    #[derive(Clone)]
855    struct Row(&'static str, &'static str, &'static str, bool, Subject);
856    fn row(kind: &'static str, id: &'static str, rel: &'static str, live: bool) -> Row {
857        Row(kind, id, rel, live, PrincipalId::user("caller").into())
858    }
859    /// `kind/id#rel` held directly by `user:<who>`.
860    fn direct(kind: &'static str, id: &'static str, rel: &'static str, who: &str) -> Row {
861        Row(kind, id, rel, true, PrincipalId::user(who).into())
862    }
863    /// `kind/id#rel ← sk/si#srel`.
864    fn via(
865        kind: &'static str,
866        id: &'static str,
867        rel: &'static str,
868        sk: &str,
869        si: &str,
870        srel: &str,
871    ) -> Row {
872        Row(kind, id, rel, true, Subject::set(sk, si, srel))
873    }
874
875    /// An in-memory [`TupleSource`]. It returns revoked rows too, so the tests
876    /// pin that the walks re-check liveness themselves.
877    struct Mem(Vec<Row>);
878
879    #[async_trait]
880    impl TupleSource for Mem {
881        type Tuple = Row;
882        async fn list_for_principal(&self, p: &PrincipalId) -> Result<Vec<Row>, StoreError> {
883            Ok(self.0.iter().filter(|r| r.4.principal() == Some(p)).cloned().collect())
884        }
885        async fn list_for_resource(&self, kind: &str, id: &str) -> Result<Vec<Row>, StoreError> {
886            Ok(self.0.iter().filter(|r| r.0 == kind && r.1 == id).cloned().collect())
887        }
888        async fn list_for_subject_set(&self, kind: &str, id: &str) -> Result<Vec<Row>, StoreError> {
889            Ok(self
890                .0
891                .iter()
892                .filter(|r| r.4.as_set().is_some_and(|(k, i, _)| k == kind && i == id))
893                .cloned()
894                .collect())
895        }
896    }
897
898    fn holds(r: &SchemaRegistry, m: &Mem, who: &str, kind: &str, id: &str, rel: &str) -> bool {
899        block_on(r.holds(m, &PrincipalId::user(who), kind, id, rel)).unwrap()
900    }
901    fn may_grant(r: &SchemaRegistry, m: &Mem, who: &str, kind: &str, id: &str, rel: &str) -> bool {
902        block_on(r.may_grant(m, &PrincipalId::user(who), kind, id, rel)).unwrap()
903    }
904    fn held_by(r: &SchemaRegistry, m: &Mem, who: &str) -> Holdings {
905        block_on(r.held_by(m, &PrincipalId::user(who))).unwrap()
906    }
907    fn members(r: &SchemaRegistry, m: &Mem, kind: &str, id: &str) -> Vec<(String, String)> {
908        block_on(r.members(m, kind, id))
909            .unwrap()
910            .into_iter()
911            .map(|m| (m.principal.to_string(), m.relation))
912            .collect()
913    }
914    /// `members` with provenance: `principal#relation` -> `via` as `kind/id`.
915    fn members_via(r: &SchemaRegistry, m: &Mem, kind: &str, id: &str) -> Vec<(String, Vec<String>)> {
916        block_on(r.members(m, kind, id))
917            .unwrap()
918            .into_iter()
919            .map(|m| {
920                let via = m.via.iter().map(|(k, i)| format!("{k}/{i}")).collect();
921                (format!("{}#{}", m.principal, m.relation), via)
922            })
923            .collect()
924    }
925    impl RelationTuple for Row {
926        fn subject(&self) -> &Subject {
927            &self.4
928        }
929        fn resource_kind(&self) -> &str {
930            self.0
931        }
932        fn resource_id(&self) -> &str {
933            self.1
934        }
935        fn relation(&self) -> &str {
936            self.2
937        }
938        fn is_live(&self) -> bool {
939            self.3
940        }
941    }
942
943    fn reg() -> SchemaRegistry {
944        SchemaRegistry::build(&[ISSUE, SERVICE, TEAM], &scopes()).unwrap()
945    }
946
947    fn resolved<'a>(r: &'a SchemaRegistry, k: &str, id: &str, rel: &str) -> &'a ResolvedRelation {
948        match r.lookup(k, id, rel) {
949            Lookup::Resolved(x) => x,
950            other => panic!("{other:?}"),
951        }
952    }
953
954    #[test]
955    fn admin_does_not_imply_read_unless_declared() {
956        let r = reg();
957        let admin = resolved(&r, "issue", "i1", "admin");
958        assert!(admin.scopes.contains(&ISSUE_ADMIN));
959        assert!(admin.scopes.contains(&ISSUE_TRIAGE));
960        assert!(!admin.scopes.contains(&ISSUE_READ));
961    }
962
963    #[test]
964    fn implied_relations_carry_their_grants() {
965        let r = reg();
966        let admin = resolved(&r, "issue", "i1", "admin");
967        // `reader` comes only from triager's grants, via admin -> triager.
968        assert!(admin.grants.contains("reader"));
969        assert!(admin.grants.contains("admin"));
970        assert_eq!(admin.relations, BTreeSet::from(["admin", "triager"]));
971        let m = Mem(vec![row("issue", "i1", "admin", true)]);
972        assert!(may_grant(&r, &m, "caller", "issue", "i1", "reader"));
973    }
974
975    #[test]
976    fn lookup_distinguishes_no_schema_and_unknown_relation() {
977        let r = reg();
978        assert_eq!(r.lookup("camp", "c", "owns"), Lookup::NoSchema);
979        assert_eq!(r.lookup("issue", "i", "bogus"), Lookup::UnknownRelation);
980        assert_eq!(r.lookup(KIND_RESOURCE, "nope", "provisioner"), Lookup::NoSchema);
981        assert!(matches!(r.lookup(KIND_RESOURCE, "service", "provisioner"), Lookup::Resolved(_)));
982    }
983
984    #[test]
985    fn may_revoke_rules() {
986        let r = reg();
987        let may_revoke = |m: &Mem, t: &Row| {
988            block_on(r.may_revoke(m, &PrincipalId::user("caller"), t)).unwrap()
989        };
990        let none = Mem(vec![]);
991        // A holder may revoke its own tuple with no grant rights at all.
992        assert!(may_revoke(&none, &direct("issue", "i1", "triager", "caller")));
993        // A non-holder without grant rights may not.
994        assert!(!may_revoke(&none, &direct("issue", "i1", "triager", "someone")));
995        // Grant rights still authorise revoking another's tuple.
996        let triager = Mem(vec![row("issue", "i1", "triager", true)]);
997        assert!(may_revoke(&triager, &direct("issue", "i1", "reader", "someone")));
998        // A set-subject tuple is not self-revocable by a member of the set.
999        let member = Mem(vec![direct("team", "t1", "member", "caller")]);
1000        assert!(!may_revoke(&member, &via("issue", "i1", "reader", "team", "t1", "member")));
1001    }
1002
1003    #[test]
1004    fn may_grant_rules() {
1005        let r = reg();
1006        let triager = Mem(vec![row("issue", "i1", "triager", true)]);
1007        assert!(may_grant(&r, &triager, "caller", "issue", "i1", "reader"));
1008        assert!(!may_grant(&r, &triager, "caller", "issue", "i1", "triager"));
1009        assert!(!may_grant(&r, &triager, "caller", "issue", "i2", "reader"), "other resource");
1010        assert!(!may_grant(&r, &triager, "someone", "issue", "i1", "reader"), "other principal");
1011        let revoked = Mem(vec![row("issue", "i1", "admin", false)]);
1012        assert!(!may_grant(&r, &revoked, "caller", "issue", "i1", "reader"), "revoked row grants nothing");
1013        assert!(!may_grant(&r, &Mem(vec![]), "caller", "issue", "i1", "reader"));
1014    }
1015
1016    #[test]
1017    fn may_grant_kind_level() {
1018        let r = reg();
1019        let prov = Mem(vec![row(KIND_RESOURCE, "service", "provisioner", true)]);
1020        assert!(may_grant(&r, &prov, "caller", "service", "svc-a", "owns"));
1021        assert!(may_grant(&r, &prov, "caller", "service", "svc-b", "owns"));
1022        assert!(!may_grant(&r, &prov, "caller", "issue", "i1", "reader"), "other kind");
1023        let dead = Mem(vec![row(KIND_RESOURCE, "service", "provisioner", false)]);
1024        assert!(!may_grant(&r, &dead, "caller", "service", "svc-a", "owns"));
1025    }
1026
1027    #[test]
1028    fn holds_a_direct_tuple_and_nothing_else() {
1029        let r = reg();
1030        let m = Mem(vec![direct("issue", "i1", "reader", "alice")]);
1031        assert!(holds(&r, &m, "alice", "issue", "i1", "reader"));
1032        assert!(!holds(&r, &m, "alice", "issue", "i1", "triager"));
1033        assert!(!holds(&r, &m, "alice", "issue", "i2", "reader"));
1034        assert!(!holds(&r, &m, "bob", "issue", "i1", "reader"));
1035        // A kind with no schema holds exactly the named relation.
1036        let m = Mem(vec![direct("node", "n1", "owns", "alice")]);
1037        assert!(holds(&r, &m, "alice", "node", "n1", "owns"));
1038        // A revoked tuple holds nothing, even when the source returns it.
1039        let m = Mem(vec![Row("issue", "i1", "reader", false, PrincipalId::user("alice").into())]);
1040        assert!(!holds(&r, &m, "alice", "issue", "i1", "reader"));
1041    }
1042
1043    #[test]
1044    fn holds_through_the_implies_closure() {
1045        let r = reg();
1046        let m = Mem(vec![direct("issue", "i1", "admin", "alice")]);
1047        assert!(holds(&r, &m, "alice", "issue", "i1", "admin"));
1048        assert!(holds(&r, &m, "alice", "issue", "i1", "triager"), "admin implies triager");
1049        assert!(!holds(&r, &m, "alice", "issue", "i1", "reader"), "admin does not imply reader");
1050    }
1051
1052    #[test]
1053    fn holds_through_a_one_hop_set() {
1054        let r = reg();
1055        let m = Mem(vec![
1056            via("issue", "i1", "reader", "team", "t1", "member"),
1057            direct("team", "t1", "member", "alice"),
1058            // owner implies member, so an owner is in the set too.
1059            direct("team", "t1", "owner", "olga"),
1060            // A different relation on the set's resource is a different set.
1061            via("issue", "i1", "admin", "team", "t1", "owner"),
1062        ]);
1063        assert!(holds(&r, &m, "alice", "issue", "i1", "reader"));
1064        assert!(!holds(&r, &m, "alice", "issue", "i1", "admin"));
1065        assert!(holds(&r, &m, "olga", "issue", "i1", "reader"));
1066        assert!(holds(&r, &m, "olga", "issue", "i1", "admin"));
1067        // The set's relation confers its closure: admin implies triager.
1068        assert!(holds(&r, &m, "olga", "issue", "i1", "triager"));
1069        assert!(!holds(&r, &m, "bob", "issue", "i1", "reader"));
1070        // A revoked set tuple confers nothing.
1071        let m = Mem(vec![
1072            Row("issue", "i1", "reader", false, Subject::set("team", "t1", "member")),
1073            direct("team", "t1", "member", "alice"),
1074        ]);
1075        assert!(!holds(&r, &m, "alice", "issue", "i1", "reader"));
1076    }
1077
1078    #[test]
1079    fn holds_through_a_multi_hop_set() {
1080        let r = reg();
1081        // issue/i1#reader <- team/eng#member <- team/org#member <- alice
1082        let m = Mem(vec![
1083            via("issue", "i1", "reader", "team", "eng", "member"),
1084            via("team", "eng", "member", "team", "org", "member"),
1085            direct("team", "org", "member", "alice"),
1086        ]);
1087        assert!(holds(&r, &m, "alice", "issue", "i1", "reader"));
1088        assert!(holds(&r, &m, "alice", "team", "eng", "member"));
1089        assert!(!holds(&r, &m, "alice", "team", "eng", "owner"));
1090    }
1091
1092    #[test]
1093    fn a_set_cycle_terminates() {
1094        let r = reg();
1095        // team/a#member ⊇ team/b#member ⊇ team/a#member
1096        let cycle = vec![
1097            via("team", "a", "member", "team", "b", "member"),
1098            via("team", "b", "member", "team", "a", "member"),
1099            via("issue", "i1", "reader", "team", "a", "member"),
1100        ];
1101        let m = Mem(cycle.clone());
1102        assert!(!holds(&r, &m, "alice", "team", "a", "member"));
1103        assert!(!holds(&r, &m, "alice", "issue", "i1", "reader"));
1104        assert!(held_by(&r, &m, "alice").is_empty());
1105        assert!(members(&r, &m, "issue", "i1").is_empty());
1106
1107        let mut rows = cycle;
1108        rows.push(direct("team", "b", "member", "alice"));
1109        let m = Mem(rows);
1110        assert!(holds(&r, &m, "alice", "team", "a", "member"));
1111        assert!(holds(&r, &m, "alice", "issue", "i1", "reader"));
1112        let held = held_by(&r, &m, "alice");
1113        assert!(held.contains("team", "a", "member"));
1114        assert!(held.contains("issue", "i1", "reader"));
1115        assert_eq!(members(&r, &m, "team", "a"), vec![("user:alice".into(), "member".into())]);
1116    }
1117
1118    /// `issue/i1#reader ← team/t1#member ← … ← team/t<hops>#member ← alice`:
1119    /// alice holds reader on i1 through exactly `hops` set hops.
1120    fn chain(hops: usize) -> Mem {
1121        const TEAMS: [&str; 6] = ["t1", "t2", "t3", "t4", "t5", "t6"];
1122        let mut rows = vec![via("issue", "i1", "reader", "team", TEAMS[0], "member")];
1123        for w in TEAMS[..hops].windows(2) {
1124            rows.push(via("team", w[0], "member", "team", w[1], "member"));
1125        }
1126        rows.push(direct("team", TEAMS[hops - 1], "member", "alice"));
1127        Mem(rows)
1128    }
1129
1130    #[test]
1131    fn the_depth_bound_holds_hop_four_and_refuses_hop_five() {
1132        assert_eq!(MAX_SET_HOPS, 4);
1133        let r = reg();
1134        for hops in 1..=MAX_SET_HOPS {
1135            let m = chain(hops);
1136            assert!(holds(&r, &m, "alice", "issue", "i1", "reader"), "{hops} hops");
1137            assert!(held_by(&r, &m, "alice").contains("issue", "i1", "reader"), "{hops} hops");
1138            assert_eq!(members(&r, &m, "issue", "i1"), vec![("user:alice".into(), "reader".into())]);
1139        }
1140        // Hop 5 is past the bound: not held, and not an error.
1141        let m = chain(MAX_SET_HOPS + 1);
1142        assert!(!holds(&r, &m, "alice", "issue", "i1", "reader"));
1143        assert!(!held_by(&r, &m, "alice").contains("issue", "i1", "reader"));
1144        assert!(held_by(&r, &m, "alice").contains("team", "t1", "member"), "t1 is 4 hops");
1145        assert!(members(&r, &m, "issue", "i1").is_empty());
1146    }
1147
1148    #[test]
1149    fn may_grant_honours_set_derived_rights() {
1150        let r = reg();
1151        let m = Mem(vec![
1152            via("issue", "i1", "admin", "team", "t1", "member"),
1153            direct("team", "t1", "member", "alice"),
1154            via(KIND_RESOURCE, "service", "provisioner", "team", "ops", "member"),
1155            direct("team", "ops", "member", "olga"),
1156        ]);
1157        // admin grants admin, and through triager, reader.
1158        assert!(may_grant(&r, &m, "alice", "issue", "i1", "reader"));
1159        assert!(may_grant(&r, &m, "alice", "issue", "i1", "admin"));
1160        assert!(!may_grant(&r, &m, "alice", "issue", "i2", "reader"));
1161        assert!(!may_grant(&r, &m, "bob", "issue", "i1", "reader"));
1162        assert!(block_on(r.may_grant_any(&m, &PrincipalId::user("alice"), "issue", "i1")).unwrap());
1163        assert!(!block_on(r.may_grant_any(&m, &PrincipalId::user("bob"), "issue", "i1")).unwrap());
1164        // A kind-level relation held through a set grants on every resource of the kind.
1165        assert!(may_grant(&r, &m, "olga", "service", "svc-a", "owns"));
1166        assert!(!may_grant(&r, &m, "alice", "service", "svc-a", "owns"));
1167    }
1168
1169    #[test]
1170    fn held_by_and_members_flatten_sets_and_fold_the_closure() {
1171        let r = reg();
1172        let m = Mem(vec![
1173            via("issue", "i1", "admin", "team", "t1", "owner"),
1174            via("issue", "i1", "reader", "team", "t1", "member"),
1175            direct("team", "t1", "owner", "olga"),
1176            direct("team", "t1", "member", "alice"),
1177            direct("issue", "i1", "reader", "bob"),
1178            direct(KIND_RESOURCE, "service", "provisioner", "olga"),
1179        ]);
1180        let held: Vec<_> = held_by(&r, &m, "olga")
1181            .iter()
1182            .map(|(k, i, rel)| format!("{k}/{i}#{rel}"))
1183            .collect();
1184        assert_eq!(
1185            held,
1186            [
1187                "issue/i1#admin",
1188                "issue/i1#reader",
1189                "issue/i1#triager",
1190                "kind/service#provisioner",
1191                "team/t1#member",
1192                "team/t1#owner",
1193            ]
1194        );
1195        let pairs = |v: &[(&str, &str)]| -> Vec<(String, String)> {
1196            v.iter().map(|(p, r)| ((*p).into(), (*r).into())).collect()
1197        };
1198        assert_eq!(
1199            members(&r, &m, "issue", "i1"),
1200            pairs(&[
1201                ("user:alice", "reader"),
1202                ("user:bob", "reader"),
1203                ("user:olga", "admin"),
1204                ("user:olga", "reader"),
1205                ("user:olga", "triager"),
1206            ])
1207        );
1208    }
1209
1210    const USERS: [&str; 3] = ["u1", "u2", "u3"];
1211    const RESOURCES: [(&str, &str); 8] = [
1212        ("team", "a"),
1213        ("team", "b"),
1214        ("team", "c"),
1215        ("team", "d"),
1216        ("team", "e"),
1217        ("issue", "i1"),
1218        ("issue", "i2"),
1219        ("node", "n1"),
1220    ];
1221    fn relations(kind: &str) -> &'static [&'static str] {
1222        match kind {
1223            "team" => &["owner", "member"],
1224            "issue" => &["reader", "triager", "admin"],
1225            _ => &["owns"],
1226        }
1227    }
1228
1229    /// `count` random small graphs over [`RESOURCES`] and [`USERS`] from a
1230    /// fixed seed: cycles, chains past the bound, a no-schema kind, and ~10%
1231    /// revoked rows.
1232    fn random_graphs(count: usize) -> Vec<Mem> {
1233        let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
1234        let mut rand = move |n: usize| -> usize {
1235            state ^= state << 13;
1236            state ^= state >> 7;
1237            state ^= state << 17;
1238            (state % n as u64) as usize
1239        };
1240        (0..count)
1241            .map(|_| {
1242                let mut rows = Vec::new();
1243                for _ in 0..(4 + rand(12)) {
1244                    let (kind, id) = RESOURCES[rand(RESOURCES.len())];
1245                    let rel = relations(kind)[rand(relations(kind).len())];
1246                    let subject = if rand(3) == 0 {
1247                        PrincipalId::user(USERS[rand(USERS.len())]).into()
1248                    } else {
1249                        let (sk, si) = RESOURCES[rand(RESOURCES.len())];
1250                        Subject::set(sk, si, relations(sk)[rand(relations(sk).len())])
1251                    };
1252                    rows.push(Row(kind, id, rel, rand(10) != 0, subject));
1253                }
1254                Mem(rows)
1255            })
1256            .collect()
1257    }
1258
1259    /// The up-walk and both down-walks must agree on every question, cycles
1260    /// and over-deep chains included.
1261    #[test]
1262    fn up_and_down_walks_agree_on_random_graphs() {
1263        let r = reg();
1264        // Holdings with no direct tuple on the resource: only sets explain them.
1265        let mut set_derived = 0;
1266        for m in random_graphs(300) {
1267            let members_of: HashMap<_, _> =
1268                RESOURCES.iter().map(|&(k, i)| ((k, i), members(&r, &m, k, i))).collect();
1269            for user in USERS {
1270                let held = held_by(&r, &m, user);
1271                for &(kind, id) in &RESOURCES {
1272                    for &rel in relations(kind) {
1273                        let down = holds(&r, &m, user, kind, id, rel);
1274                        assert_eq!(down, held.contains(kind, id, rel), "{user} {kind}/{id}#{rel}");
1275                        let listed = members_of[&(kind, id)]
1276                            .contains(&(format!("user:{user}"), rel.to_owned()));
1277                        assert_eq!(down, listed, "members {user} {kind}/{id}#{rel}");
1278                        let p = PrincipalId::user(user);
1279                        let direct = m.0.iter().any(|t| {
1280                            t.0 == kind && t.1 == id && t.3 && t.4.principal() == Some(&p)
1281                        });
1282                        if down && !direct {
1283                            set_derived += 1;
1284                        }
1285                    }
1286                }
1287            }
1288        }
1289        assert!(set_derived > 100, "the graphs barely exercise sets: {set_derived}");
1290    }
1291
1292    /// R732-F4: `via` names the resources of a member's own direct tuples the
1293    /// relation derives from — the child itself for a direct member, the
1294    /// parent for an inherited one, both when both hold.
1295    #[test]
1296    fn members_carry_the_direct_tuples_they_derive_from() {
1297        let r = reg();
1298        let m = Mem(vec![
1299            via("team", "child", "member", "team", "parent", "member"),
1300            direct("team", "parent", "member", "alice"),
1301            direct("team", "parent", "owner", "bob"),
1302            direct("team", "child", "member", "bob"),
1303            direct("team", "child", "owner", "carol"),
1304            // Revoked: confers nothing, so dave is no member.
1305            Row("team", "child", "owner", false, PrincipalId::user("dave").into()),
1306        ]);
1307        let v = |s: &[&str]| -> Vec<String> { s.iter().map(|x| (*x).to_owned()).collect() };
1308        assert_eq!(
1309            members_via(&r, &m, "team", "child"),
1310            vec![
1311                ("user:alice#member".to_owned(), v(&["team/parent"])),
1312                ("user:bob#member".to_owned(), v(&["team/child", "team/parent"])),
1313                ("user:carol#member".to_owned(), v(&["team/child"])),
1314                ("user:carol#owner".to_owned(), v(&["team/child"])),
1315            ]
1316        );
1317    }
1318
1319    /// The property a snapshot's revocation mask rests on: revoking a user's
1320    /// direct tuples on every `via` resource ends `(user, relation)`, and
1321    /// sparing any one of them keeps it — over the same random graphs.
1322    #[test]
1323    fn via_is_exactly_the_direct_tuples_whose_loss_ends_a_member() {
1324        let r = reg();
1325        let without = |m: &Mem, user: &PrincipalId, gone: &[&(String, String)]| -> Mem {
1326            Mem(m
1327                .0
1328                .iter()
1329                .cloned()
1330                .map(|mut t| {
1331                    let hit = gone.iter().any(|(k, i)| t.0 == k && t.1 == i);
1332                    if hit && t.4.principal() == Some(user) {
1333                        t.3 = false;
1334                    }
1335                    t
1336                })
1337                .collect())
1338        };
1339        let mut multi_via = 0;
1340        for m in random_graphs(300) {
1341            for &(kind, id) in &RESOURCES {
1342                for member in block_on(r.members(&m, kind, id)).unwrap() {
1343                    let (p, rel) = (&member.principal, member.relation.as_str());
1344                    assert!(!member.via.is_empty());
1345                    let all: Vec<_> = member.via.iter().collect();
1346                    let gone = without(&m, p, &all);
1347                    let still = block_on(r.holds(&gone, p, kind, id, rel)).unwrap();
1348                    assert!(!still, "{p} {kind}/{id}#{rel} survives losing every via {all:?}");
1349                    if all.len() > 1 {
1350                        multi_via += 1;
1351                    }
1352                    for spared in &all {
1353                        let rest: Vec<_> = all.iter().copied().filter(|v| v != spared).collect();
1354                        let kept = without(&m, p, &rest);
1355                        let held = block_on(r.holds(&kept, p, kind, id, rel)).unwrap();
1356                        assert!(held, "{p} {kind}/{id}#{rel} lost although {spared:?} was spared");
1357                    }
1358                }
1359            }
1360        }
1361        assert!(multi_via > 20, "the graphs barely exercise multi-via members: {multi_via}");
1362    }
1363
1364    #[test]
1365    fn subject_serializes_flat_in_column_names() {
1366        let p: Subject = PrincipalId::user("alice").into();
1367        assert_eq!(serde_json::to_value(&p).unwrap(), serde_json::json!({"principal_id": "user:alice"}));
1368        let s = Subject::set("namespace", "n1", "member");
1369        let v = serde_json::to_value(&s).unwrap();
1370        assert_eq!(
1371            v,
1372            serde_json::json!({"subject_kind": "namespace", "subject_id": "n1", "subject_relation": "member"})
1373        );
1374        assert_eq!(serde_json::from_value::<Subject>(v).unwrap(), s);
1375        assert_eq!(s.to_string(), "namespace/n1#member");
1376        assert_eq!(s.as_set(), Some(("namespace", "n1", "member")));
1377        assert_eq!(p.principal(), Some(&PrincipalId::user("alice")));
1378    }
1379
1380    #[test]
1381    fn subject_requires_exactly_one_form() {
1382        for bad in [
1383            serde_json::json!({}),
1384            serde_json::json!({"subject_kind": "namespace", "subject_id": "n1"}),
1385            serde_json::json!({
1386                "principal_id": "user:alice",
1387                "subject_kind": "namespace", "subject_id": "n1", "subject_relation": "member"
1388            }),
1389        ] {
1390            assert!(serde_json::from_value::<Subject>(bad.clone()).is_err(), "{bad}");
1391        }
1392        assert_eq!(
1393            Subject::from_parts(Some(PrincipalId::user("a")), Some("k".into()), None, None),
1394            Err(SubjectFormError)
1395        );
1396    }
1397
1398    fn leak(rels: Vec<RelationDef>) -> &'static [RelationDef] {
1399        Box::leak(rels.into_boxed_slice())
1400    }
1401
1402    fn one(rels: &'static [RelationDef]) -> Result<SchemaRegistry, SchemaError> {
1403        SchemaRegistry::build(
1404            &[ResourceSchema { kind: "k", relations: rels, kind_relations: &[] }],
1405            &scopes(),
1406        )
1407    }
1408
1409    fn rel(name: &'static str, implies: &'static [&'static str]) -> RelationDef {
1410        RelationDef { name, membership: true, implies, scopes: &[], grants: &[] }
1411    }
1412
1413    #[test]
1414    fn build_refuses_bad_schemas() {
1415        assert!(matches!(
1416            one(leak(vec![rel("a", &["b"]), rel("b", &["a"])])),
1417            Err(SchemaError::Cycle { .. })
1418        ));
1419        assert!(matches!(one(leak(vec![rel("a", &["a"])])), Err(SchemaError::Cycle { .. })));
1420        assert!(matches!(
1421            one(leak(vec![rel("a", &["zz"])])),
1422            Err(SchemaError::UnknownTarget { .. })
1423        ));
1424        assert!(matches!(
1425            one(leak(vec![RelationDef { name: "a", membership: true, implies: &[], scopes: &[], grants: &["zz"] }])),
1426            Err(SchemaError::UnknownTarget { .. })
1427        ));
1428        assert!(matches!(
1429            one(leak(vec![rel("a", &[]), rel("a", &[])])),
1430            Err(SchemaError::DuplicateRelation { .. })
1431        ));
1432        static UNREG: [Scope; 1] = [Scope::from_static("other:thing")];
1433        assert!(matches!(
1434            one(leak(vec![RelationDef { name: "a", membership: true, implies: &[], scopes: &UNREG, grants: &[] }])),
1435            Err(SchemaError::UnregisteredScope { .. })
1436        ));
1437        assert!(matches!(
1438            SchemaRegistry::build(&[ISSUE, ISSUE], &scopes()),
1439            Err(SchemaError::DuplicateKind(_))
1440        ));
1441        assert!(matches!(
1442            SchemaRegistry::build(
1443                &[ResourceSchema { kind: KIND_RESOURCE, relations: &[], kind_relations: &[] }],
1444                &scopes()
1445            ),
1446            Err(SchemaError::ReservedKind(_))
1447        ));
1448        // diamond is fine (not a cycle)
1449        assert!(one(leak(vec![
1450            rel("a", &["b", "c"]),
1451            rel("b", &["d"]),
1452            rel("c", &["d"]),
1453            rel("d", &[])
1454        ]))
1455        .is_ok());
1456        let _: &[ScopeDef] = DEFS;
1457    }
1458}