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//! Policies that accumulate state (the rate limiter) scope it to their
11//! instance, so two engines never share buckets — a second engine would
12//! silently double every budget. [`GovernanceEngine::global`] is therefore the
13//! way every enforcement point in a process reaches the chain: the MCP
14//! governance webhook and the `/v1/messages` gateway must charge the same
15//! limiter, not one each. [`GovernanceEngine::from_config`] remains available
16//! for tests and for callers that genuinely want an isolated chain.
17//!
18//! Copyright (c) systemprompt.io — Business Source License 1.1.
19//! See <https://systemprompt.io> for licensing details.
20
21use std::collections::{HashMap, HashSet};
22use std::path::PathBuf;
23use std::sync::LazyLock;
24
25use systemprompt_config::ProfileBootstrap;
26use systemprompt_identifiers::PolicyId;
27
28use super::audit::{ChainEntryOutcome, ChainEntryResult};
29use super::config::{GovernanceConfig, PolicyConfig};
30use super::registry::{PolicyFactory, PolicyRegistration};
31use super::types::{GovernancePolicy, PolicyContext};
32use crate::authz::types::{Decision, MatchedBy};
33
34/// The outcome of one traced chain run: the first-deny-wins [`Decision`] and
35/// the ordered per-entry trace destined for the audit row.
36#[derive(Debug)]
37pub struct Evaluation {
38    pub decision: Decision,
39    pub chain: Vec<ChainEntryOutcome>,
40}
41
42struct ChainEntry {
43    config: PolicyConfig,
44    instance: Box<dyn GovernancePolicy>,
45}
46
47pub struct GovernanceEngine {
48    enabled: bool,
49    entries: Vec<ChainEntry>,
50}
51
52impl std::fmt::Debug for GovernanceEngine {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("GovernanceEngine")
55            .field("enabled", &self.enabled)
56            .field(
57                "policies",
58                &self
59                    .entries
60                    .iter()
61                    .map(|e| e.config.id.as_str())
62                    .collect::<Vec<_>>(),
63            )
64            .finish()
65    }
66}
67
68impl GovernanceEngine {
69    /// The process-wide engine, built on first use from the active profile's
70    /// `<services>/governance/config.yaml`.
71    ///
72    /// The path is resolved here rather than through an `init()` seam because
73    /// a `OnceLock` seeded before the profile bootstrap completed would pin
74    /// the built-in defaults permanently, silently dropping every operator
75    /// policy. Resolving lazily means the first caller — whenever that is —
76    /// sees the configured chain. A profile that cannot be read falls back to
77    /// [`GovernanceConfig::defaults`], matching [`GovernanceConfig::load`].
78    pub fn global() -> &'static Self {
79        static ENGINE: LazyLock<GovernanceEngine> = LazyLock::new(|| {
80            let config = governance_config_path()
81                .map_or_else(GovernanceConfig::defaults, |p| GovernanceConfig::load(&p));
82            GovernanceEngine::from_config(&config)
83        });
84        &ENGINE
85    }
86
87    /// Instantiate the chain from `config` against the inventory registry.
88    ///
89    /// Configured ids with no registered factory are logged and skipped.
90    /// Registered policies absent from the config are appended `enabled:
91    /// false`, so the audit trace shows them as skipped rather than omitting
92    /// them.
93    #[must_use]
94    pub fn from_config(config: &GovernanceConfig) -> Self {
95        if !config.enabled {
96            tracing::warn!(
97                "governance is DISABLED by config: no scope, secret, blocklist or rate-limit \
98                 check will run on any request"
99            );
100        }
101        let factories: HashMap<&'static str, PolicyFactory> =
102            inventory::iter::<PolicyRegistration>()
103                .map(|r| (r.id, r.factory))
104                .collect();
105
106        let mut entries = Vec::with_capacity(config.policies.len());
107        for cfg in &config.policies {
108            let Some(factory) = factories.get(cfg.id.as_str()) else {
109                tracing::warn!(
110                    policy = %cfg.id,
111                    "governance policy in config has no registered impl — skipping"
112                );
113                continue;
114            };
115            entries.push(ChainEntry {
116                config: cfg.clone(),
117                instance: factory(&cfg.params),
118            });
119        }
120
121        let mentioned: HashSet<&str> = entries.iter().map(|e| e.config.id.as_str()).collect();
122        let unmentioned: Vec<&PolicyRegistration> = inventory::iter::<PolicyRegistration>()
123            .filter(|r| !mentioned.contains(r.id))
124            .collect();
125        for r in unmentioned {
126            let cfg = PolicyConfig {
127                id: r.id.to_owned(),
128                enabled: false,
129                params: serde_yaml::Value::Null,
130            };
131            let instance = (r.factory)(&cfg.params);
132            entries.push(ChainEntry {
133                config: cfg,
134                instance,
135            });
136        }
137
138        Self {
139            enabled: config.enabled,
140            entries,
141        }
142    }
143
144    /// The instantiated chain in evaluation order, for dashboards and UI
145    /// projections.
146    pub fn policies(&self) -> impl Iterator<Item = (&PolicyConfig, &dyn GovernancePolicy)> {
147        self.entries
148            .iter()
149            .map(|e| (&e.config, e.instance.as_ref()))
150    }
151
152    /// Run the chain first-deny-wins, tracing every entry.
153    ///
154    /// Entries switched off by config record a
155    /// [`ChainEntryResult::Disabled`] and entries after the first deny a
156    /// [`ChainEntryResult::Skip`], both with zero duration; an empty or
157    /// all-pass chain allows with [`MatchedBy::DefaultIncluded`].
158    #[must_use]
159    pub fn evaluate(&self, ctx: &PolicyContext<'_>) -> Evaluation {
160        if !self.enabled {
161            return Evaluation {
162                decision: Decision::Allow {
163                    matched_by: MatchedBy::DefaultIncluded,
164                },
165                chain: self
166                    .entries
167                    .iter()
168                    .map(|entry| {
169                        chain_entry(
170                            &entry.config,
171                            ChainEntryResult::Disabled,
172                            "Governance disabled by master switch",
173                        )
174                    })
175                    .collect(),
176            };
177        }
178
179        let mut chain: Vec<ChainEntryOutcome> = Vec::with_capacity(self.entries.len());
180        let mut denied: Option<Decision> = None;
181
182        for entry in &self.entries {
183            if !entry.config.enabled {
184                chain.push(chain_entry(
185                    &entry.config,
186                    ChainEntryResult::Disabled,
187                    "Policy disabled in governance config",
188                ));
189                continue;
190            }
191            if denied.is_some() {
192                chain.push(chain_entry(
193                    &entry.config,
194                    ChainEntryResult::Skip,
195                    "Skipped — already denied by an earlier policy",
196                ));
197                continue;
198            }
199            let started = std::time::Instant::now();
200            let decision = entry.instance.evaluate(ctx);
201            let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
202            match &decision {
203                Decision::Allow { matched_by } => chain.push(ChainEntryOutcome {
204                    policy_id: entry.instance.id(),
205                    result: ChainEntryResult::Pass,
206                    detail: allow_detail(matched_by),
207                    duration_ms,
208                }),
209                Decision::Deny { reason } => {
210                    chain.push(ChainEntryOutcome {
211                        policy_id: entry.instance.id(),
212                        result: ChainEntryResult::Fail,
213                        detail: reason.to_string(),
214                        duration_ms,
215                    });
216                    denied = Some(decision);
217                },
218            }
219        }
220
221        Evaluation {
222            decision: denied.unwrap_or(Decision::Allow {
223                matched_by: MatchedBy::DefaultIncluded,
224            }),
225            chain,
226        }
227    }
228}
229
230fn governance_config_path() -> Option<PathBuf> {
231    let profile = ProfileBootstrap::get()
232        .inspect_err(|e| {
233            tracing::error!(
234                error = %e,
235                "governance profile bootstrap failed; policies fall back to built-in defaults"
236            );
237        })
238        .ok()?;
239    Some(PathBuf::from(&profile.paths.services).join("governance/config.yaml"))
240}
241
242fn chain_entry(cfg: &PolicyConfig, result: ChainEntryResult, detail: &str) -> ChainEntryOutcome {
243    ChainEntryOutcome {
244        policy_id: PolicyId::new(cfg.id.clone()),
245        result,
246        detail: detail.to_owned(),
247        duration_ms: 0.0,
248    }
249}
250
251fn allow_detail(matched_by: &MatchedBy) -> String {
252    match matched_by {
253        MatchedBy::PolicyAllow { detail, .. } => detail.to_string(),
254        MatchedBy::UserAllow => "user allow".to_owned(),
255        MatchedBy::RoleAllow { role } => format!("role allow: {role}"),
256        MatchedBy::AttributeAllow { rule_type, value } => format!("{rule_type} allow: {value}"),
257        MatchedBy::DefaultIncluded => "default included".to_owned(),
258    }
259}