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    // Reject keys that the header validator would accept but schema-mode
168    // provisioning would later reject, so the drift surfaces at registration
169    // time rather than at the first schema-mode DDL job (#333).
170    crate::routes::graphql::tenant_key::validate_tenant_key(&key)
171        .map_err(|e| ApiError::validation_error(e.to_string()))?;
172
173    let registry = state
174        .tenant_registry()
175        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
176
177    let factory = state
178        .tenant_executor_factory()
179        .ok_or_else(|| ApiError::internal_error("tenant executor factory not configured"))?;
180
181    let schema_json = serde_json::to_string(&body.schema)
182        .map_err(|e| ApiError::validation_error(format!("invalid schema JSON: {e}")))?;
183
184    let executor =
185        factory(key.clone(), schema_json, body.connection).await.map_err(|e| match &e {
186            fraiseql_error::FraiseQLError::Parse { .. }
187            | fraiseql_error::FraiseQLError::Validation { .. } => ApiError::validation_error(e),
188            fraiseql_error::FraiseQLError::ConnectionPool { .. }
189            | fraiseql_error::FraiseQLError::Database { .. } => {
190                ApiError::new(format!("Connection failed: {e}"), "SERVICE_UNAVAILABLE")
191            },
192            _ => ApiError::internal_error(e),
193        })?;
194
195    let quota = TenantQuota {
196        max_requests_per_sec: body.max_requests_per_sec,
197        max_concurrent:       body.max_concurrent,
198        max_storage_bytes:    body.max_storage_bytes,
199    };
200
201    let was_insert = registry.upsert_with_quota(&key, executor, quota);
202    let status = if was_insert { "created" } else { "updated" };
203
204    info!(tenant_key = %key, status, "tenant executor registered");
205
206    // Record audit event (fire-and-forget — audit failure must not block the operation)
207    if let Some(audit_log) = state.tenant_audit_log() {
208        let event = if was_insert {
209            TenantEventKind::Created
210        } else {
211            TenantEventKind::ConfigChanged
212        };
213        if let Err(e) = audit_log.record(&key, event, None, None).await {
214            tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
215        }
216    }
217
218    Ok(Json(TenantResponse { key, status }))
219}
220
221/// `DELETE /api/v1/admin/tenants/{key}` — remove a tenant.
222///
223/// In-flight requests on the old executor complete via Arc semantics.
224///
225/// # Errors
226///
227/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
228/// key is not found.
229pub async fn delete_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
230    State(state): State<AppState<A>>,
231    Path(key): Path<String>,
232) -> Result<Json<TenantResponse>, ApiError> {
233    let registry = state
234        .tenant_registry()
235        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
236
237    registry
238        .remove(&key)
239        .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
240
241    info!(tenant_key = %key, "tenant executor removed");
242
243    if let Some(audit_log) = state.tenant_audit_log() {
244        if let Err(e) = audit_log.record(&key, TenantEventKind::Deleted, None, None).await {
245            tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
246        }
247    }
248
249    Ok(Json(TenantResponse {
250        key,
251        status: "removed",
252    }))
253}
254
255/// `POST /api/v1/admin/tenants/{key}/suspend` — suspend a tenant.
256///
257/// Suspended tenants' data requests return 503 with `Retry-After: 60`.
258/// No executor teardown occurs — database connections remain open.
259///
260/// # Errors
261///
262/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
263/// key is not found.
264pub async fn suspend_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
265    State(state): State<AppState<A>>,
266    Path(key): Path<String>,
267) -> Result<Json<TenantResponse>, ApiError> {
268    let registry = state
269        .tenant_registry()
270        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
271
272    registry
273        .suspend(&key)
274        .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
275
276    info!(tenant_key = %key, "tenant suspended");
277
278    if let Some(audit_log) = state.tenant_audit_log() {
279        if let Err(e) = audit_log.record(&key, TenantEventKind::Suspended, None, None).await {
280            tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
281        }
282    }
283
284    Ok(Json(TenantResponse {
285        key,
286        status: "suspended",
287    }))
288}
289
290/// `POST /api/v1/admin/tenants/{key}/resume` — resume a suspended tenant.
291///
292/// Restores the tenant to active status so data requests are served normally.
293///
294/// # Errors
295///
296/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
297/// key is not found.
298pub async fn resume_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
299    State(state): State<AppState<A>>,
300    Path(key): Path<String>,
301) -> Result<Json<TenantResponse>, ApiError> {
302    let registry = state
303        .tenant_registry()
304        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
305
306    registry
307        .resume(&key)
308        .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
309
310    info!(tenant_key = %key, "tenant resumed");
311
312    if let Some(audit_log) = state.tenant_audit_log() {
313        if let Err(e) = audit_log.record(&key, TenantEventKind::Resumed, None, None).await {
314            tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
315        }
316    }
317
318    Ok(Json(TenantResponse {
319        key,
320        status: "resumed",
321    }))
322}
323
324/// `GET /api/v1/admin/tenants/{key}` — get tenant metadata.
325///
326/// Returns query/mutation counts. Never includes credentials.
327///
328/// # Errors
329///
330/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
331/// key is not found.
332pub async fn get_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
333    State(state): State<AppState<A>>,
334    Path(key): Path<String>,
335) -> Result<Json<TenantMetadata>, ApiError> {
336    let registry = state
337        .tenant_registry()
338        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
339
340    let status = registry
341        .tenant_status(&key)
342        .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
343
344    let executor = registry
345        .executor_for_admin(&key)
346        .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
347
348    Ok(Json(TenantMetadata {
349        key,
350        status: status.as_str(),
351        query_count: executor.schema().queries.len(),
352        mutation_count: executor.schema().mutations.len(),
353    }))
354}
355
356/// `GET /api/v1/admin/tenants` — list all registered tenant keys.
357///
358/// Never includes credentials.
359///
360/// # Errors
361///
362/// Returns `ApiError` with 404 if multi-tenant mode is disabled.
363pub async fn list_tenants_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
364    State(state): State<AppState<A>>,
365) -> Result<Json<TenantListResponse>, ApiError> {
366    let registry = state
367        .tenant_registry()
368        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
369
370    let tenants = registry.tenant_keys();
371    let count = tenants.len();
372
373    Ok(Json(TenantListResponse { tenants, count }))
374}
375
376/// `GET /api/v1/admin/tenants/{key}/health` — health check a tenant's pool.
377///
378/// # Errors
379///
380/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
381/// key is not found. Returns 503 if the health check fails.
382pub async fn tenant_health_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
383    State(state): State<AppState<A>>,
384    Path(key): Path<String>,
385) -> Result<Json<TenantHealthResponse>, ApiError> {
386    let registry = state
387        .tenant_registry()
388        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
389
390    registry.health_check(&key).await.map_err(|e| match &e {
391        fraiseql_error::FraiseQLError::NotFound { .. } => {
392            ApiError::not_found(format!("tenant '{key}'"))
393        },
394        _ => ApiError::new(format!("Health check failed: {e}"), "SERVICE_UNAVAILABLE"),
395    })?;
396
397    Ok(Json(TenantHealthResponse {
398        key,
399        status: "healthy",
400    }))
401}
402
403/// Maximum events per page to prevent abuse.
404const MAX_EVENTS_LIMIT: usize = 200;
405
406/// `GET /api/v1/admin/tenants/{key}/events` — query tenant audit trail.
407///
408/// Returns lifecycle events for a specific tenant, newest first.
409/// Supports pagination via `limit` and `offset` query parameters.
410///
411/// # Errors
412///
413/// Returns `ApiError` with 404 if multi-tenant mode is disabled, the tenant
414/// key is not found, or no audit log is configured.
415pub async fn tenant_events_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
416    State(state): State<AppState<A>>,
417    Path(key): Path<String>,
418    Query(params): Query<EventsQuery>,
419) -> Result<Json<TenantEventsResponse>, ApiError> {
420    // Multi-tenant mode must be enabled + verify tenant exists
421    let registry = state
422        .tenant_registry()
423        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
424
425    registry
426        .executor_for_admin(&key)
427        .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
428
429    let audit_log = state
430        .tenant_audit_log()
431        .ok_or_else(|| ApiError::not_found("audit log not configured"))?;
432
433    let limit = params.limit.min(MAX_EVENTS_LIMIT);
434    let events = audit_log
435        .events_for(&key, limit, params.offset)
436        .await
437        .map_err(|e| ApiError::internal_error(format!("failed to query audit events: {e}")))?;
438
439    let count = events.len();
440
441    Ok(Json(TenantEventsResponse { key, events, count }))
442}
443
444// ── Domain management handlers ──────────────────────────────────────────
445
446/// `PUT /api/v1/admin/domains/{domain}` — register a domain → tenant mapping.
447///
448/// Validates that the referenced tenant key exists in the tenant registry
449/// (when multi-tenant mode is enabled). Overwrites any existing mapping
450/// for the same domain.
451///
452/// # Errors
453///
454/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the
455/// referenced tenant key is not registered.
456pub async fn upsert_domain_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
457    State(state): State<AppState<A>>,
458    Path(domain): Path<String>,
459    Json(body): Json<DomainRegistrationRequest>,
460) -> Result<Json<DomainResponse>, ApiError> {
461    // Multi-tenant mode must be enabled
462    let registry = state
463        .tenant_registry()
464        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
465
466    // Verify the tenant key is actually registered
467    registry
468        .executor_for(Some(&body.tenant_key))
469        .map_err(|_| ApiError::not_found(format!("tenant '{}'", body.tenant_key)))?;
470
471    state.domain_registry().register(&domain, &body.tenant_key);
472
473    info!(domain = %domain, tenant_key = %body.tenant_key, "domain mapping registered");
474
475    Ok(Json(DomainResponse {
476        domain,
477        status: "registered",
478        tenant_key: Some(body.tenant_key),
479    }))
480}
481
482/// `DELETE /api/v1/admin/domains/{domain}` — remove a domain mapping.
483///
484/// # Errors
485///
486/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the
487/// domain is not registered.
488pub async fn delete_domain_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
489    State(state): State<AppState<A>>,
490    Path(domain): Path<String>,
491) -> Result<Json<DomainResponse>, ApiError> {
492    state
493        .tenant_registry()
494        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
495
496    if !state.domain_registry().remove(&domain) {
497        return Err(ApiError::not_found(format!("domain '{domain}'")));
498    }
499
500    info!(domain = %domain, "domain mapping removed");
501
502    Ok(Json(DomainResponse {
503        domain,
504        status: "removed",
505        tenant_key: None,
506    }))
507}
508
509/// `GET /api/v1/admin/domains` — list all domain → tenant mappings.
510///
511/// # Errors
512///
513/// Returns `ApiError` with 404 if multi-tenant mode is disabled.
514pub async fn list_domains_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
515    State(state): State<AppState<A>>,
516) -> Result<Json<DomainListResponse>, ApiError> {
517    state
518        .tenant_registry()
519        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
520
521    let mappings = state.domain_registry().domains();
522    let count = mappings.len();
523
524    Ok(Json(DomainListResponse {
525        domains: mappings
526            .into_iter()
527            .map(|(domain, tenant_key)| DomainMapping { domain, tenant_key })
528            .collect(),
529        count,
530    }))
531}