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