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