Skip to main content

roboticus_core/
security.rs

1//! Claim-based RBAC authority resolution.
2//!
3//! Every message entry point calls into this module to derive the sender's
4//! effective [`InputAuthority`] from all authentication layers.
5//!
6//! # Algorithm
7//!
8//! ```text
9//! effective = min(max(positive_grants…), min(negative_ceilings…))
10//! ```
11//!
12//! Positive grants **OR** across layers (any layer can grant authority).
13//! Negative ceilings **AND** across layers (strictest restriction wins).
14
15use crate::config::SecurityConfig;
16use crate::types::{ClaimSource, InputAuthority, SecurityClaim};
17
18/// Inputs describing what a channel adapter knows about the sender.
19#[derive(Debug, Clone)]
20pub struct ChannelContext<'a> {
21    /// Sender's platform-specific ID (chat ID, phone number, etc.).
22    pub sender_id: &'a str,
23    /// Chat/group/guild ID, if distinct from sender (e.g., Telegram chat ID).
24    pub chat_id: &'a str,
25    /// Platform name (e.g., "telegram", "discord", "api").
26    pub channel: &'a str,
27    /// Whether the sender passed the adapter's allow-list check.
28    pub sender_in_allowlist: bool,
29    /// Whether the adapter's allow-list is non-empty (has entries).
30    pub allowlist_configured: bool,
31    /// Whether the threat scanner flagged this input as Caution-level.
32    pub threat_is_caution: bool,
33    /// The global `channels.trusted_sender_ids` list.
34    pub trusted_sender_ids: &'a [String],
35}
36
37/// Resolve a [`SecurityClaim`] for a channel-originated message.
38///
39/// This is the single authority resolution path for Telegram, Discord,
40/// WhatsApp, Signal, and Email messages.
41pub fn resolve_channel_claim(ctx: &ChannelContext<'_>, sec: &SecurityConfig) -> SecurityClaim {
42    let mut grants: Vec<InputAuthority> = Vec::new();
43    let mut sources: Vec<ClaimSource> = Vec::new();
44    let mut ceilings: Vec<InputAuthority> = Vec::new();
45
46    // ── Positive grants ─────────────────────────────────────────────
47
48    // Layer 1: Channel allow-list
49    if ctx.allowlist_configured {
50        // Non-empty allow-list — grant if sender passed it
51        if ctx.sender_in_allowlist {
52            grants.push(sec.allowlist_authority);
53            sources.push(ClaimSource::ChannelAllowList);
54        }
55        // Sender NOT in a configured allow-list: no grant from this layer
56    }
57    // Empty allow-list: no grant (secure default)
58
59    // Layer 2: trusted_sender_ids
60    if !ctx.trusted_sender_ids.is_empty() {
61        let is_trusted = ctx
62            .trusted_sender_ids
63            .iter()
64            .any(|id| id == ctx.chat_id || id == ctx.sender_id);
65        if is_trusted {
66            grants.push(sec.trusted_authority);
67            sources.push(ClaimSource::TrustedSenderId);
68        }
69    }
70
71    // ── Negative ceilings ───────────────────────────────────────────
72
73    if ctx.threat_is_caution {
74        ceilings.push(sec.threat_caution_ceiling);
75    }
76
77    // ── Compose ─────────────────────────────────────────────────────
78
79    compose_claim(grants, sources, ceilings, ctx.sender_id, ctx.channel)
80}
81
82/// Resolve a [`SecurityClaim`] for an HTTP API or WebSocket request.
83///
84/// API callers are authenticated by API key (currently implicit — the API
85/// is only accessible on localhost). The threat scanner can still apply a
86/// ceiling.
87pub fn resolve_api_claim(
88    threat_is_caution: bool,
89    channel: &str,
90    sec: &SecurityConfig,
91) -> SecurityClaim {
92    let grants = vec![sec.api_authority];
93    let sources = vec![ClaimSource::ApiKey];
94    let mut ceilings: Vec<InputAuthority> = Vec::new();
95
96    if threat_is_caution {
97        ceilings.push(sec.threat_caution_ceiling);
98    }
99
100    compose_claim(grants, sources, ceilings, "api", channel)
101}
102
103/// Resolve a [`SecurityClaim`] for an A2A (agent-to-agent) session.
104///
105/// A2A peers are authenticated via ECDH X25519 key exchange. They receive
106/// `Peer` authority — never `Creator`. The threat scanner still applies.
107pub fn resolve_a2a_claim(
108    threat_is_caution: bool,
109    sender_id: &str,
110    sec: &SecurityConfig,
111) -> SecurityClaim {
112    let grants = vec![InputAuthority::Peer];
113    let sources = vec![ClaimSource::A2aSession];
114    let mut ceilings: Vec<InputAuthority> = Vec::new();
115
116    if threat_is_caution {
117        ceilings.push(sec.threat_caution_ceiling);
118    }
119
120    compose_claim(grants, sources, ceilings, sender_id, "a2a")
121}
122
123/// Compose grants and ceilings into a final [`SecurityClaim`].
124///
125/// ```text
126/// effective = min(max(grants…), min(ceilings…))
127/// ```
128fn compose_claim(
129    grants: Vec<InputAuthority>,
130    mut sources: Vec<ClaimSource>,
131    ceilings: Vec<InputAuthority>,
132    sender_id: &str,
133    channel: &str,
134) -> SecurityClaim {
135    // Best grant (OR — any layer can grant).
136    // NOTE: The unwrap_or_else closure mutates `sources` to record Anonymous
137    // when no grants were provided. This is safe because `sources` is owned
138    // by this function and consumed into the returned SecurityClaim.
139    let effective_grant = grants.iter().copied().max().unwrap_or_else(|| {
140        sources.push(ClaimSource::Anonymous);
141        InputAuthority::External
142    });
143
144    // Strictest ceiling (AND — all must allow)
145    let effective_ceiling = ceilings
146        .iter()
147        .copied()
148        .min()
149        .unwrap_or(InputAuthority::Creator); // no restrictions
150
151    let final_authority = effective_grant.min(effective_ceiling);
152    let threat_downgraded = !ceilings.is_empty() && final_authority < effective_grant;
153
154    SecurityClaim {
155        authority: final_authority,
156        sources,
157        ceiling: effective_ceiling,
158        threat_downgraded,
159        sender_id: sender_id.to_string(),
160        channel: channel.to_string(),
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn default_sec() -> SecurityConfig {
169        SecurityConfig::default()
170    }
171
172    fn channel_ctx<'a>(
173        sender_id: &'a str,
174        chat_id: &'a str,
175        channel: &'a str,
176        sender_in_allowlist: bool,
177        allowlist_configured: bool,
178        threat_is_caution: bool,
179        trusted: &'a [String],
180    ) -> ChannelContext<'a> {
181        ChannelContext {
182            sender_id,
183            chat_id,
184            channel,
185            sender_in_allowlist,
186            allowlist_configured,
187            threat_is_caution,
188            trusted_sender_ids: trusted,
189        }
190    }
191
192    // ── Grant composition ───────────────────────────────────────────
193
194    #[test]
195    fn no_grants_yields_external() {
196        let sec = default_sec();
197        let ctx = channel_ctx("u1", "c1", "telegram", false, true, false, &[]);
198        let claim = resolve_channel_claim(&ctx, &sec);
199        assert_eq!(claim.authority, InputAuthority::External);
200        assert!(claim.sources.contains(&ClaimSource::Anonymous));
201    }
202
203    #[test]
204    fn allowlist_only_yields_peer() {
205        let sec = default_sec();
206        let ctx = channel_ctx("u1", "c1", "telegram", true, true, false, &[]);
207        let claim = resolve_channel_claim(&ctx, &sec);
208        assert_eq!(claim.authority, InputAuthority::Peer);
209        assert!(claim.sources.contains(&ClaimSource::ChannelAllowList));
210    }
211
212    #[test]
213    fn trusted_only_yields_creator() {
214        let sec = default_sec();
215        let trusted = vec!["u1".to_string()];
216        let ctx = channel_ctx("u1", "c1", "telegram", false, true, false, &trusted);
217        let claim = resolve_channel_claim(&ctx, &sec);
218        assert_eq!(claim.authority, InputAuthority::Creator);
219        assert!(claim.sources.contains(&ClaimSource::TrustedSenderId));
220    }
221
222    #[test]
223    fn trusted_by_chat_id() {
224        let sec = default_sec();
225        let trusted = vec!["c1".to_string()];
226        let ctx = channel_ctx("u1", "c1", "telegram", false, true, false, &trusted);
227        let claim = resolve_channel_claim(&ctx, &sec);
228        assert_eq!(claim.authority, InputAuthority::Creator);
229    }
230
231    #[test]
232    fn both_allowlist_and_trusted_yields_creator() {
233        // OR: best grant wins
234        let sec = default_sec();
235        let trusted = vec!["u1".to_string()];
236        let ctx = channel_ctx("u1", "c1", "telegram", true, true, false, &trusted);
237        let claim = resolve_channel_claim(&ctx, &sec);
238        assert_eq!(claim.authority, InputAuthority::Creator);
239        assert!(claim.sources.contains(&ClaimSource::ChannelAllowList));
240        assert!(claim.sources.contains(&ClaimSource::TrustedSenderId));
241    }
242
243    // ── Ceiling composition ─────────────────────────────────────────
244
245    #[test]
246    fn threat_ceiling_downgrades_creator() {
247        let sec = default_sec();
248        let trusted = vec!["u1".to_string()];
249        let ctx = channel_ctx("u1", "c1", "telegram", true, true, true, &trusted);
250        let claim = resolve_channel_claim(&ctx, &sec);
251        // Creator grant capped by External ceiling
252        assert_eq!(claim.authority, InputAuthority::External);
253        assert!(claim.threat_downgraded);
254        assert_eq!(claim.ceiling, InputAuthority::External);
255    }
256
257    #[test]
258    fn custom_threat_ceiling() {
259        let mut sec = default_sec();
260        sec.threat_caution_ceiling = InputAuthority::Peer;
261        let trusted = vec!["u1".to_string()];
262        let ctx = channel_ctx("u1", "c1", "telegram", true, true, true, &trusted);
263        let claim = resolve_channel_claim(&ctx, &sec);
264        // Creator grant capped by Peer ceiling
265        assert_eq!(claim.authority, InputAuthority::Peer);
266        assert!(claim.threat_downgraded);
267    }
268
269    #[test]
270    fn no_threat_means_no_ceiling() {
271        let sec = default_sec();
272        let trusted = vec!["u1".to_string()];
273        let ctx = channel_ctx("u1", "c1", "telegram", true, true, false, &trusted);
274        let claim = resolve_channel_claim(&ctx, &sec);
275        assert_eq!(claim.authority, InputAuthority::Creator);
276        assert!(!claim.threat_downgraded);
277        assert_eq!(claim.ceiling, InputAuthority::Creator); // no restriction
278    }
279
280    // ── Empty allow-list behavior ───────────────────────────────────
281
282    #[test]
283    fn empty_allowlist_deny_on_empty_true_rejects() {
284        let sec = default_sec(); // deny_on_empty_allowlist = true
285        let ctx = channel_ctx("u1", "c1", "telegram", false, false, false, &[]);
286        let claim = resolve_channel_claim(&ctx, &sec);
287        assert_eq!(claim.authority, InputAuthority::External);
288        assert!(claim.sources.contains(&ClaimSource::Anonymous));
289    }
290
291    #[test]
292    fn empty_allowlist_still_rejects_even_if_flag_is_false() {
293        let mut sec = default_sec();
294        // Runtime no longer supports permissive empty allow-lists. Repair/update
295        // migrates this value back to true before persisted configs are reloaded.
296        sec.deny_on_empty_allowlist = false;
297        let ctx = channel_ctx("u1", "c1", "telegram", false, false, false, &[]);
298        let claim = resolve_channel_claim(&ctx, &sec);
299        assert_eq!(claim.authority, InputAuthority::External);
300        assert!(claim.sources.contains(&ClaimSource::Anonymous));
301    }
302
303    // ── API claims ──────────────────────────────────────────────────
304
305    #[test]
306    fn api_claim_default_creator() {
307        let sec = default_sec();
308        let claim = resolve_api_claim(false, "api", &sec);
309        assert_eq!(claim.authority, InputAuthority::Creator);
310        assert!(claim.sources.contains(&ClaimSource::ApiKey));
311    }
312
313    #[test]
314    fn api_claim_threat_downgrade() {
315        let sec = default_sec();
316        let claim = resolve_api_claim(true, "api", &sec);
317        assert_eq!(claim.authority, InputAuthority::External);
318        assert!(claim.threat_downgraded);
319    }
320
321    // ── A2A claims ──────────────────────────────────────────────────
322
323    #[test]
324    fn a2a_claim_always_peer() {
325        let sec = default_sec();
326        let claim = resolve_a2a_claim(false, "peer-agent", &sec);
327        assert_eq!(claim.authority, InputAuthority::Peer);
328        assert!(claim.sources.contains(&ClaimSource::A2aSession));
329    }
330
331    #[test]
332    fn a2a_claim_threat_downgrade() {
333        let sec = default_sec();
334        let claim = resolve_a2a_claim(true, "peer-agent", &sec);
335        assert_eq!(claim.authority, InputAuthority::External);
336        assert!(claim.threat_downgraded);
337    }
338
339    // ── Configurable authority levels ───────────────────────────────
340
341    #[test]
342    fn custom_allowlist_authority() {
343        let mut sec = default_sec();
344        sec.allowlist_authority = InputAuthority::Creator;
345        let ctx = channel_ctx("u1", "c1", "telegram", true, true, false, &[]);
346        let claim = resolve_channel_claim(&ctx, &sec);
347        assert_eq!(claim.authority, InputAuthority::Creator);
348    }
349
350    #[test]
351    fn custom_api_authority_downgraded() {
352        let mut sec = default_sec();
353        sec.api_authority = InputAuthority::Peer;
354        let claim = resolve_api_claim(false, "api", &sec);
355        assert_eq!(claim.authority, InputAuthority::Peer);
356    }
357
358    // ── Monotonicity properties ─────────────────────────────────────
359
360    #[test]
361    fn adding_grant_never_decreases_authority() {
362        let sec = default_sec();
363        // Without trusted
364        let ctx1 = channel_ctx("u1", "c1", "telegram", true, true, false, &[]);
365        let claim1 = resolve_channel_claim(&ctx1, &sec);
366
367        // With trusted (additional grant)
368        let trusted = vec!["u1".to_string()];
369        let ctx2 = channel_ctx("u1", "c1", "telegram", true, true, false, &trusted);
370        let claim2 = resolve_channel_claim(&ctx2, &sec);
371
372        assert!(claim2.authority >= claim1.authority);
373    }
374
375    #[test]
376    fn adding_ceiling_never_increases_authority() {
377        let sec = default_sec();
378        let trusted = vec!["u1".to_string()];
379
380        // Without threat
381        let ctx1 = channel_ctx("u1", "c1", "telegram", true, true, false, &trusted);
382        let claim1 = resolve_channel_claim(&ctx1, &sec);
383
384        // With threat (additional ceiling)
385        let ctx2 = channel_ctx("u1", "c1", "telegram", true, true, true, &trusted);
386        let claim2 = resolve_channel_claim(&ctx2, &sec);
387
388        assert!(claim2.authority <= claim1.authority);
389    }
390
391    // ── Edge cases: threat_downgraded correctness ───────────────────
392
393    #[test]
394    fn threat_present_but_not_binding_does_not_set_downgraded() {
395        // External user with no grants + threat_is_caution = true.
396        // Ceiling is External, grant is External → ceiling is not binding.
397        let sec = default_sec();
398        let ctx = channel_ctx("unknown", "c1", "telegram", false, true, true, &[]);
399        let claim = resolve_channel_claim(&ctx, &sec);
400        assert_eq!(claim.authority, InputAuthority::External);
401        // Ceiling exists but didn't actually reduce authority
402        assert!(!claim.threat_downgraded);
403    }
404
405    #[test]
406    fn api_claim_with_custom_ceiling_and_threat() {
407        // API with Peer authority + Peer ceiling → no downgrade (ceiling = grant)
408        let mut sec = default_sec();
409        sec.api_authority = InputAuthority::Peer;
410        sec.threat_caution_ceiling = InputAuthority::Peer;
411        let claim = resolve_api_claim(true, "api", &sec);
412        assert_eq!(claim.authority, InputAuthority::Peer);
413        assert!(!claim.threat_downgraded); // ceiling = grant, not binding
414
415        // API with Creator authority + Peer ceiling → downgrade
416        let mut sec2 = default_sec();
417        sec2.threat_caution_ceiling = InputAuthority::Peer;
418        let claim2 = resolve_api_claim(true, "api", &sec2);
419        assert_eq!(claim2.authority, InputAuthority::Peer);
420        assert!(claim2.threat_downgraded); // ceiling < grant, binding
421    }
422}