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::{
17    Actor, AgentId, CallId, ClientId, ContextId, McpToolName, PluginId, PolicyId, SessionId,
18    TraceId, UserId,
19};
20use systemprompt_models::errors::RepositoryError;
21
22use super::types::AccessScope;
23use crate::authz::types::{Decision, DecisionTag};
24use crate::authz::{GovernanceDecisionRecord, insert_governance_decision};
25
26#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
27#[serde(tag = "result", rename_all = "lowercase")]
28pub enum ChainEntryResult {
29    Pass,
30    Fail,
31    Warn,
32    Disabled,
33    Skip,
34    Hold,
35}
36
37/// One traced chain entry: which policy, what it decided, and what it cost.
38#[derive(Debug, Serialize, Clone)]
39pub struct ChainEntryOutcome {
40    pub policy_id: PolicyId,
41    #[serde(flatten)]
42    pub result: ChainEntryResult,
43    pub detail: String,
44    pub duration_ms: f64,
45}
46
47/// Who the decision was made for, as verified from the credential; an
48/// identity the caller merely claims about itself never enters it.
49#[derive(Debug, Serialize, Clone)]
50pub struct PrincipalSnapshot {
51    pub user_id: UserId,
52    pub session_id: SessionId,
53    pub agent_session: Option<SessionId>,
54    pub agent_id: Option<AgentId>,
55    pub agent_scope: AccessScope,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub client_id: Option<ClientId>,
58}
59
60#[derive(Debug, Serialize, Clone)]
61pub struct AuditTarget {
62    pub tool_name: McpToolName,
63    pub plugin_id: Option<PluginId>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub tool_use_id: Option<String>,
66}
67
68#[derive(Debug, Serialize, Clone)]
69pub struct ApproverStamp {
70    pub user_id: UserId,
71    pub username: String,
72    pub decided_at: chrono::DateTime<chrono::Utc>,
73    pub action: &'static str,
74}
75
76/// Whether an audit row is the first judgement of a call or a later
77/// enforcement point re-verifying it.
78#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
79#[serde(rename_all = "snake_case")]
80pub enum AuditOrigin {
81    Governed,
82    Reverified,
83}
84
85#[derive(Debug, Serialize, Clone)]
86pub struct DecisionAudit {
87    pub id: String,
88    pub call_id: CallId,
89    pub origin: AuditOrigin,
90    pub decision: Decision,
91    pub principal: PrincipalSnapshot,
92    pub target: AuditTarget,
93    pub chain: Vec<ChainEntryOutcome>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub approver: Option<ApproverStamp>,
96    #[serde(skip_serializing_if = "Vec::is_empty")]
97    pub act_chain: Vec<Actor>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub context_id: Option<ContextId>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub trace_id: Option<TraceId>,
102}
103
104fn allow_policy_label(chain: &[ChainEntryOutcome]) -> &'static str {
105    if !chain.is_empty() && chain.iter().all(|e| e.result == ChainEntryResult::Disabled) {
106        return "governance_disabled";
107    }
108    "default_allow"
109}
110
111fn approved_policy_label(audit: &DecisionAudit) -> Option<String> {
112    audit.approver.as_ref()?;
113    audit
114        .chain
115        .iter()
116        .find(|e| e.result == ChainEntryResult::Pass)
117        .map(|e| e.policy_id.as_str().to_owned())
118}
119
120#[derive(Serialize)]
121struct VersionedAudit<'a> {
122    #[serde(flatten)]
123    audit: &'a DecisionAudit,
124    detector_version: &'static str,
125}
126
127fn decision_fields(audit: &DecisionAudit) -> (DecisionTag, String, String) {
128    match &audit.decision {
129        Decision::Allow { .. } => (
130            DecisionTag::Allow,
131            String::new(),
132            approved_policy_label(audit)
133                .unwrap_or_else(|| allow_policy_label(&audit.chain).to_owned()),
134        ),
135        Decision::Deny { reason } => {
136            let policy_str = audit
137                .chain
138                .iter()
139                .find(|e| e.result == ChainEntryResult::Fail)
140                .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
141            (DecisionTag::Deny, reason.to_string(), policy_str)
142        },
143        Decision::Warn { reason } => {
144            let policy_str = audit
145                .chain
146                .iter()
147                .find(|e| e.result == ChainEntryResult::Warn)
148                .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
149            (DecisionTag::Warn, reason.to_string(), policy_str)
150        },
151        Decision::Pending { reason } => {
152            let policy_str = audit
153                .chain
154                .iter()
155                .find(|e| e.result == ChainEntryResult::Hold)
156                .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
157            (DecisionTag::Pending, reason.to_string(), policy_str)
158        },
159    }
160}
161
162pub async fn record_decision(pool: &PgPool, audit: &DecisionAudit) -> Result<(), RepositoryError> {
163    let actor = Actor::from_tool_name(
164        audit.principal.user_id.clone(),
165        audit.principal.agent_id.as_ref(),
166        audit.target.tool_name.as_str(),
167    );
168    let (decision_tag, reason_str, policy_str) = decision_fields(audit);
169    let evaluated_rules = serde_json::to_value(VersionedAudit {
170        audit,
171        detector_version: concat!(env!("CARGO_PKG_VERSION"), "-calibration-1"),
172    })
173    .unwrap_or_else(|e| {
174        tracing::error!(
175            error = %e,
176            tool_name = %audit.target.tool_name,
177            "could not serialise the governance evaluation trace; recording the decision \
178             without it"
179        );
180        serde_json::Value::Null
181    });
182
183    let context_id = audit
184        .context_id
185        .clone()
186        .unwrap_or_else(|| ContextId::derived_from_session(&audit.principal.session_id));
187    let record = GovernanceDecisionRecord {
188        id: &audit.id,
189        actor: &actor,
190        session_id: Some(&audit.principal.session_id),
191        tool_name: audit.target.tool_name.as_str(),
192        agent_id: audit.principal.agent_id.as_ref(),
193        agent_scope: Some(audit.principal.agent_scope),
194        decision: decision_tag,
195        policy: &policy_str,
196        reason: &reason_str,
197        evaluated_rules: &evaluated_rules,
198        plugin_id: audit.target.plugin_id.as_ref(),
199        act_chain: &audit.act_chain,
200        context_id: &context_id,
201        task_id: None,
202        trace_id: audit.trace_id.as_ref(),
203        client_id: audit.principal.client_id.as_ref(),
204        tool_use_id: audit.target.tool_use_id.as_deref(),
205    };
206
207    insert_governance_decision(pool, &record).await
208}