Skip to main content

systemprompt_security/policy/
engine.rs

1//! Traced first-deny-wins evaluation of the configured policy chain.
2//!
3//! [`GovernanceEngine`] owns the instantiated chain: policies resolved from
4//! the inventory registry against a [`GovernanceConfig`], in declaration
5//! order. [`GovernanceEngine::evaluate`] records a per-entry
6//! [`ChainEntryOutcome`] — including disabled and skipped-after-deny entries —
7//! so the audit row preserves the full evaluation order, not just the first
8//! deny.
9//!
10//! The engine is caller-owned and holds no process-global state; policies
11//! that accumulate state (the rate limiter) scope it to their instance, so
12//! two engines never share buckets. Hold one engine per process for shared
13//! enforcement.
14//!
15//! Copyright (c) systemprompt.io — Business Source License 1.1.
16//! See <https://systemprompt.io> for licensing details.
17
18use std::collections::{HashMap, HashSet};
19
20use systemprompt_identifiers::PolicyId;
21
22use super::audit::{ChainEntryOutcome, ChainEntryResult};
23use super::config::{GovernanceConfig, PolicyConfig};
24use super::registry::{PolicyFactory, PolicyRegistration};
25use super::types::{GovernancePolicy, PolicyContext};
26use crate::authz::types::{Decision, MatchedBy};
27
28/// The outcome of one traced chain run: the first-deny-wins [`Decision`] and
29/// the ordered per-entry trace destined for the audit row.
30#[derive(Debug)]
31pub struct Evaluation {
32    pub decision: Decision,
33    pub chain: Vec<ChainEntryOutcome>,
34}
35
36struct ChainEntry {
37    config: PolicyConfig,
38    instance: Box<dyn GovernancePolicy>,
39}
40
41pub struct GovernanceEngine {
42    entries: Vec<ChainEntry>,
43}
44
45impl std::fmt::Debug for GovernanceEngine {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("GovernanceEngine")
48            .field(
49                "policies",
50                &self
51                    .entries
52                    .iter()
53                    .map(|e| e.config.id.as_str())
54                    .collect::<Vec<_>>(),
55            )
56            .finish()
57    }
58}
59
60impl GovernanceEngine {
61    /// Instantiate the chain from `config` against the inventory registry.
62    ///
63    /// Configured ids with no registered factory are logged and skipped.
64    /// Registered policies absent from the config are appended `enabled:
65    /// false`, so the audit trace shows them as skipped rather than omitting
66    /// them.
67    #[must_use]
68    pub fn from_config(config: &GovernanceConfig) -> Self {
69        let factories: HashMap<&'static str, PolicyFactory> =
70            inventory::iter::<PolicyRegistration>()
71                .map(|r| (r.id, r.factory))
72                .collect();
73
74        let mut entries = Vec::with_capacity(config.policies.len());
75        for cfg in &config.policies {
76            let Some(factory) = factories.get(cfg.id.as_str()) else {
77                tracing::warn!(
78                    policy = %cfg.id,
79                    "governance policy in config has no registered impl — skipping"
80                );
81                continue;
82            };
83            entries.push(ChainEntry {
84                config: cfg.clone(),
85                instance: factory(&cfg.params),
86            });
87        }
88
89        let mentioned: HashSet<&str> = entries.iter().map(|e| e.config.id.as_str()).collect();
90        let unmentioned: Vec<&PolicyRegistration> = inventory::iter::<PolicyRegistration>()
91            .filter(|r| !mentioned.contains(r.id))
92            .collect();
93        for r in unmentioned {
94            let cfg = PolicyConfig {
95                id: r.id.to_owned(),
96                enabled: false,
97                params: serde_yaml::Value::Null,
98            };
99            let instance = (r.factory)(&cfg.params);
100            entries.push(ChainEntry {
101                config: cfg,
102                instance,
103            });
104        }
105
106        Self { entries }
107    }
108
109    /// The instantiated chain in evaluation order, for dashboards and UI
110    /// projections.
111    pub fn policies(&self) -> impl Iterator<Item = (&PolicyConfig, &dyn GovernancePolicy)> {
112        self.entries
113            .iter()
114            .map(|e| (&e.config, e.instance.as_ref()))
115    }
116
117    /// Run the chain first-deny-wins, tracing every entry.
118    ///
119    /// Disabled entries and entries after the first deny record a
120    /// [`ChainEntryResult::Skip`] with zero duration; an empty or all-pass
121    /// chain allows with [`MatchedBy::DefaultIncluded`].
122    #[must_use]
123    pub fn evaluate(&self, ctx: &PolicyContext<'_>) -> Evaluation {
124        let mut chain: Vec<ChainEntryOutcome> = Vec::with_capacity(self.entries.len());
125        let mut denied: Option<Decision> = None;
126
127        for entry in &self.entries {
128            if !entry.config.enabled {
129                chain.push(skip_entry(
130                    &entry.config,
131                    "Policy disabled in governance config",
132                ));
133                continue;
134            }
135            if denied.is_some() {
136                chain.push(skip_entry(
137                    &entry.config,
138                    "Skipped — already denied by an earlier policy",
139                ));
140                continue;
141            }
142            let started = std::time::Instant::now();
143            let decision = entry.instance.evaluate(ctx);
144            let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
145            match &decision {
146                Decision::Allow { matched_by } => chain.push(ChainEntryOutcome {
147                    policy_id: entry.instance.id(),
148                    result: ChainEntryResult::Pass,
149                    detail: allow_detail(matched_by),
150                    duration_ms,
151                }),
152                Decision::Deny { reason } => {
153                    chain.push(ChainEntryOutcome {
154                        policy_id: entry.instance.id(),
155                        result: ChainEntryResult::Fail,
156                        detail: reason.to_string(),
157                        duration_ms,
158                    });
159                    denied = Some(decision);
160                },
161            }
162        }
163
164        Evaluation {
165            decision: denied.unwrap_or(Decision::Allow {
166                matched_by: MatchedBy::DefaultIncluded,
167            }),
168            chain,
169        }
170    }
171}
172
173fn skip_entry(cfg: &PolicyConfig, detail: &str) -> ChainEntryOutcome {
174    ChainEntryOutcome {
175        policy_id: PolicyId::new(cfg.id.clone()),
176        result: ChainEntryResult::Skip,
177        detail: detail.to_owned(),
178        duration_ms: 0.0,
179    }
180}
181
182fn allow_detail(matched_by: &MatchedBy) -> String {
183    match matched_by {
184        MatchedBy::PolicyAllow { detail, .. } => detail.to_string(),
185        MatchedBy::UserAllow => "user allow".to_owned(),
186        MatchedBy::RoleAllow { role } => format!("role allow: {role}"),
187        MatchedBy::AttributeAllow { rule_type, value } => format!("{rule_type} allow: {value}"),
188        MatchedBy::DefaultIncluded => "default included".to_owned(),
189    }
190}