Skip to main content

stateset_authz/
engine.rs

1//! The authorization engine — combines roles, rate limiting, audit, and redaction.
2//!
3//! [`AuthzEngine`] is the main entry point for authorization checks. It is IO-free
4//! and framework-agnostic, designed to be embedded in any runtime.
5
6use std::collections::HashMap;
7
8use crate::{
9    AccessDecision, Action, AuditFilter, AuditLog, AuditRecord, AuthzError, AuthzResult,
10    RateLimitDecision, RateLimitRule, RateLimiter, RedactionConfig, Resource, Role,
11};
12
13/// Default audit log capacity.
14const DEFAULT_AUDIT_MAX_SIZE: usize = 1000;
15
16/// The central authorization engine.
17///
18/// Combines role-based access control, rate limiting, audit logging, and
19/// field redaction into a single, coherent API.
20///
21/// ```rust
22/// use stateset_authz::{AuthzEngineBuilder, Role, Action, Resource};
23///
24/// let mut engine = AuthzEngineBuilder::new()
25///     .add_role(Role::admin())
26///     .add_role(Role::viewer())
27///     .assign_role("alice", "admin")
28///     .assign_role("bob", "viewer")
29///     .build();
30///
31/// let decision = engine.authorize("alice", &Resource::new("orders"), &Action::Delete);
32/// assert!(decision.is_allowed());
33///
34/// let decision = engine.authorize("bob", &Resource::new("orders"), &Action::Delete);
35/// assert!(decision.is_denied());
36/// ```
37#[derive(Debug)]
38pub struct AuthzEngine {
39    roles: HashMap<String, Role>,
40    actor_roles: HashMap<String, String>,
41    rate_limiter: RateLimiter,
42    audit_log: AuditLog,
43    redaction_config: RedactionConfig,
44    approval_required: Vec<ApprovalRule>,
45}
46
47/// A rule that requires explicit approval for specific operations.
48#[derive(Debug, Clone)]
49struct ApprovalRule {
50    resource_type: Option<String>,
51    action: Option<Action>,
52}
53
54impl AuthzEngine {
55    /// Checks whether `actor_id` is allowed to perform `action` on `resource`.
56    ///
57    /// This method:
58    /// 1. Looks up the actor's role
59    /// 2. Checks the role's permission for the resource type
60    /// 3. Checks rate limits
61    /// 4. Checks approval-required rules
62    /// 5. Records an audit entry
63    ///
64    /// ```rust
65    /// use stateset_authz::{AuthzEngineBuilder, Role, Action, Resource};
66    ///
67    /// let mut engine = AuthzEngineBuilder::new()
68    ///     .add_role(Role::viewer())
69    ///     .assign_role("bob", "viewer")
70    ///     .build();
71    ///
72    /// let d = engine.authorize("bob", &Resource::new("orders"), &Action::Read);
73    /// assert!(d.is_allowed());
74    /// ```
75    pub fn authorize(
76        &mut self,
77        actor_id: &str,
78        resource: &Resource,
79        action: &Action,
80    ) -> AccessDecision {
81        // 1. Look up role
82        let role = if let Some(r) = self.resolve_role(actor_id) {
83            r
84        } else {
85            let decision =
86                AccessDecision::denied(format!("actor '{actor_id}' has no assigned role"));
87            self.record_audit(actor_id, action, resource, &decision);
88            return decision;
89        };
90
91        // 2. Check permission level
92        let decision = role.check(resource.resource_type(), action);
93        if decision.is_denied() {
94            self.record_audit(actor_id, action, resource, &decision);
95            return decision;
96        }
97
98        // 3. Check rate limits
99        let rate_decision = self.rate_limiter.check_and_record(actor_id, resource.resource_type());
100        if let RateLimitDecision::Exceeded { retry_after } = rate_decision {
101            let decision = AccessDecision::denied(format!(
102                "rate limit exceeded for '{}' on '{}' (retry after {}ms)",
103                actor_id,
104                resource.resource_type(),
105                retry_after.as_millis(),
106            ));
107            self.record_audit(actor_id, action, resource, &decision);
108            return decision;
109        }
110
111        // 4. Check approval-required rules
112        for rule in &self.approval_required {
113            let resource_match =
114                rule.resource_type.as_ref().is_none_or(|rt| rt == resource.resource_type());
115            let action_match = rule.action.as_ref().is_none_or(|a| a == action);
116
117            if resource_match && action_match {
118                let decision = AccessDecision::requires_approval(format!(
119                    "action '{action}' on '{}' requires explicit approval",
120                    resource.resource_type(),
121                ));
122                self.record_audit(actor_id, action, resource, &decision);
123                return decision;
124            }
125        }
126
127        // 5. All checks passed
128        let decision = AccessDecision::Allowed;
129        self.record_audit(actor_id, action, resource, &decision);
130        decision
131    }
132
133    /// Adds a new role to the engine.
134    ///
135    /// If a role with the same name already exists, it is replaced.
136    pub fn add_role(&mut self, role: Role) {
137        self.roles.insert(role.name().to_owned(), role);
138    }
139
140    /// Assigns a role to an actor.
141    ///
142    /// Returns an error if the role does not exist.
143    pub fn assign_role(&mut self, actor_id: &str, role_name: &str) -> AuthzResult<()> {
144        if !self.roles.contains_key(role_name) {
145            return Err(AuthzError::invalid_role(role_name));
146        }
147        self.actor_roles.insert(actor_id.to_owned(), role_name.to_owned());
148        Ok(())
149    }
150
151    /// Removes a role assignment for an actor.
152    pub fn remove_role(&mut self, actor_id: &str) {
153        self.actor_roles.remove(actor_id);
154    }
155
156    /// Returns the role assigned to an actor, if any.
157    #[must_use]
158    pub fn actor_role(&self, actor_id: &str) -> Option<&str> {
159        self.actor_roles.get(actor_id).map(String::as_str)
160    }
161
162    /// Returns a reference to the rate limiter.
163    #[must_use]
164    pub const fn rate_limiter(&self) -> &RateLimiter {
165        &self.rate_limiter
166    }
167
168    /// Returns a mutable reference to the rate limiter.
169    pub const fn rate_limiter_mut(&mut self) -> &mut RateLimiter {
170        &mut self.rate_limiter
171    }
172
173    /// Returns a reference to the audit log.
174    #[must_use]
175    pub const fn audit_log(&self) -> &AuditLog {
176        &self.audit_log
177    }
178
179    /// Queries the audit log with the given filter.
180    #[must_use]
181    pub fn query_audit(&self, filter: &AuditFilter) -> Vec<&AuditRecord> {
182        self.audit_log.query(filter)
183    }
184
185    /// Returns a reference to the redaction config.
186    #[must_use]
187    pub const fn redaction_config(&self) -> &RedactionConfig {
188        &self.redaction_config
189    }
190
191    /// Redacts sensitive fields in a JSON value using the engine's redaction config.
192    pub fn redact(&self, value: &mut serde_json::Value) {
193        crate::redact_value(value, &self.redaction_config);
194    }
195
196    /// Adds a rate limit rule.
197    pub fn add_rate_limit_rule(&mut self, rule: RateLimitRule) {
198        self.rate_limiter.add_rule(rule);
199    }
200
201    /// Requires explicit approval for operations matching the criteria.
202    pub fn require_approval(&mut self, resource_type: Option<String>, action: Option<Action>) {
203        self.approval_required.push(ApprovalRule { resource_type, action });
204    }
205
206    // -- Private helpers --
207
208    fn resolve_role(&self, actor_id: &str) -> Option<Role> {
209        let role_name = self.actor_roles.get(actor_id)?;
210        self.roles.get(role_name).cloned()
211    }
212
213    fn record_audit(
214        &mut self,
215        actor_id: &str,
216        action: &Action,
217        resource: &Resource,
218        decision: &AccessDecision,
219    ) {
220        let record = AuditRecord::new(actor_id, *action, resource.clone(), decision.clone());
221        self.audit_log.record(record);
222    }
223}
224
225/// Builder for constructing an [`AuthzEngine`].
226///
227/// ```rust
228/// use stateset_authz::{AuthzEngineBuilder, Role, RateLimitRule, RedactionConfig};
229/// use std::time::Duration;
230///
231/// let engine = AuthzEngineBuilder::new()
232///     .add_role(Role::admin())
233///     .add_role(Role::viewer())
234///     .assign_role("alice", "admin")
235///     .assign_role("bob", "viewer")
236///     .rate_limit_rule(RateLimitRule::new("orders", 100, Duration::from_secs(60)))
237///     .redaction_config(RedactionConfig::default())
238///     .audit_max_size(5000)
239///     .build();
240/// ```
241#[derive(Debug)]
242pub struct AuthzEngineBuilder {
243    roles: HashMap<String, Role>,
244    actor_roles: HashMap<String, String>,
245    rate_limit_rules: Vec<RateLimitRule>,
246    redaction_config: RedactionConfig,
247    audit_max_size: usize,
248    approval_rules: Vec<ApprovalRule>,
249}
250
251impl AuthzEngineBuilder {
252    /// Creates a new builder with default settings.
253    #[must_use]
254    pub fn new() -> Self {
255        Self {
256            roles: HashMap::new(),
257            actor_roles: HashMap::new(),
258            rate_limit_rules: Vec::new(),
259            redaction_config: RedactionConfig::default(),
260            audit_max_size: DEFAULT_AUDIT_MAX_SIZE,
261            approval_rules: Vec::new(),
262        }
263    }
264
265    /// Adds a role to the engine.
266    #[must_use]
267    pub fn add_role(mut self, role: Role) -> Self {
268        self.roles.insert(role.name().to_owned(), role);
269        self
270    }
271
272    /// Assigns a role to an actor.
273    ///
274    /// [`build`](Self::build) and [`build_checked`](Self::build_checked) validate
275    /// that every assigned role exists before constructing the engine.
276    #[must_use]
277    pub fn assign_role(
278        mut self,
279        actor_id: impl Into<String>,
280        role_name: impl Into<String>,
281    ) -> Self {
282        self.actor_roles.insert(actor_id.into(), role_name.into());
283        self
284    }
285
286    /// Adds a rate limit rule.
287    #[must_use]
288    pub fn rate_limit_rule(mut self, rule: RateLimitRule) -> Self {
289        self.rate_limit_rules.push(rule);
290        self
291    }
292
293    /// Sets the redaction configuration.
294    #[must_use]
295    pub fn redaction_config(mut self, config: RedactionConfig) -> Self {
296        self.redaction_config = config;
297        self
298    }
299
300    /// Sets the maximum number of audit records to retain.
301    #[must_use]
302    pub const fn audit_max_size(mut self, max_size: usize) -> Self {
303        self.audit_max_size = max_size;
304        self
305    }
306
307    /// Adds an approval requirement.
308    #[must_use]
309    pub fn require_approval(
310        mut self,
311        resource_type: Option<String>,
312        action: Option<Action>,
313    ) -> Self {
314        self.approval_rules.push(ApprovalRule { resource_type, action });
315        self
316    }
317
318    /// Builds the [`AuthzEngine`], returning an error when any assignment
319    /// references a role that has not been added to the builder.
320    pub fn build_checked(self) -> AuthzResult<AuthzEngine> {
321        self.validate_assignments()?;
322        Ok(self.build_unchecked())
323    }
324
325    /// Builds the [`AuthzEngine`].
326    ///
327    /// # Panics
328    ///
329    /// Panics when any assignment references a role that has not been added.
330    /// Prefer [`build_checked`](Self::build_checked) to surface configuration
331    /// errors as `Result` without panicking.
332    #[must_use]
333    pub fn build(self) -> AuthzEngine {
334        self.build_checked().expect("invalid AuthzEngineBuilder configuration")
335    }
336
337    fn validate_assignments(&self) -> AuthzResult<()> {
338        if let Some(role_name) =
339            self.actor_roles.values().find(|role_name| !self.roles.contains_key(role_name.as_str()))
340        {
341            return Err(AuthzError::invalid_role(role_name.clone()));
342        }
343        Ok(())
344    }
345
346    fn build_unchecked(self) -> AuthzEngine {
347        let mut rate_limiter = RateLimiter::new();
348        for rule in self.rate_limit_rules {
349            rate_limiter.add_rule(rule);
350        }
351
352        AuthzEngine {
353            roles: self.roles,
354            actor_roles: self.actor_roles,
355            rate_limiter,
356            audit_log: AuditLog::new(self.audit_max_size),
357            redaction_config: self.redaction_config,
358            approval_required: self.approval_rules,
359        }
360    }
361}
362
363impl Default for AuthzEngineBuilder {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use crate::{PermissionLevel, RoleBuilder};
372    use std::time::Duration;
373
374    use super::*;
375
376    fn basic_engine() -> AuthzEngine {
377        AuthzEngineBuilder::new()
378            .add_role(Role::admin())
379            .add_role(Role::viewer())
380            .add_role(Role::none())
381            .assign_role("alice", "admin")
382            .assign_role("bob", "viewer")
383            .assign_role("nobody", "none")
384            .build()
385    }
386
387    // -- authorize --
388
389    #[test]
390    fn admin_can_do_everything() {
391        let mut engine = basic_engine();
392        for &action in Action::all() {
393            let d = engine.authorize("alice", &Resource::new("orders"), &action);
394            assert!(d.is_allowed(), "admin should allow {action}");
395        }
396    }
397
398    #[test]
399    fn viewer_can_only_read() {
400        let mut engine = basic_engine();
401        assert!(engine.authorize("bob", &Resource::new("orders"), &Action::Read).is_allowed());
402        assert!(engine.authorize("bob", &Resource::new("orders"), &Action::Create).is_denied());
403    }
404
405    #[test]
406    fn none_role_denied() {
407        let mut engine = basic_engine();
408        assert!(engine.authorize("nobody", &Resource::new("orders"), &Action::Read).is_denied());
409    }
410
411    #[test]
412    fn unknown_actor_denied() {
413        let mut engine = basic_engine();
414        let d = engine.authorize("ghost", &Resource::new("orders"), &Action::Read);
415        assert!(d.is_denied());
416        assert!(d.reason().unwrap().contains("no assigned role"));
417    }
418
419    // -- Rate limiting --
420
421    #[test]
422    fn rate_limit_blocks_after_max() {
423        let mut engine = AuthzEngineBuilder::new()
424            .add_role(Role::admin())
425            .assign_role("alice", "admin")
426            .rate_limit_rule(RateLimitRule::new("orders", 2, Duration::from_secs(60)))
427            .build();
428
429        assert!(engine.authorize("alice", &Resource::new("orders"), &Action::Read).is_allowed());
430        assert!(engine.authorize("alice", &Resource::new("orders"), &Action::Read).is_allowed());
431        let d = engine.authorize("alice", &Resource::new("orders"), &Action::Read);
432        assert!(d.is_denied());
433        assert!(d.reason().unwrap().contains("rate limit"));
434    }
435
436    // -- Approval required --
437
438    #[test]
439    fn approval_required_triggers() {
440        let mut engine = AuthzEngineBuilder::new()
441            .add_role(Role::admin())
442            .assign_role("alice", "admin")
443            .require_approval(Some("orders".to_owned()), Some(Action::Delete))
444            .build();
445
446        // Delete requires approval even for admin
447        let d = engine.authorize("alice", &Resource::new("orders"), &Action::Delete);
448        assert!(d.requires_approval_check());
449        assert!(d.reason().unwrap().contains("requires explicit approval"));
450
451        // But read is fine
452        assert!(engine.authorize("alice", &Resource::new("orders"), &Action::Read).is_allowed());
453    }
454
455    #[test]
456    fn approval_required_wildcard_action() {
457        let mut engine = AuthzEngineBuilder::new()
458            .add_role(Role::admin())
459            .assign_role("alice", "admin")
460            .require_approval(Some("payments".to_owned()), None)
461            .build();
462
463        // Any action on payments requires approval
464        let d = engine.authorize("alice", &Resource::new("payments"), &Action::Read);
465        assert!(d.requires_approval_check());
466    }
467
468    // -- Audit logging --
469
470    #[test]
471    fn authorize_records_audit() {
472        let mut engine = basic_engine();
473        engine.authorize("alice", &Resource::new("orders"), &Action::Read);
474
475        assert_eq!(engine.audit_log().len(), 1);
476        let records = engine.query_audit(&AuditFilter::new().actor("alice"));
477        assert_eq!(records.len(), 1);
478        assert!(records[0].decision().is_allowed());
479    }
480
481    #[test]
482    fn denied_operations_also_audited() {
483        let mut engine = basic_engine();
484        engine.authorize("bob", &Resource::new("orders"), &Action::Delete);
485
486        let records = engine.query_audit(&AuditFilter::new().actor("bob"));
487        assert_eq!(records.len(), 1);
488        assert!(records[0].decision().is_denied());
489    }
490
491    // -- Role management --
492
493    #[test]
494    fn add_role_at_runtime() {
495        let mut engine = basic_engine();
496        let custom = RoleBuilder::new("custom").default_level(PermissionLevel::Write).build();
497
498        engine.add_role(custom);
499        engine.assign_role("charlie", "custom").unwrap();
500
501        assert!(
502            engine.authorize("charlie", &Resource::new("orders"), &Action::Create).is_allowed()
503        );
504    }
505
506    #[test]
507    fn assign_invalid_role_returns_error() {
508        let mut engine = basic_engine();
509        let err = engine.assign_role("charlie", "nonexistent").unwrap_err();
510        assert!(err.to_string().contains("nonexistent"));
511    }
512
513    #[test]
514    fn remove_role_denies_access() {
515        let mut engine = basic_engine();
516        assert!(engine.authorize("alice", &Resource::new("orders"), &Action::Read).is_allowed());
517
518        engine.remove_role("alice");
519
520        assert!(engine.authorize("alice", &Resource::new("orders"), &Action::Read).is_denied());
521    }
522
523    #[test]
524    fn actor_role_accessor() {
525        let engine = basic_engine();
526        assert_eq!(engine.actor_role("alice"), Some("admin"));
527        assert_eq!(engine.actor_role("ghost"), None);
528    }
529
530    // -- Redaction --
531
532    #[test]
533    fn engine_redact_uses_config() {
534        let engine = AuthzEngineBuilder::new().build();
535        let mut value = serde_json::json!({
536            "name": "Test",
537            "password": "secret"
538        });
539
540        engine.redact(&mut value);
541        assert_eq!(value["password"], "[REDACTED]");
542        assert_eq!(value["name"], "Test");
543    }
544
545    // -- Builder --
546
547    #[test]
548    fn builder_default() {
549        let builder = AuthzEngineBuilder::default();
550        let engine = builder.build();
551        assert_eq!(engine.audit_log().max_size(), DEFAULT_AUDIT_MAX_SIZE);
552    }
553
554    #[test]
555    fn builder_custom_audit_size() {
556        let engine = AuthzEngineBuilder::new().audit_max_size(50).build();
557        assert_eq!(engine.audit_log().max_size(), 50);
558    }
559
560    #[test]
561    fn builder_custom_redaction() {
562        let config = RedactionConfig::with_fields(["custom_field"]);
563        let engine = AuthzEngineBuilder::new().redaction_config(config).build();
564
565        assert!(engine.redaction_config().should_redact("custom_field"));
566        assert!(!engine.redaction_config().should_redact("password"));
567    }
568
569    #[test]
570    fn builder_build_checked_rejects_unknown_role_assignment() {
571        let err = AuthzEngineBuilder::new()
572            .add_role(Role::admin())
573            .assign_role("alice", "missing")
574            .build_checked()
575            .unwrap_err();
576
577        assert_eq!(err, AuthzError::invalid_role("missing"));
578    }
579
580    // -- Full flow integration --
581
582    #[test]
583    fn full_flow_assign_authorize_audit() {
584        let mut engine = AuthzEngineBuilder::new()
585            .add_role(Role::admin())
586            .add_role(Role::viewer())
587            .assign_role("alice", "admin")
588            .assign_role("bob", "viewer")
589            .rate_limit_rule(RateLimitRule::new("orders", 10, Duration::from_secs(60)))
590            .build();
591
592        // Alice can create orders
593        let d = engine.authorize("alice", &Resource::new("orders"), &Action::Create);
594        assert!(d.is_allowed());
595
596        // Bob cannot create orders
597        let d = engine.authorize("bob", &Resource::new("orders"), &Action::Create);
598        assert!(d.is_denied());
599
600        // Both operations were audited
601        assert_eq!(engine.audit_log().len(), 2);
602
603        let alice_records = engine.query_audit(&AuditFilter::new().actor("alice"));
604        assert_eq!(alice_records.len(), 1);
605        assert!(alice_records[0].decision().is_allowed());
606
607        let bob_records = engine.query_audit(&AuditFilter::new().actor("bob"));
608        assert_eq!(bob_records.len(), 1);
609        assert!(bob_records[0].decision().is_denied());
610    }
611
612    #[test]
613    fn rate_limiting_integration() {
614        let mut engine = AuthzEngineBuilder::new()
615            .add_role(Role::admin())
616            .assign_role("alice", "admin")
617            .rate_limit_rule(RateLimitRule::new("orders", 3, Duration::from_secs(60)))
618            .build();
619
620        for _ in 0..3 {
621            assert!(
622                engine.authorize("alice", &Resource::new("orders"), &Action::Read).is_allowed()
623            );
624        }
625
626        let d = engine.authorize("alice", &Resource::new("orders"), &Action::Read);
627        assert!(d.is_denied());
628
629        // All 4 operations (3 allowed + 1 denied) are audited
630        assert_eq!(engine.audit_log().len(), 4);
631    }
632
633    #[test]
634    fn rate_limiter_accessors() {
635        let mut engine = AuthzEngineBuilder::new()
636            .rate_limit_rule(RateLimitRule::new("orders", 5, Duration::from_secs(60)))
637            .build();
638
639        assert_eq!(engine.rate_limiter().rule_count(), 1);
640
641        // Add another via mutable accessor
642        engine.rate_limiter_mut().add_rule(RateLimitRule::new(
643            "customers",
644            10,
645            Duration::from_secs(60),
646        ));
647        assert_eq!(engine.rate_limiter().rule_count(), 2);
648    }
649
650    #[test]
651    fn require_approval_at_runtime() {
652        let mut engine =
653            AuthzEngineBuilder::new().add_role(Role::admin()).assign_role("alice", "admin").build();
654
655        // Initially allowed
656        assert!(engine.authorize("alice", &Resource::new("orders"), &Action::Delete).is_allowed());
657
658        // Add approval requirement
659        engine.require_approval(Some("orders".to_owned()), Some(Action::Delete));
660
661        // Now requires approval
662        let d = engine.authorize("alice", &Resource::new("orders"), &Action::Delete);
663        assert!(d.requires_approval_check());
664    }
665}