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 TrustedIngest,
33}
34
35impl Capability {
36 pub fn parse_set(s: &str) -> CapabilitySet {
38 let mut set = CapabilitySet::new();
39 for part in s.split(',') {
40 let trimmed = part.trim().to_ascii_lowercase();
41 let cap = match trimmed.as_str() {
42 "read" | "query" => Some(Capability::Read),
43 "write" | "declare" => Some(Capability::Write),
44 "ingest" => Some(Capability::Ingest),
45 "sample" => Some(Capability::Sample),
46 "admin" => Some(Capability::Admin),
47 "redact" => Some(Capability::Redact),
48 "trusted_ingest" => Some(Capability::TrustedIngest),
49 _ => None,
50 };
51 if let Some(c) = cap {
52 set.insert(c);
53 }
54 }
55 set
56 }
57
58 pub fn as_str(&self) -> &'static str {
60 match self {
61 Capability::Read => "read",
62 Capability::Write => "write",
63 Capability::Ingest => "ingest",
64 Capability::Sample => "sample",
65 Capability::Admin => "admin",
66 Capability::Redact => "redact",
67 Capability::TrustedIngest => "trusted_ingest",
68 }
69 }
70}
71
72pub type CapabilitySet = BTreeSet<Capability>;
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct Scope {
78 pub spaces: Vec<String>,
80 pub caps: CapabilitySet,
82 pub predicate_filter: Option<Vec<String>>,
84 pub entity_type_filter: Option<Vec<String>>,
86 pub expires_at: Option<Timestamp>,
88 #[serde(default)]
90 pub label: String,
91}
92
93impl Scope {
94 pub fn permits(&self, cap: Capability, space: &str, now: Timestamp) -> bool {
96 self.caps.contains(&cap)
97 && self.spaces.iter().any(|s| s == space)
98 && self.expires_at.is_none_or(|exp| now < exp)
99 }
100
101 pub fn permits_predicate(&self, predicate: &str) -> bool {
103 self.predicate_filter
104 .as_ref()
105 .is_none_or(|filter| filter.iter().any(|p| p == predicate))
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct TokenInfo {
117 pub id: String,
119 pub scope: Scope,
121 pub issued_at: Timestamp,
123 pub issued_by: String,
125 pub revoked_at: Option<Timestamp>,
127 pub label: Option<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(tag = "kind", rename_all = "snake_case")]
138pub enum RedactTarget {
139 Episode { id: String },
141 Entity { space: String, entity_id: String },
143 PredicateScoped {
145 space: String,
146 entity_id: String,
147 predicate: String,
148 },
149}
150
151#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct RedactionClosure {
154 pub episodes: Vec<String>,
155 pub assertions: Vec<String>,
156 pub statements: Vec<String>,
157 pub mentions: Vec<String>,
158 pub extractions: Vec<String>,
159 pub summaries: Vec<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct RedactionResult {
165 pub closure: RedactionClosure,
166 pub beliefs_refolded: usize,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct AuditEntry {
176 pub id: i64,
177 pub ts: Timestamp,
178 pub actor: String,
179 pub scope: Option<String>,
180 pub operation: String,
181 pub target: Option<String>,
182 pub detail_json: Option<String>,
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use oxibrain_ports::Timestamp;
189
190 fn now() -> Timestamp {
191 Timestamp::from_millis(1_000_000)
192 }
193
194 #[test]
195 fn capability_parse_set() {
196 let set = Capability::parse_set("read, write, ingest");
197 assert!(set.contains(&Capability::Read));
198 assert!(set.contains(&Capability::Write));
199 assert!(set.contains(&Capability::Ingest));
200 assert!(!set.contains(&Capability::Admin));
201 }
202
203 #[test]
204 fn capability_parse_unknown_ignored() {
205 let set = Capability::parse_set("read, bogus, write");
206 assert_eq!(set.len(), 2);
207 }
208
209 #[test]
210 fn scope_permits_grants() {
211 let scope = Scope {
212 spaces: vec!["work".into()],
213 caps: Capability::parse_set("read,write"),
214 ..Default::default()
215 };
216 assert!(scope.permits(Capability::Read, "work", now()));
217 assert!(scope.permits(Capability::Write, "work", now()));
218 }
219
220 #[test]
221 fn scope_permits_denies_wrong_space() {
222 let scope = Scope {
223 spaces: vec!["work".into()],
224 caps: Capability::parse_set("read"),
225 ..Default::default()
226 };
227 assert!(!scope.permits(Capability::Read, "personal", now()));
228 }
229
230 #[test]
231 fn scope_permits_denies_missing_cap() {
232 let scope = Scope {
233 spaces: vec!["work".into()],
234 caps: Capability::parse_set("read"),
235 ..Default::default()
236 };
237 assert!(!scope.permits(Capability::Write, "work", now()));
238 }
239
240 #[test]
241 fn scope_permits_denies_expired() {
242 let scope = Scope {
243 spaces: vec!["work".into()],
244 caps: Capability::parse_set("read"),
245 expires_at: Some(Timestamp::from_millis(500)),
246 ..Default::default()
247 };
248 assert!(!scope.permits(Capability::Read, "work", now()));
249 assert!(scope.permits(Capability::Read, "work", Timestamp::from_millis(400)));
251 }
252
253 #[test]
254 fn scope_default_permits_nothing() {
255 let scope = Scope::default();
256 assert!(!scope.permits(Capability::Read, "work", now()));
257 }
258
259 #[test]
260 fn scope_predicate_filter() {
261 let scope = Scope {
262 spaces: vec!["work".into()],
263 caps: Capability::parse_set("read"),
264 predicate_filter: Some(vec!["works_on".into()]),
265 ..Default::default()
266 };
267 assert!(scope.permits_predicate("works_on"));
268 assert!(!scope.permits_predicate("salary"));
269 }
270
271 #[test]
272 fn scope_no_predicate_filter_allows_all() {
273 let scope = Scope {
274 spaces: vec!["work".into()],
275 caps: Capability::parse_set("read"),
276 ..Default::default()
277 };
278 assert!(scope.permits_predicate("anything"));
279 }
280}