use std::sync::Arc;
use axum::{
Router, middleware,
routing::{get, post, put},
};
use fraiseql_core::security::{IntrospectionPolicy, OidcValidator};
use tracing::{info, warn};
use super::super::{
BearerAuthState, PlaygroundState, Server, SubscriptionState, admin_auth_middleware, api,
bearer_auth_middleware, health_handler, introspection_handler, liveness_handler,
metrics_handler, metrics_json_handler, oidc_auth_middleware, playground_handler,
readiness_handler, required_auth_middleware, subscription_handler,
};
use crate::routes::graphql::AppState;
impl Server {
#[allow(clippy::cognitive_complexity)] pub(super) fn mount_base_and_admin_routes(&self, mut app: Router, state: &AppState) -> Router {
let base_routes = Router::new()
.route(&self.config.health_path, get(health_handler))
.route(&self.config.liveness_path, get(liveness_handler))
.route(&self.config.readiness_path, get(readiness_handler))
.with_state(state.clone());
app = app.merge(base_routes);
{
use crate::routes::studio::{studio_asset_handler, studio_handler};
let studio_router = Router::new()
.route("/studio", get(studio_handler))
.route("/studio/assets/{file}", get(studio_asset_handler))
.route("/studio/{*path}", get(studio_handler));
info!("Studio admin dashboard mounted at /studio");
app = app.merge(studio_router);
}
if self.config.admin_api_enabled {
if let Some(ref token) = self.config.admin_token {
app = self.mount_studio_admin_api(app, state, token);
}
}
if self.config.admin_api_enabled {
if let (Some(token), Some(validator)) =
(self.config.admin_token.as_ref(), self.oidc_validator.as_ref())
{
app = self.mount_jwks_refresh(app, token, validator);
}
}
if self.config.playground_enabled {
app = self.mount_playground(app, state);
}
if let Some(ref contact) = self.config.security_contact {
info!(
contact = %contact,
"/.well-known/security.txt endpoint enabled"
);
let security_router = Router::new()
.route(
"/.well-known/security.txt",
get(crate::routes::well_known::security_txt_handler),
)
.with_state(contact.clone());
app = app.merge(security_router);
}
if self.config.subscriptions_enabled {
app = self.mount_subscriptions(app, state);
}
if IntrospectionPolicy::from_config(
self.config.introspection_enabled,
self.config.introspection_require_auth,
) != IntrospectionPolicy::Disabled
{
app = self.mount_introspection(app, state);
}
if self.config.metrics_enabled {
app = self.mount_metrics(app, state);
}
if self.config.admin_api_enabled {
app = self.mount_admin_api(app, state);
}
app = self.mount_design_audit(app, state);
app
}
fn mount_studio_admin_api(&self, app: Router, state: &AppState, token: &str) -> Router {
use crate::routes::studio::{
admin::{
health_handler as studio_health_handler, schema_handler as studio_schema_handler,
},
auth_users::{
invite_user_handler, list_users_handler, mfa_status_handler, revoke_user_handler,
},
data::{mutate_handler as data_mutate_handler, query_handler as data_query_handler},
function_ops::{
delete_secret_handler, function_logs_handler, invoke_function_handler,
list_functions_handler, list_secrets_handler, set_secret_handler,
},
metrics_summary::summary_handler as metrics_summary_handler,
storage_browser::{
delete_object_handler, list_buckets_handler, list_objects_handler, presign_handler,
},
};
let auth = BearerAuthState::with_max_failures(
token.to_string(),
self.config.admin_auth_max_failures,
);
let studio_admin_router = Router::new()
.route("/admin/v1/schema", get(studio_schema_handler))
.route("/admin/v1/health/detailed", get(studio_health_handler))
.route("/admin/v1/data/{entity}/query", post(data_query_handler))
.route("/admin/v1/data/{entity}/mutate", post(data_mutate_handler))
.route("/admin/v1/users", get(list_users_handler))
.route("/admin/v1/users/invite", post(invite_user_handler))
.route("/admin/v1/users/{id}/revoke", post(revoke_user_handler))
.route("/admin/v1/users/{id}/mfa", get(mfa_status_handler))
.route("/admin/v1/storage/buckets", get(list_buckets_handler))
.route("/admin/v1/storage/objects", get(list_objects_handler))
.route("/admin/v1/storage/objects/sign", post(presign_handler))
.route("/admin/v1/storage/objects", axum::routing::delete(delete_object_handler))
.route("/admin/v1/functions", get(list_functions_handler))
.route("/admin/v1/functions/{name}/invoke", post(invoke_function_handler))
.route("/admin/v1/functions/{name}/logs", get(function_logs_handler))
.route("/admin/v1/functions/{name}/secrets", get(list_secrets_handler))
.route(
"/admin/v1/functions/{name}/secrets/{key}",
put(set_secret_handler).delete(delete_secret_handler),
)
.route("/admin/v1/metrics/summary", get(metrics_summary_handler))
.route_layer(middleware::from_fn_with_state(auth, bearer_auth_middleware))
.with_state(state.clone());
info!("Studio admin API mounted at /admin/v1/* (bearer token required)");
app.merge(studio_admin_router)
}
fn mount_jwks_refresh(
&self,
app: Router,
token: &str,
validator: &Arc<OidcValidator>,
) -> Router {
let auth = BearerAuthState::with_max_failures(
token.to_string(),
self.config.admin_auth_max_failures,
);
let router = Router::new()
.route(
"/admin/v1/auth/refresh-jwks",
post(crate::routes::jwks_admin::refresh_jwks_handler),
)
.route_layer(middleware::from_fn_with_state(auth, bearer_auth_middleware))
.with_state(Arc::clone(validator));
info!(
"JWKS refresh endpoint mounted: POST /admin/v1/auth/refresh-jwks (admin token required)"
);
app.merge(router)
}
fn mount_playground(&self, mut app: Router, _state: &AppState) -> Router {
let playground_require_auth = self
.config
.playground_require_auth
.unwrap_or(self.config.introspection_require_auth);
let playground_state =
PlaygroundState::new(self.config.graphql_path.clone(), self.config.playground_tool);
if playground_require_auth {
if let Some(ref validator) = self.oidc_validator {
info!(
playground_path = %self.config.playground_path,
playground_tool = ?self.config.playground_tool,
"GraphQL playground enabled (OIDC auth required)"
);
let auth_state = self.oidc_auth_state(validator.clone());
let playground_router = Router::new()
.route(&self.config.playground_path, get(playground_handler))
.route_layer(middleware::from_fn_with_state(auth_state, oidc_auth_middleware))
.with_state(playground_state);
app = app.merge(playground_router);
} else {
warn!(
playground_path = %self.config.playground_path,
"playground_require_auth is true but no OIDC configured — playground disabled"
);
}
} else {
info!(
playground_path = %self.config.playground_path,
playground_tool = ?self.config.playground_tool,
"GraphQL playground enabled (no auth required)"
);
let playground_router = Router::new()
.route(&self.config.playground_path, get(playground_handler))
.with_state(playground_state);
app = app.merge(playground_router);
}
app
}
fn mount_subscriptions(&self, mut app: Router, state: &AppState) -> Router {
let strict_tenant_validation = self.executor.schema().has_rls_configured();
let subscription_policies = Arc::new(
crate::routes::subscriptions::build_subscription_policies(self.executor.schema()),
);
let executor_swap = state.executor.clone();
let live_subscription_policies: crate::routes::subscriptions::LiveSubscriptionPolicies =
Arc::new(move || {
Arc::new(crate::routes::subscriptions::build_subscription_policies(
executor_swap.load().schema(),
))
});
let schema_swap = state.executor.clone();
let live_schema: crate::routes::subscriptions::LiveSchema =
Arc::new(move || Arc::new(schema_swap.load().schema().clone()));
let planner_swap = state.executor.clone();
let live_executor: crate::routes::subscriptions::LiveExecutor =
Arc::new(move || planner_swap.load_full());
#[allow(unused_mut)]
let mut subscription_state = SubscriptionState::new(self.subscription_manager.clone())
.with_lifecycle(self.subscription_lifecycle.clone())
.with_max_subscriptions(self.max_subscriptions_per_connection)
.with_tenant_context(state.domain_registry().clone(), strict_tenant_validation)
.with_authorizer(self.executor.config().authorizer.clone())
.with_subscription_policies(subscription_policies)
.with_live_subscription_policies(Some(live_subscription_policies))
.with_live_schema(Some(live_schema))
.with_live_executor(Some(live_executor))
.with_policy_reload(Some(state.subscribe_policy_reload()))
.with_drain_signal(Some(self.subscription_drain.subscribe()))
.with_service_account_authenticator(state.service_account_authenticator.clone())
.with_revocation_manager(state.revocation_manager.clone())
.with_auth_recheck_interval(std::time::Duration::from_secs(
self.config.subscription_auth_recheck_secs,
))
.with_tenant_status_source(
state
.tenant_registry()
.map(|r| Arc::clone(r) as Arc<dyn crate::routes::graphql::TenantStatusSource>),
);
#[cfg(feature = "auth")]
{
subscription_state =
subscription_state.with_identity_resolver(state.identity_resolver.clone());
}
let subscription_require_auth = self
.config
.subscription_require_auth
.unwrap_or(self.config.introspection_require_auth);
if subscription_require_auth {
if let Some(ref validator) = self.oidc_validator {
info!(
subscription_path = %self.config.subscription_path,
"GraphQL subscriptions enabled (graphql-transport-ws + graphql-ws protocols, OIDC auth required)"
);
let auth_state = self.oidc_auth_state(validator.clone());
let subscription_router = Router::new()
.route(&self.config.subscription_path, get(subscription_handler))
.route_layer(middleware::from_fn_with_state(auth_state, oidc_auth_middleware))
.with_state(subscription_state);
app = app.merge(subscription_router);
} else {
warn!(
subscription_path = %self.config.subscription_path,
"subscription_require_auth is true but no OIDC configured — subscriptions disabled"
);
}
} else {
info!(
subscription_path = %self.config.subscription_path,
"GraphQL subscriptions enabled (graphql-transport-ws + graphql-ws protocols)"
);
let subscription_router = Router::new()
.route(&self.config.subscription_path, get(subscription_handler))
.with_state(subscription_state);
app = app.merge(subscription_router);
}
app
}
fn mount_introspection(&self, mut app: Router, state: &AppState) -> Router {
let metadata_require_auth = self
.config
.metadata_require_auth
.unwrap_or(self.config.introspection_require_auth);
let schema_export_require_auth = self
.config
.schema_export_require_auth
.unwrap_or(self.config.introspection_require_auth);
if self.config.introspection_require_auth {
if let Some(ref validator) = self.oidc_validator {
info!(
introspection_path = %self.config.introspection_path,
"Introspection endpoint enabled (OIDC auth required)"
);
let auth_state = self.oidc_auth_state(validator.clone());
let introspection_router = Router::new()
.route(&self.config.introspection_path, get(introspection_handler))
.route_layer(middleware::from_fn_with_state(
auth_state,
required_auth_middleware,
))
.with_state(state.clone());
app = app.merge(introspection_router);
} else {
warn!(
"introspection_require_auth is true but no OIDC configured - introspection disabled"
);
}
} else {
info!(
introspection_path = %self.config.introspection_path,
"Introspection endpoint enabled (no auth required - USE ONLY IN DEVELOPMENT)"
);
let introspection_router = Router::new()
.route(&self.config.introspection_path, get(introspection_handler))
.with_state(state.clone());
app = app.merge(introspection_router);
}
if schema_export_require_auth {
if let Some(ref validator) = self.oidc_validator {
info!("Schema export endpoints enabled (OIDC auth required)");
let auth_state = self.oidc_auth_state(validator.clone());
let schema_router = Router::new()
.route("/api/v1/schema.graphql", get(api::schema::export_sdl_handler))
.route("/api/v1/schema.json", get(api::schema::export_json_handler))
.route_layer(middleware::from_fn_with_state(
auth_state,
required_auth_middleware,
))
.with_state(state.clone());
app = app.merge(schema_router);
} else {
warn!(
"schema_export_require_auth is true but no OIDC configured - schema export disabled"
);
}
} else {
info!("Schema export endpoints enabled (no auth required)");
let schema_router = Router::new()
.route("/api/v1/schema.graphql", get(api::schema::export_sdl_handler))
.route("/api/v1/schema.json", get(api::schema::export_json_handler))
.with_state(state.clone());
app = app.merge(schema_router);
}
if metadata_require_auth {
if let Some(ref validator) = self.oidc_validator {
info!("Schema metadata endpoint enabled (OIDC auth required)");
let auth_state = self.oidc_auth_state(validator.clone());
let metadata_router = Router::new()
.route("/api/v1/schema/metadata", get(api::metadata::metadata_handler))
.route_layer(middleware::from_fn_with_state(
auth_state,
required_auth_middleware,
))
.with_state(state.clone());
app = app.merge(metadata_router);
} else {
warn!(
"metadata_require_auth is true but no OIDC configured - metadata endpoint disabled"
);
}
} else {
info!("Schema metadata endpoint enabled (no auth required)");
let metadata_router = Router::new()
.route("/api/v1/schema/metadata", get(api::metadata::metadata_handler))
.with_state(state.clone());
app = app.merge(metadata_router);
}
app
}
fn mount_metrics(&self, mut app: Router, state: &AppState) -> Router {
if let Some(ref token) = self.config.metrics_token {
info!(
metrics_path = %self.config.metrics_path,
metrics_json_path = %self.config.metrics_json_path,
"Metrics endpoints enabled (bearer token required)"
);
let auth_state = BearerAuthState::with_max_failures(
token.clone(),
self.config.admin_auth_max_failures,
);
let metrics_router = Router::new()
.route(&self.config.metrics_path, get(metrics_handler))
.route(&self.config.metrics_json_path, get(metrics_json_handler))
.route_layer(middleware::from_fn_with_state(auth_state, bearer_auth_middleware))
.with_state(state.clone());
app = app.merge(metrics_router);
} else {
warn!(
"metrics_enabled is true but metrics_token is not set - metrics endpoints disabled"
);
}
app
}
fn mount_admin_api(&self, mut app: Router, state: &AppState) -> Router {
if let Some(ref write_token) = self.config.admin_token {
let write_auth = BearerAuthState::with_max_failures(
write_token.clone(),
self.config.admin_auth_max_failures,
);
let admin_write_router = Router::new()
.route("/api/v1/admin/reload-schema", post(api::admin::reload_schema_handler))
.route("/api/v1/admin/cache/clear", post(api::admin::cache_clear_handler))
.route(
"/api/v1/admin/query-stats/reset",
post(api::query_stats::query_stats_reset_handler),
)
.route(
"/api/v1/admin/tenants/{key}",
put(api::tenant_admin::upsert_tenant_handler)
.delete(api::tenant_admin::delete_tenant_handler),
)
.route(
"/api/v1/admin/tenants/{key}/suspend",
post(api::tenant_admin::suspend_tenant_handler),
)
.route(
"/api/v1/admin/tenants/{key}/resume",
post(api::tenant_admin::resume_tenant_handler),
)
.route(
"/api/v1/admin/domains/{domain}",
put(api::tenant_admin::upsert_domain_handler)
.delete(api::tenant_admin::delete_domain_handler),
)
.route_layer(middleware::from_fn_with_state(write_auth, bearer_auth_middleware))
.with_state(state.clone());
app = app.merge(admin_write_router);
let read_token = self.config.admin_readonly_token.as_ref().unwrap_or(write_token);
if self.config.admin_readonly_token.is_none() {
warn!(
admin_write_routes = "reload-schema, cache/clear",
admin_read_routes =
"cache/stats, config, explain, query/explain, grafana-dashboard",
"Admin API running in single-token mode: admin_token grants ALL operations \
including destructive ones. Set admin_readonly_token to scope access."
);
} else {
info!(
"Admin API running in split-token mode: \
admin_token=write-only, admin_readonly_token=read-only"
);
}
let read_auth = BearerAuthState::with_max_failures(
read_token.clone(),
self.config.admin_auth_max_failures,
);
let admin_read_router = Router::new()
.route("/api/v1/admin/cache/stats", get(api::admin::cache_stats_handler))
.route("/api/v1/admin/config", get(api::admin::config_handler))
.route("/api/v1/admin/explain", post(api::admin::explain_handler))
.route("/api/v1/admin/tenants", get(api::tenant_admin::list_tenants_handler))
.route("/api/v1/admin/tenants/{key}", get(api::tenant_admin::get_tenant_handler))
.route(
"/api/v1/admin/tenants/{key}/health",
get(api::tenant_admin::tenant_health_handler),
)
.route(
"/api/v1/admin/tenants/{key}/events",
get(api::tenant_admin::tenant_events_handler),
)
.route("/api/v1/admin/domains", get(api::tenant_admin::list_domains_handler))
.route("/api/v1/query/explain", post(api::query::explain_handler))
.route(
"/api/v1/admin/grafana-dashboard",
get(api::admin::grafana_dashboard_handler),
)
.route("/api/v1/admin/usage", get(api::usage::usage_handler))
.route("/api/v1/admin/query-stats", get(api::query_stats::query_stats_handler))
.route(
"/api/v1/admin/query-stats/{queryid}",
get(api::query_stats::query_stats_detail_handler),
)
.route_layer(middleware::from_fn_with_state(read_auth, bearer_auth_middleware))
.with_state(state.clone());
app = app.merge(admin_read_router);
app = self.mount_storage_policy_admin(app, write_token);
app = self.mount_admin_sql_console(app, state, write_token);
info!("Admin API endpoints enabled (bearer token required)");
} else {
warn!(
"admin_api_enabled is true but admin_token is not set - admin endpoints disabled"
);
}
app
}
fn mount_storage_policy_admin(&self, mut app: Router, write_token: &str) -> Router {
use api::storage_policies::{
delete_bucket_policy_handler, get_bucket_policy_handler, put_bucket_policy_handler,
};
const POLICIES_PATH: &str = "/api/v1/admin/storage/{bucket}/policies";
let Some(ref storage_state) = self.storage_state else {
return app;
};
let write_auth = BearerAuthState::with_max_failures(
write_token.to_string(),
self.config.admin_auth_max_failures,
);
let write_router = Router::new()
.route(
POLICIES_PATH,
put(put_bucket_policy_handler).delete(delete_bucket_policy_handler),
)
.route_layer(middleware::from_fn_with_state(write_auth, bearer_auth_middleware))
.with_state(storage_state.clone());
let read_token = self.config.admin_readonly_token.as_deref().unwrap_or(write_token);
let read_auth = BearerAuthState::with_max_failures(
read_token.to_string(),
self.config.admin_auth_max_failures,
);
let read_router = Router::new()
.route(POLICIES_PATH, get(get_bucket_policy_handler))
.route_layer(middleware::from_fn_with_state(read_auth, bearer_auth_middleware))
.with_state(storage_state.clone());
app = app.merge(write_router).merge(read_router);
info!(
path = POLICIES_PATH,
"Storage bucket policy admin endpoints enabled (GET under the read token, \
PUT/DELETE under the write token)"
);
app
}
#[cfg(feature = "admin-sql")]
fn mount_admin_sql_console(
&self,
mut app: Router,
state: &AppState,
write_token: &str,
) -> Router {
use crate::{
middleware::{AdminDualAuthState, admin_dual_auth_middleware},
routes::api::admin_sql::{AdminSqlState, admin_sql_handler},
};
const SQL_PATH: &str = "/api/v1/admin/sql";
let Some(ref config) = self.config.admin_sql else {
return app;
};
if !config.enabled {
return app;
}
let auth = AdminDualAuthState::new(
write_token.to_string(),
self.config.admin_readonly_token.clone(),
self.config.admin_auth_max_failures,
);
let console = Router::new()
.route(SQL_PATH, post(admin_sql_handler))
.route_layer(middleware::from_fn_with_state(auth, admin_dual_auth_middleware))
.with_state(AdminSqlState {
app: state.clone(),
config: config.clone(),
});
warn!(
path = SQL_PATH,
statement_timeout_ms = config.statement_timeout_ms,
max_rows = config.max_rows,
allow_commit = config.allow_commit,
readonly_token_configured = self.config.admin_readonly_token.is_some(),
"Admin SQL console ENABLED: this endpoint executes operator-supplied SQL. \
Statements roll back unless `commit: true` is sent; admin_readonly_token runs \
READ ONLY. Every execution is written to the audit ledger."
);
app = app.merge(console);
app
}
#[cfg(not(feature = "admin-sql"))]
#[allow(clippy::unused_self)] const fn mount_admin_sql_console(
&self,
app: Router,
_state: &AppState,
_write_token: &str,
) -> Router {
app
}
fn mount_design_audit(&self, mut app: Router, state: &AppState) -> Router {
if self.config.design_api_require_auth {
if let Some(ref validator) = self.oidc_validator {
info!("Design audit API endpoints enabled (admin scope 'fraiseql:admin' required)");
let auth_state = self.oidc_auth_state(validator.clone());
let design_router = Router::new()
.route("/design/federation-audit", post(api::design::federation_audit_handler))
.route("/design/cost-audit", post(api::design::cost_audit_handler))
.route("/design/cache-audit", post(api::design::cache_audit_handler))
.route("/design/auth-audit", post(api::design::auth_audit_handler))
.route(
"/design/compilation-audit",
post(api::design::compilation_audit_handler),
)
.route("/design/audit", post(api::design::overall_design_audit_handler))
.route_layer(middleware::from_fn_with_state(auth_state, admin_auth_middleware))
.with_state(state.clone());
app = app.nest("/api/v1", design_router);
} else {
warn!(
"SECURITY: design_api_require_auth is true but no OIDC configured — \
design API endpoints are DISABLED. Configure an OIDC validator \
or set design_api_require_auth = false (development only)."
);
}
} else {
info!("Design audit API endpoints enabled (no auth required)");
let design_router = Router::new()
.route("/design/federation-audit", post(api::design::federation_audit_handler))
.route("/design/cost-audit", post(api::design::cost_audit_handler))
.route("/design/cache-audit", post(api::design::cache_audit_handler))
.route("/design/auth-audit", post(api::design::auth_audit_handler))
.route("/design/compilation-audit", post(api::design::compilation_audit_handler))
.route("/design/audit", post(api::design::overall_design_audit_handler))
.with_state(state.clone());
app = app.nest("/api/v1", design_router);
}
app
}
}