systemprompt_security/policy/
audit.rs1use 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#[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#[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#[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}