use super::types::{AuthError, Permission, User};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GraphScope {
AllNamedGraphs,
SpecificGraph { iri: String },
}
impl GraphScope {
pub fn specific(iri: impl Into<String>) -> Self {
GraphScope::SpecificGraph { iri: iri.into() }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyTemplate {
pub name: String,
pub description: String,
pub permissions: Vec<Permission>,
pub graph_scopes: Vec<GraphScope>,
}
impl PolicyTemplate {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
permissions: Vec<Permission>,
graph_scopes: Vec<GraphScope>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
permissions,
graph_scopes,
}
}
}
fn dba_template() -> PolicyTemplate {
PolicyTemplate::new(
"dba",
"Database Administrator — full CRUD, indexing, backup, and admin control",
vec![
Permission::Read,
Permission::Write,
Permission::Admin,
Permission::GlobalAdmin,
Permission::GlobalRead,
Permission::GlobalWrite,
Permission::DatasetCreate,
Permission::DatasetDelete,
Permission::DatasetManage,
Permission::UserManage,
Permission::SystemConfig,
Permission::QueryExecute,
Permission::UpdateExecute,
Permission::SparqlQuery,
Permission::SparqlUpdate,
Permission::GraphStore,
Permission::Upload,
Permission::Download,
Permission::Backup,
Permission::Restore,
Permission::Monitor,
Permission::Audit,
Permission::ServiceManage,
Permission::ClusterManage,
Permission::FederationManage,
],
vec![GraphScope::AllNamedGraphs],
)
}
fn readonly_template() -> PolicyTemplate {
PolicyTemplate::new(
"readonly",
"Read-only analyst — SPARQL SELECT/ASK/CONSTRUCT/DESCRIBE only; no write or admin access",
vec![
Permission::Read,
Permission::GlobalRead,
Permission::QueryExecute,
Permission::SparqlQuery,
Permission::Download,
],
vec![GraphScope::AllNamedGraphs],
)
}
fn auditor_template() -> PolicyTemplate {
PolicyTemplate::new(
"auditor",
"Auditor — read access to all named graphs plus audit-log inspection",
vec![
Permission::Read,
Permission::GlobalRead,
Permission::QueryExecute,
Permission::SparqlQuery,
Permission::Download,
Permission::Audit,
Permission::ReadAudit,
],
vec![GraphScope::AllNamedGraphs],
)
}
pub struct PolicyTemplateRegistry {
templates: HashMap<String, PolicyTemplate>,
}
impl PolicyTemplateRegistry {
pub fn with_defaults() -> Self {
let mut templates = HashMap::with_capacity(3);
for tmpl in [dba_template(), readonly_template(), auditor_template()] {
templates.insert(tmpl.name.clone(), tmpl);
}
Self { templates }
}
pub fn register(&mut self, template: PolicyTemplate) -> Result<(), AuthError> {
if self.templates.contains_key(&template.name) {
return Err(AuthError::DuplicateTemplate(template.name.clone()));
}
self.templates.insert(template.name.clone(), template);
Ok(())
}
pub fn get(&self, name: &str) -> Option<&PolicyTemplate> {
self.templates.get(name)
}
pub fn list(&self) -> Vec<&PolicyTemplate> {
let mut items: Vec<&PolicyTemplate> = self.templates.values().collect();
items.sort_by(|a, b| a.name.cmp(&b.name));
items
}
pub fn apply_to_user(&self, user: &mut User, template_name: &str) -> Result<(), AuthError> {
let template = self
.templates
.get(template_name)
.ok_or_else(|| AuthError::UnknownTemplate(template_name.to_string()))?;
for permission in &template.permissions {
if !user.permissions.contains(permission) {
user.permissions.push(permission.clone());
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::types::{AuthError, Permission, User};
use chrono::Utc;
fn make_empty_user(username: &str) -> User {
User {
username: username.to_string(),
roles: vec![],
email: None,
full_name: None,
last_login: Some(Utc::now()),
permissions: vec![],
}
}
fn make_user_with_permissions(username: &str, permissions: Vec<Permission>) -> User {
User {
username: username.to_string(),
roles: vec![],
email: None,
full_name: None,
last_login: Some(Utc::now()),
permissions,
}
}
#[test]
fn test_with_defaults_has_three_templates() {
let registry = PolicyTemplateRegistry::with_defaults();
assert_eq!(registry.list().len(), 3);
}
#[test]
fn test_get_dba_returns_some() {
let registry = PolicyTemplateRegistry::with_defaults();
assert!(registry.get("dba").is_some());
}
#[test]
fn test_get_readonly_returns_some() {
let registry = PolicyTemplateRegistry::with_defaults();
assert!(registry.get("readonly").is_some());
}
#[test]
fn test_get_auditor_returns_some() {
let registry = PolicyTemplateRegistry::with_defaults();
assert!(registry.get("auditor").is_some());
}
#[test]
fn test_get_nonexistent_returns_none() {
let registry = PolicyTemplateRegistry::with_defaults();
assert!(registry.get("nonexistent").is_none());
}
#[test]
fn test_register_duplicate_returns_err() {
let mut registry = PolicyTemplateRegistry::with_defaults();
let duplicate = PolicyTemplate::new(
"dba",
"Another DBA template",
vec![Permission::Read],
vec![GraphScope::AllNamedGraphs],
);
let result = registry.register(duplicate);
assert!(
matches!(result, Err(AuthError::DuplicateTemplate(ref name)) if name == "dba"),
"Expected DuplicateTemplate(\"dba\"), got {:?}",
result
);
}
#[test]
fn test_register_unique_name_returns_ok() {
let mut registry = PolicyTemplateRegistry::with_defaults();
let custom = PolicyTemplate::new(
"custom_role",
"A custom policy template",
vec![Permission::Read, Permission::Monitor],
vec![GraphScope::specific("http://example.org/g1")],
);
assert!(registry.register(custom).is_ok());
assert!(registry.get("custom_role").is_some());
}
#[test]
fn test_list_returns_all_three_defaults() {
let registry = PolicyTemplateRegistry::with_defaults();
let names: Vec<&str> = registry.list().iter().map(|t| t.name.as_str()).collect();
assert_eq!(names.len(), 3);
assert!(names.contains(&"dba"));
assert!(names.contains(&"readonly"));
assert!(names.contains(&"auditor"));
}
#[test]
fn test_dba_has_write_and_admin() {
let registry = PolicyTemplateRegistry::with_defaults();
let dba = registry.get("dba").expect("dba template must exist");
assert!(
dba.permissions.contains(&Permission::Write),
"DBA template must contain Permission::Write"
);
assert!(
dba.permissions.contains(&Permission::Admin),
"DBA template must contain Permission::Admin"
);
}
#[test]
fn test_readonly_does_not_include_write() {
let registry = PolicyTemplateRegistry::with_defaults();
let readonly = registry
.get("readonly")
.expect("readonly template must exist");
assert!(
!readonly.permissions.contains(&Permission::Write),
"ReadOnly template must NOT contain Permission::Write"
);
assert!(
!readonly.permissions.contains(&Permission::Admin),
"ReadOnly template must NOT contain Permission::Admin"
);
}
#[test]
fn test_auditor_includes_read_audit() {
let registry = PolicyTemplateRegistry::with_defaults();
let auditor = registry
.get("auditor")
.expect("auditor template must exist");
assert!(
auditor.permissions.contains(&Permission::ReadAudit),
"Auditor template must contain Permission::ReadAudit"
);
assert!(
auditor.permissions.contains(&Permission::Audit),
"Auditor template must contain Permission::Audit"
);
}
#[test]
fn test_apply_to_user_grants_permissions() {
let registry = PolicyTemplateRegistry::with_defaults();
let mut user = make_empty_user("alice");
registry
.apply_to_user(&mut user, "readonly")
.expect("apply_to_user with 'readonly' must succeed");
assert!(
user.permissions.contains(&Permission::Read),
"User must have Read after applying readonly template"
);
assert!(
user.permissions.contains(&Permission::QueryExecute),
"User must have QueryExecute after applying readonly template"
);
assert!(
!user.permissions.contains(&Permission::Write),
"User must NOT have Write after applying readonly template"
);
}
#[test]
fn test_apply_to_user_unknown_template_returns_err() {
let registry = PolicyTemplateRegistry::with_defaults();
let mut user = make_empty_user("bob");
let result = registry.apply_to_user(&mut user, "does_not_exist");
assert!(
matches!(result, Err(AuthError::UnknownTemplate(ref name)) if name == "does_not_exist"),
"Expected UnknownTemplate(\"does_not_exist\"), got {:?}",
result
);
}
#[test]
fn test_policy_template_serde_roundtrip() {
let original = PolicyTemplate::new(
"test_template",
"Round-trip serialization test",
vec![Permission::Read, Permission::Write, Permission::ReadAudit],
vec![
GraphScope::AllNamedGraphs,
GraphScope::specific("http://example.org/graph/test"),
],
);
let json = serde_json::to_string(&original).expect("serialization must succeed");
let restored: PolicyTemplate =
serde_json::from_str(&json).expect("deserialization must succeed");
assert_eq!(restored.name, original.name);
assert_eq!(restored.description, original.description);
assert_eq!(restored.permissions.len(), original.permissions.len());
assert_eq!(restored.graph_scopes.len(), original.graph_scopes.len());
assert!(restored.permissions.contains(&Permission::ReadAudit));
assert!(restored.graph_scopes.contains(&GraphScope::AllNamedGraphs));
}
#[test]
fn test_apply_to_user_no_duplicate_permissions() {
let registry = PolicyTemplateRegistry::with_defaults();
let mut user = make_user_with_permissions("carol", vec![Permission::Read]);
registry
.apply_to_user(&mut user, "readonly")
.expect("apply_to_user must succeed");
let read_count = user
.permissions
.iter()
.filter(|p| **p == Permission::Read)
.count();
assert_eq!(read_count, 1, "Read permission must not be duplicated");
}
#[test]
fn test_apply_dba_template_grants_full_access() {
let registry = PolicyTemplateRegistry::with_defaults();
let mut user = make_empty_user("dba_user");
registry
.apply_to_user(&mut user, "dba")
.expect("apply_to_user with 'dba' must succeed");
let required = [
Permission::Read,
Permission::Write,
Permission::Admin,
Permission::Backup,
Permission::Restore,
Permission::UserManage,
Permission::SystemConfig,
];
for perm in &required {
assert!(
user.permissions.contains(perm),
"DBA user must have {:?}",
perm
);
}
}
#[test]
fn test_list_is_sorted_by_name() {
let registry = PolicyTemplateRegistry::with_defaults();
let names: Vec<&str> = registry.list().iter().map(|t| t.name.as_str()).collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(
names, sorted,
"list() must return templates in sorted order"
);
}
#[test]
fn test_graph_scope_specific_stores_iri() {
let scope = GraphScope::specific("http://example.org/my-graph");
match &scope {
GraphScope::SpecificGraph { iri } => {
assert_eq!(iri, "http://example.org/my-graph");
}
GraphScope::AllNamedGraphs => panic!("Expected SpecificGraph variant"),
}
}
}