use axum::{
Json,
extract::{Path, Query, State},
};
use fraiseql_core::{schema::TenancyMode, security::ActorType};
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::{
extractors::OptionalSecurityContext,
routes::{
api::types::ApiError,
graphql::{
AppState,
tenant_registry::{ActorCostBudget, TenantQuota},
},
},
tenancy::{
audit::{AuditActor, TenantEventKind},
pool_factory::TenantPoolConfig,
},
};
fn audit_actor(ctx: &OptionalSecurityContext) -> AuditActor {
ctx.0.as_ref().map_or_else(
|| AuditActor {
id: Some("admin_token".to_string()),
actor_type: Some(ActorType::ServiceAccount.as_str().to_string()),
acting_for: None,
},
|c| AuditActor {
id: Some(c.user_id.as_str().to_string()),
actor_type: Some(c.actor_type().as_str().to_string()),
acting_for: c.acting_for(),
},
)
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TenantRegistrationRequest {
pub schema: serde_json::Value,
pub connection: TenantPoolConfig,
#[serde(default)]
pub max_requests_per_sec: Option<u32>,
#[serde(default)]
pub max_concurrent: Option<u32>,
#[serde(default)]
pub max_storage_bytes_advisory: Option<u64>,
#[serde(default)]
pub cost_budget: Option<usize>,
#[serde(default)]
pub cost_budget_per_minute: Option<usize>,
#[serde(default)]
pub cost_budget_per_actor: std::collections::HashMap<ActorType, ActorCostBudget>,
}
#[derive(Debug, Serialize)]
pub struct TenantResponse {
pub key: String,
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_retained: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_dropped: Option<String>,
}
impl TenantResponse {
const fn status_only(key: String, status: &'static str) -> Self {
Self {
key,
status,
schema_retained: None,
schema_dropped: None,
}
}
}
#[derive(Debug, Deserialize)]
pub struct DeleteTenantQuery {
#[serde(default)]
pub purge: bool,
}
#[derive(Debug, Serialize)]
pub struct TenantMetadata {
pub key: String,
pub status: &'static str,
pub query_count: usize,
pub mutation_count: usize,
}
#[derive(Debug, Serialize)]
pub struct TenantListResponse {
pub tenants: Vec<String>,
pub count: usize,
}
#[derive(Debug, Serialize)]
pub struct TenantHealthResponse {
pub key: String,
pub status: &'static str,
}
#[derive(Debug, Deserialize)]
pub struct EventsQuery {
#[serde(default = "default_events_limit")]
pub limit: usize,
#[serde(default)]
pub offset: usize,
}
const fn default_events_limit() -> usize {
50
}
#[derive(Debug, Serialize)]
pub struct TenantEventsResponse {
pub key: String,
pub events: Vec<crate::tenancy::audit::TenantEvent>,
pub count: usize,
}
#[derive(Debug, Deserialize)]
pub struct DomainRegistrationRequest {
pub tenant_key: String,
}
#[derive(Debug, Serialize)]
pub struct DomainResponse {
pub domain: String,
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub tenant_key: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct DomainListResponse {
pub domains: Vec<DomainMapping>,
pub count: usize,
}
#[derive(Debug, Serialize)]
pub struct DomainMapping {
pub domain: String,
pub tenant_key: String,
}
pub async fn upsert_tenant_handler(
State(state): State<AppState>,
Path(key): Path<String>,
ctx: OptionalSecurityContext,
Json(body): Json<TenantRegistrationRequest>,
) -> Result<Json<TenantResponse>, ApiError> {
crate::routes::graphql::tenant_key::validate_tenant_key(&key)
.map_err(|e| ApiError::validation_error(e.to_string()))?;
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
let factory = state
.tenant_executor_factory()
.ok_or_else(|| ApiError::internal_error("tenant executor factory not configured"))?;
let schema_json = serde_json::to_string(&body.schema)
.map_err(|e| ApiError::validation_error(format!("invalid schema JSON: {e}")))?;
let quota = TenantQuota {
max_requests_per_sec: body.max_requests_per_sec,
max_concurrent: body.max_concurrent,
max_storage_bytes_advisory: body.max_storage_bytes_advisory,
cost_budget: body.cost_budget,
cost_budget_per_minute: body.cost_budget_per_minute,
cost_budget_per_actor: body.cost_budget_per_actor.clone(),
};
quota.validate().map_err(ApiError::validation_error)?;
let runtime_config = state.executor().config().clone();
let executor = factory(key.clone(), schema_json, body.connection, runtime_config)
.await
.map_err(|e| match &e {
fraiseql_error::FraiseQLError::Parse { .. }
| fraiseql_error::FraiseQLError::Validation { .. } => ApiError::validation_error(e),
fraiseql_error::FraiseQLError::ConnectionPool { .. }
| fraiseql_error::FraiseQLError::Database { .. } => {
ApiError::new(format!("Connection failed: {e}"), "SERVICE_UNAVAILABLE")
},
_ => ApiError::internal_error(e),
})?;
let was_insert = registry.upsert_with_quota(&key, executor, quota);
let status = if was_insert { "created" } else { "updated" };
info!(tenant_key = %key, status, "tenant executor registered");
if let Some(audit_log) = state.tenant_audit_log() {
let event = if was_insert {
TenantEventKind::Created
} else {
TenantEventKind::ConfigChanged
};
if let Err(e) = audit_log.record(&key, event, Some(&audit_actor(&ctx)), None).await {
tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
}
}
Ok(Json(TenantResponse::status_only(key, status)))
}
pub async fn delete_tenant_handler(
State(state): State<AppState>,
Path(key): Path<String>,
Query(params): Query<DeleteTenantQuery>,
ctx: OptionalSecurityContext,
) -> Result<Json<TenantResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
let executor = registry
.executor_for_admin(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
let owns_a_schema = executor.schema().tenancy_mode() == TenancyMode::Schema;
if params.purge && !owns_a_schema {
return Err(ApiError::validation_error(format!(
"tenant '{key}' does not use schema isolation (tenancy.mode is not \"schema\"), \
so there is no schema to purge. Remove ?purge=true."
)));
}
let schema_name = owns_a_schema
.then(|| crate::tenancy::schema_isolation::tenant_schema_name(&key))
.transpose()
.map_err(|e| ApiError::validation_error(e.to_string()))?;
let dropped = if params.purge {
crate::tenancy::destroy_tenant_schema(&key, &executor)
.await
.map_err(|e| ApiError::internal_error(format!("failed to drop tenant schema: {e}")))?;
schema_name.clone()
} else {
None
};
drop(executor);
registry
.remove(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
let retained = if params.purge { None } else { schema_name };
info!(
tenant_key = %key,
purged = params.purge,
schema_retained = ?retained,
"tenant executor removed"
);
if let Some(audit_log) = state.tenant_audit_log() {
let payload = serde_json::json!({
"purged": params.purge,
"schema_retained": retained,
"schema_dropped": dropped,
});
if let Err(e) = audit_log
.record(&key, TenantEventKind::Deleted, Some(&audit_actor(&ctx)), Some(payload))
.await
{
tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
}
}
Ok(Json(TenantResponse {
status: if params.purge {
"removed_and_purged"
} else if retained.is_some() {
"removed_schema_retained"
} else {
"removed"
},
key,
schema_retained: retained,
schema_dropped: dropped,
}))
}
pub async fn suspend_tenant_handler(
State(state): State<AppState>,
Path(key): Path<String>,
ctx: OptionalSecurityContext,
) -> Result<Json<TenantResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
registry
.suspend(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
info!(tenant_key = %key, "tenant suspended");
if let Some(audit_log) = state.tenant_audit_log() {
if let Err(e) = audit_log
.record(&key, TenantEventKind::Suspended, Some(&audit_actor(&ctx)), None)
.await
{
tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
}
}
Ok(Json(TenantResponse::status_only(key, "suspended")))
}
pub async fn resume_tenant_handler(
State(state): State<AppState>,
Path(key): Path<String>,
ctx: OptionalSecurityContext,
) -> Result<Json<TenantResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
registry
.resume(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
info!(tenant_key = %key, "tenant resumed");
if let Some(audit_log) = state.tenant_audit_log() {
if let Err(e) = audit_log
.record(&key, TenantEventKind::Resumed, Some(&audit_actor(&ctx)), None)
.await
{
tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
}
}
Ok(Json(TenantResponse::status_only(key, "resumed")))
}
pub async fn get_tenant_handler(
State(state): State<AppState>,
Path(key): Path<String>,
) -> Result<Json<TenantMetadata>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
let status = registry
.tenant_status(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
let executor = registry
.executor_for_admin(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
Ok(Json(TenantMetadata {
key,
status: status.as_str(),
query_count: executor.schema().queries.len(),
mutation_count: executor.schema().mutations.len(),
}))
}
pub async fn list_tenants_handler(
State(state): State<AppState>,
) -> Result<Json<TenantListResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
let tenants = registry.tenant_keys();
let count = tenants.len();
Ok(Json(TenantListResponse { tenants, count }))
}
pub async fn tenant_health_handler(
State(state): State<AppState>,
Path(key): Path<String>,
) -> Result<Json<TenantHealthResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
registry.health_check(&key).await.map_err(|e| match &e {
fraiseql_error::FraiseQLError::NotFound { .. } => {
ApiError::not_found(format!("tenant '{key}'"))
},
_ => ApiError::new(format!("Health check failed: {e}"), "SERVICE_UNAVAILABLE"),
})?;
Ok(Json(TenantHealthResponse {
key,
status: "healthy",
}))
}
const MAX_EVENTS_LIMIT: usize = 200;
pub async fn tenant_events_handler(
State(state): State<AppState>,
Path(key): Path<String>,
Query(params): Query<EventsQuery>,
) -> Result<Json<TenantEventsResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
registry
.executor_for_admin(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
let audit_log = state
.tenant_audit_log()
.ok_or_else(|| ApiError::not_found("audit log not configured"))?;
let limit = params.limit.min(MAX_EVENTS_LIMIT);
let events = audit_log
.events_for(&key, limit, params.offset)
.await
.map_err(|e| ApiError::internal_error(format!("failed to query audit events: {e}")))?;
let count = events.len();
Ok(Json(TenantEventsResponse { key, events, count }))
}
pub async fn upsert_domain_handler(
State(state): State<AppState>,
Path(domain): Path<String>,
Json(body): Json<DomainRegistrationRequest>,
) -> Result<Json<DomainResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
registry
.executor_for(Some(&body.tenant_key))
.map_err(|_| ApiError::not_found(format!("tenant '{}'", body.tenant_key)))?;
state.domain_registry().register(&domain, &body.tenant_key);
info!(domain = %domain, tenant_key = %body.tenant_key, "domain mapping registered");
Ok(Json(DomainResponse {
domain,
status: "registered",
tenant_key: Some(body.tenant_key),
}))
}
pub async fn delete_domain_handler(
State(state): State<AppState>,
Path(domain): Path<String>,
) -> Result<Json<DomainResponse>, ApiError> {
state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
if !state.domain_registry().remove(&domain) {
return Err(ApiError::not_found(format!("domain '{domain}'")));
}
info!(domain = %domain, "domain mapping removed");
Ok(Json(DomainResponse {
domain,
status: "removed",
tenant_key: None,
}))
}
pub async fn list_domains_handler(
State(state): State<AppState>,
) -> Result<Json<DomainListResponse>, ApiError> {
state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
let mappings = state.domain_registry().domains();
let count = mappings.len();
Ok(Json(DomainListResponse {
domains: mappings
.into_iter()
.map(|(domain, tenant_key)| DomainMapping { domain, tenant_key })
.collect(),
count,
}))
}