use std::collections::{HashSet, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::{Result, TdbError};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TenantId(Arc<str>);
impl TenantId {
pub fn new(id: impl AsRef<str>) -> Result<Self> {
let s = id.as_ref();
if s.is_empty() {
return Err(TdbError::InvalidInput(
"TenantId must not be empty".to_string(),
));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(TdbError::InvalidInput(format!(
"TenantId '{}' contains disallowed characters (only [a-zA-Z0-9_-] allowed)",
s
)));
}
if s.len() > 128 {
return Err(TdbError::InvalidInput(
"TenantId must be at most 128 characters".to_string(),
));
}
Ok(Self(Arc::from(s)))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn namespace_prefix(&self) -> String {
format!("tenant:{}:", self.0)
}
}
impl std::fmt::Display for TenantId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone)]
pub struct TenantConfig {
pub max_triples: u64,
pub max_graphs: u64,
pub quota_bytes: u64,
pub allowed_predicates: Vec<String>,
pub allowed_prefixes: Vec<String>,
pub active: bool,
}
impl Default for TenantConfig {
fn default() -> Self {
Self {
max_triples: 0,
max_graphs: 0,
quota_bytes: 0,
allowed_predicates: vec![],
allowed_prefixes: vec![],
active: true,
}
}
}
impl TenantConfig {
pub fn unlimited() -> Self {
Self::default()
}
pub fn with_limits(max_triples: u64, max_graphs: u64, quota_bytes: u64) -> Self {
Self {
max_triples,
max_graphs,
quota_bytes,
allowed_predicates: vec![],
allowed_prefixes: vec![],
active: true,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TenantStats {
pub triple_count: u64,
pub graph_count: u64,
pub bytes_used: u64,
pub writes: u64,
pub reads: u64,
pub quota_rejections: u64,
pub predicate_rejections: u64,
}
impl TenantStats {
pub fn estimate_bytes(triple_count: u64) -> u64 {
triple_count * 100
}
}
#[derive(Debug)]
pub(crate) struct TenantEntry {
pub(crate) config: TenantConfig,
pub(crate) stats: TenantStats,
pub(crate) graphs: HashSet<String>,
}
impl TenantEntry {
pub(crate) fn new(config: TenantConfig) -> Self {
Self {
config,
stats: TenantStats::default(),
graphs: HashSet::new(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum TenantError {
#[error("Tenant not found: {0}")]
NotFound(String),
#[error("Tenant already exists: {0}")]
AlreadyExists(String),
#[error("Tenant is inactive: {0}")]
Inactive(String),
#[error("Triple quota exceeded for tenant '{tenant}': {current}/{limit}")]
QuotaTriples {
tenant: String,
current: u64,
limit: u64,
},
#[error("Storage quota exceeded for tenant '{tenant}': {current}/{limit} bytes")]
QuotaBytes {
tenant: String,
current: u64,
limit: u64,
},
#[error("Graph quota exceeded for tenant '{tenant}': {current}/{limit}")]
QuotaGraphs {
tenant: String,
current: u64,
limit: u64,
},
#[error("Predicate '{predicate}' not allowed for tenant '{tenant}'")]
PredicateNotAllowed {
predicate: String,
tenant: String,
},
#[error("Cross-tenant access denied: tenant '{accessor}' tried to access '{target}'")]
CrossTenantAccess {
accessor: String,
target: String,
},
#[error("Graph IRI '{graph}' not allowed for tenant '{tenant}': must match an allowed prefix")]
GraphPrefixNotAllowed {
graph: String,
tenant: String,
},
}
impl From<TenantError> for TdbError {
fn from(e: TenantError) -> Self {
TdbError::Other(e.to_string())
}
}
pub type TenantResult<T> = std::result::Result<T, TenantError>;
#[derive(Debug, Clone)]
pub struct TenantAuditEvent {
pub timestamp_secs: u64,
pub accessor: TenantId,
pub target: Option<TenantId>,
pub description: String,
pub blocked: bool,
}
#[derive(Debug)]
pub struct TenantAuditLog {
events: Arc<Mutex<VecDeque<TenantAuditEvent>>>,
max_events: usize,
}
impl TenantAuditLog {
pub fn new(max_events: usize) -> Self {
Self {
events: Arc::new(Mutex::new(VecDeque::with_capacity(max_events.min(1024)))),
max_events,
}
}
pub fn record(&self, event: TenantAuditEvent) {
let Ok(mut guard) = self.events.lock() else {
return;
};
if guard.len() >= self.max_events {
guard.pop_front();
}
guard.push_back(event);
}
pub fn record_cross_tenant(&self, accessor: TenantId, target: TenantId, blocked: bool) {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.record(TenantAuditEvent {
timestamp_secs: ts,
accessor,
target: Some(target),
description: "Cross-tenant data access attempt".to_string(),
blocked,
});
}
pub fn record_quota_violation(&self, accessor: TenantId, description: String) {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.record(TenantAuditEvent {
timestamp_secs: ts,
accessor,
target: None,
description,
blocked: true,
});
}
pub fn events(&self) -> Vec<TenantAuditEvent> {
self.events
.lock()
.map(|g| g.iter().cloned().collect())
.unwrap_or_default()
}
pub fn events_for_tenant(&self, tenant: &TenantId) -> Vec<TenantAuditEvent> {
self.events()
.into_iter()
.filter(|e| {
&e.accessor == tenant || e.target.as_ref().map(|t| t == tenant).unwrap_or(false)
})
.collect()
}
pub fn len(&self) -> usize {
self.events.lock().map(|g| g.len()).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn clear(&self) {
if let Ok(mut g) = self.events.lock() {
g.clear();
}
}
}