1use oxibrain_ports::Timestamp;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum Capability {
19 Read,
21 Write,
23 Ingest,
25 Sample,
27 Admin,
29 Redact,
31}
32
33impl Capability {
34 pub fn parse_set(s: &str) -> CapabilitySet {
36 let mut set = CapabilitySet::new();
37 for part in s.split(',') {
38 let trimmed = part.trim().to_ascii_lowercase();
39 let cap = match trimmed.as_str() {
40 "read" | "query" => Some(Capability::Read),
41 "write" | "declare" => Some(Capability::Write),
42 "ingest" => Some(Capability::Ingest),
43 "sample" => Some(Capability::Sample),
44 "admin" => Some(Capability::Admin),
45 "redact" => Some(Capability::Redact),
46 _ => None,
47 };
48 if let Some(c) = cap {
49 set.insert(c);
50 }
51 }
52 set
53 }
54
55 pub fn as_str(&self) -> &'static str {
57 match self {
58 Capability::Read => "read",
59 Capability::Write => "write",
60 Capability::Ingest => "ingest",
61 Capability::Sample => "sample",
62 Capability::Admin => "admin",
63 Capability::Redact => "redact",
64 }
65 }
66}
67
68pub type CapabilitySet = BTreeSet<Capability>;
70
71#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct Scope {
74 pub spaces: Vec<String>,
76 pub caps: CapabilitySet,
78 pub predicate_filter: Option<Vec<String>>,
80 pub entity_type_filter: Option<Vec<String>>,
82 pub expires_at: Option<Timestamp>,
84}
85
86impl Scope {
87 pub fn permits(&self, cap: Capability, space: &str, now: Timestamp) -> bool {
89 self.caps.contains(&cap)
90 && self.spaces.iter().any(|s| s == space)
91 && self.expires_at.is_none_or(|exp| now < exp)
92 }
93
94 pub fn permits_predicate(&self, predicate: &str) -> bool {
96 self.predicate_filter
97 .as_ref()
98 .is_none_or(|filter| filter.iter().any(|p| p == predicate))
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct TokenInfo {
110 pub id: String,
112 pub scope: Scope,
114 pub issued_at: Timestamp,
116 pub issued_by: String,
118 pub revoked_at: Option<Timestamp>,
120 pub label: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(tag = "kind", rename_all = "snake_case")]
131pub enum RedactTarget {
132 Episode { id: String },
134 Entity { space: String, entity_id: String },
136 PredicateScoped {
138 space: String,
139 entity_id: String,
140 predicate: String,
141 },
142}
143
144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146pub struct RedactionClosure {
147 pub episodes: Vec<String>,
148 pub assertions: Vec<String>,
149 pub statements: Vec<String>,
150 pub mentions: Vec<String>,
151 pub extractions: Vec<String>,
152 pub summaries: Vec<String>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct RedactionResult {
158 pub closure: RedactionClosure,
159 pub beliefs_refolded: usize,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct AuditEntry {
169 pub id: i64,
170 pub ts: Timestamp,
171 pub actor: String,
172 pub scope: Option<String>,
173 pub operation: String,
174 pub target: Option<String>,
175 pub detail_json: Option<String>,
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use oxibrain_ports::Timestamp;
182
183 fn now() -> Timestamp {
184 Timestamp::from_millis(1_000_000)
185 }
186
187 #[test]
188 fn capability_parse_set() {
189 let set = Capability::parse_set("read, write, ingest");
190 assert!(set.contains(&Capability::Read));
191 assert!(set.contains(&Capability::Write));
192 assert!(set.contains(&Capability::Ingest));
193 assert!(!set.contains(&Capability::Admin));
194 }
195
196 #[test]
197 fn capability_parse_unknown_ignored() {
198 let set = Capability::parse_set("read, bogus, write");
199 assert_eq!(set.len(), 2);
200 }
201
202 #[test]
203 fn scope_permits_grants() {
204 let scope = Scope {
205 spaces: vec!["work".into()],
206 caps: Capability::parse_set("read,write"),
207 ..Default::default()
208 };
209 assert!(scope.permits(Capability::Read, "work", now()));
210 assert!(scope.permits(Capability::Write, "work", now()));
211 }
212
213 #[test]
214 fn scope_permits_denies_wrong_space() {
215 let scope = Scope {
216 spaces: vec!["work".into()],
217 caps: Capability::parse_set("read"),
218 ..Default::default()
219 };
220 assert!(!scope.permits(Capability::Read, "personal", now()));
221 }
222
223 #[test]
224 fn scope_permits_denies_missing_cap() {
225 let scope = Scope {
226 spaces: vec!["work".into()],
227 caps: Capability::parse_set("read"),
228 ..Default::default()
229 };
230 assert!(!scope.permits(Capability::Write, "work", now()));
231 }
232
233 #[test]
234 fn scope_permits_denies_expired() {
235 let scope = Scope {
236 spaces: vec!["work".into()],
237 caps: Capability::parse_set("read"),
238 expires_at: Some(Timestamp::from_millis(500)),
239 ..Default::default()
240 };
241 assert!(!scope.permits(Capability::Read, "work", now()));
242 assert!(scope.permits(Capability::Read, "work", Timestamp::from_millis(400)));
244 }
245
246 #[test]
247 fn scope_default_permits_nothing() {
248 let scope = Scope::default();
249 assert!(!scope.permits(Capability::Read, "work", now()));
250 }
251
252 #[test]
253 fn scope_predicate_filter() {
254 let scope = Scope {
255 spaces: vec!["work".into()],
256 caps: Capability::parse_set("read"),
257 predicate_filter: Some(vec!["works_on".into()]),
258 ..Default::default()
259 };
260 assert!(scope.permits_predicate("works_on"));
261 assert!(!scope.permits_predicate("salary"));
262 }
263
264 #[test]
265 fn scope_no_predicate_filter_allows_all() {
266 let scope = Scope {
267 spaces: vec!["work".into()],
268 caps: Capability::parse_set("read"),
269 ..Default::default()
270 };
271 assert!(scope.permits_predicate("anything"));
272 }
273}