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 Disabled,
30 Skip,
31 Hold,
32}
33
34#[derive(Debug, Serialize, Clone)]
36pub struct ChainEntryOutcome {
37 pub policy_id: PolicyId,
38 #[serde(flatten)]
39 pub result: ChainEntryResult,
40 pub detail: String,
41 pub duration_ms: f64,
42}
43
44#[derive(Debug, Serialize, Clone)]
52pub struct PrincipalSnapshot {
53 pub user_id: UserId,
54 pub session_id: SessionId,
55 pub agent_session: Option<SessionId>,
56 pub agent_id: Option<AgentId>,
57 pub agent_scope: AccessScope,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub client_id: Option<ClientId>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub claimed: Option<ClaimedAgent>,
62}
63
64#[derive(Debug, Serialize, Clone)]
65pub struct ClaimedAgent {
66 pub agent_id: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub agent_type: Option<String>,
69}
70
71#[derive(Debug, Serialize, Clone)]
72pub struct AuditTarget {
73 pub tool_name: String,
74 pub plugin_id: Option<PluginId>,
75}
76
77#[derive(Debug, Serialize, Clone)]
78pub struct ApproverStamp {
79 pub user_id: UserId,
80 pub username: String,
81 pub decided_at: chrono::DateTime<chrono::Utc>,
82 pub action: &'static str,
83}
84
85#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
88#[serde(rename_all = "snake_case")]
89pub enum AuditOrigin {
90 Governed,
91 Reverified,
92}
93
94#[derive(Debug, Serialize, Clone)]
95pub struct DecisionAudit {
96 pub id: String,
97 pub call_id: String,
98 pub origin: AuditOrigin,
99 pub decision: Decision,
100 pub principal: PrincipalSnapshot,
101 pub target: AuditTarget,
102 pub chain: Vec<ChainEntryOutcome>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub approver: Option<ApproverStamp>,
105 #[serde(skip_serializing_if = "Vec::is_empty")]
106 pub act_chain: Vec<Actor>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub context_id: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
112 pub trace_id: Option<String>,
113}
114
115fn allow_policy_label(chain: &[ChainEntryOutcome]) -> &'static str {
120 if !chain.is_empty() && chain.iter().all(|e| e.result == ChainEntryResult::Disabled) {
121 return "governance_disabled";
122 }
123 "default_allow"
124}
125
126fn approved_policy_label(audit: &DecisionAudit) -> Option<String> {
132 audit.approver.as_ref()?;
133 audit
134 .chain
135 .iter()
136 .find(|e| e.result == ChainEntryResult::Pass)
137 .map(|e| e.policy_id.as_str().to_owned())
138}
139
140pub async fn record_decision(pool: &PgPool, audit: &DecisionAudit) -> Result<(), sqlx::Error> {
141 let actor = Actor::from_tool_name(
142 audit.principal.user_id.clone(),
143 audit.principal.agent_id.as_ref().map(AgentId::as_str),
144 &audit.target.tool_name,
145 );
146 let (decision_tag, reason_str, policy_str) = match &audit.decision {
147 Decision::Allow { .. } => (
148 DecisionTag::Allow,
149 String::new(),
150 approved_policy_label(audit)
151 .unwrap_or_else(|| allow_policy_label(&audit.chain).to_owned()),
152 ),
153 Decision::Deny { reason } => {
154 let policy_str = audit
155 .chain
156 .iter()
157 .find(|e| e.result == ChainEntryResult::Fail)
158 .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
159 (DecisionTag::Deny, reason.to_string(), policy_str)
160 },
161 Decision::Pending { reason } => {
162 let policy_str = audit
163 .chain
164 .iter()
165 .find(|e| e.result == ChainEntryResult::Hold)
166 .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
167 (DecisionTag::Pending, reason.to_string(), policy_str)
168 },
169 };
170 let evaluated_rules = serde_json::to_value(audit).unwrap_or_else(|e| {
171 tracing::error!(
172 error = %e,
173 tool_name = %audit.target.tool_name,
174 "could not serialise the governance evaluation trace; recording the decision \
175 without it"
176 );
177 serde_json::Value::Null
178 });
179
180 let context_id = audit
181 .context_id
182 .as_deref()
183 .and_then(|s| ContextId::try_new(s).ok())
184 .unwrap_or_else(|| ContextId::derived_from_session(&audit.principal.session_id));
185 let record = GovernanceDecisionRecord {
186 id: &audit.id,
187 actor: &actor,
188 session_id: audit.principal.session_id.as_str(),
189 tool_name: &audit.target.tool_name,
190 agent_id: audit.principal.agent_id.as_ref().map(AgentId::as_str),
191 agent_scope: Some(audit.principal.agent_scope),
192 decision: decision_tag,
193 policy: &policy_str,
194 reason: &reason_str,
195 evaluated_rules: &evaluated_rules,
196 plugin_id: audit.target.plugin_id.as_ref().map(PluginId::as_str),
197 act_chain: &audit.act_chain,
198 context_id: context_id.as_str(),
199 task_id: None,
200 trace_id: audit.trace_id.as_deref(),
201 client_id: audit.principal.client_id.as_ref().map(ClientId::as_str),
202 };
203
204 insert_governance_decision(pool, &record).await
205}