1use std::collections::BTreeSet;
27use std::sync::Arc;
28
29use async_trait::async_trait;
30use cheers_core::{Lookup, PrincipalId, SchemaRegistry, ScopeRegistry, StoreError};
31
32use crate::bundles::ScopeOrBundle;
33use crate::ownership::{OwnershipStore, OwnershipTuples};
34
35#[async_trait]
41pub trait GrantStore: Send + Sync {
42 async fn list_for(
47 &self,
48 principal: &PrincipalId,
49 aud: &str,
50 ) -> Result<Vec<ScopeOrBundle>, StoreError>;
51}
52
53pub struct SchemaGrantStore<O> {
62 ownership: O,
63 schema: Arc<SchemaRegistry>,
64 scopes: Arc<ScopeRegistry>,
65}
66
67impl<O: OwnershipStore> SchemaGrantStore<O> {
68 pub fn new(ownership: O, schema: Arc<SchemaRegistry>, scopes: Arc<ScopeRegistry>) -> Self {
69 Self { ownership, schema, scopes }
70 }
71}
72
73#[async_trait]
74impl<O: OwnershipStore> GrantStore for SchemaGrantStore<O> {
75 async fn list_for(
76 &self,
77 principal: &PrincipalId,
78 aud: &str,
79 ) -> Result<Vec<ScopeOrBundle>, StoreError> {
80 let held = self.schema.held_by(&OwnershipTuples::at(&self.ownership, crate::revocation::now_unix()), principal).await?;
81 let mut out = BTreeSet::new();
82 for (kind, id, relation) in held.iter() {
83 match self.schema.lookup(kind, id, relation) {
84 Lookup::NoSchema => {}
85 Lookup::UnknownRelation => tracing::warn!(
86 %principal,
87 kind,
88 id,
89 relation,
90 "ownership tuple names a relation the schema does not declare; skipped"
91 ),
92 Lookup::Resolved(res) => out.extend(
93 res.scopes.iter().filter(|s| self.scopes.is_valid_at(s, aud)).cloned(),
94 ),
95 }
96 }
97 Ok(out.into_iter().map(ScopeOrBundle::Scope).collect())
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use crate::ownership::{MemoryOwnershipStore, NewOwnership};
105 use cheers_core::{
106 scopes, RelationDef, ResourceSchema, Scope, Subject, KIND_RESOURCE,
107 };
108 use pollster::block_on;
109
110 scopes! {
111 DOC_READ = "doc:read" { description: "read", audiences: ["https://a", "https://b"] };
112 DOC_EDIT = "doc:edit" { description: "edit", audiences: ["https://a"] };
113 }
114
115 const DOC: ResourceSchema = ResourceSchema {
116 kind: "doc",
117 relations: &[
118 RelationDef { name: "reader", membership: true, implies: &[], scopes: &[DOC_READ], grants: &[] },
119 RelationDef { name: "editor", membership: true, implies: &["reader"], scopes: &[DOC_EDIT], grants: &[] },
120 RelationDef { name: "admin", membership: true, implies: &[], scopes: &[], grants: &["reader"] },
121 ],
122 kind_relations: &[RelationDef {
123 name: "auditor",
124 membership: true,
125 implies: &[],
126 scopes: &[DOC_READ],
127 grants: &[],
128 }],
129 };
130
131 fn store() -> SchemaGrantStore<MemoryOwnershipStore> {
132 let scopes = Arc::new(ScopeRegistry::builder().with(DEFS).build().unwrap());
133 let schema = Arc::new(SchemaRegistry::build(&[DOC], &scopes).unwrap());
134 SchemaGrantStore::new(MemoryOwnershipStore::new(), schema, scopes)
135 }
136
137 fn seed(s: &SchemaGrantStore<MemoryOwnershipStore>, p: &PrincipalId, kind: &str, id: &str, rel: &str) -> String {
138 seed_subject(s, p.clone().into(), kind, id, rel)
139 }
140
141 fn seed_subject(s: &SchemaGrantStore<MemoryOwnershipStore>, who: Subject, kind: &str, id: &str, rel: &str) -> String {
142 let n = NewOwnership::new(who, kind, id, rel, PrincipalId::service("seed"), None).unwrap();
143 block_on(s.ownership.insert(&n, 1)).unwrap().row.id
144 }
145
146 fn scopes_of(s: &SchemaGrantStore<MemoryOwnershipStore>, p: &PrincipalId, aud: &str) -> Vec<Scope> {
147 block_on(s.list_for(p, aud))
148 .unwrap()
149 .into_iter()
150 .map(|e| match e {
151 ScopeOrBundle::Scope(s) => s,
152 other => panic!("schema store yields scopes only: {other:?}"),
153 })
154 .collect()
155 }
156
157 #[test]
158 fn no_rows_yields_empty_list() {
159 let s = store();
160 assert!(scopes_of(&s, &PrincipalId::user("u1"), "https://a").is_empty());
161 }
162
163 #[test]
164 fn closure_unlocks_implied_scopes_and_aud_filters() {
165 let s = store();
166 let u = PrincipalId::user("alice");
167 seed(&s, &u, "doc", "d1", "editor");
168 assert_eq!(scopes_of(&s, &u, "https://a"), vec![DOC_EDIT, DOC_READ]);
169 assert_eq!(scopes_of(&s, &u, "https://b"), vec![DOC_READ], "doc:edit is aud-bound");
170 }
171
172 #[test]
173 fn relation_without_scopes_confers_none() {
174 let s = store();
175 let u = PrincipalId::user("alice");
176 seed(&s, &u, "doc", "d1", "admin");
177 assert!(scopes_of(&s, &u, "https://a").is_empty(), "admin grants reader but does not imply it");
178 }
179
180 #[test]
181 fn unknown_kind_and_relation_are_ignored_and_revoked_rows_confer_nothing() {
182 let s = store();
183 let u = PrincipalId::user("alice");
184 seed(&s, &u, "service", "svc", "owns");
185 seed(&s, &u, "doc", "d1", "bogus");
186 let id = seed(&s, &u, "doc", "d1", "reader");
187 assert_eq!(scopes_of(&s, &u, "https://a"), vec![DOC_READ]);
188 block_on(s.ownership.revoke_by_id(&id, 2)).unwrap();
189 assert!(scopes_of(&s, &u, "https://a").is_empty());
190 }
191
192 #[test]
193 fn kind_level_tuple_unlocks_its_scopes() {
194 let s = store();
195 let u = PrincipalId::service("auditor");
196 seed(&s, &u, KIND_RESOURCE, "doc", "auditor");
197 assert_eq!(scopes_of(&s, &u, "https://a"), vec![DOC_READ]);
198 }
199
200 #[test]
201 fn set_tuples_confer_derived_grants_at_mint() {
202 let s = store();
203 let alice = PrincipalId::user("alice");
204 let set_row = seed_subject(&s, Subject::set("team", "t1", "member"), "doc", "d1", "editor");
206 seed_subject(&s, Subject::set("team", "org", "member"), "team", "t1", "member");
207 assert!(scopes_of(&s, &alice, "https://a").is_empty(), "not a member yet");
208 seed(&s, &alice, "team", "org", "member");
209 assert_eq!(scopes_of(&s, &alice, "https://a"), vec![DOC_EDIT, DOC_READ]);
210 assert_eq!(scopes_of(&s, &alice, "https://b"), vec![DOC_READ]);
211 assert!(scopes_of(&s, &PrincipalId::user("bob"), "https://a").is_empty());
212 block_on(s.ownership.revoke_by_id(&set_row, 2)).unwrap();
214 assert!(scopes_of(&s, &alice, "https://a").is_empty());
215 }
216}