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";