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,
28 Skip,
29}
30
31#[derive(Debug, Serialize, Clone)]
33pub struct ChainEntryOutcome {
34 pub policy_id: PolicyId,
35 #[serde(flatten)]
36 pub result: ChainEntryResult,
37 pub detail: String,
38 pub duration_ms: f64,
39}
40
41#[derive(Debug, Serialize, Clone)]
42pub struct PrincipalSnapshot {
43 pub user_id: UserId,
44 pub session_id: SessionId,
45 pub agent_session: Option<SessionId>,
46 pub agent_id: Option<AgentId>,
47 pub agent_scope: AccessScope,
48}
49
50#[derive(Debug, Serialize, Clone)]
51pub struct AuditTarget {
52 pub tool_name: String,
53 pub plugin_id: Option<PluginId>,
54}
55
56#[derive(Debug, Serialize, Clone)]
57pub struct ApproverStamp {
58 pub user_id: UserId,
59 pub username: String,
60 pub decided_at: chrono::DateTime<chrono::Utc>,
61 pub action: &'static str,
62}
63
64#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
67#[serde(rename_all = "snake_case")]
68pub enum AuditOrigin {
69 Governed,
70 Reverified,
71}
72
73#[derive(Debug, Serialize, Clone)]
74pub struct DecisionAudit {
75 pub id: String,
76 pub call_id: String,
77 pub origin: AuditOrigin,
78 pub decision: Decision,
79 pub principal: PrincipalSnapshot,
80 pub target: AuditTarget,
81 pub chain: Vec<ChainEntryOutcome>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub approver: Option<ApproverStamp>,
84 #[serde(skip_serializing_if = "Vec::is_empty")]
85 pub act_chain: Vec<Actor>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub context_id: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
91 pub trace_id: Option<String>,
92}
93
94fn allow_policy_label(chain: &[ChainEntryOutcome]) -> &'static str {
99 if !chain.is_empty() && chain.iter().all(|e| e.result == ChainEntryResult::Disabled) {
100 return "governance_disabled";
101 }
102 "default_allow"
103}
104
105pub async fn record_decision(pool: &PgPool, audit: &DecisionAudit) -> Result<(), sqlx::Error> {
106 let actor = Actor::from_tool_name(
107 audit.principal.user_id.clone(),
108 audit.principal.agent_id.as_ref().map(AgentId::as_str),
109 &audit.target.tool_name,
110 );
111 let (decision_tag, reason_str, policy_str) = match &audit.decision {
112 Decision::Allow { .. } => (
113 DecisionTag::Allow,
114 String::new(),
115 allow_policy_label(&audit.chain).to_owned(),
116 ),
117 Decision::Deny { reason } => {
118 let policy_str = audit
119 .chain
120 .iter()
121 .find(|e| e.result == ChainEntryResult::Fail)
122 .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
123 (DecisionTag::Deny, reason.to_string(), policy_str)
124 },
125 };
126 let evaluated_rules = serde_json::to_value(audit).unwrap_or_else(|e| {
127 tracing::error!(
128 error = %e,
129 tool_name = %audit.target.tool_name,
130 "could not serialise the governance evaluation trace; recording the decision \
131 without it"
132 );
133 serde_json::Value::Null
134 });
135
136 let context_id = audit
137 .context_id
138 .as_deref()
139 .and_then(|s| ContextId::try_new(s).ok())
140 .unwrap_or_else(|| ContextId::derived_from_session(&audit.principal.session_id));
141 let record = GovernanceDecisionRecord {
142 id: &audit.id,
143 actor: &actor,
144 session_id: audit.principal.session_id.as_str(),
145 tool_name: &audit.target.tool_name,
146 agent_id: audit.principal.agent_id.as_ref().map(AgentId::as_str),
147 agent_scope: Some(audit.principal.agent_scope),
148 decision: decision_tag,
149 policy: &policy_str,
150 reason: &reason_str,
151 evaluated_rules: &evaluated_rules,
152 plugin_id: audit.target.plugin_id.as_ref().map(PluginId::as_str),
153 act_chain: &audit.act_chain,
154 context_id: context_id.as_str(),
155 task_id: None,
156 trace_id: audit.trace_id.as_deref(),
157 };
158
159 insert_governance_decision(pool, &record).await
160}