fraiseql-server 2.8.0

HTTP server for FraiseQL v2 GraphQL engine
Documentation
//! Tenant audit trail — append-only event log for tenant lifecycle operations.
//!
//! Events are recorded after each admin operation (create, update, suspend,
//! resume, delete). The log is append-only by API design: no update or delete
//! methods exist on the trait.
//!
//! The default [`InMemoryAuditLog`] implementation stores events in a `Vec`
//! behind a `RwLock`. A database-backed implementation can be added later by
//! implementing the [`TenantAuditLog`] trait.

use std::sync::Arc;

use async_trait::async_trait;
use serde::Serialize;
use tokio::sync::RwLock;
use uuid::Uuid;

/// Tenant lifecycle event types.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TenantEventKind {
    /// Tenant was created (first registration).
    Created,
    /// Tenant configuration was updated (subsequent PUT).
    ConfigChanged,
    /// Tenant was suspended (data requests return 503).
    Suspended,
    /// Tenant was resumed (data requests restored).
    Resumed,
    /// Tenant was deleted.
    Deleted,
}

impl TenantEventKind {
    /// Returns the string label for this event kind.
    #[must_use]
    pub const fn as_str(&self) -> &'static str {
        match self {
            Self::Created => "created",
            Self::ConfigChanged => "config_changed",
            Self::Suspended => "suspended",
            Self::Resumed => "resumed",
            Self::Deleted => "deleted",
        }
    }
}

/// The principal that triggered an audited tenant lifecycle operation (#390).
///
/// Bundled so the audit `record` call stays a single actor argument. Built at the
/// admin handler from the request's `SecurityContext` (a JWT principal) or, for
/// the static-admin-token path, a synthetic service-account actor.
#[derive(Debug, Clone, Default, Serialize)]
pub struct AuditActor {
    /// The actor's identity (JWT `sub`, or `"admin_token"` for the static admin
    /// path). `None` when no principal could be determined.
    pub id:         Option<String>,
    /// The actor classification — the `snake_case`
    /// [`ActorType`](fraiseql_core::security::ActorType) token (`"human_user"`,
    /// `"service_account"`, `"ai_agent"`, `"system_job"`).
    pub actor_type: Option<String>,
    /// For a delegated agent, the underlying human the agent acts for.
    pub acting_for: Option<Uuid>,
}

/// A single audit trail event.
#[derive(Debug, Clone, Serialize)]
pub struct TenantEvent {
    /// The tenant key this event relates to.
    pub tenant_key:         String,
    /// The kind of lifecycle event.
    pub event:              TenantEventKind,
    /// The actor who triggered the event (JWT `sub` claim or `"admin_token"`).
    pub actor:              Option<String>,
    /// The actor's classification (the `snake_case` `ActorType` token — #390).
    pub actor_type:         Option<String>,
    /// For a delegated agent actor, the underlying human's UUID (#390).
    pub acting_for_user_id: Option<Uuid>,
    /// Event-specific metadata (e.g., quota changes, config diffs).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub payload:            Option<serde_json::Value>,
    /// When the event occurred (ISO 8601).
    pub occurred_at:        String,
}

/// Trait for tenant audit log backends.
///
/// Implementations must be `Send + Sync` for use in async contexts.
/// The trait is append-only by design — no update or delete methods.
// Reason: async_trait required for dyn compatibility (used in AppState)
#[async_trait]
pub trait TenantAuditLog: Send + Sync {
    /// Record a tenant lifecycle event.
    ///
    /// # Errors
    ///
    /// Returns an error if the event cannot be persisted.
    async fn record(
        &self,
        tenant_key: &str,
        event: TenantEventKind,
        actor: Option<&AuditActor>,
        payload: Option<serde_json::Value>,
    ) -> fraiseql_error::Result<()>;

    /// Query events for a specific tenant, ordered by occurrence (newest first).
    ///
    /// # Errors
    ///
    /// Returns an error if the query fails.
    async fn events_for(
        &self,
        tenant_key: &str,
        limit: usize,
        offset: usize,
    ) -> fraiseql_error::Result<Vec<TenantEvent>>;
}

/// In-memory audit log for testing and lightweight deployments.
///
/// Events are stored in a `Vec` behind a `RwLock`. Not durable — events are
/// lost on server restart. Use a database-backed implementation for production.
pub struct InMemoryAuditLog {
    events: RwLock<Vec<TenantEvent>>,
}

impl InMemoryAuditLog {
    /// Create a new empty in-memory audit log.
    #[must_use]
    pub fn new() -> Self {
        Self {
            events: RwLock::new(Vec::new()),
        }
    }
}

impl Default for InMemoryAuditLog {
    fn default() -> Self {
        Self::new()
    }
}

// Reason: async_trait required for dyn compatibility
#[async_trait]
impl TenantAuditLog for InMemoryAuditLog {
    async fn record(
        &self,
        tenant_key: &str,
        event: TenantEventKind,
        actor: Option<&AuditActor>,
        payload: Option<serde_json::Value>,
    ) -> fraiseql_error::Result<()> {
        let entry = TenantEvent {
            tenant_key: tenant_key.to_string(),
            event,
            actor: actor.and_then(|a| a.id.clone()),
            actor_type: actor.and_then(|a| a.actor_type.clone()),
            acting_for_user_id: actor.and_then(|a| a.acting_for),
            payload,
            occurred_at: chrono::Utc::now().to_rfc3339(),
        };
        self.events.write().await.push(entry);
        Ok(())
    }

    async fn events_for(
        &self,
        tenant_key: &str,
        limit: usize,
        offset: usize,
    ) -> fraiseql_error::Result<Vec<TenantEvent>> {
        let events = self.events.read().await;
        let matching: Vec<TenantEvent> = events
            .iter()
            .rev() // newest first
            .filter(|e| e.tenant_key == tenant_key)
            .skip(offset)
            .take(limit)
            .cloned()
            .collect();
        Ok(matching)
    }
}

/// Type-erased audit log handle for use in `AppState`.
pub type AuditLogHandle = Arc<dyn TenantAuditLog>;

/// SQL DDL for the tenant events table in the control plane database.
///
/// This migration creates an append-only audit trail for tenant lifecycle events.
/// The table is designed for the control plane database (not tenant databases).
pub const TENANT_EVENTS_DDL: &str = "\
CREATE TABLE IF NOT EXISTS _fraiseql_tenant_events (
    id                  BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_key          TEXT NOT NULL,
    event               TEXT NOT NULL,
    actor               TEXT,
    actor_type          TEXT,
    acting_for_user_id  UUID,
    payload             JSONB,
    occurred_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tenant_events_key
    ON _fraiseql_tenant_events (tenant_key, occurred_at);
";