use oxibrain_ports::Timestamp;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Capability {
Read,
Write,
Ingest,
Sample,
Admin,
Redact,
TrustedIngest,
}
impl Capability {
pub fn parse_set(s: &str) -> CapabilitySet {
let mut set = CapabilitySet::new();
for part in s.split(',') {
let trimmed = part.trim().to_ascii_lowercase();
let cap = match trimmed.as_str() {
"read" | "query" => Some(Capability::Read),
"write" | "declare" => Some(Capability::Write),
"ingest" => Some(Capability::Ingest),
"sample" => Some(Capability::Sample),
"admin" => Some(Capability::Admin),
"redact" => Some(Capability::Redact),
"trusted_ingest" => Some(Capability::TrustedIngest),
_ => None,
};
if let Some(c) = cap {
set.insert(c);
}
}
set
}
pub fn as_str(&self) -> &'static str {
match self {
Capability::Read => "read",
Capability::Write => "write",
Capability::Ingest => "ingest",
Capability::Sample => "sample",
Capability::Admin => "admin",
Capability::Redact => "redact",
Capability::TrustedIngest => "trusted_ingest",
}
}
}
pub type CapabilitySet = BTreeSet<Capability>;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Scope {
pub spaces: Vec<String>,
pub caps: CapabilitySet,
pub predicate_filter: Option<Vec<String>>,
pub entity_type_filter: Option<Vec<String>>,
pub expires_at: Option<Timestamp>,
#[serde(default)]
pub label: String,
}
impl Scope {
pub fn permits(&self, cap: Capability, space: &str, now: Timestamp) -> bool {
self.caps.contains(&cap)
&& self.spaces.iter().any(|s| s == space)
&& self.expires_at.is_none_or(|exp| now < exp)
}
pub fn permits_predicate(&self, predicate: &str) -> bool {
self.predicate_filter
.as_ref()
.is_none_or(|filter| filter.iter().any(|p| p == predicate))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenInfo {
pub id: String,
pub scope: Scope,
pub issued_at: Timestamp,
pub issued_by: String,
pub revoked_at: Option<Timestamp>,
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RedactTarget {
Episode { id: String },
Entity { space: String, entity_id: String },
PredicateScoped {
space: String,
entity_id: String,
predicate: String,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RedactionClosure {
pub episodes: Vec<String>,
pub assertions: Vec<String>,
pub statements: Vec<String>,
pub mentions: Vec<String>,
pub extractions: Vec<String>,
pub summaries: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedactionResult {
pub closure: RedactionClosure,
pub beliefs_refolded: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: i64,
pub ts: Timestamp,
pub actor: String,
pub scope: Option<String>,
pub operation: String,
pub target: Option<String>,
pub detail_json: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use oxibrain_ports::Timestamp;
fn now() -> Timestamp {
Timestamp::from_millis(1_000_000)
}
#[test]
fn capability_parse_set() {
let set = Capability::parse_set("read, write, ingest");
assert!(set.contains(&Capability::Read));
assert!(set.contains(&Capability::Write));
assert!(set.contains(&Capability::Ingest));
assert!(!set.contains(&Capability::Admin));
}
#[test]
fn capability_parse_unknown_ignored() {
let set = Capability::parse_set("read, bogus, write");
assert_eq!(set.len(), 2);
}
#[test]
fn scope_permits_grants() {
let scope = Scope {
spaces: vec!["work".into()],
caps: Capability::parse_set("read,write"),
..Default::default()
};
assert!(scope.permits(Capability::Read, "work", now()));
assert!(scope.permits(Capability::Write, "work", now()));
}
#[test]
fn scope_permits_denies_wrong_space() {
let scope = Scope {
spaces: vec!["work".into()],
caps: Capability::parse_set("read"),
..Default::default()
};
assert!(!scope.permits(Capability::Read, "personal", now()));
}
#[test]
fn scope_permits_denies_missing_cap() {
let scope = Scope {
spaces: vec!["work".into()],
caps: Capability::parse_set("read"),
..Default::default()
};
assert!(!scope.permits(Capability::Write, "work", now()));
}
#[test]
fn scope_permits_denies_expired() {
let scope = Scope {
spaces: vec!["work".into()],
caps: Capability::parse_set("read"),
expires_at: Some(Timestamp::from_millis(500)),
..Default::default()
};
assert!(!scope.permits(Capability::Read, "work", now()));
assert!(scope.permits(Capability::Read, "work", Timestamp::from_millis(400)));
}
#[test]
fn scope_default_permits_nothing() {
let scope = Scope::default();
assert!(!scope.permits(Capability::Read, "work", now()));
}
#[test]
fn scope_predicate_filter() {
let scope = Scope {
spaces: vec!["work".into()],
caps: Capability::parse_set("read"),
predicate_filter: Some(vec!["works_on".into()]),
..Default::default()
};
assert!(scope.permits_predicate("works_on"));
assert!(!scope.permits_predicate("salary"));
}
#[test]
fn scope_no_predicate_filter_allows_all() {
let scope = Scope {
spaces: vec!["work".into()],
caps: Capability::parse_set("read"),
..Default::default()
};
assert!(scope.permits_predicate("anything"));
}
}