Skip to main content

systemprompt_security/policy/
audit.rs

1//! Audit blob for governed-call decisions.
2//!
3//! [`DecisionAudit`] is the typed shape serialized whole into
4//! `governance_decisions.evaluated_rules`; the flat columns (`decision`,
5//! `reason`, `policy`) are derived from it by [`record_decision`], which
6//! delegates to the canonical
7//! [`insert_governance_decision`] writer. The serialized shape is a persisted
8//! contract rendered by dashboards
9//! — field renames here are schema changes.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14use serde::Serialize;
15use sqlx::PgPool;
16use systemprompt_identifiers::{Actor, AgentId, ContextId, PluginId, PolicyId, SessionId, UserId};
17
18use super::types::AccessScope;
19use crate::authz::types::{Decision, DecisionTag};
20use crate::authz::{GovernanceDecisionRecord, insert_governance_decision};
21
22#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
23#[serde(tag = "result", rename_all = "lowercase")]
24pub enum ChainEntryResult {
25    Pass,
26    Fail,
27    /// Switched off by config, either per-policy or by the master switch. Kept
28    /// distinct from [`ChainEntryResult::Skip`] so a reader can tell a chain
29    /// that was never armed from one that stopped early: both leave a policy
30    /// unevaluated, but only one of them means the installation is unguarded.
31    Disabled,
32    Skip,
33}
34
35/// One traced chain entry: which policy, what it decided, and what it cost.
36#[derive(Debug, Serialize, Clone)]
37pub struct ChainEntryOutcome {
38    pub policy_id: PolicyId,
39    #[serde(flatten)]
40    pub result: ChainEntryResult,
41    pub detail: String,
42    /// Wall-clock cost of evaluating this policy. Zero for entries that never
43    /// ran (disabled, skipped-after-deny, or synthesized outcomes).
44    pub duration_ms: f64,
45}
46
47#[derive(Debug, Serialize, Clone)]
48pub struct PrincipalSnapshot {
49    pub user_id: UserId,
50    /// The credential's session, attested against `user_sessions` — the same
51    /// class of evidence as `ai_requests.session_id`, so the inference and
52    /// tool-call halves of the audit spine join on comparable ids. Prefixed
53    /// `unattested_` when the lookup failed.
54    pub session_id: SessionId,
55    /// The `session_id` the hook payload carried: the agent's own local
56    /// conversation label. Useful for correlating one agent run, but the
57    /// server never issued it, so it is recorded here rather than in the
58    /// attested column.
59    pub agent_session: Option<SessionId>,
60    pub agent_id: Option<AgentId>,
61    pub agent_scope: AccessScope,
62}
63
64#[derive(Debug, Serialize, Clone)]
65pub struct AuditTarget {
66    pub tool_name: String,
67    pub plugin_id: Option<PluginId>,
68}
69
70#[derive(Debug, Serialize, Clone)]
71pub struct ApproverStamp {
72    pub user_id: UserId,
73    pub username: String,
74    pub decided_at: chrono::DateTime<chrono::Utc>,
75    /// `"approved"` or `"denied"`.
76    pub action: &'static str,
77}
78
79/// Whether an audit row is the first judgement of a call or a later
80/// enforcement point re-verifying it.
81#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
82#[serde(rename_all = "snake_case")]
83pub enum AuditOrigin {
84    Governed,
85    Reverified,
86}
87
88#[derive(Debug, Serialize, Clone)]
89pub struct DecisionAudit {
90    /// The `governance_decisions.id` this blob will land under. Minted by the
91    /// caller (not the repository) so surfaces that saw the decision live can
92    /// hand out the same id as a trace link.
93    pub id: String,
94    /// Identity of the call this row judged, shared by every row that judged
95    /// the same one. `id` distinguishes evaluations; this groups them.
96    pub call_id: String,
97    pub origin: AuditOrigin,
98    pub decision: Decision,
99    pub principal: PrincipalSnapshot,
100    pub target: AuditTarget,
101    pub chain: Vec<ChainEntryOutcome>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub approver: Option<ApproverStamp>,
104    /// RFC 8693 delegation lineage in outermost-first order. Empty for
105    /// direct (non-delegated) tokens.
106    #[serde(skip_serializing_if = "Vec::is_empty")]
107    pub act_chain: Vec<Actor>,
108    /// The conversational context the call belongs to, when the enforcement
109    /// point knows one. The MCP webhook does not; the gateway does, and
110    /// without it an inference decision cannot be joined back to the request
111    /// it judged.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub context_id: Option<String>,
114}
115
116// Why: an allow because nothing ran and an allow because everything passed are
117// the same `Decision`, and the flat `policy` column is what operational queries
118// filter on. Collapsing both to `default_allow` would make an unguarded
119// installation indistinguishable from a healthy one.
120fn allow_policy_label(chain: &[ChainEntryOutcome]) -> &'static str {
121    if !chain.is_empty() && chain.iter().all(|e| e.result == ChainEntryResult::Disabled) {
122        return "governance_disabled";
123    }
124    "default_allow"
125}
126
127/// Persist one governed-call decision: derive the flat columns (`policy` is
128/// the first [`ChainEntryResult::Fail`] entry) and write the blob through the
129/// canonical `governance_decisions` insert.
130pub async fn record_decision(pool: &PgPool, audit: &DecisionAudit) -> Result<(), sqlx::Error> {
131    let actor = Actor::from_tool_name(
132        audit.principal.user_id.clone(),
133        audit.principal.agent_id.as_ref().map(AgentId::as_str),
134        &audit.target.tool_name,
135    );
136    let (decision_tag, reason_str, policy_str) = match &audit.decision {
137        Decision::Allow { .. } => (
138            DecisionTag::Allow,
139            String::new(),
140            allow_policy_label(&audit.chain).to_owned(),
141        ),
142        Decision::Deny { reason } => {
143            let policy_str = audit
144                .chain
145                .iter()
146                .find(|e| e.result == ChainEntryResult::Fail)
147                .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
148            (DecisionTag::Deny, reason.to_string(), policy_str)
149        },
150    };
151    let evaluated_rules = serde_json::to_value(audit).unwrap_or_else(|e| {
152        tracing::error!(
153            error = %e,
154            tool_name = %audit.target.tool_name,
155            "could not serialise the governance evaluation trace; recording the decision \
156             without it"
157        );
158        serde_json::Value::Null
159    });
160
161    let context_id = audit
162        .context_id
163        .as_deref()
164        .and_then(|s| ContextId::try_new(s).ok())
165        .unwrap_or_else(|| ContextId::derived_from_session(&audit.principal.session_id));
166    let record = GovernanceDecisionRecord {
167        id: &audit.id,
168        actor: &actor,
169        session_id: audit.principal.session_id.as_str(),
170        tool_name: &audit.target.tool_name,
171        agent_id: audit.principal.agent_id.as_ref().map(AgentId::as_str),
172        agent_scope: Some(audit.principal.agent_scope),
173        decision: decision_tag,
174        policy: &policy_str,
175        reason: &reason_str,
176        evaluated_rules: &evaluated_rules,
177        plugin_id: audit.target.plugin_id.as_ref().map(PluginId::as_str),
178        act_chain: &audit.act_chain,
179        context_id: context_id.as_str(),
180        task_id: None,
181    };
182
183    insert_governance_decision(pool, &record).await
184}