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