systemprompt_security/policy/
audit.rs1use serde::Serialize;
15use sqlx::PgPool;
16use systemprompt_identifiers::{Actor, AgentId, 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 Skip,
28}
29
30#[derive(Debug, Serialize, Clone)]
32pub struct ChainEntryOutcome {
33 pub policy_id: PolicyId,
34 #[serde(flatten)]
35 pub result: ChainEntryResult,
36 pub detail: String,
37 pub duration_ms: f64,
40}
41
42#[derive(Debug, Serialize, Clone)]
43pub struct PrincipalSnapshot {
44 pub user_id: UserId,
45 pub session_id: SessionId,
50 pub agent_session: Option<SessionId>,
55 pub agent_id: Option<AgentId>,
56 pub agent_scope: AccessScope,
57}
58
59#[derive(Debug, Serialize, Clone)]
60pub struct AuditTarget {
61 pub tool_name: String,
62 pub plugin_id: Option<PluginId>,
63}
64
65#[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,
75}
76
77#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
80#[serde(rename_all = "snake_case")]
81pub enum AuditOrigin {
82 Governed,
83 Reverified,
84}
85
86#[derive(Debug, Serialize, Clone)]
87pub struct DecisionAudit {
88 pub id: String,
92 pub call_id: String,
95 pub origin: AuditOrigin,
96 pub decision: Decision,
97 pub principal: PrincipalSnapshot,
98 pub target: AuditTarget,
99 pub chain: Vec<ChainEntryOutcome>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub approver: Option<ApproverStamp>,
102 #[serde(skip_serializing_if = "Vec::is_empty")]
105 pub act_chain: Vec<Actor>,
106}
107
108pub async fn record_decision(pool: &PgPool, audit: &DecisionAudit) -> Result<(), sqlx::Error> {
112 let actor = Actor::from_tool_name(
113 audit.principal.user_id.clone(),
114 audit.principal.agent_id.as_ref().map(AgentId::as_str),
115 &audit.target.tool_name,
116 );
117 let (decision_tag, reason_str, policy_str) = match &audit.decision {
118 Decision::Allow { .. } => (
119 DecisionTag::Allow,
120 String::new(),
121 "default_allow".to_owned(),
122 ),
123 Decision::Deny { reason } => {
124 let policy_str = audit
125 .chain
126 .iter()
127 .find(|e| e.result == ChainEntryResult::Fail)
128 .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
129 (DecisionTag::Deny, reason.to_string(), policy_str)
130 },
131 };
132 let evaluated_rules = serde_json::to_value(audit).unwrap_or_else(|e| {
133 tracing::error!(
134 error = %e,
135 tool_name = %audit.target.tool_name,
136 "could not serialise the governance evaluation trace; recording the decision \
137 without it"
138 );
139 serde_json::Value::Null
140 });
141
142 let record = GovernanceDecisionRecord {
143 id: &audit.id,
144 actor: &actor,
145 session_id: audit.principal.session_id.as_str(),
146 tool_name: &audit.target.tool_name,
147 agent_id: audit.principal.agent_id.as_ref().map(AgentId::as_str),
148 agent_scope: Some(audit.principal.agent_scope),
149 decision: decision_tag,
150 policy: &policy_str,
151 reason: &reason_str,
152 evaluated_rules: &evaluated_rules,
153 plugin_id: audit.target.plugin_id.as_ref().map(PluginId::as_str),
154 act_chain: &audit.act_chain,
155 context_id: None,
156 task_id: None,
157 };
158
159 insert_governance_decision(pool, &record).await
160}