use std::collections::BTreeSet;
use std::sync::Arc;
use async_trait::async_trait;
use cheers_core::{Lookup, PrincipalId, SchemaRegistry, ScopeRegistry, StoreError};
use crate::bundles::ScopeOrBundle;
use crate::ownership::{OwnershipStore, OwnershipTuples};
#[async_trait]
pub trait GrantStore: Send + Sync {
async fn list_for(
&self,
principal: &PrincipalId,
aud: &str,
) -> Result<Vec<ScopeOrBundle>, StoreError>;
}
pub struct SchemaGrantStore<O> {
ownership: O,
schema: Arc<SchemaRegistry>,
scopes: Arc<ScopeRegistry>,
}
impl<O: OwnershipStore> SchemaGrantStore<O> {
pub fn new(ownership: O, schema: Arc<SchemaRegistry>, scopes: Arc<ScopeRegistry>) -> Self {
Self { ownership, schema, scopes }
}
}
#[async_trait]
impl<O: OwnershipStore> GrantStore for SchemaGrantStore<O> {
async fn list_for(
&self,
principal: &PrincipalId,
aud: &str,
) -> Result<Vec<ScopeOrBundle>, StoreError> {
let held = self.schema.held_by(&OwnershipTuples::at(&self.ownership, crate::revocation::now_unix()), principal).await?;
let mut out = BTreeSet::new();
for (kind, id, relation) in held.iter() {
match self.schema.lookup(kind, id, relation) {
Lookup::NoSchema => {}
Lookup::UnknownRelation => tracing::warn!(
%principal,
kind,
id,
relation,
"ownership tuple names a relation the schema does not declare; skipped"
),
Lookup::Resolved(res) => out.extend(
res.scopes.iter().filter(|s| self.scopes.is_valid_at(s, aud)).cloned(),
),
}
}
Ok(out.into_iter().map(ScopeOrBundle::Scope).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ownership::{MemoryOwnershipStore, NewOwnership};
use cheers_core::{
scopes, RelationDef, ResourceSchema, Scope, Subject, KIND_RESOURCE,
};
use pollster::block_on;
scopes! {
DOC_READ = "doc:read" { description: "read", audiences: ["https://a", "https://b"] };
DOC_EDIT = "doc:edit" { description: "edit", audiences: ["https://a"] };
}
const DOC: ResourceSchema = ResourceSchema {
kind: "doc",
relations: &[
RelationDef { name: "reader", membership: true, implies: &[], scopes: &[DOC_READ], grants: &[] },
RelationDef { name: "editor", membership: true, implies: &["reader"], scopes: &[DOC_EDIT], grants: &[] },
RelationDef { name: "admin", membership: true, implies: &[], scopes: &[], grants: &["reader"] },
],
kind_relations: &[RelationDef {
name: "auditor",
membership: true,
implies: &[],
scopes: &[DOC_READ],
grants: &[],
}],
};
fn store() -> SchemaGrantStore<MemoryOwnershipStore> {
let scopes = Arc::new(ScopeRegistry::builder().with(DEFS).build().unwrap());
let schema = Arc::new(SchemaRegistry::build(&[DOC], &scopes).unwrap());
SchemaGrantStore::new(MemoryOwnershipStore::new(), schema, scopes)
}
fn seed(s: &SchemaGrantStore<MemoryOwnershipStore>, p: &PrincipalId, kind: &str, id: &str, rel: &str) -> String {
seed_subject(s, p.clone().into(), kind, id, rel)
}
fn seed_subject(s: &SchemaGrantStore<MemoryOwnershipStore>, who: Subject, kind: &str, id: &str, rel: &str) -> String {
let n = NewOwnership::new(who, kind, id, rel, PrincipalId::service("seed"), None).unwrap();
block_on(s.ownership.insert(&n, 1)).unwrap().row.id
}
fn scopes_of(s: &SchemaGrantStore<MemoryOwnershipStore>, p: &PrincipalId, aud: &str) -> Vec<Scope> {
block_on(s.list_for(p, aud))
.unwrap()
.into_iter()
.map(|e| match e {
ScopeOrBundle::Scope(s) => s,
other => panic!("schema store yields scopes only: {other:?}"),
})
.collect()
}
#[test]
fn no_rows_yields_empty_list() {
let s = store();
assert!(scopes_of(&s, &PrincipalId::user("u1"), "https://a").is_empty());
}
#[test]
fn closure_unlocks_implied_scopes_and_aud_filters() {
let s = store();
let u = PrincipalId::user("alice");
seed(&s, &u, "doc", "d1", "editor");
assert_eq!(scopes_of(&s, &u, "https://a"), vec![DOC_EDIT, DOC_READ]);
assert_eq!(scopes_of(&s, &u, "https://b"), vec![DOC_READ], "doc:edit is aud-bound");
}
#[test]
fn relation_without_scopes_confers_none() {
let s = store();
let u = PrincipalId::user("alice");
seed(&s, &u, "doc", "d1", "admin");
assert!(scopes_of(&s, &u, "https://a").is_empty(), "admin grants reader but does not imply it");
}
#[test]
fn unknown_kind_and_relation_are_ignored_and_revoked_rows_confer_nothing() {
let s = store();
let u = PrincipalId::user("alice");
seed(&s, &u, "service", "svc", "owns");
seed(&s, &u, "doc", "d1", "bogus");
let id = seed(&s, &u, "doc", "d1", "reader");
assert_eq!(scopes_of(&s, &u, "https://a"), vec![DOC_READ]);
block_on(s.ownership.revoke_by_id(&id, 2)).unwrap();
assert!(scopes_of(&s, &u, "https://a").is_empty());
}
#[test]
fn kind_level_tuple_unlocks_its_scopes() {
let s = store();
let u = PrincipalId::service("auditor");
seed(&s, &u, KIND_RESOURCE, "doc", "auditor");
assert_eq!(scopes_of(&s, &u, "https://a"), vec![DOC_READ]);
}
#[test]
fn set_tuples_confer_derived_grants_at_mint() {
let s = store();
let alice = PrincipalId::user("alice");
let set_row = seed_subject(&s, Subject::set("team", "t1", "member"), "doc", "d1", "editor");
seed_subject(&s, Subject::set("team", "org", "member"), "team", "t1", "member");
assert!(scopes_of(&s, &alice, "https://a").is_empty(), "not a member yet");
seed(&s, &alice, "team", "org", "member");
assert_eq!(scopes_of(&s, &alice, "https://a"), vec![DOC_EDIT, DOC_READ]);
assert_eq!(scopes_of(&s, &alice, "https://b"), vec![DOC_READ]);
assert!(scopes_of(&s, &PrincipalId::user("bob"), "https://a").is_empty());
block_on(s.ownership.revoke_by_id(&set_row, 2)).unwrap();
assert!(scopes_of(&s, &alice, "https://a").is_empty());
}
}