systemprompt_security/policy/
audit.rs1use serde::Serialize;
15use sqlx::PgPool;
16use systemprompt_identifiers::{
17 Actor, AgentId, ClientId, ContextId, PluginId, PolicyId, SessionId, UserId,
18};
19
20use super::types::AccessScope;
21use crate::authz::types::{Decision, DecisionTag};
22use crate::authz::{GovernanceDecisionRecord, insert_governance_decision};
23
24#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
25#[serde(tag = "result", rename_all = "lowercase")]
26pub enum ChainEntryResult {
27 Pass,
28 Fail,
29 Warn,
33 Disabled,
34 Skip,
35 Hold,
36}
37
38#[derive(Debug, Serialize, Clone)]
40pub struct ChainEntryOutcome {
41 pub policy_id: PolicyId,
42 #[serde(flatten)]
43 pub result: ChainEntryResult,
44 pub detail: String,
45 pub duration_ms: f64,
46}
47
48#[derive(Debug, Serialize, Clone)]
56pub struct PrincipalSnapshot {
57 pub user_id: UserId,
58 pub session_id: SessionId,
59 pub agent_session: Option<SessionId>,
60 pub agent_id: Option<AgentId>,
61 pub agent_scope: AccessScope,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub client_id: Option<ClientId>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub claimed: Option<ClaimedAgent>,
66}
67
68#[derive(Debug, Serialize, Clone)]
69pub struct ClaimedAgent {
70 pub agent_id: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub agent_type: Option<String>,
73}
74
75#[derive(Debug, Serialize, Clone)]
76pub struct AuditTarget {
77 pub tool_name: String,
78 pub plugin_id: Option<PluginId>,
79}
80
81#[derive(Debug, Serialize, Clone)]
82pub struct ApproverStamp {
83 pub user_id: UserId,
84 pub username: String,
85 pub decided_at: chrono::DateTime<chrono::Utc>,
86 pub action: &'static str,
87}
88
89#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
92#[serde(rename_all = "snake_case")]
93pub enum AuditOrigin {
94 Governed,
95 Reverified,
96}
97
98#[derive(Debug, Serialize, Clone)]
99pub struct DecisionAudit {
100 pub id: String,
101 pub call_id: String,
102 pub origin: AuditOrigin,
103 pub decision: Decision,
104 pub principal: PrincipalSnapshot,
105 pub target: AuditTarget,
106 pub chain: Vec<ChainEntryOutcome>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub approver: Option<ApproverStamp>,
109 #[serde(skip_serializing_if = "Vec::is_empty")]
110 pub act_chain: Vec<Actor>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub context_id: Option<String>,
113 #[serde(skip_serializing_if = "Option::is_none")]
116 pub trace_id: Option<String>,
117}
118
119fn allow_policy_label(chain: &[ChainEntryOutcome]) -> &'static str {
124 if !chain.is_empty() && chain.iter().all(|e| e.result == ChainEntryResult::Disabled) {
125 return "governance_disabled";
126 }
127 "default_allow"
128}
129
130fn approved_policy_label(audit: &DecisionAudit) -> Option<String> {
136 audit.approver.as_ref()?;
137 audit
138 .chain
139 .iter()
140 .find(|e| e.result == ChainEntryResult::Pass)
141 .map(|e| e.policy_id.as_str().to_owned())
142}
143
144pub async fn record_decision(pool: &PgPool, audit: &DecisionAudit) -> Result<(), sqlx::Error> {
145 let actor = Actor::from_tool_name(
146 audit.principal.user_id.clone(),
147 audit.principal.agent_id.as_ref().map(AgentId::as_str),
148 &audit.target.tool_name,
149 );
150 let (decision_tag, reason_str, policy_str) = match &audit.decision {
151 Decision::Allow { .. } => (
152 DecisionTag::Allow,
153 String::new(),
154 approved_policy_label(audit)
155 .unwrap_or_else(|| allow_policy_label(&audit.chain).to_owned()),
156 ),
157 Decision::Deny { reason } => {
158 let policy_str = audit
159 .chain
160 .iter()
161 .find(|e| e.result == ChainEntryResult::Fail)
162 .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
163 (DecisionTag::Deny, reason.to_string(), policy_str)
164 },
165 Decision::Warn { reason } => {
169 let policy_str = audit
170 .chain
171 .iter()
172 .find(|e| e.result == ChainEntryResult::Warn)
173 .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
174 (DecisionTag::Warn, reason.to_string(), policy_str)
175 },
176 Decision::Pending { reason } => {
177 let policy_str = audit
178 .chain
179 .iter()
180 .find(|e| e.result == ChainEntryResult::Hold)
181 .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
182 (DecisionTag::Pending, reason.to_string(), policy_str)
183 },
184 };
185 let evaluated_rules = serde_json::to_value(audit).unwrap_or_else(|e| {
186 tracing::error!(
187 error = %e,
188 tool_name = %audit.target.tool_name,
189 "could not serialise the governance evaluation trace; recording the decision \
190 without it"
191 );
192 serde_json::Value::Null
193 });
194
195 let context_id = audit
196 .context_id
197 .as_deref()
198 .and_then(|s| ContextId::try_new(s).ok())
199 .unwrap_or_else(|| ContextId::derived_from_session(&audit.principal.session_id));
200 let record = GovernanceDecisionRecord {
201 id: &audit.id,
202 actor: &actor,
203 session_id: audit.principal.session_id.as_str(),
204 tool_name: &audit.target.tool_name,
205 agent_id: audit.principal.agent_id.as_ref().map(AgentId::as_str),
206 agent_scope: Some(audit.principal.agent_scope),
207 decision: decision_tag,
208 policy: &policy_str,
209 reason: &reason_str,
210 evaluated_rules: &evaluated_rules,
211 plugin_id: audit.target.plugin_id.as_ref().map(PluginId::as_str),
212 act_chain: &audit.act_chain,
213 context_id: context_id.as_str(),
214 task_id: None,
215 trace_id: audit.trace_id.as_deref(),
216 client_id: audit.principal.client_id.as_ref().map(ClientId::as_str),
217 };
218
219 insert_governance_decision(pool, &record).await
220}