use crate::auth::types::{Permission, User};
use crate::error::{FusekiError, FusekiResult};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
#[serde(rename_all = "lowercase")]
pub enum GraphPermission {
Read,
Write,
Admin,
}
impl GraphPermission {
pub fn implies(self, other: GraphPermission) -> bool {
match self {
GraphPermission::Admin => true,
GraphPermission::Write => {
matches!(other, GraphPermission::Read | GraphPermission::Write)
}
GraphPermission::Read => matches!(other, GraphPermission::Read),
}
}
pub fn as_str(self) -> &'static str {
match self {
GraphPermission::Read => "read",
GraphPermission::Write => "write",
GraphPermission::Admin => "admin",
}
}
}
impl std::fmt::Display for GraphPermission {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Principal {
User { name: String },
Role { name: String },
}
impl Principal {
pub fn user(name: impl Into<String>) -> Self {
Principal::User { name: name.into() }
}
pub fn role(name: impl Into<String>) -> Self {
Principal::Role { name: name.into() }
}
pub fn matches_user(&self, user: &User) -> bool {
match self {
Principal::User { name } => &user.username == name,
Principal::Role { name } => user.roles.contains(name),
}
}
}
impl std::fmt::Display for Principal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Principal::User { name } => write!(f, "user:{name}"),
Principal::Role { name } => write!(f, "role:{name}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphAclEntry {
pub graph_iri: String,
pub principal: Principal,
pub permission: GraphPermission,
pub granted_at: DateTime<Utc>,
pub granted_by: Option<String>,
}
impl GraphAclEntry {
fn new(
graph_iri: impl Into<String>,
principal: Principal,
permission: GraphPermission,
granted_by: Option<String>,
) -> Self {
Self {
graph_iri: graph_iri.into(),
principal,
permission,
granted_at: Utc::now(),
granted_by,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GrantGraphAccess {
pub graph_iri: String,
pub principal: Principal,
pub permission: GraphPermission,
pub granted_by: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevokeGraphAccess {
pub graph_iri: String,
pub principal: Principal,
pub permission: Option<GraphPermission>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AclDecision {
Allow { reason: AllowReason },
Deny { reason: DenyReason },
}
impl AclDecision {
pub fn is_allow(&self) -> bool {
matches!(self, AclDecision::Allow { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AllowReason {
DatasetAdmin,
ExplicitAcl,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DenyReason {
DatasetRbacDenied,
NoAclEntry,
}
#[derive(Debug, Default, Clone)]
struct GraphAcl {
entries: HashMap<Principal, HashSet<GraphPermission>>,
}
impl GraphAcl {
fn grant(&mut self, principal: Principal, permission: GraphPermission) {
self.entries
.entry(principal)
.or_insert_with(HashSet::new)
.insert(permission);
}
fn revoke(&mut self, principal: &Principal, permission: Option<GraphPermission>) -> bool {
match self.entries.get_mut(principal) {
None => false,
Some(perms) => {
if let Some(p) = permission {
let removed = perms.remove(&p);
if perms.is_empty() {
self.entries.remove(principal);
}
removed
} else {
self.entries.remove(principal).is_some()
}
}
}
}
fn check(&self, principal: &Principal, permission: GraphPermission) -> bool {
match self.entries.get(principal) {
None => false,
Some(perms) => perms.iter().any(|p| p.implies(permission)),
}
}
fn check_user(&self, user: &User, permission: GraphPermission) -> bool {
self.entries.iter().any(|(principal, perms)| {
principal.matches_user(user) && perms.iter().any(|p| p.implies(permission))
})
}
fn list(&self) -> Vec<(Principal, GraphPermission)> {
let mut out = Vec::new();
for (principal, perms) in &self.entries {
for perm in perms {
out.push((principal.clone(), *perm));
}
}
out.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.to_string().cmp(&b.0.to_string())));
out
}
}
#[derive(Debug, Clone)]
pub struct GraphAclStore {
inner: Arc<RwLock<HashMap<String, GraphAcl>>>,
}
impl GraphAclStore {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn grant(&self, req: &GrantGraphAccess) -> FusekiResult<GraphAclEntry> {
let mut store = self.inner.write().await;
let acl = store
.entry(req.graph_iri.clone())
.or_insert_with(GraphAcl::default);
acl.grant(req.principal.clone(), req.permission);
let entry = GraphAclEntry::new(
&req.graph_iri,
req.principal.clone(),
req.permission,
req.granted_by.clone(),
);
info!(
"GraphACL grant: {} {} on <{}>",
req.principal, req.permission, req.graph_iri
);
Ok(entry)
}
pub async fn revoke(&self, req: &RevokeGraphAccess) -> FusekiResult<bool> {
let mut store = self.inner.write().await;
match store.get_mut(&req.graph_iri) {
None => {
debug!("GraphACL revoke: no ACL for <{}> (noop)", req.graph_iri);
Ok(false)
}
Some(acl) => {
let removed = acl.revoke(&req.principal, req.permission);
if removed {
info!(
"GraphACL revoke: {} {:?} on <{}>",
req.principal, req.permission, req.graph_iri
);
if acl.entries.is_empty() {
store.remove(&req.graph_iri);
}
}
Ok(removed)
}
}
}
pub async fn check(
&self,
graph_iri: &str,
principal: &Principal,
permission: GraphPermission,
) -> bool {
let store = self.inner.read().await;
match store.get(graph_iri) {
None => false,
Some(acl) => acl.check(principal, permission),
}
}
pub async fn check_user(
&self,
graph_iri: &str,
user: &User,
permission: GraphPermission,
) -> bool {
let store = self.inner.read().await;
match store.get(graph_iri) {
None => false,
Some(acl) => acl.check_user(user, permission),
}
}
pub async fn list_graph(&self, graph_iri: &str) -> Vec<GraphAclEntry> {
let store = self.inner.read().await;
match store.get(graph_iri) {
None => vec![],
Some(acl) => acl
.list()
.into_iter()
.map(|(principal, permission)| {
GraphAclEntry::new(graph_iri, principal, permission, None)
})
.collect(),
}
}
pub async fn list_all(&self) -> Vec<GraphAclEntry> {
let store = self.inner.read().await;
let mut out = Vec::new();
for (graph_iri, acl) in store.iter() {
for (principal, permission) in acl.list() {
out.push(GraphAclEntry::new(graph_iri, principal, permission, None));
}
}
out.sort_by(|a, b| {
a.graph_iri
.cmp(&b.graph_iri)
.then(a.permission.cmp(&b.permission))
});
out
}
pub async fn graphs_for_principal(
&self,
principal: &Principal,
) -> Vec<(String, GraphPermission)> {
let store = self.inner.read().await;
let mut out = Vec::new();
for (graph_iri, acl) in store.iter() {
if let Some(perms) = acl.entries.get(principal) {
for perm in perms {
out.push((graph_iri.clone(), *perm));
}
}
}
out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
out
}
pub async fn purge_graph(&self, graph_iri: &str) -> FusekiResult<usize> {
let mut store = self.inner.write().await;
match store.remove(graph_iri) {
None => Ok(0),
Some(acl) => {
let count = acl.entries.values().map(|s| s.len()).sum();
info!(
"GraphACL purge: removed {} entries for <{}>",
count, graph_iri
);
Ok(count)
}
}
}
pub async fn entry_count(&self) -> usize {
let store = self.inner.read().await;
store
.values()
.map(|acl| acl.entries.values().map(|s| s.len()).sum::<usize>())
.sum()
}
}
impl Default for GraphAclStore {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone)]
pub struct GraphAclPolicy {
store: GraphAclStore,
}
impl GraphAclPolicy {
pub fn new(store: GraphAclStore) -> Self {
Self { store }
}
pub async fn check(
&self,
user: &User,
dataset_id: &str,
graph_iri: &str,
permission: GraphPermission,
) -> AclDecision {
let is_global_admin = user.permissions.contains(&Permission::GlobalAdmin);
let is_dataset_admin = user
.permissions
.contains(&Permission::DatasetAdmin(dataset_id.to_string()));
if is_global_admin || is_dataset_admin {
debug!(
"GraphAclPolicy: admin bypass for {} on <{}>",
user.username, graph_iri
);
return AclDecision::Allow {
reason: AllowReason::DatasetAdmin,
};
}
let dataset_ok = self.dataset_rbac_allows(user, dataset_id, permission);
if !dataset_ok {
debug!(
"GraphAclPolicy: dataset RBAC denied {} {:?} in dataset '{}'",
user.username, permission, dataset_id
);
return AclDecision::Deny {
reason: DenyReason::DatasetRbacDenied,
};
}
let acl_allows = self.store.check_user(graph_iri, user, permission).await;
if acl_allows {
debug!(
"GraphAclPolicy: ACL allowed {} {:?} on <{}>",
user.username, permission, graph_iri
);
AclDecision::Allow {
reason: AllowReason::ExplicitAcl,
}
} else {
debug!(
"GraphAclPolicy: no ACL entry for {} {:?} on <{}>",
user.username, permission, graph_iri
);
AclDecision::Deny {
reason: DenyReason::NoAclEntry,
}
}
}
fn dataset_rbac_allows(
&self,
user: &User,
dataset_id: &str,
permission: GraphPermission,
) -> bool {
user.permissions.iter().any(|p| match permission {
GraphPermission::Read => {
matches!(
p,
Permission::GlobalRead
| Permission::GlobalWrite
| Permission::GlobalAdmin
| Permission::SparqlQuery
) || matches!(p, Permission::DatasetRead(id) if id == dataset_id)
|| matches!(p, Permission::DatasetWrite(id) if id == dataset_id)
|| matches!(p, Permission::DatasetAdmin(id) if id == dataset_id)
}
GraphPermission::Write => {
matches!(
p,
Permission::GlobalWrite | Permission::GlobalAdmin | Permission::SparqlUpdate
) || matches!(p, Permission::DatasetWrite(id) if id == dataset_id)
|| matches!(p, Permission::DatasetAdmin(id) if id == dataset_id)
}
GraphPermission::Admin => {
matches!(p, Permission::GlobalAdmin)
|| matches!(p, Permission::DatasetAdmin(id) if id == dataset_id)
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn make_user(username: &str, roles: &[&str], permissions: &[Permission]) -> User {
User {
username: username.to_string(),
roles: roles.iter().map(|s| s.to_string()).collect(),
email: None,
full_name: None,
last_login: Some(Utc::now()),
permissions: permissions.to_vec(),
}
}
fn grant_req(
graph_iri: &str,
principal: Principal,
permission: GraphPermission,
) -> GrantGraphAccess {
GrantGraphAccess {
graph_iri: graph_iri.to_string(),
principal,
permission,
granted_by: Some("admin".to_string()),
}
}
fn revoke_req(
graph_iri: &str,
principal: Principal,
permission: Option<GraphPermission>,
) -> RevokeGraphAccess {
RevokeGraphAccess {
graph_iri: graph_iri.to_string(),
principal,
permission,
}
}
const G1: &str = "http://example.org/graph/g1";
const G2: &str = "http://example.org/graph/g2";
const G3: &str = "http://example.org/graph/g3";
#[tokio::test]
async fn test_grant_read_allows_read() {
let store = GraphAclStore::new();
let alice = Principal::user("alice");
store
.grant(&grant_req(G1, alice.clone(), GraphPermission::Read))
.await
.unwrap();
assert!(store.check(G1, &alice, GraphPermission::Read).await);
}
#[tokio::test]
async fn test_grant_read_denies_write() {
let store = GraphAclStore::new();
let alice = Principal::user("alice");
store
.grant(&grant_req(G1, alice.clone(), GraphPermission::Read))
.await
.unwrap();
assert!(!store.check(G1, &alice, GraphPermission::Write).await);
}
#[tokio::test]
async fn test_grant_write_implies_read() {
let store = GraphAclStore::new();
let bob = Principal::user("bob");
store
.grant(&grant_req(G1, bob.clone(), GraphPermission::Write))
.await
.unwrap();
assert!(store.check(G1, &bob, GraphPermission::Read).await);
assert!(store.check(G1, &bob, GraphPermission::Write).await);
assert!(!store.check(G1, &bob, GraphPermission::Admin).await);
}
#[tokio::test]
async fn test_grant_admin_implies_all() {
let store = GraphAclStore::new();
let carol = Principal::user("carol");
store
.grant(&grant_req(G1, carol.clone(), GraphPermission::Admin))
.await
.unwrap();
assert!(store.check(G1, &carol, GraphPermission::Read).await);
assert!(store.check(G1, &carol, GraphPermission::Write).await);
assert!(store.check(G1, &carol, GraphPermission::Admin).await);
}
#[tokio::test]
async fn test_revoke_permission() {
let store = GraphAclStore::new();
let alice = Principal::user("alice");
store
.grant(&grant_req(G1, alice.clone(), GraphPermission::Write))
.await
.unwrap();
let removed = store
.revoke(&revoke_req(G1, alice.clone(), Some(GraphPermission::Write)))
.await
.unwrap();
assert!(removed);
assert!(!store.check(G1, &alice, GraphPermission::Write).await);
}
#[tokio::test]
async fn test_revoke_all_permissions() {
let store = GraphAclStore::new();
let alice = Principal::user("alice");
store
.grant(&grant_req(G1, alice.clone(), GraphPermission::Read))
.await
.unwrap();
store
.grant(&grant_req(G1, alice.clone(), GraphPermission::Write))
.await
.unwrap();
let removed = store
.revoke(&revoke_req(G1, alice.clone(), None))
.await
.unwrap();
assert!(removed);
assert!(!store.check(G1, &alice, GraphPermission::Read).await);
assert!(!store.check(G1, &alice, GraphPermission::Write).await);
assert_eq!(store.entry_count().await, 0);
}
#[tokio::test]
async fn test_revoke_missing_entry_is_ok() {
let store = GraphAclStore::new();
let alice = Principal::user("alice");
let result = store
.revoke(&revoke_req(G1, alice.clone(), Some(GraphPermission::Read)))
.await;
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[tokio::test]
async fn test_no_entry_denies_access() {
let store = GraphAclStore::new();
let alice = Principal::user("alice");
assert!(!store.check(G1, &alice, GraphPermission::Read).await);
}
#[tokio::test]
async fn test_multiple_principals_multiple_graphs() {
let store = GraphAclStore::new();
let alice = Principal::user("alice");
let bob = Principal::user("bob");
store
.grant(&grant_req(G1, alice.clone(), GraphPermission::Read))
.await
.unwrap();
store
.grant(&grant_req(G2, bob.clone(), GraphPermission::Write))
.await
.unwrap();
assert!(store.check(G1, &alice, GraphPermission::Read).await);
assert!(!store.check(G1, &bob, GraphPermission::Read).await);
assert!(store.check(G2, &bob, GraphPermission::Read).await);
assert!(!store.check(G2, &alice, GraphPermission::Write).await);
}
#[tokio::test]
async fn test_role_principal_matches_user_with_role() {
let store = GraphAclStore::new();
let editor_role = Principal::role("editor");
store
.grant(&grant_req(G1, editor_role, GraphPermission::Write))
.await
.unwrap();
let alice = make_user("alice", &["editor"], &[Permission::GlobalRead]);
assert!(store.check_user(G1, &alice, GraphPermission::Write).await);
}
#[tokio::test]
async fn test_role_principal_no_match_without_role() {
let store = GraphAclStore::new();
let editor_role = Principal::role("editor");
store
.grant(&grant_req(G1, editor_role, GraphPermission::Write))
.await
.unwrap();
let bob = make_user("bob", &["reader"], &[Permission::GlobalRead]);
assert!(!store.check_user(G1, &bob, GraphPermission::Write).await);
}
#[tokio::test]
async fn test_grant_returns_entry() {
let store = GraphAclStore::new();
let alice = Principal::user("alice");
let entry = store
.grant(&grant_req(G1, alice.clone(), GraphPermission::Read))
.await
.unwrap();
assert_eq!(entry.graph_iri, G1);
assert_eq!(entry.principal, alice);
assert_eq!(entry.permission, GraphPermission::Read);
assert_eq!(entry.granted_by.as_deref(), Some("admin"));
}
#[tokio::test]
async fn test_list_graph_entries() {
let store = GraphAclStore::new();
store
.grant(&grant_req(
G1,
Principal::user("alice"),
GraphPermission::Read,
))
.await
.unwrap();
store
.grant(&grant_req(
G1,
Principal::user("bob"),
GraphPermission::Write,
))
.await
.unwrap();
store
.grant(&grant_req(
G2,
Principal::user("carol"),
GraphPermission::Admin,
))
.await
.unwrap();
let g1_entries = store.list_graph(G1).await;
assert_eq!(g1_entries.len(), 2);
assert!(g1_entries.iter().all(|e| e.graph_iri == G1));
let g2_entries = store.list_graph(G2).await;
assert_eq!(g2_entries.len(), 1);
}
#[tokio::test]
async fn test_list_all_entries() {
let store = GraphAclStore::new();
store
.grant(&grant_req(
G1,
Principal::user("alice"),
GraphPermission::Read,
))
.await
.unwrap();
store
.grant(&grant_req(
G2,
Principal::user("bob"),
GraphPermission::Write,
))
.await
.unwrap();
store
.grant(&grant_req(
G3,
Principal::role("admin_role"),
GraphPermission::Admin,
))
.await
.unwrap();
let all = store.list_all().await;
assert_eq!(all.len(), 3);
}
#[tokio::test]
async fn test_purge_graph() {
let store = GraphAclStore::new();
store
.grant(&grant_req(
G1,
Principal::user("alice"),
GraphPermission::Read,
))
.await
.unwrap();
store
.grant(&grant_req(
G1,
Principal::user("bob"),
GraphPermission::Write,
))
.await
.unwrap();
store
.grant(&grant_req(
G2,
Principal::user("carol"),
GraphPermission::Read,
))
.await
.unwrap();
let removed = store.purge_graph(G1).await.unwrap();
assert_eq!(removed, 2);
assert_eq!(store.entry_count().await, 1); assert!(
!store
.check(G1, &Principal::user("alice"), GraphPermission::Read)
.await
);
}
#[tokio::test]
async fn test_graphs_for_principal() {
let store = GraphAclStore::new();
let alice = Principal::user("alice");
store
.grant(&grant_req(G1, alice.clone(), GraphPermission::Read))
.await
.unwrap();
store
.grant(&grant_req(G2, alice.clone(), GraphPermission::Write))
.await
.unwrap();
store
.grant(&grant_req(
G3,
Principal::user("bob"),
GraphPermission::Read,
))
.await
.unwrap();
let alice_graphs = store.graphs_for_principal(&alice).await;
assert_eq!(alice_graphs.len(), 2);
assert!(alice_graphs.iter().any(|(g, _)| g == G1));
assert!(alice_graphs.iter().any(|(g, _)| g == G2));
}
#[tokio::test]
async fn test_policy_dataset_admin_bypasses_acl() {
let store = GraphAclStore::new();
let policy = GraphAclPolicy::new(store);
let admin = make_user("admin", &["admin"], &[Permission::GlobalAdmin]);
let decision = policy
.check(&admin, "ds1", G1, GraphPermission::Write)
.await;
assert!(decision.is_allow());
assert_eq!(
decision,
AclDecision::Allow {
reason: AllowReason::DatasetAdmin
}
);
}
#[tokio::test]
async fn test_policy_dataset_scoped_admin_bypass() {
let store = GraphAclStore::new();
let policy = GraphAclPolicy::new(store);
let ds_admin = make_user(
"ds_admin",
&["dataset_admin"],
&[Permission::DatasetAdmin("ds1".to_string())],
);
let decision = policy
.check(&ds_admin, "ds1", G1, GraphPermission::Admin)
.await;
assert!(decision.is_allow());
assert_eq!(
decision,
AclDecision::Allow {
reason: AllowReason::DatasetAdmin
}
);
}
#[tokio::test]
async fn test_policy_dataset_rbac_denied() {
let store = GraphAclStore::new();
store
.grant(&grant_req(
G1,
Principal::user("alice"),
GraphPermission::Write,
))
.await
.unwrap();
let policy = GraphAclPolicy::new(store);
let alice = make_user("alice", &[], &[]); let decision = policy
.check(&alice, "ds1", G1, GraphPermission::Write)
.await;
assert!(!decision.is_allow());
assert_eq!(
decision,
AclDecision::Deny {
reason: DenyReason::DatasetRbacDenied
}
);
}
#[tokio::test]
async fn test_policy_no_acl_entry_denied() {
let store = GraphAclStore::new();
let policy = GraphAclPolicy::new(store);
let alice = make_user("alice", &["reader"], &[Permission::GlobalRead]);
let decision = policy.check(&alice, "ds1", G1, GraphPermission::Read).await;
assert!(!decision.is_allow());
assert_eq!(
decision,
AclDecision::Deny {
reason: DenyReason::NoAclEntry
}
);
}
#[tokio::test]
async fn test_policy_combined_allows() {
let store = GraphAclStore::new();
store
.grant(&grant_req(
G1,
Principal::user("alice"),
GraphPermission::Read,
))
.await
.unwrap();
let policy = GraphAclPolicy::new(store);
let alice = make_user("alice", &["reader"], &[Permission::GlobalRead]);
let decision = policy.check(&alice, "ds1", G1, GraphPermission::Read).await;
assert!(decision.is_allow());
assert_eq!(
decision,
AclDecision::Allow {
reason: AllowReason::ExplicitAcl
}
);
}
#[tokio::test]
async fn test_policy_dataset_denies_write_despite_graph_acl() {
let store = GraphAclStore::new();
store
.grant(&grant_req(
G1,
Principal::user("alice"),
GraphPermission::Write,
))
.await
.unwrap();
let policy = GraphAclPolicy::new(store);
let alice = make_user("alice", &["reader"], &[Permission::GlobalRead]);
let decision = policy
.check(&alice, "ds1", G1, GraphPermission::Write)
.await;
assert!(!decision.is_allow());
assert_eq!(
decision,
AclDecision::Deny {
reason: DenyReason::DatasetRbacDenied
}
);
}
#[tokio::test]
async fn test_policy_role_based_principal_combined() {
let store = GraphAclStore::new();
store
.grant(&grant_req(
G1,
Principal::role("writer"),
GraphPermission::Write,
))
.await
.unwrap();
let policy = GraphAclPolicy::new(store);
let alice = make_user("alice", &["writer"], &[Permission::GlobalWrite]);
let decision = policy
.check(&alice, "ds1", G1, GraphPermission::Write)
.await;
assert!(decision.is_allow());
}
#[test]
fn test_graph_permission_display() {
assert_eq!(GraphPermission::Read.as_str(), "read");
assert_eq!(GraphPermission::Write.as_str(), "write");
assert_eq!(GraphPermission::Admin.as_str(), "admin");
assert_eq!(format!("{}", GraphPermission::Read), "read");
}
#[tokio::test]
async fn test_entry_count_accurate() {
let store = GraphAclStore::new();
assert_eq!(store.entry_count().await, 0);
store
.grant(&grant_req(
G1,
Principal::user("alice"),
GraphPermission::Read,
))
.await
.unwrap();
assert_eq!(store.entry_count().await, 1);
store
.grant(&grant_req(
G1,
Principal::user("bob"),
GraphPermission::Write,
))
.await
.unwrap();
assert_eq!(store.entry_count().await, 2);
store
.revoke(&revoke_req(
G1,
Principal::user("alice"),
Some(GraphPermission::Read),
))
.await
.unwrap();
assert_eq!(store.entry_count().await, 1);
}
#[tokio::test]
async fn test_idempotent_grant() {
let store = GraphAclStore::new();
let alice = Principal::user("alice");
store
.grant(&grant_req(G1, alice.clone(), GraphPermission::Read))
.await
.unwrap();
store
.grant(&grant_req(G1, alice.clone(), GraphPermission::Read))
.await
.unwrap();
assert_eq!(store.entry_count().await, 1); }
}