Skip to main content

fraiseql_server/routes/api/
tenant_admin.rs

1//! Tenant management admin API endpoints.
2//!
3//! All endpoints require multi-tenant mode to be enabled (tenant registry present
4//! in `AppState`). When disabled, they return 404 to avoid leaking the feature.
5//!
6//! Write endpoints (PUT, DELETE) require `admin_token`.
7//! Read endpoints (GET, health) accept `admin_readonly_token` or `admin_token`.
8
9use axum::{
10    Json,
11    extract::{Path, Query, State},
12};
13use fraiseql_core::db::traits::DatabaseAdapter;
14use serde::{Deserialize, Serialize};
15use tracing::info;
16
17use crate::{
18    routes::{
19        api::types::ApiError,
20        graphql::{AppState, tenant_registry::TenantQuota},
21    },
22    tenancy::{audit::TenantEventKind, pool_factory::TenantPoolConfig},
23};
24
25// ── Request / Response types ─────────────────────────────────────────────
26
27/// Body for `PUT /api/v1/admin/tenants/{key}`.
28#[derive(Debug, Deserialize)]
29pub struct TenantRegistrationRequest {
30    /// Compiled schema JSON (the full `schema.compiled.json` contents).
31    pub schema:               serde_json::Value,
32    /// Database connection configuration for this tenant.
33    pub connection:           TenantPoolConfig,
34    /// Maximum requests per second (token bucket rate). `None` = unlimited.
35    #[serde(default)]
36    pub max_requests_per_sec: Option<u32>,
37    /// Maximum concurrent in-flight requests. `None` = unlimited.
38    #[serde(default)]
39    pub max_concurrent:       Option<u32>,
40    /// Maximum storage in bytes (soft limit). `None` = unlimited.
41    #[serde(default)]
42    pub max_storage_bytes:    Option<u64>,
43}
44
45/// Response for tenant write operations.
46#[derive(Debug, Serialize)]
47pub struct TenantResponse {
48    /// The tenant key.
49    pub key:    String,
50    /// Whether this was `"created"`, `"updated"`, or `"removed"`.
51    pub status: &'static str,
52}
53
54/// Response for `GET /api/v1/admin/tenants/{key}`.
55#[derive(Debug, Serialize)]
56pub struct TenantMetadata {
57    /// The tenant key.
58    pub key:            String,
59    /// Tenant lifecycle status (`"active"` or `"suspended"`).
60    pub status:         &'static str,
61    /// Number of queries in the tenant's compiled schema.
62    pub query_count:    usize,
63    /// Number of mutations in the tenant's compiled schema.
64    pub mutation_count: usize,
65}
66
67/// Response for `GET /api/v1/admin/tenants`.
68#[derive(Debug, Serialize)]
69pub struct TenantListResponse {
70    /// All registered tenant keys.
71    pub tenants: Vec<String>,
72    /// Number of registered tenants.
73    pub count:   usize,
74}
75
76/// Response for `GET /api/v1/admin/tenants/{key}/health`.
77#[derive(Debug, Serialize)]
78pub struct TenantHealthResponse {
79    /// The tenant key.
80    pub key:    String,
81    /// Health status.
82    pub status: &'static str,
83}
84
85/// Query parameters for `GET /api/v1/admin/tenants/{key}/events`.
86#[derive(Debug, Deserialize)]
87pub struct EventsQuery {
88    /// Maximum number of events to return (default: 50, max: 200).
89    #[serde(default = "default_events_limit")]
90    pub limit:  usize,
91    /// Offset for pagination (default: 0).
92    #[serde(default)]
93    pub offset: usize,
94}
95
96const fn default_events_limit() -> usize {
97    50
98}
99
100/// Response for `GET /api/v1/admin/tenants/{key}/events`.
101#[derive(Debug, Serialize)]
102pub struct TenantEventsResponse {
103    /// The tenant key.
104    pub key:    String,
105    /// The events, newest first.
106    pub events: Vec<crate::tenancy::audit::TenantEvent>,
107    /// Total number of events returned.
108    pub count:  usize,
109}
110
111/// Body for `PUT /api/v1/admin/domains/{domain}`.
112#[derive(Debug, Deserialize)]
113pub struct DomainRegistrationRequest {
114    /// The tenant key to map this domain to.
115    pub tenant_key: String,
116}
117
118/// Response for domain write operations.
119#[derive(Debug, Serialize)]
120pub struct DomainResponse {
121    /// The domain name.
122    pub domain:     String,
123    /// Whether this was `"registered"` or `"removed"`.
124    pub status:     &'static str,
125    /// The tenant key the domain maps to (omitted on removal).
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub tenant_key: Option<String>,
128}
129
130/// Response for `GET /api/v1/admin/domains`.
131#[derive(Debug, Serialize)]
132pub struct DomainListResponse {
133    /// All registered domain → tenant key mappings.
134    pub domains: Vec<DomainMapping>,
135    /// Number of registered domains.
136    pub count:   usize,
137}
138
139/// A single domain → tenant key mapping.
140#[derive(Debug, Serialize)]
141pub struct DomainMapping {
142    /// The custom domain.
143    pub domain:     String,
144    /// The tenant key it resolves to.
145    pub tenant_key: String,
146}
147
148// ── Handlers ─────────────────────────────────────────────────────────────
149
150/// `PUT /api/v1/admin/tenants/{key}` — register or update a tenant.
151///
152/// Accepts compiled schema JSON and connection configuration in a single request.
153/// Returns `"created"` or `"updated"` status.
154///
155/// Uses the `TenantExecutorFactory` stored in `AppState` to construct the
156/// executor, avoiding the need for `A: FromPoolConfig` on the handler.
157///
158/// # Errors
159///
160/// Returns `ApiError` with 404 if multi-tenant mode is disabled, 400 for invalid
161/// schema JSON, or 503 if the connection cannot be established.
162pub async fn upsert_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
163    State(state): State<AppState<A>>,
164    Path(key): Path<String>,
165    Json(body): Json<TenantRegistrationRequest>,
166) -> Result<Json<TenantResponse>, ApiError> {
167    let registry = state
168        .tenant_registry()
169        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
170
171    let factory = state
172        .tenant_executor_factory()
173        .ok_or_else(|| ApiError::internal_error("tenant executor factory not configured"))?;
174
175    let schema_json = serde_json::to_string(&body.schema)
176        .map_err(|e| ApiError::validation_error(format!("invalid schema JSON: {e}")))?;
177
178    let executor =
179        factory(key.clone(), schema_json, body.connection).await.map_err(|e| match &e {
180            fraiseql_error::FraiseQLError::Parse { .. }
181            | fraiseql_error::FraiseQLError::Validation { .. } => ApiError::validation_error(e),
182            fraiseql_error::FraiseQLError::ConnectionPool { .. }
183            | fraiseql_error::FraiseQLError::Database { .. } => {
184                ApiError::new(format!("Connection failed: {e}"), "SERVICE_UNAVAILABLE")
185            },
186            _ => ApiError::internal_error(e),
187        })?;
188
189    let quota = TenantQuota {
190        max_requests_per_sec: body.max_requests_per_sec,
191        max_concurrent:       body.max_concurrent,
192        max_storage_bytes:    body.max_storage_bytes,
193    };
194
195    let was_insert = registry.upsert_with_quota(&key, executor, quota);
196    let status = if was_insert { "created" } else { "updated" };
197
198    info!(tenant_key = %key, status, "tenant executor registered");
199
200    // Record audit event (fire-and-forget — audit failure must not block the operation)
201    if let Some(audit_log) = state.tenant_audit_log() {
202        let event = if was_insert {
203            TenantEventKind::Created
204        } else {
205            TenantEventKind::ConfigChanged
206        };
207        if let Err(e) = audit_log.record(&key, event, None, None).await {
208            tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
209        }
210    }
211
212    Ok(Json(TenantResponse { key, status }))
213}
214
215/// `DELETE /api/v1/admin/tenants/{key}` — remove a tenant.
216///
217/// In-flight requests on the old executor complete via Arc semantics.
218///
219/// # Errors
220///
221/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
222/// key is not found.
223pub async fn delete_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
224    State(state): State<AppState<A>>,
225    Path(key): Path<String>,
226) -> Result<Json<TenantResponse>, ApiError> {
227    let registry = state
228        .tenant_registry()
229        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
230
231    registry
232        .remove(&key)
233        .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
234
235    info!(tenant_key = %key, "tenant executor removed");
236
237    if let Some(audit_log) = state.tenant_audit_log() {
238        if let Err(e) = audit_log.record(&key, TenantEventKind::Deleted, None, None).await {
239            tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
240        }
241    }
242
243    Ok(Json(TenantResponse {
244        key,
245        status: "removed",
246    }))
247}
248
249/// `POST /api/v1/admin/tenants/{key}/suspend` — suspend a tenant.
250///
251/// Suspended tenants' data requests return 503 with `Retry-After: 60`.
252/// No executor teardown occurs — database connections remain open.
253///
254/// # Errors
255///
256/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
257/// key is not found.
258pub async fn suspend_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
259    State(state): State<AppState<A>>,
260    Path(key): Path<String>,
261) -> Result<Json<TenantResponse>, ApiError> {
262    let registry = state
263        .tenant_registry()
264        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
265
266    registry
267        .suspend(&key)
268        .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
269
270    info!(tenant_key = %key, "tenant suspended");
271
272    if let Some(audit_log) = state.tenant_audit_log() {
273        if let Err(e) = audit_log.record(&key, TenantEventKind::Suspended, None, None).await {
274            tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
275        }
276    }
277
278    Ok(Json(TenantResponse {
279        key,
280        status: "suspended",
281    }))
282}
283
284/// `POST /api/v1/admin/tenants/{key}/resume` — resume a suspended tenant.
285///
286/// Restores the tenant to active status so data requests are served normally.
287///
288/// # Errors
289///
290/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
291/// key is not found.
292pub async fn resume_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
293    State(state): State<AppState<A>>,
294    Path(key): Path<String>,
295) -> Result<Json<TenantResponse>, ApiError> {
296    let registry = state
297        .tenant_registry()
298        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
299
300    registry
301        .resume(&key)
302        .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
303
304    info!(tenant_key = %key, "tenant resumed");
305
306    if let Some(audit_log) = state.tenant_audit_log() {
307        if let Err(e) = audit_log.record(&key, TenantEventKind::Resumed, None, None).await {
308            tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
309        }
310    }
311
312    Ok(Json(TenantResponse {
313        key,
314        status: "resumed",
315    }))
316}
317
318/// `GET /api/v1/admin/tenants/{key}` — get tenant metadata.
319///
320/// Returns query/mutation counts. Never includes credentials.
321///
322/// # Errors
323///
324/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
325/// key is not found.
326pub async fn get_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
327    State(state): State<AppState<A>>,
328    Path(key): Path<String>,
329) -> Result<Json<TenantMetadata>, ApiError> {
330    let registry = state
331        .tenant_registry()
332        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
333
334    let status = registry
335        .tenant_status(&key)
336        .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
337
338    let executor = registry
339        .executor_for_admin(&key)
340        .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
341
342    Ok(Json(TenantMetadata {
343        key,
344        status: status.as_str(),
345        query_count: executor.schema().queries.len(),
346        mutation_count: executor.schema().mutations.len(),
347    }))
348}
349
350/// `GET /api/v1/admin/tenants` — list all registered tenant keys.
351///
352/// Never includes credentials.
353///
354/// # Errors
355///
356/// Returns `ApiError` with 404 if multi-tenant mode is disabled.
357pub async fn list_tenants_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
358    State(state): State<AppState<A>>,
359) -> Result<Json<TenantListResponse>, ApiError> {
360    let registry = state
361        .tenant_registry()
362        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
363
364    let tenants = registry.tenant_keys();
365    let count = tenants.len();
366
367    Ok(Json(TenantListResponse { tenants, count }))
368}
369
370/// `GET /api/v1/admin/tenants/{key}/health` — health check a tenant's pool.
371///
372/// # Errors
373///
374/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
375/// key is not found. Returns 503 if the health check fails.
376pub async fn tenant_health_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
377    State(state): State<AppState<A>>,
378    Path(key): Path<String>,
379) -> Result<Json<TenantHealthResponse>, ApiError> {
380    let registry = state
381        .tenant_registry()
382        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
383
384    registry.health_check(&key).await.map_err(|e| match &e {
385        fraiseql_error::FraiseQLError::NotFound { .. } => {
386            ApiError::not_found(format!("tenant '{key}'"))
387        },
388        _ => ApiError::new(format!("Health check failed: {e}"), "SERVICE_UNAVAILABLE"),
389    })?;
390
391    Ok(Json(TenantHealthResponse {
392        key,
393        status: "healthy",
394    }))
395}
396
397/// Maximum events per page to prevent abuse.
398const MAX_EVENTS_LIMIT: usize = 200;
399
400/// `GET /api/v1/admin/tenants/{key}/events` — query tenant audit trail.
401///
402/// Returns lifecycle events for a specific tenant, newest first.
403/// Supports pagination via `limit` and `offset` query parameters.
404///
405/// # Errors
406///
407/// Returns `ApiError` with 404 if multi-tenant mode is disabled, the tenant
408/// key is not found, or no audit log is configured.
409pub async fn tenant_events_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
410    State(state): State<AppState<A>>,
411    Path(key): Path<String>,
412    Query(params): Query<EventsQuery>,
413) -> Result<Json<TenantEventsResponse>, ApiError> {
414    // Multi-tenant mode must be enabled + verify tenant exists
415    let registry = state
416        .tenant_registry()
417        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
418
419    registry
420        .executor_for_admin(&key)
421        .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
422
423    let audit_log = state
424        .tenant_audit_log()
425        .ok_or_else(|| ApiError::not_found("audit log not configured"))?;
426
427    let limit = params.limit.min(MAX_EVENTS_LIMIT);
428    let events = audit_log
429        .events_for(&key, limit, params.offset)
430        .await
431        .map_err(|e| ApiError::internal_error(format!("failed to query audit events: {e}")))?;
432
433    let count = events.len();
434
435    Ok(Json(TenantEventsResponse { key, events, count }))
436}
437
438// ── Domain management handlers ──────────────────────────────────────────
439
440/// `PUT /api/v1/admin/domains/{domain}` — register a domain → tenant mapping.
441///
442/// Validates that the referenced tenant key exists in the tenant registry
443/// (when multi-tenant mode is enabled). Overwrites any existing mapping
444/// for the same domain.
445///
446/// # Errors
447///
448/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the
449/// referenced tenant key is not registered.
450pub async fn upsert_domain_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
451    State(state): State<AppState<A>>,
452    Path(domain): Path<String>,
453    Json(body): Json<DomainRegistrationRequest>,
454) -> Result<Json<DomainResponse>, ApiError> {
455    // Multi-tenant mode must be enabled
456    let registry = state
457        .tenant_registry()
458        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
459
460    // Verify the tenant key is actually registered
461    registry
462        .executor_for(Some(&body.tenant_key))
463        .map_err(|_| ApiError::not_found(format!("tenant '{}'", body.tenant_key)))?;
464
465    state.domain_registry().register(&domain, &body.tenant_key);
466
467    info!(domain = %domain, tenant_key = %body.tenant_key, "domain mapping registered");
468
469    Ok(Json(DomainResponse {
470        domain,
471        status: "registered",
472        tenant_key: Some(body.tenant_key),
473    }))
474}
475
476/// `DELETE /api/v1/admin/domains/{domain}` — remove a domain mapping.
477///
478/// # Errors
479///
480/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the
481/// domain is not registered.
482pub async fn delete_domain_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
483    State(state): State<AppState<A>>,
484    Path(domain): Path<String>,
485) -> Result<Json<DomainResponse>, ApiError> {
486    state
487        .tenant_registry()
488        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
489
490    if !state.domain_registry().remove(&domain) {
491        return Err(ApiError::not_found(format!("domain '{domain}'")));
492    }
493
494    info!(domain = %domain, "domain mapping removed");
495
496    Ok(Json(DomainResponse {
497        domain,
498        status: "removed",
499        tenant_key: None,
500    }))
501}
502
503/// `GET /api/v1/admin/domains` — list all domain → tenant mappings.
504///
505/// # Errors
506///
507/// Returns `ApiError` with 404 if multi-tenant mode is disabled.
508pub async fn list_domains_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
509    State(state): State<AppState<A>>,
510) -> Result<Json<DomainListResponse>, ApiError> {
511    state
512        .tenant_registry()
513        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
514
515    let mappings = state.domain_registry().domains();
516    let count = mappings.len();
517
518    Ok(Json(DomainListResponse {
519        domains: mappings
520            .into_iter()
521            .map(|(domain, tenant_key)| DomainMapping { domain, tenant_key })
522            .collect(),
523        count,
524    }))
525}