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, resolves them through the
5//! entity's plugin and marketplace parent chain, and emits an
6//! `AuthzDecision`. The chain's membership is supplied at construction,
7//! because this crate cannot load the services configuration itself, and is
8//! fixed for the process lifetime. Exposed as a hook so
9//! extensions can compose it explicitly with their own ABAC predicates via
10//! [`super::CompositeAuthzHook`]:
11//!
12//! ```ignore
13//! let composite = CompositeAuthzHook::new(vec![
14//!     Arc::new(RuleBasedHook::new(pool.clone(), sink.clone())),
15//!     Arc::new(MyAbacHook::new(...)),
16//! ]);
17//! ```
18//!
19//! Put `RuleBasedHook` first so a coarse-grained RBAC reject short-circuits
20//! the chain before any per-attribute lookup runs.
21//!
22//! Copyright (c) systemprompt.io — Business Source License 1.1.
23//! See <https://systemprompt.io> for licensing details.
24
25use std::sync::Arc;
26
27use async_trait::async_trait;
28use sqlx::PgPool;
29
30use super::audit::{AuthzAuditSink, AuthzSource};
31use super::hook::AuthzDecisionHook;
32use super::parent_chain::{ChainSources, ParentChainIndex, ResolveBase};
33use super::registry::AuthzHookContext;
34use super::repository::AccessControlRepository;
35use super::subject::{
36    SharedSubjectAttributeProvider, SubjectDimension, dimensions_of, discover_subject_providers,
37    gather_subject_attributes,
38};
39use super::types::{AuthzDecision, AuthzRequest, Decision, DenyReason};
40
41#[derive(Clone)]
42pub struct RuleBasedHook {
43    repo: AccessControlRepository,
44    sink: Arc<dyn AuthzAuditSink>,
45    providers: Vec<SharedSubjectAttributeProvider>,
46    dimensions: Vec<SubjectDimension>,
47    sources: ChainSources,
48}
49
50impl std::fmt::Debug for RuleBasedHook {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("RuleBasedHook")
53            .field("repo", &self.repo)
54            .field("sink", &self.sink)
55            .field("providers", &self.providers)
56            .field("dimensions", &self.dimensions)
57            .finish_non_exhaustive()
58    }
59}
60
61impl RuleBasedHook {
62    #[must_use]
63    pub fn new(pool: Arc<PgPool>, sink: Arc<dyn AuthzAuditSink>, sources: ChainSources) -> Self {
64        let providers = discover_subject_providers(&AuthzHookContext {
65            pool: Arc::clone(&pool),
66            sink: Arc::clone(&sink),
67        });
68        Self {
69            repo: AccessControlRepository::from_pool(pool),
70            sink,
71            dimensions: dimensions_of(&providers),
72            providers,
73            sources,
74        }
75    }
76
77    async fn chain_index(&self) -> Result<ParentChainIndex, String> {
78        ParentChainIndex::load(&self.repo, self.sources.clone())
79            .await
80            .map_err(|e| e.to_string())
81    }
82
83    async fn fault(&self, req: &AuthzRequest, detail: &str) -> AuthzDecision {
84        let policy = AuthzSource::RuleBased.policy().to_owned();
85        let decision = AuthzDecision::Deny {
86            reason: DenyReason::HookUnavailable {
87                policy: policy.clone(),
88            },
89            policy,
90        };
91        tracing::warn!(
92            entity = %req.entity,
93            user_id = %req.user_id,
94            error = %detail,
95            "rule-based authz hook fault",
96        );
97        self.sink
98            .record(req, &decision, AuthzSource::RuleBased)
99            .await;
100        decision
101    }
102}
103
104#[async_trait]
105impl AuthzDecisionHook for RuleBasedHook {
106    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
107        let kind = req.entity.kind();
108        let id = req.entity.id_str();
109
110        let entity = match self.repo.get_entity(kind, id).await {
111            Ok(row) => row,
112            Err(err) => return self.fault(&req, &err.to_string()).await,
113        };
114        let rules = match self.repo.list_rules_for_entity(kind, id).await {
115            Ok(rules) => rules,
116            Err(err) => return self.fault(&req, &err.to_string()).await,
117        };
118
119        let index = match self.chain_index().await {
120            Ok(index) => index,
121            Err(detail) => return self.fault(&req, &detail).await,
122        };
123
124        let attributes = gather_subject_attributes(&self.providers, &req.user_id).await;
125        let decision = index.resolve(
126            kind,
127            id,
128            ResolveBase {
129                rules: &rules,
130                user_id: &req.user_id,
131                user_roles: &req.roles,
132                default_included: entity.map(|e| e.default_included),
133                attributes: &attributes,
134                dimensions: &self.dimensions,
135            },
136        );
137
138        let policy = AuthzSource::RuleBased.policy().to_owned();
139        let authz_decision = match decision {
140            Decision::Allow { .. } => AuthzDecision::Allow,
141            Decision::Deny { reason } => AuthzDecision::Deny { reason, policy },
142            // Why: the rule resolver answers "may this subject reach this
143            // entity", which has no third answer — only the governance chain's
144            // `require_approval` returns `Pending`, and it never runs here. A
145            // hold reaching this plane means a policy was mounted where it
146            // cannot be honoured, so it degrades to a deny rather than an
147            // allow.
148            Decision::Pending { reason } => {
149                tracing::error!(
150                    %reason,
151                    "a governance hold reached the rule-based resolver, which cannot park a \
152                     request; refusing it"
153                );
154                AuthzDecision::Deny {
155                    reason: DenyReason::PolicyViolation {
156                        policy: "require_approval".to_owned(),
157                        detail: std::borrow::Cow::Borrowed(
158                            "approval required, but this enforcement point cannot hold a request",
159                        ),
160                    },
161                    policy,
162                }
163            },
164        };
165        self.sink
166            .record(&req, &authz_decision, AuthzSource::RuleBased)
167            .await;
168        authz_decision
169    }
170}