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