Skip to main content

fraiseql_server/tenancy/
audit.rs

1//! Tenant audit trail — append-only event log for tenant lifecycle operations.
2//!
3//! Events are recorded after each admin operation (create, update, suspend,
4//! resume, delete). The log is append-only by API design: no update or delete
5//! methods exist on the trait.
6//!
7//! The default [`InMemoryAuditLog`] implementation stores events in a `Vec`
8//! behind a `RwLock`. A database-backed implementation can be added later by
9//! implementing the [`TenantAuditLog`] trait.
10
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use serde::Serialize;
15use tokio::sync::RwLock;
16
17/// Tenant lifecycle event types.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum TenantEventKind {
21    /// Tenant was created (first registration).
22    Created,
23    /// Tenant configuration was updated (subsequent PUT).
24    ConfigChanged,
25    /// Tenant was suspended (data requests return 503).
26    Suspended,
27    /// Tenant was resumed (data requests restored).
28    Resumed,
29    /// Tenant was deleted.
30    Deleted,
31}
32
33impl TenantEventKind {
34    /// Returns the string label for this event kind.
35    #[must_use]
36    pub const fn as_str(&self) -> &'static str {
37        match self {
38            Self::Created => "created",
39            Self::ConfigChanged => "config_changed",
40            Self::Suspended => "suspended",
41            Self::Resumed => "resumed",
42            Self::Deleted => "deleted",
43        }
44    }
45}
46
47/// A single audit trail event.
48#[derive(Debug, Clone, Serialize)]
49pub struct TenantEvent {
50    /// The tenant key this event relates to.
51    pub tenant_key:  String,
52    /// The kind of lifecycle event.
53    pub event:       TenantEventKind,
54    /// The actor who triggered the event (JWT `sub` claim or `"admin_token"`).
55    pub actor:       Option<String>,
56    /// Event-specific metadata (e.g., quota changes, config diffs).
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub payload:     Option<serde_json::Value>,
59    /// When the event occurred (ISO 8601).
60    pub occurred_at: String,
61}
62
63/// Trait for tenant audit log backends.
64///
65/// Implementations must be `Send + Sync` for use in async contexts.
66/// The trait is append-only by design — no update or delete methods.
67// Reason: async_trait required for dyn compatibility (used in AppState)
68#[async_trait]
69pub trait TenantAuditLog: Send + Sync {
70    /// Record a tenant lifecycle event.
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if the event cannot be persisted.
75    async fn record(
76        &self,
77        tenant_key: &str,
78        event: TenantEventKind,
79        actor: Option<&str>,
80        payload: Option<serde_json::Value>,
81    ) -> fraiseql_error::Result<()>;
82
83    /// Query events for a specific tenant, ordered by occurrence (newest first).
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the query fails.
88    async fn events_for(
89        &self,
90        tenant_key: &str,
91        limit: usize,
92        offset: usize,
93    ) -> fraiseql_error::Result<Vec<TenantEvent>>;
94}
95
96/// In-memory audit log for testing and lightweight deployments.
97///
98/// Events are stored in a `Vec` behind a `RwLock`. Not durable — events are
99/// lost on server restart. Use a database-backed implementation for production.
100pub struct InMemoryAuditLog {
101    events: RwLock<Vec<TenantEvent>>,
102}
103
104impl InMemoryAuditLog {
105    /// Create a new empty in-memory audit log.
106    #[must_use]
107    pub fn new() -> Self {
108        Self {
109            events: RwLock::new(Vec::new()),
110        }
111    }
112}
113
114impl Default for InMemoryAuditLog {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120// Reason: async_trait required for dyn compatibility
121#[async_trait]
122impl TenantAuditLog for InMemoryAuditLog {
123    async fn record(
124        &self,
125        tenant_key: &str,
126        event: TenantEventKind,
127        actor: Option<&str>,
128        payload: Option<serde_json::Value>,
129    ) -> fraiseql_error::Result<()> {
130        let entry = TenantEvent {
131            tenant_key: tenant_key.to_string(),
132            event,
133            actor: actor.map(ToString::to_string),
134            payload,
135            occurred_at: chrono::Utc::now().to_rfc3339(),
136        };
137        self.events.write().await.push(entry);
138        Ok(())
139    }
140
141    async fn events_for(
142        &self,
143        tenant_key: &str,
144        limit: usize,
145        offset: usize,
146    ) -> fraiseql_error::Result<Vec<TenantEvent>> {
147        let events = self.events.read().await;
148        let matching: Vec<TenantEvent> = events
149            .iter()
150            .rev() // newest first
151            .filter(|e| e.tenant_key == tenant_key)
152            .skip(offset)
153            .take(limit)
154            .cloned()
155            .collect();
156        Ok(matching)
157    }
158}
159
160/// Type-erased audit log handle for use in `AppState`.
161pub type AuditLogHandle = Arc<dyn TenantAuditLog>;
162
163/// SQL DDL for the tenant events table in the control plane database.
164///
165/// This migration creates an append-only audit trail for tenant lifecycle events.
166/// The table is designed for the control plane database (not tenant databases).
167pub const TENANT_EVENTS_DDL: &str = "\
168CREATE TABLE IF NOT EXISTS _fraiseql_tenant_events (
169    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
170    tenant_key   TEXT NOT NULL,
171    event        TEXT NOT NULL,
172    actor        TEXT,
173    payload      JSONB,
174    occurred_at  TIMESTAMPTZ NOT NULL DEFAULT now()
175);
176CREATE INDEX IF NOT EXISTS idx_tenant_events_key
177    ON _fraiseql_tenant_events (tenant_key, occurred_at);
178";