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