systemprompt_security/policy/
audit.rs1use serde::Serialize;
15use sqlx::PgPool;
16use systemprompt_identifiers::{Actor, AgentId, ContextId, PluginId, PolicyId, SessionId, UserId};
17
18use super::types::AccessScope;
19use crate::authz::types::{Decision, DecisionTag};
20use crate::authz::{GovernanceDecisionRecord, insert_governance_decision};
21
22#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
23#[serde(tag = "result", rename_all = "lowercase")]
24pub enum ChainEntryResult {
25 Pass,
26 Fail,
27 Disabled,
32 Skip,
33}
34
35#[derive(Debug, Serialize, Clone)]
37pub struct ChainEntryOutcome {
38 pub policy_id: PolicyId,
39 #[serde(flatten)]
40 pub result: ChainEntryResult,
41 pub detail: String,
42 pub duration_ms: f64,
45}
46
47#[derive(Debug, Serialize, Clone)]
48pub struct PrincipalSnapshot {
49 pub user_id: UserId,
50 pub session_id: SessionId,
55 pub agent_session: Option<SessionId>,
60 pub agent_id: Option<AgentId>,
61 pub agent_scope: AccessScope,
62}
63
64#[derive(Debug, Serialize, Clone)]
65pub struct AuditTarget {
66 pub tool_name: String,
67 pub plugin_id: Option<PluginId>,
68}
69
70#[derive(Debug, Serialize, Clone)]
71pub struct ApproverStamp {
72 pub user_id: UserId,
73 pub username: String,
74 pub decided_at: chrono::DateTime<chrono::Utc>,
75 pub action: &'static str,
77}
78
79#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
82#[serde(rename_all = "snake_case")]
83pub enum AuditOrigin {
84 Governed,
85 Reverified,
86}
87
88#[derive(Debug, Serialize, Clone)]
89pub struct DecisionAudit {
90 pub id: String,
94 pub call_id: String,
97 pub origin: AuditOrigin,
98 pub decision: Decision,
99 pub principal: PrincipalSnapshot,
100 pub target: AuditTarget,
101 pub chain: Vec<ChainEntryOutcome>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub approver: Option<ApproverStamp>,
104 #[serde(skip_serializing_if = "Vec::is_empty")]
107 pub act_chain: Vec<Actor>,
108 #[serde(skip_serializing_if = "Option::is_none")]
113 pub context_id: Option<String>,
114}
115
116fn allow_policy_label(chain: &[ChainEntryOutcome]) -> &'static str {
121 if !chain.is_empty() && chain.iter().all(|e| e.result == ChainEntryResult::Disabled) {
122 return "governance_disabled";
123 }
124 "default_allow"
125}
126
127pub async fn record_decision(pool: &PgPool, audit: &DecisionAudit) -> Result<(), sqlx::Error> {
131 let actor = Actor::from_tool_name(
132 audit.principal.user_id.clone(),
133 audit.principal.agent_id.as_ref().map(AgentId::as_str),
134 &audit.target.tool_name,
135 );
136 let (decision_tag, reason_str, policy_str) = match &audit.decision {
137 Decision::Allow { .. } => (
138 DecisionTag::Allow,
139 String::new(),
140 allow_policy_label(&audit.chain).to_owned(),
141 ),
142 Decision::Deny { reason } => {
143 let policy_str = audit
144 .chain
145 .iter()
146 .find(|e| e.result == ChainEntryResult::Fail)
147 .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
148 (DecisionTag::Deny, reason.to_string(), policy_str)
149 },
150 };
151 let evaluated_rules = serde_json::to_value(audit).unwrap_or_else(|e| {
152 tracing::error!(
153 error = %e,
154 tool_name = %audit.target.tool_name,
155 "could not serialise the governance evaluation trace; recording the decision \
156 without it"
157 );
158 serde_json::Value::Null
159 });
160
161 let context_id = audit
162 .context_id
163 .as_deref()
164 .and_then(|s| ContextId::try_new(s).ok())
165 .unwrap_or_else(|| ContextId::derived_from_session(&audit.principal.session_id));
166 let record = GovernanceDecisionRecord {
167 id: &audit.id,
168 actor: &actor,
169 session_id: audit.principal.session_id.as_str(),
170 tool_name: &audit.target.tool_name,
171 agent_id: audit.principal.agent_id.as_ref().map(AgentId::as_str),
172 agent_scope: Some(audit.principal.agent_scope),
173 decision: decision_tag,
174 policy: &policy_str,
175 reason: &reason_str,
176 evaluated_rules: &evaluated_rules,
177 plugin_id: audit.target.plugin_id.as_ref().map(PluginId::as_str),
178 act_chain: &audit.act_chain,
179 context_id: context_id.as_str(),
180 task_id: None,
181 };
182
183 insert_governance_decision(pool, &record).await
184}