Skip to main content

systemprompt_security/authz/
rule_based.rs

1//! Core `AuthzDecisionHook` wrapping the in-process [`super::resolver`].
2//!
3//! `RuleBasedHook` is the canonical RBAC layer: it loads
4//! `access_control_rules` for the request's entity, runs the sync resolver
5//! over them, and emits an `AuthzDecision`. Exposed as a hook so extensions
6//! can compose it explicitly with their own ABAC predicates via
7//! [`super::CompositeAuthzHook`]:
8//!
9//! ```ignore
10//! let composite = CompositeAuthzHook::new(vec![
11//!     Arc::new(RuleBasedHook::new(pool.clone(), sink.clone())),
12//!     Arc::new(MyAbacHook::new(...)),
13//! ]);
14//! ```
15//!
16//! Put `RuleBasedHook` first so a coarse-grained RBAC reject short-circuits
17//! the chain before any per-attribute lookup runs.
18//!
19//! Copyright (c) systemprompt.io — Business Source License 1.1.
20//! See <https://systemprompt.io> for licensing details.
21
22use std::sync::Arc;
23
24use async_trait::async_trait;
25use sqlx::PgPool;
26
27use super::audit::{AuthzAuditSink, AuthzSource};
28use super::hook::AuthzDecisionHook;
29use super::registry::AuthzHookContext;
30use super::repository::AccessControlRepository;
31use super::resolver::{ResolveInput, resolve};
32use super::subject::{
33    SharedSubjectAttributeProvider, SubjectDimension, dimensions_of, discover_subject_providers,
34    gather_subject_attributes,
35};
36use super::types::{AuthzDecision, AuthzRequest, Decision, DenyReason};
37
38#[derive(Debug, Clone)]
39pub struct RuleBasedHook {
40    repo: AccessControlRepository,
41    sink: Arc<dyn AuthzAuditSink>,
42    providers: Vec<SharedSubjectAttributeProvider>,
43    dimensions: Vec<SubjectDimension>,
44}
45
46impl RuleBasedHook {
47    /// Binds the extension subject-attribute providers registered via
48    /// [`register_subject_attribute_provider!
49    /// `][crate::register_subject_attribute_provider]
50    /// once, at construction, so every evaluation resolves the same ladder the
51    /// access matrix renders.
52    #[must_use]
53    pub fn new(pool: Arc<PgPool>, sink: Arc<dyn AuthzAuditSink>) -> Self {
54        let providers = discover_subject_providers(&AuthzHookContext {
55            pool: Arc::clone(&pool),
56            sink: Arc::clone(&sink),
57        });
58        Self {
59            repo: AccessControlRepository::from_pool(pool),
60            sink,
61            dimensions: dimensions_of(&providers),
62            providers,
63        }
64    }
65
66    async fn fault(&self, req: &AuthzRequest, detail: &str) -> AuthzDecision {
67        let policy = AuthzSource::RuleBased.policy().to_owned();
68        let decision = AuthzDecision::Deny {
69            reason: DenyReason::HookUnavailable {
70                policy: policy.clone(),
71            },
72            policy,
73        };
74        tracing::warn!(
75            entity = %req.entity,
76            user_id = %req.user_id,
77            error = %detail,
78            "rule-based authz hook fault",
79        );
80        self.sink
81            .record(req, &decision, AuthzSource::RuleBased)
82            .await;
83        decision
84    }
85}
86
87#[async_trait]
88impl AuthzDecisionHook for RuleBasedHook {
89    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
90        let kind = req.entity.kind();
91        let id = req.entity.id_str();
92
93        let entity = match self.repo.get_entity(kind, id).await {
94            Ok(row) => row,
95            Err(err) => return self.fault(&req, &err.to_string()).await,
96        };
97        let rules = match self.repo.list_rules_for_entity(kind, id).await {
98            Ok(rules) => rules,
99            Err(err) => return self.fault(&req, &err.to_string()).await,
100        };
101
102        let attributes = gather_subject_attributes(&self.providers, &req.user_id).await;
103        let decision = resolve(ResolveInput {
104            entity: &req.entity,
105            rules: &rules,
106            user_id: &req.user_id,
107            user_roles: &req.roles,
108            default_included: entity.map(|e| e.default_included),
109            parents: &[],
110            attributes: &attributes,
111            dimensions: &self.dimensions,
112        });
113
114        let policy = AuthzSource::RuleBased.policy().to_owned();
115        let authz_decision = match decision {
116            Decision::Allow { .. } => AuthzDecision::Allow,
117            Decision::Deny { reason } => AuthzDecision::Deny { reason, policy },
118        };
119        self.sink
120            .record(&req, &authz_decision, AuthzSource::RuleBased)
121            .await;
122        authz_decision
123    }
124}