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}