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::error::{AuthzError, AuthzResult};
32use super::hook::AuthzDecisionHook;
33use super::parent_chain::{ChainSources, ParentChainIndex, ResolveBase};
34use super::registry::AuthzHookContext;
35use super::repository::AccessControlRepository;
36use super::subject::{
37    SharedSubjectAttributeProvider, SubjectDimension, dimensions_of, discover_subject_providers,
38    gather_subject_attributes,
39};
40use super::types::{AuthzDecision, AuthzRequest, Decision, DenyReason};
41
42#[derive(Clone)]
43pub struct RuleBasedHook {
44    repo: AccessControlRepository,
45    sink: Arc<dyn AuthzAuditSink>,
46    providers: Vec<SharedSubjectAttributeProvider>,
47    dimensions: Vec<SubjectDimension>,
48    sources: Arc<ChainSources>,
49}
50
51impl std::fmt::Debug for RuleBasedHook {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("RuleBasedHook")
54            .field("repo", &self.repo)
55            .field("sink", &self.sink)
56            .field("providers", &self.providers)
57            .field("dimensions", &self.dimensions)
58            .finish_non_exhaustive()
59    }
60}
61
62impl RuleBasedHook {
63    #[must_use]
64    pub fn new(pool: Arc<PgPool>, sink: Arc<dyn AuthzAuditSink>, sources: ChainSources) -> Self {
65        let providers = discover_subject_providers(&AuthzHookContext {
66            pool: Arc::clone(&pool),
67            sink: Arc::clone(&sink),
68        });
69        Self {
70            repo: AccessControlRepository::from_pool(pool),
71            sink,
72            dimensions: dimensions_of(&providers),
73            providers,
74            sources: Arc::new(sources),
75        }
76    }
77
78    async fn chain_index(&self) -> AuthzResult<ParentChainIndex> {
79        ParentChainIndex::load(&self.repo, Arc::clone(&self.sources)).await
80    }
81
82    // Why: takes the typed error rather than a rendered string so the reason
83    // reaching the audit row names the cause. Stringifying at the call site put
84    // it in a log line and left every fault row identical.
85    async fn fault(&self, req: &AuthzRequest, error: &AuthzError) -> AuthzDecision {
86        let policy = AuthzSource::RuleBased.policy().to_owned();
87        let detail = error.to_string();
88        let decision = AuthzDecision::Deny {
89            reason: DenyReason::HookUnavailable {
90                policy: policy.clone(),
91                detail: detail.clone(),
92            },
93            policy,
94        };
95        tracing::warn!(
96            entity = %req.entity,
97            user_id = %req.user_id,
98            error = %detail,
99            "rule-based authz hook fault",
100        );
101        self.sink
102            .record(req, &decision, AuthzSource::RuleBased)
103            .await;
104        decision
105    }
106}
107
108#[async_trait]
109impl AuthzDecisionHook for RuleBasedHook {
110    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
111        let kind = req.entity.kind();
112        let id = req.entity.id_str();
113
114        let entity = match self.repo.get_entity(kind, id).await {
115            Ok(row) => row,
116            Err(err) => return self.fault(&req, &err).await,
117        };
118        let rules = match self.repo.list_rules_for_entity(kind, id).await {
119            Ok(rules) => rules,
120            Err(err) => return self.fault(&req, &err).await,
121        };
122
123        let index = match self.chain_index().await {
124            Ok(index) => index,
125            Err(err) => return self.fault(&req, &err).await,
126        };
127
128        let attributes = gather_subject_attributes(&self.providers, &req.user_id).await;
129        let decision = index.resolve(
130            kind,
131            id,
132            ResolveBase {
133                rules: &rules,
134                user_id: &req.user_id,
135                user_roles: &req.roles,
136                default_included: entity.map(|e| e.default_included),
137                attributes: &attributes,
138                dimensions: &self.dimensions,
139            },
140        );
141
142        let policy = AuthzSource::RuleBased.policy().to_owned();
143        let authz_decision = match decision {
144            Decision::Allow { .. } => AuthzDecision::Allow,
145            Decision::Deny { reason } => AuthzDecision::Deny { reason, policy },
146            // Why: the rule resolver answers "may this subject reach this
147            // entity", which has no third answer — only the governance chain's
148            // `require_approval` returns `Pending`, and it never runs here. A
149            // hold reaching this plane means a policy was mounted where it
150            // cannot be honoured, so it degrades to a deny rather than an
151            // allow.
152            Decision::Pending { reason } => {
153                tracing::error!(
154                    %reason,
155                    "a governance hold reached the rule-based resolver, which cannot park a \
156                     request; refusing it"
157                );
158                AuthzDecision::Deny {
159                    reason: DenyReason::PolicyViolation {
160                        policy: "require_approval".to_owned(),
161                        detail: std::borrow::Cow::Borrowed(
162                            "approval required, but this enforcement point cannot hold a request",
163                        ),
164                    },
165                    policy,
166                }
167            },
168        };
169        self.sink
170            .record(&req, &authz_decision, AuthzSource::RuleBased)
171            .await;
172        authz_decision
173    }
174}