use super::rebac::{CheckRequest, CheckResponse, RebacEvaluator};
use crate::error::{FusekiError, FusekiResult};
use async_trait::async_trait;
use std::collections::HashSet;
use std::sync::Arc;
use tracing::{debug, instrument};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GraphPermission {
Read,
Write,
Delete,
Manage,
}
impl GraphPermission {
pub fn to_relation(&self) -> &'static str {
match self {
GraphPermission::Read => "can_read",
GraphPermission::Write => "can_write",
GraphPermission::Delete => "can_delete",
GraphPermission::Manage => "owner",
}
}
pub fn implies(&self, other: &GraphPermission) -> bool {
match self {
GraphPermission::Manage => true, GraphPermission::Write => matches!(other, GraphPermission::Read), GraphPermission::Delete => false,
GraphPermission::Read => matches!(other, GraphPermission::Read),
}
}
}
#[derive(Debug, Clone)]
pub struct GraphAuthRequest {
pub subject: String,
pub dataset: String,
pub graph_uri: Option<String>,
pub permission: GraphPermission,
}
impl GraphAuthRequest {
pub fn new(
subject: impl Into<String>,
dataset: impl Into<String>,
graph_uri: Option<String>,
permission: GraphPermission,
) -> Self {
Self {
subject: subject.into(),
dataset: dataset.into(),
graph_uri,
permission,
}
}
}
#[derive(Debug, Clone)]
pub struct GraphAuthResponse {
pub allowed: bool,
pub reason: Option<String>,
pub granted_by: Option<GrantLevel>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GrantLevel {
Dataset,
Graph,
}
pub struct GraphAuthorizationManager {
rebac: Arc<dyn RebacEvaluator>,
cache_enabled: bool,
}
impl GraphAuthorizationManager {
pub fn new(rebac: Arc<dyn RebacEvaluator>) -> Self {
Self {
rebac,
cache_enabled: true,
}
}
pub fn with_cache(mut self, enabled: bool) -> Self {
self.cache_enabled = enabled;
self
}
#[instrument(skip(self))]
pub async fn check_graph_permission(
&self,
request: &GraphAuthRequest,
) -> FusekiResult<GraphAuthResponse> {
let relation = request.permission.to_relation();
let dataset_resource = format!("dataset:{}", request.dataset);
let mut permissions_to_check = vec![request.permission];
for higher_perm in [
GraphPermission::Manage,
GraphPermission::Write,
GraphPermission::Delete,
] {
if higher_perm.implies(&request.permission) && higher_perm != request.permission {
permissions_to_check.push(higher_perm);
}
}
if let Some(graph_uri) = &request.graph_uri {
let graph_resource = format!("graph:{}", graph_uri);
for perm in &permissions_to_check {
let perm_relation = perm.to_relation();
let graph_check =
CheckRequest::new(&request.subject, perm_relation, &graph_resource);
if let Ok(response) = self.rebac.check(&graph_check).await {
if response.allowed {
debug!(
"Graph permission granted: {} {} {} (explicit, via {})",
request.subject, relation, graph_uri, perm_relation
);
return Ok(GraphAuthResponse {
allowed: true,
reason: Some(format!("Explicit graph permission: {}", perm_relation)),
granted_by: Some(GrantLevel::Graph),
});
}
}
}
}
for perm in &permissions_to_check {
let perm_relation = perm.to_relation();
let dataset_check =
CheckRequest::new(&request.subject, perm_relation, &dataset_resource);
if let Ok(response) = self.rebac.check(&dataset_check).await {
if response.allowed {
debug!(
"Graph permission granted: {} {} (inherited from dataset, via {})",
request.subject, relation, perm_relation
);
return Ok(GraphAuthResponse {
allowed: true,
reason: Some(format!(
"Inherited from dataset permission: {}",
perm_relation
)),
granted_by: Some(GrantLevel::Dataset),
});
}
}
}
debug!(
"Graph permission denied: {} {} {} (no matching permission)",
request.subject,
relation,
request.graph_uri.as_deref().unwrap_or("default")
);
Ok(GraphAuthResponse {
allowed: false,
reason: Some("No matching permission found".to_string()),
granted_by: None,
})
}
#[instrument(skip(self, graph_uris))]
pub async fn filter_authorized_graphs(
&self,
subject: &str,
dataset: &str,
graph_uris: &[String],
permission: GraphPermission,
) -> FusekiResult<Vec<String>> {
let mut authorized = Vec::new();
let dataset_request =
GraphAuthRequest::new(subject.to_string(), dataset.to_string(), None, permission);
if let Ok(response) = self.check_graph_permission(&dataset_request).await {
if response.allowed && response.granted_by == Some(GrantLevel::Dataset) {
debug!(
"User {} has dataset-level {} permission, returning all {} graphs",
subject,
permission.to_relation(),
graph_uris.len()
);
return Ok(graph_uris.to_vec());
}
}
for graph_uri in graph_uris {
let request = GraphAuthRequest::new(
subject.to_string(),
dataset.to_string(),
Some(graph_uri.clone()),
permission,
);
if let Ok(response) = self.check_graph_permission(&request).await {
if response.allowed {
authorized.push(graph_uri.clone());
}
}
}
debug!(
"Filtered {} graphs to {} authorized for user {}",
graph_uris.len(),
authorized.len(),
subject
);
Ok(authorized)
}
#[instrument(skip(self, requests))]
pub async fn batch_check_graphs(
&self,
requests: &[GraphAuthRequest],
) -> FusekiResult<Vec<GraphAuthResponse>> {
let mut responses = Vec::with_capacity(requests.len());
for request in requests {
let response = self.check_graph_permission(request).await?;
responses.push(response);
}
Ok(responses)
}
#[instrument(skip(self))]
pub async fn get_authorized_graphs(
&self,
subject: &str,
dataset: &str,
permission: GraphPermission,
) -> FusekiResult<Vec<String>> {
let relation = permission.to_relation();
let tuples = self
.rebac
.list_subject_tuples(subject)
.await
.map_err(|e| FusekiError::internal(format!("Failed to list tuples: {}", e)))?;
let mut graphs = HashSet::new();
let dataset_resource = format!("dataset:{}", dataset);
if tuples.iter().any(|t| {
t.object == dataset_resource
&& (t.relation == relation || t.relation == GraphPermission::Manage.to_relation())
}) {
return Ok(vec!["*".to_string()]);
}
for tuple in tuples {
if tuple.relation == relation && tuple.object.starts_with("graph:") {
if let Some(graph_uri) = tuple.object.strip_prefix("graph:") {
graphs.insert(graph_uri.to_string());
}
}
}
Ok(graphs.into_iter().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::rebac::{InMemoryRebacManager, RelationshipTuple};
#[tokio::test]
async fn test_explicit_graph_permission() {
let rebac = Arc::new(InMemoryRebacManager::new());
rebac
.add_tuple(RelationshipTuple::new(
"user:alice",
"can_read",
"graph:http://example.org/g1",
))
.await
.unwrap();
let manager = GraphAuthorizationManager::new(rebac);
let request = GraphAuthRequest::new(
"user:alice",
"dataset:public",
Some("http://example.org/g1".to_string()),
GraphPermission::Read,
);
let response = manager.check_graph_permission(&request).await.unwrap();
assert!(response.allowed);
assert_eq!(response.granted_by, Some(GrantLevel::Graph));
}
#[tokio::test]
async fn test_dataset_level_inheritance() {
let rebac = Arc::new(InMemoryRebacManager::new());
rebac
.add_tuple(RelationshipTuple::new(
"user:alice",
"can_read",
"dataset:public",
))
.await
.unwrap();
let manager = GraphAuthorizationManager::new(rebac);
let request = GraphAuthRequest::new(
"user:alice",
"public", Some("http://example.org/any-graph".to_string()),
GraphPermission::Read,
);
let response = manager.check_graph_permission(&request).await.unwrap();
assert!(response.allowed);
assert_eq!(response.granted_by, Some(GrantLevel::Dataset));
}
#[tokio::test]
async fn test_permission_implication() {
let rebac = Arc::new(InMemoryRebacManager::new());
rebac
.add_tuple(RelationshipTuple::new(
"user:alice",
"owner",
"dataset:public",
))
.await
.unwrap();
let manager = GraphAuthorizationManager::new(rebac);
let request = GraphAuthRequest::new(
"user:alice",
"public", Some("http://example.org/g1".to_string()),
GraphPermission::Read,
);
let response = manager.check_graph_permission(&request).await.unwrap();
assert!(response.allowed);
}
#[tokio::test]
async fn test_filter_authorized_graphs() {
let rebac = Arc::new(InMemoryRebacManager::new());
rebac
.add_tuple(RelationshipTuple::new(
"user:alice",
"can_read",
"graph:http://example.org/g1",
))
.await
.unwrap();
rebac
.add_tuple(RelationshipTuple::new(
"user:alice",
"can_read",
"graph:http://example.org/g2",
))
.await
.unwrap();
let manager = GraphAuthorizationManager::new(rebac);
let all_graphs = vec![
"http://example.org/g1".to_string(),
"http://example.org/g2".to_string(),
"http://example.org/g3".to_string(),
];
let authorized = manager
.filter_authorized_graphs("user:alice", "public", &all_graphs, GraphPermission::Read)
.await
.unwrap();
assert_eq!(authorized.len(), 2);
assert!(authorized.contains(&"http://example.org/g1".to_string()));
assert!(authorized.contains(&"http://example.org/g2".to_string()));
}
#[tokio::test]
async fn test_filter_with_dataset_permission() {
let rebac = Arc::new(InMemoryRebacManager::new());
rebac
.add_tuple(RelationshipTuple::new(
"user:alice",
"can_read",
"dataset:public",
))
.await
.unwrap();
let manager = GraphAuthorizationManager::new(rebac);
let all_graphs = vec![
"http://example.org/g1".to_string(),
"http://example.org/g2".to_string(),
"http://example.org/g3".to_string(),
];
let authorized = manager
.filter_authorized_graphs("user:alice", "public", &all_graphs, GraphPermission::Read)
.await
.unwrap();
assert_eq!(authorized.len(), 3); }
#[tokio::test]
async fn test_no_permission() {
let rebac = Arc::new(InMemoryRebacManager::new());
let manager = GraphAuthorizationManager::new(rebac);
let request = GraphAuthRequest::new(
"user:bob",
"public", Some("http://example.org/g1".to_string()),
GraphPermission::Read,
);
let response = manager.check_graph_permission(&request).await.unwrap();
assert!(!response.allowed);
}
}