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 record_decision_with(pool, audit).await
164}
165
166pub async fn record_decision_with<'e, E>(
167 executor: E,
168 audit: &DecisionAudit,
169) -> Result<(), RepositoryError>
170where
171 E: sqlx::PgExecutor<'e>,
172{
173 let actor = Actor::from_tool_name(
174 audit.principal.user_id.clone(),
175 audit.principal.agent_id.as_ref(),
176 audit.target.tool_name.as_str(),
177 );
178 let (decision_tag, reason_str, policy_str) = decision_fields(audit);
179 let evaluated_rules = serde_json::to_value(VersionedAudit {
180 audit,
181 detector_version: concat!(env!("CARGO_PKG_VERSION"), "-calibration-1"),
182 })
183 .unwrap_or_else(|e| {
184 tracing::error!(
185 error = %e,
186 tool_name = %audit.target.tool_name,
187 "could not serialise the governance evaluation trace; recording the decision \
188 without it"
189 );
190 serde_json::Value::Null
191 });
192
193 let context_id = audit
194 .context_id
195 .clone()
196 .unwrap_or_else(|| ContextId::derived_from_session(&audit.principal.session_id));
197 let record = GovernanceDecisionRecord {
198 id: &audit.id,
199 actor: &actor,
200 session_id: Some(&audit.principal.session_id),
201 tool_name: audit.target.tool_name.as_str(),
202 agent_id: audit.principal.agent_id.as_ref(),
203 agent_scope: Some(audit.principal.agent_scope),
204 decision: decision_tag,
205 policy: &policy_str,
206 reason: &reason_str,
207 evaluated_rules: &evaluated_rules,
208 plugin_id: audit.target.plugin_id.as_ref(),
209 act_chain: &audit.act_chain,
210 context_id: &context_id,
211 task_id: None,
212 trace_id: audit.trace_id.as_ref(),
213 client_id: audit.principal.client_id.as_ref(),
214 tool_use_id: audit.target.tool_use_id.as_deref(),
215 };
216
217 insert_governance_decision(executor, &record).await
218}