1use 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
48pub const KIND_RESOURCE: &str = "kind";
50
51pub const MAX_SET_HOPS: usize = 4;
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct RelationDef {
61 pub name: &'static str,
62 pub membership: bool,
68 pub implies: &'static [&'static str],
70 pub scopes: &'static [Scope],
72 pub grants: &'static [&'static str],
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ResourceSchema {
80 pub kind: &'static str,
81 pub relations: &'static [RelationDef],
82 pub kind_relations: &'static [RelationDef],
84}
85
86#[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#[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#[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 pub fn principal(&self) -> Option<&PrincipalId> {
139 match self {
140 Self::Principal(p) => Some(p),
141 Self::Set { .. } => None,
142 }
143 }
144
145 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 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 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#[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
225pub trait RelationTuple {
228 fn subject(&self) -> &Subject;
230 fn resource_kind(&self) -> &str;
231 fn resource_id(&self) -> &str;
232 fn relation(&self) -> &str;
233 fn is_live(&self) -> bool;
235}
236
237#[async_trait]
243pub trait TupleSource: Send + Sync {
244 type Tuple: RelationTuple + Send + Sync;
245
246 async fn list_for_principal(
248 &self,
249 principal: &PrincipalId,
250 ) -> Result<Vec<Self::Tuple>, StoreError>;
251
252 async fn list_for_resource(&self, kind: &str, id: &str)
254 -> Result<Vec<Self::Tuple>, StoreError>;
255
256 async fn list_for_subject_set(
258 &self,
259 kind: &str,
260 id: &str,
261 ) -> Result<Vec<Self::Tuple>, StoreError>;
262}
263
264#[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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct Member {
297 pub principal: PrincipalId,
298 pub relation: String,
299 pub via: BTreeSet<(String, String)>,
305}
306
307type Node = (String, String, String);
310
311type RowCache<T> = HashMap<(String, String), Vec<T>>;
314
315#[derive(Debug, Clone, Default, PartialEq, Eq)]
317pub struct ResolvedRelation {
318 pub relations: BTreeSet<&'static str>,
321 pub scopes: BTreeSet<Scope>,
323 pub grants: BTreeSet<&'static str>,
325 pub membership: bool,
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum Lookup<'a> {
333 NoSchema,
336 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
667type Credits = HashMap<(PrincipalId, String), BTreeSet<(String, String)>>;
669
670fn 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 fn visit(
744 name: &'static str,
745 by_name: &HashMap<&'static str, &'static RelationDef>,
746 state: &mut HashMap<&'static str, bool>, ) -> 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 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 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 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 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 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 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 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 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 assert!(may_revoke(&none, &direct("issue", "i1", "triager", "caller")));
993 assert!(!may_revoke(&none, &direct("issue", "i1", "triager", "someone")));
995 let triager = Mem(vec![row("issue", "i1", "triager", true)]);
997 assert!(may_revoke(&triager, &direct("issue", "i1", "reader", "someone")));
998 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 let m = Mem(vec![direct("node", "n1", "owns", "alice")]);
1037 assert!(holds(&r, &m, "alice", "node", "n1", "owns"));
1038 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 direct("team", "t1", "owner", "olga"),
1060 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 assert!(holds(&r, &m, "olga", "issue", "i1", "triager"));
1069 assert!(!holds(&r, &m, "bob", "issue", "i1", "reader"));
1070 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 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 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 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 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 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 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 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 #[test]
1262 fn up_and_down_walks_agree_on_random_graphs() {
1263 let r = reg();
1264 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 #[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 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 #[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 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}