systemprompt-security 0.14.2

Security infrastructure for systemprompt.io AI governance: JWT, OAuth2 token extraction, scope enforcement, ChaCha20-Poly1305 secret encryption, the four-layer tool-call governance pipeline, and the unified authz decision plane (deny-overrides resolver + AuthzDecisionHook) shared by gateway and MCP enforcement.
Documentation
//! [`AuthzAuditSink`] backed by [`GovernanceDecisionRepository`].
//!
//! Failure to insert is logged via `tracing::error!` but never propagates —
//! the calling hook has already decided; losing the audit row must not flip
//! a deny to an allow.

use async_trait::async_trait;
use systemprompt_identifiers::{Actor, SessionId};

use super::repository::{GovernanceDecisionRecord, GovernanceDecisionRepository};
use super::{AuthzAuditSink, AuthzSource};
use crate::authz::types::{AuthzDecision, AuthzRequest, DecisionTag};

#[derive(Debug, Clone)]
pub struct DbAuditSink {
    repo: GovernanceDecisionRepository,
}

impl DbAuditSink {
    pub const fn new(repo: GovernanceDecisionRepository) -> Self {
        Self { repo }
    }
}

#[async_trait]
impl AuthzAuditSink for DbAuditSink {
    async fn record(&self, req: &AuthzRequest, decision: &AuthzDecision, source: AuthzSource) {
        let id = uuid::Uuid::new_v4().to_string();
        let decision_tag = DecisionTag::from(decision);
        let reason_str = match decision {
            AuthzDecision::Allow => String::new(),
            AuthzDecision::Deny { reason, .. } => reason.to_string(),
        };
        let entity_type = req.entity.kind().as_str();
        let entity_id = req.entity.id_str();
        // JSON: audit blob constructed for core-side (non-template) authz
        // denies — same payload shape as template's `DecisionAudit`.
        let evaluated = serde_json::json!({
            "entity_type": entity_type,
            "entity_id": entity_id,
            "trace_id": req.trace_id.as_str(),
            "roles": req.roles,
            "attributes": req.attributes,
            "context": req.context,
            "source": format!("{:?}", source),
        });
        let actor = Actor::user(req.user_id.clone());
        let record = GovernanceDecisionRecord {
            id: &id,
            actor: &actor,
            session_id: req.session_id.as_ref().map_or("", SessionId::as_str),
            tool_name: entity_id,
            agent_id: None,
            // Why: the authz path operates on entities (not agent invocations) so
            // there's no AccessScope to record. entity_type still flows into the
            // evaluated_rules JSON above for forensic lookup.
            agent_scope: None,
            decision: decision_tag,
            policy: source.policy(),
            reason: &reason_str,
            evaluated_rules: &evaluated,
            plugin_id: None,
            act_chain: &req.act_chain,
        };
        if let Err(err) = self.repo.insert(&record).await {
            tracing::error!(
                error = %err,
                policy = source.policy(),
                entity_type = %entity_type,
                entity_id = %entity_id,
                "failed to record core-side authz decision"
            );
        }
    }
}