core_policy/
policy.rs

1//! Policy definitions and validation logic
2//!
3//! This module provides the core domain types for RBAC/ABAC authorization:
4//! - `Action`: What operations can be performed
5//! - `Resource`: What can be accessed
6//! - `PolicyRule`: Individual authorization rule
7//! - `Policy`: Collection of rules with versioning
8//!
9//! ## Security Constraints
10//!
11//! The following limits are enforced to prevent resource exhaustion:
12//! - `MAX_RULES_PER_POLICY` (1024): Maximum rules per policy
13//! - `MAX_POLICY_NAME_LENGTH` (128): Maximum policy name length
14//! - `MAX_RESOURCE_PATTERN_LENGTH` (256): Maximum pattern length
15
16use crate::context_expr::ContextExpr;
17use crate::error::{PolicyError, Result};
18use crate::path::PathPattern;
19use crate::{MAX_POLICY_NAME_LENGTH, MAX_RULES_PER_POLICY};
20use alloc::collections::BTreeMap;
21use alloc::string::{String, ToString};
22use alloc::vec::Vec;
23use serde::{Deserialize, Serialize};
24
25/// Action that can be performed on a resource
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum Action {
28    /// Read access
29    Read,
30    /// Write access
31    Write,
32    /// Execute access
33    Execute,
34    /// Delete access
35    Delete,
36    /// All actions
37    All,
38    /// Custom action
39    Custom(String),
40}
41
42impl Action {
43    /// Check if this action matches another action (considering wildcards)
44    #[must_use]
45    pub fn matches(&self, other: &Self) -> bool {
46        match (self, other) {
47            (Self::All, _) | (_, Self::All) => true,
48            (a, b) => a == b,
49        }
50    }
51}
52
53/// Resource that can be accessed
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55pub enum Resource {
56    /// File system path
57    File(String),
58    /// USB device
59    Usb(String),
60    /// Network tunnel
61    Tunnel(String),
62    /// All resources
63    All,
64    /// Custom resource
65    Custom {
66        /// Resource type identifier
67        resource_type: String,
68        /// Resource path
69        path: String,
70    },
71}
72
73impl Resource {
74    /// Check if this resource matches another resource (considering wildcards)
75    #[must_use]
76    pub fn matches(&self, other: &Self) -> bool {
77        match (self, other) {
78            (Self::All, _) | (_, Self::All) => true,
79            (Self::File(pattern), Self::File(path)) => {
80                // Use unchecked since patterns in existing resources are assumed valid
81                PathPattern::new_unchecked(pattern).matches(path)
82            }
83            (Self::Usb(pattern), Self::Usb(device)) => {
84                PathPattern::new_unchecked(pattern).matches(device)
85            }
86            (Self::Tunnel(pattern), Self::Tunnel(name)) => {
87                PathPattern::new_unchecked(pattern).matches(name)
88            }
89            (
90                Self::Custom {
91                    resource_type: t1,
92                    path: p1,
93                },
94                Self::Custom {
95                    resource_type: t2,
96                    path: p2,
97                },
98            ) => t1 == t2 && PathPattern::new_unchecked(p1).matches(p2),
99            _ => false,
100        }
101    }
102}
103
104/// A single policy rule with optional ABAC (Attribute-Based Access Control) features
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct PolicyRule {
107    /// Peer ID that this rule applies to
108    pub peer_id: String,
109    /// Action allowed by this rule
110    pub action: Action,
111    /// Resource this rule applies to
112    pub resource: Resource,
113
114    // ===== ABAC Features =====
115    /// Optional expiration timestamp (Unix seconds)
116    /// If set, the rule is only valid before this time
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub expires_at: Option<u64>,
119
120    /// Optional context attributes for conditional access (legacy - simple key-value matching)
121    /// Examples: {"location": "office", "security_level": "high"}
122    ///
123    /// Uses BTreeMap for deterministic serialization (cryptographic safety)
124    ///
125    /// **Note:** This is the legacy ABAC mechanism. For complex boolean logic,
126    /// use `context_expr` instead.
127    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
128    pub attributes: BTreeMap<String, String>,
129
130    /// Optional context expression for advanced ABAC (boolean logic)
131    ///
132    /// This provides more powerful conditional logic than simple attribute matching:
133    /// - Boolean operators: AND, OR, NOT
134    /// - Comparison operators: ==, !=, <, <=, >, >=
135    /// - Attribute existence checks: HAS
136    ///
137    /// Examples:
138    /// - `role == "admin" AND department == "IT"`
139    /// - `(role == "admin" OR role == "moderator") AND active == "true"`
140    /// - `NOT (status == "banned")`
141    ///
142    /// When both `attributes` and `context_expr` are present, **both** must match.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub context_expr: Option<ContextExpr>,
145}
146
147impl PolicyRule {
148    /// Create a new policy rule with basic RBAC
149    #[must_use]
150    pub fn new(peer_id: String, action: Action, resource: Resource) -> Self {
151        Self {
152            peer_id,
153            action,
154            resource,
155            expires_at: None,
156            attributes: BTreeMap::new(),
157            context_expr: None,
158        }
159    }
160
161    /// Create a new policy rule with expiration (ABAC)
162    #[must_use]
163    pub fn with_expiration(
164        peer_id: String,
165        action: Action,
166        resource: Resource,
167        expires_at: u64,
168    ) -> Self {
169        Self {
170            peer_id,
171            action,
172            resource,
173            expires_at: Some(expires_at),
174            attributes: BTreeMap::new(),
175            context_expr: None,
176        }
177    }
178
179    /// Create a new policy rule with attributes (ABAC)
180    #[must_use]
181    pub const fn with_attributes(
182        peer_id: String,
183        action: Action,
184        resource: Resource,
185        attributes: BTreeMap<String, String>,
186    ) -> Self {
187        Self {
188            peer_id,
189            action,
190            resource,
191            expires_at: None,
192            attributes,
193            context_expr: None,
194        }
195    }
196
197    /// Add an expiration time to this rule
198    #[must_use]
199    pub const fn expires_at(mut self, timestamp: u64) -> Self {
200        self.expires_at = Some(timestamp);
201        self
202    }
203
204    /// Add an attribute to this rule
205    #[must_use]
206    pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
207        self.attributes.insert(key.into(), value.into());
208        self
209    }
210
211    /// Add a context expression to this rule (advanced ABAC)
212    ///
213    /// # Example
214    ///
215    /// ```
216    /// use core_policy::{PolicyRule, Action, Resource, ContextExpr};
217    ///
218    /// let rule = PolicyRule::new("alice".into(), Action::Read, Resource::All)
219    ///     .with_context_expr(ContextExpr::parse("role == \"admin\"").unwrap());
220    /// ```
221    #[must_use]
222    pub fn with_context_expr(mut self, expr: ContextExpr) -> Self {
223        self.context_expr = Some(expr);
224        self
225    }
226
227    /// Check if this rule has expired
228    #[must_use]
229    pub fn is_expired(&self, current_time: u64) -> bool {
230        self.expires_at.is_some_and(|exp| current_time >= exp)
231    }
232
233    /// Check if this rule's attributes match the given context
234    ///
235    /// This method evaluates both legacy attribute matching and the new context expression:
236    /// 1. If `attributes` is non-empty, all attributes must match (legacy behavior)
237    /// 2. If `context_expr` is present, it must evaluate to true
238    /// 3. Both conditions must be satisfied if both are present
239    ///
240    /// Returns true if all context constraints match.
241    #[must_use]
242    pub fn matches_context(&self, context: &BTreeMap<String, String>) -> bool {
243        // Legacy attribute matching (simple key-value equality)
244        let attributes_match = if self.attributes.is_empty() {
245            true // No constraints = always matches
246        } else {
247            // All rule attributes must be present in context and match
248            self.attributes
249                .iter()
250                .all(|(key, value)| context.get(key) == Some(value))
251        };
252
253        // New context expression evaluation (boolean logic)
254        let expr_match = match &self.context_expr {
255            None => true, // No expression = always matches
256            Some(expr) => {
257                // Evaluate expression with depth 0 (start of recursion)
258                // If evaluation fails (e.g., too deep), treat as non-match for security
259                expr.evaluate(context, 0).unwrap_or(false)
260            }
261        };
262
263        // Both must match
264        attributes_match && expr_match
265    }
266
267    /// Check if this rule allows a specific action on a resource for a peer
268    /// Basic RBAC check (no time or context validation)
269    #[must_use]
270    pub fn allows(&self, peer_id: &str, action: &Action, resource: &Resource) -> bool {
271        self.peer_id == peer_id && self.action.matches(action) && self.resource.matches(resource)
272    }
273
274    /// Check if this rule allows a specific action on a resource for a peer
275    /// Includes time-based and attribute-based checks
276    #[must_use]
277    pub fn allows_with_context(
278        &self,
279        peer_id: &str,
280        action: &Action,
281        resource: &Resource,
282        current_time: u64,
283        context: &BTreeMap<String, String>,
284    ) -> bool {
285        // Basic RBAC check
286        if !self.allows(peer_id, action, resource) {
287            return false;
288        }
289
290        // Time-based check (if rule has expiration)
291        if self.is_expired(current_time) {
292            return false;
293        }
294
295        // Attribute-based check (if rule has attributes)
296        if !self.matches_context(context) {
297            return false;
298        }
299
300        true
301    }
302}
303
304/// A policy containing multiple rules
305///
306/// # Security
307///
308/// Fields are private to enforce validation through deserialization.
309/// Use `Policy::new()` or deserialize from TOML/JSON to create instances.
310/// The `#[serde(try_from)]` attribute ensures all deserialized policies
311/// are validated against T20 limits (max rules, max name length, etc.).
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(try_from = "PolicyRaw")]
314pub struct Policy {
315    /// Policy name/identifier
316    name: String,
317
318    // ===== Version Control =====
319    /// Policy version (monotonic counter, starts at 1)
320    version: u64,
321
322    /// Unix timestamp when this policy was issued
323    issued_at: u64,
324
325    /// Unix timestamp when this policy expires
326    valid_until: u64,
327
328    /// List of policy rules
329    rules: Vec<PolicyRule>,
330
331    /// Metadata (uses BTreeMap for deterministic serialization)
332    metadata: BTreeMap<String, String>,
333}
334
335fn default_version() -> u64 {
336    1
337}
338
339/// Raw policy structure for deserialization (internal use only)
340///
341/// This struct is used as an intermediate representation during deserialization.
342/// After parsing, it is converted to `Policy` via `TryFrom<PolicyRaw>`, which
343/// performs validation to enforce T20 limits.
344///
345/// This pattern ensures that **all** deserialized policies are validated,
346/// preventing DoS attacks through maliciously crafted policy files.
347#[derive(Debug, Clone, Deserialize)]
348struct PolicyRaw {
349    name: String,
350    #[serde(default = "default_version")]
351    version: u64,
352    #[serde(default)]
353    issued_at: u64,
354    #[serde(default)]
355    valid_until: u64,
356    rules: Vec<PolicyRule>,
357    #[serde(default)]
358    metadata: BTreeMap<String, String>,
359}
360
361/// Convert PolicyRaw to Policy with validation
362///
363/// This is called automatically during deserialization due to the
364/// `#[serde(try_from = "PolicyRaw")]` attribute on `Policy`.
365///
366/// # Errors
367///
368/// Returns `PolicyError` if validation fails:
369/// - `TooManyRules`: More than `MAX_RULES_PER_POLICY` rules
370/// - `NameTooLong`: Policy name exceeds `MAX_POLICY_NAME_LENGTH`
371/// - `InvalidRule`: Other validation failures (empty name, no rules, etc.)
372impl TryFrom<PolicyRaw> for Policy {
373    type Error = PolicyError;
374
375    fn try_from(raw: PolicyRaw) -> Result<Self> {
376        // T20 mitigation: Enforce maximum name length
377        if raw.name.len() > MAX_POLICY_NAME_LENGTH {
378            return Err(PolicyError::NameTooLong {
379                max: MAX_POLICY_NAME_LENGTH,
380                length: raw.name.len(),
381            });
382        }
383
384        // T20 mitigation: Enforce maximum rules
385        if raw.rules.len() > MAX_RULES_PER_POLICY {
386            return Err(PolicyError::TooManyRules {
387                max: MAX_RULES_PER_POLICY,
388                attempted: raw.rules.len(),
389            });
390        }
391
392        // Create policy instance
393        let policy = Policy {
394            name: raw.name,
395            version: raw.version,
396            issued_at: raw.issued_at,
397            valid_until: raw.valid_until,
398            rules: raw.rules,
399            metadata: raw.metadata,
400        };
401
402        // Run additional validation
403        policy.validate()?;
404
405        Ok(policy)
406    }
407}
408
409impl Policy {
410    // ===== Accessors =====
411
412    /// Get the policy name
413    #[must_use]
414    pub fn name(&self) -> &str {
415        &self.name
416    }
417
418    /// Get the policy version
419    #[must_use]
420    pub const fn version(&self) -> u64 {
421        self.version
422    }
423
424    /// Get the issuance timestamp
425    #[must_use]
426    pub const fn issued_at(&self) -> u64 {
427        self.issued_at
428    }
429
430    /// Get the expiration timestamp
431    #[must_use]
432    pub const fn valid_until(&self) -> u64 {
433        self.valid_until
434    }
435
436    /// Get a reference to the policy rules
437    #[must_use]
438    pub fn rules(&self) -> &[PolicyRule] {
439        &self.rules
440    }
441
442    /// Get a reference to the metadata
443    #[must_use]
444    pub fn metadata(&self) -> &BTreeMap<String, String> {
445        &self.metadata
446    }
447
448    // ===== Constructors =====
449
450    /// Create a new empty policy with version 1
451    ///
452    /// # Arguments
453    ///
454    /// * `name` - Policy identifier
455    /// * `valid_duration_secs` - How long this policy is valid (in seconds)
456    /// * `current_time` - Current Unix timestamp (injected for purity/determinism)
457    ///
458    /// # Errors
459    ///
460    /// Returns `PolicyError::NameTooLong` if name exceeds `MAX_POLICY_NAME_LENGTH`
461    pub fn new(
462        name: impl Into<String>,
463        valid_duration_secs: u64,
464        current_time: u64,
465    ) -> Result<Self> {
466        let name = name.into();
467
468        // T20 mitigation: Enforce maximum name length
469        if name.len() > MAX_POLICY_NAME_LENGTH {
470            return Err(PolicyError::NameTooLong {
471                max: MAX_POLICY_NAME_LENGTH,
472                length: name.len(),
473            });
474        }
475
476        Ok(Self {
477            name,
478            version: 1,
479            issued_at: current_time,
480            valid_until: current_time + valid_duration_secs,
481            rules: Vec::new(),
482            metadata: BTreeMap::new(),
483        })
484    }
485
486    /// Create a policy without timestamps (for testing/legacy)
487    ///
488    /// # Errors
489    ///
490    /// Returns `PolicyError::NameTooLong` if name exceeds `MAX_POLICY_NAME_LENGTH`
491    pub fn new_unversioned(name: impl Into<String>) -> Result<Self> {
492        let name = name.into();
493
494        // T20 mitigation: Enforce maximum name length
495        if name.len() > MAX_POLICY_NAME_LENGTH {
496            return Err(PolicyError::NameTooLong {
497                max: MAX_POLICY_NAME_LENGTH,
498                length: name.len(),
499            });
500        }
501
502        Ok(Self {
503            name,
504            version: 1,
505            issued_at: 0,
506            valid_until: 2_000_000_000, // Year 2033 (reasonable far future)
507            rules: Vec::new(),
508            metadata: BTreeMap::new(),
509        })
510    }
511
512    /// Add a rule to this policy
513    ///
514    /// # Errors
515    ///
516    /// Returns `PolicyError::TooManyRules` if adding this rule would exceed `MAX_RULES_PER_POLICY`
517    pub fn add_rule(mut self, rule: PolicyRule) -> Result<Self> {
518        // T20 mitigation: Enforce maximum rules
519        if self.rules.len() >= MAX_RULES_PER_POLICY {
520            return Err(PolicyError::TooManyRules {
521                max: MAX_RULES_PER_POLICY,
522                attempted: self.rules.len() + 1,
523            });
524        }
525
526        self.rules.push(rule);
527        Ok(self)
528    }
529
530    /// Add metadata to this policy
531    #[must_use]
532    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
533        self.metadata.insert(key.into(), value.into());
534        self
535    }
536
537    /// Check if a peer is allowed to perform an action on a resource
538    ///
539    /// This method delegates to `PolicyAuthorizer` (SRP - Single Responsibility Principle).
540    /// The Policy struct focuses on construction and management, while authorization
541    /// logic is handled by the dedicated `PolicyAuthorizer`.
542    #[must_use]
543    pub fn is_allowed(&self, peer_id: &str, action: &Action, resource: &Resource) -> bool {
544        crate::authorizer::PolicyAuthorizer::new(&self.rules).is_allowed(peer_id, action, resource)
545    }
546
547    /// Validate policy (check for conflicts, invalid rules, etc.)
548    ///
549    /// # Errors
550    ///
551    /// Returns `PolicyError::InvalidRule` if:
552    /// - Policy name is empty
553    /// - Policy has no rules
554    /// - Any rule has an empty peer ID
555    pub fn validate(&self) -> Result<()> {
556        if self.name.is_empty() {
557            return Err(PolicyError::InvalidRule(
558                "Policy name cannot be empty".to_string(),
559            ));
560        }
561
562        if self.rules.is_empty() {
563            return Err(PolicyError::InvalidRule(
564                "Policy must have at least one rule".to_string(),
565            ));
566        }
567
568        for rule in &self.rules {
569            if rule.peer_id.is_empty() {
570                return Err(PolicyError::InvalidRule(
571                    "Peer ID cannot be empty".to_string(),
572                ));
573            }
574        }
575
576        Ok(())
577    }
578
579    /// Load policy from TOML string
580    ///
581    /// # Errors
582    ///
583    /// Returns an error if:
584    /// - TOML parsing fails
585    /// - Validation fails (see `validate()`)
586    pub fn from_toml(toml_str: &str) -> Result<Self> {
587        let policy: Self = toml::from_str(toml_str)?;
588        policy.validate()?;
589        Ok(policy)
590    }
591
592    /// Serialize policy to TOML string
593    ///
594    /// # Errors
595    ///
596    /// Returns `PolicyError::SerializationError` if TOML serialization fails
597    pub fn to_toml(&self) -> Result<String> {
598        toml::to_string(self).map_err(|e| PolicyError::SerializationError(e.to_string()))
599    }
600}