Skip to main content

oxibrain_core/
security.rs

1//! Security domain types (DESIGN §11). Capabilities, scopes, tokens, redaction.
2//!
3//! These are pure types — the store enforces them, the facade checks them.
4//! Token *secrets* are operational state (random, not content-derived) and are
5//! therefore exempt from the P1 reprojection contract.
6
7use oxibrain_ports::Timestamp;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10
11// ---------------------------------------------------------------------------
12// Capability + Scope
13// ---------------------------------------------------------------------------
14
15/// What a token holder may do. DESIGN §11.2.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum Capability {
19    /// search, recall, get_entity, traverse, timeline, why
20    Read,
21    /// declare, retract, merge_entities, review_merges
22    Write,
23    /// ingest, remember
24    Ingest,
25    /// sampling LlmPort — off by default (§12.3)
26    Sample,
27    /// token issue/revoke, predicate add, config change
28    Admin,
29    /// redact — separate capability on purpose (§12.2)
30    Redact,
31}
32
33impl Capability {
34    /// Parse a comma-separated capability string: "read,write,ingest".
35    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    /// Render as lowercase string.
56    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
68/// A bit set of capabilities.
69pub type CapabilitySet = BTreeSet<Capability>;
70
71/// Authorization scope carried by a token. DESIGN §11.2.
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct Scope {
74    /// Space ids this scope grants access to.
75    pub spaces: Vec<String>,
76    /// Capabilities granted.
77    pub caps: CapabilitySet,
78    /// Optional predicate allow-list (e.g. hide `health_*`).
79    pub predicate_filter: Option<Vec<String>>,
80    /// Optional entity-type allow-list.
81    pub entity_type_filter: Option<Vec<String>>,
82    /// When the token expires.
83    pub expires_at: Option<Timestamp>,
84}
85
86impl Scope {
87    /// Check if a capability is granted for a space and not expired.
88    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    /// Check if a predicate passes the filter (or there is no filter).
95    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// ---------------------------------------------------------------------------
103// Token info (public metadata; the secret is never stored)
104// ---------------------------------------------------------------------------
105
106/// Public metadata for a token. The secret itself is shown once at issuance
107/// and stored only as a SHA-256 hash.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct TokenInfo {
110    /// Content-derived from (token_hash, issued_at).
111    pub id: String,
112    /// The scope this token grants.
113    pub scope: Scope,
114    /// When the token was issued.
115    pub issued_at: Timestamp,
116    /// Who issued the token (admin token id or "cli").
117    pub issued_by: String,
118    /// When the token was revoked, if applicable.
119    pub revoked_at: Option<Timestamp>,
120    /// Human-readable hint.
121    pub label: Option<String>,
122}
123
124// ---------------------------------------------------------------------------
125// Redaction types
126// ---------------------------------------------------------------------------
127
128/// What to redact.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(tag = "kind", rename_all = "snake_case")]
131pub enum RedactTarget {
132    /// Redact a single episode and everything extracted from it.
133    Episode { id: String },
134    /// Redact all assertions about an entity across all episodes.
135    Entity { space: String, entity_id: String },
136    /// Redact assertions for a specific predicate on an entity.
137    PredicateScoped {
138        space: String,
139        entity_id: String,
140        predicate: String,
141    },
142}
143
144/// The set of objects that will be affected by a redaction.
145#[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/// What a redaction actually did.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct RedactionResult {
158    pub closure: RedactionClosure,
159    pub beliefs_refolded: usize,
160}
161
162// ---------------------------------------------------------------------------
163// Audit
164// ---------------------------------------------------------------------------
165
166/// A single audit log entry.
167#[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        // Before expiry is fine.
243        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}