fraiseql_server/routes/api/
tenant_admin.rs1use 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#[derive(Debug, Deserialize)]
29pub struct TenantRegistrationRequest {
30 pub schema: serde_json::Value,
32 pub connection: TenantPoolConfig,
34 #[serde(default)]
36 pub max_requests_per_sec: Option<u32>,
37 #[serde(default)]
39 pub max_concurrent: Option<u32>,
40 #[serde(default)]
42 pub max_storage_bytes: Option<u64>,
43}
44
45#[derive(Debug, Serialize)]
47pub struct TenantResponse {
48 pub key: String,
50 pub status: &'static str,
52}
53
54#[derive(Debug, Serialize)]
56pub struct TenantMetadata {
57 pub key: String,
59 pub status: &'static str,
61 pub query_count: usize,
63 pub mutation_count: usize,
65}
66
67#[derive(Debug, Serialize)]
69pub struct TenantListResponse {
70 pub tenants: Vec<String>,
72 pub count: usize,
74}
75
76#[derive(Debug, Serialize)]
78pub struct TenantHealthResponse {
79 pub key: String,
81 pub status: &'static str,
83}
84
85#[derive(Debug, Deserialize)]
87pub struct EventsQuery {
88 #[serde(default = "default_events_limit")]
90 pub limit: usize,
91 #[serde(default)]
93 pub offset: usize,
94}
95
96const fn default_events_limit() -> usize {
97 50
98}
99
100#[derive(Debug, Serialize)]
102pub struct TenantEventsResponse {
103 pub key: String,
105 pub events: Vec<crate::tenancy::audit::TenantEvent>,
107 pub count: usize,
109}
110
111#[derive(Debug, Deserialize)]
113pub struct DomainRegistrationRequest {
114 pub tenant_key: String,
116}
117
118#[derive(Debug, Serialize)]
120pub struct DomainResponse {
121 pub domain: String,
123 pub status: &'static str,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub tenant_key: Option<String>,
128}
129
130#[derive(Debug, Serialize)]
132pub struct DomainListResponse {
133 pub domains: Vec<DomainMapping>,
135 pub count: usize,
137}
138
139#[derive(Debug, Serialize)]
141pub struct DomainMapping {
142 pub domain: String,
144 pub tenant_key: String,
146}
147
148pub 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 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
215pub 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
249pub 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
284pub 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
318pub 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
350pub 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
370pub 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
397const MAX_EVENTS_LIMIT: usize = 200;
399
400pub 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 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
438pub 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 let registry = state
457 .tenant_registry()
458 .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
459
460 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
476pub 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
503pub 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}