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    /// May mark ingested content as trusted (bypasses server trust evaluation).
32    TrustedIngest,
33}
34
35impl Capability {
36    /// Parse a comma-separated capability string: "read,write,ingest".
37    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    /// Render as lowercase string.
59    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
72/// A bit set of capabilities.
73pub type CapabilitySet = BTreeSet<Capability>;
74
75/// Authorization scope carried by a token. DESIGN §11.2.
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct Scope {
78    /// Space ids this scope grants access to.
79    pub spaces: Vec<String>,
80    /// Capabilities granted.
81    pub caps: CapabilitySet,
82    /// Optional predicate allow-list (e.g. hide `health_*`).
83    pub predicate_filter: Option<Vec<String>>,
84    /// Optional entity-type allow-list.
85    pub entity_type_filter: Option<Vec<String>>,
86    /// When the token expires.
87    pub expires_at: Option<Timestamp>,
88    /// Human-readable label for the token (informational only).
89    #[serde(default)]
90    pub label: String,
91}
92
93impl Scope {
94    /// Check if a capability is granted for a space and not expired.
95    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    /// Check if a predicate passes the filter (or there is no filter).
102    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// ---------------------------------------------------------------------------
110// Token info (public metadata; the secret is never stored)
111// ---------------------------------------------------------------------------
112
113/// Public metadata for a token. The secret itself is shown once at issuance
114/// and stored only as a SHA-256 hash.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct TokenInfo {
117    /// Content-derived from (token_hash, issued_at).
118    pub id: String,
119    /// The scope this token grants.
120    pub scope: Scope,
121    /// When the token was issued.
122    pub issued_at: Timestamp,
123    /// Who issued the token (admin token id or "cli").
124    pub issued_by: String,
125    /// When the token was revoked, if applicable.
126    pub revoked_at: Option<Timestamp>,
127    /// Human-readable hint.
128    pub label: Option<String>,
129}
130
131// ---------------------------------------------------------------------------
132// Redaction types
133// ---------------------------------------------------------------------------
134
135/// What to redact.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(tag = "kind", rename_all = "snake_case")]
138pub enum RedactTarget {
139    /// Redact a single episode and everything extracted from it.
140    Episode { id: String },
141    /// Redact all assertions about an entity across all episodes.
142    Entity { space: String, entity_id: String },
143    /// Redact assertions for a specific predicate on an entity.
144    PredicateScoped {
145        space: String,
146        entity_id: String,
147        predicate: String,
148    },
149}
150
151/// The set of objects that will be affected by a redaction.
152#[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/// What a redaction actually did.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct RedactionResult {
165    pub closure: RedactionClosure,
166    pub beliefs_refolded: usize,
167}
168
169// ---------------------------------------------------------------------------
170// Audit
171// ---------------------------------------------------------------------------
172
173/// A single audit log entry.
174#[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        // Before expiry is fine.
250        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}