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;
16use uuid::Uuid;
17
18/// Tenant lifecycle event types.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum TenantEventKind {
22 /// Tenant was created (first registration).
23 Created,
24 /// Tenant configuration was updated (subsequent PUT).
25 ConfigChanged,
26 /// Tenant was suspended (data requests return 503).
27 Suspended,
28 /// Tenant was resumed (data requests restored).
29 Resumed,
30 /// Tenant was deleted.
31 Deleted,
32}
33
34impl TenantEventKind {
35 /// Returns the string label for this event kind.
36 #[must_use]
37 pub const fn as_str(&self) -> &'static str {
38 match self {
39 Self::Created => "created",
40 Self::ConfigChanged => "config_changed",
41 Self::Suspended => "suspended",
42 Self::Resumed => "resumed",
43 Self::Deleted => "deleted",
44 }
45 }
46}
47
48/// The principal that triggered an audited tenant lifecycle operation (#390).
49///
50/// Bundled so the audit `record` call stays a single actor argument. Built at the
51/// admin handler from the request's `SecurityContext` (a JWT principal) or, for
52/// the static-admin-token path, a synthetic service-account actor.
53#[derive(Debug, Clone, Default, Serialize)]
54pub struct AuditActor {
55 /// The actor's identity (JWT `sub`, or `"admin_token"` for the static admin
56 /// path). `None` when no principal could be determined.
57 pub id: Option<String>,
58 /// The actor classification — the `snake_case`
59 /// [`ActorType`](fraiseql_core::security::ActorType) token (`"human_user"`,
60 /// `"service_account"`, `"ai_agent"`, `"system_job"`).
61 pub actor_type: Option<String>,
62 /// For a delegated agent, the underlying human the agent acts for.
63 pub acting_for: Option<Uuid>,
64}
65
66/// A single audit trail event.
67#[derive(Debug, Clone, Serialize)]
68pub struct TenantEvent {
69 /// The tenant key this event relates to.
70 pub tenant_key: String,
71 /// The kind of lifecycle event.
72 pub event: TenantEventKind,
73 /// The actor who triggered the event (JWT `sub` claim or `"admin_token"`).
74 pub actor: Option<String>,
75 /// The actor's classification (the `snake_case` `ActorType` token — #390).
76 pub actor_type: Option<String>,
77 /// For a delegated agent actor, the underlying human's UUID (#390).
78 pub acting_for_user_id: Option<Uuid>,
79 /// Event-specific metadata (e.g., quota changes, config diffs).
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub payload: Option<serde_json::Value>,
82 /// When the event occurred (ISO 8601).
83 pub occurred_at: String,
84}
85
86/// Trait for tenant audit log backends.
87///
88/// Implementations must be `Send + Sync` for use in async contexts.
89/// The trait is append-only by design — no update or delete methods.
90// Reason: async_trait required for dyn compatibility (used in AppState)
91#[async_trait]
92pub trait TenantAuditLog: Send + Sync {
93 /// Record a tenant lifecycle event.
94 ///
95 /// # Errors
96 ///
97 /// Returns an error if the event cannot be persisted.
98 async fn record(
99 &self,
100 tenant_key: &str,
101 event: TenantEventKind,
102 actor: Option<&AuditActor>,
103 payload: Option<serde_json::Value>,
104 ) -> fraiseql_error::Result<()>;
105
106 /// Query events for a specific tenant, ordered by occurrence (newest first).
107 ///
108 /// # Errors
109 ///
110 /// Returns an error if the query fails.
111 async fn events_for(
112 &self,
113 tenant_key: &str,
114 limit: usize,
115 offset: usize,
116 ) -> fraiseql_error::Result<Vec<TenantEvent>>;
117}
118
119/// In-memory audit log for testing and lightweight deployments.
120///
121/// Events are stored in a `Vec` behind a `RwLock`. Not durable — events are
122/// lost on server restart. Use a database-backed implementation for production.
123pub struct InMemoryAuditLog {
124 events: RwLock<Vec<TenantEvent>>,
125}
126
127impl InMemoryAuditLog {
128 /// Create a new empty in-memory audit log.
129 #[must_use]
130 pub fn new() -> Self {
131 Self {
132 events: RwLock::new(Vec::new()),
133 }
134 }
135}
136
137impl Default for InMemoryAuditLog {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143// Reason: async_trait required for dyn compatibility
144#[async_trait]
145impl TenantAuditLog for InMemoryAuditLog {
146 async fn record(
147 &self,
148 tenant_key: &str,
149 event: TenantEventKind,
150 actor: Option<&AuditActor>,
151 payload: Option<serde_json::Value>,
152 ) -> fraiseql_error::Result<()> {
153 let entry = TenantEvent {
154 tenant_key: tenant_key.to_string(),
155 event,
156 actor: actor.and_then(|a| a.id.clone()),
157 actor_type: actor.and_then(|a| a.actor_type.clone()),
158 acting_for_user_id: actor.and_then(|a| a.acting_for),
159 payload,
160 occurred_at: chrono::Utc::now().to_rfc3339(),
161 };
162 self.events.write().await.push(entry);
163 Ok(())
164 }
165
166 async fn events_for(
167 &self,
168 tenant_key: &str,
169 limit: usize,
170 offset: usize,
171 ) -> fraiseql_error::Result<Vec<TenantEvent>> {
172 let events = self.events.read().await;
173 let matching: Vec<TenantEvent> = events
174 .iter()
175 .rev() // newest first
176 .filter(|e| e.tenant_key == tenant_key)
177 .skip(offset)
178 .take(limit)
179 .cloned()
180 .collect();
181 Ok(matching)
182 }
183}
184
185/// Type-erased audit log handle for use in `AppState`.
186pub type AuditLogHandle = Arc<dyn TenantAuditLog>;
187
188/// SQL DDL for the tenant events table in the control plane database.
189///
190/// This migration creates an append-only audit trail for tenant lifecycle events.
191/// The table is designed for the control plane database (not tenant databases).
192pub const TENANT_EVENTS_DDL: &str = "\
193CREATE TABLE IF NOT EXISTS _fraiseql_tenant_events (
194 id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
195 tenant_key TEXT NOT NULL,
196 event TEXT NOT NULL,
197 actor TEXT,
198 actor_type TEXT,
199 acting_for_user_id UUID,
200 payload JSONB,
201 occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
202);
203CREATE INDEX IF NOT EXISTS idx_tenant_events_key
204 ON _fraiseql_tenant_events (tenant_key, occurred_at);
205";