1use axum::Router;
31use axum::extract::{Path, Query, State};
32use axum::http::{HeaderMap, StatusCode, header};
33use axum::response::{IntoResponse, Json, Response};
34use axum::routing::{get, post};
35use serde::{Deserialize, Serialize};
36use serde_json::{Value, json};
37
38use assay_workflow::WorkflowStore;
39
40use crate::config::{BackendConfig, EngineConfig};
41use crate::state::EngineState;
42
43pub fn router<S>() -> Router<EngineState<S>>
47where
48 S: WorkflowStore + Clone + 'static,
49{
50 Router::new()
51 .route("/api/v1/engine/core/info", get(engine_info::<S>))
52 .route("/api/v1/engine/core/health", get(engine_health::<S>))
53 .route(
54 "/api/v1/engine/core/active-modules",
55 get(active_modules::<S>),
56 )
57 .route("/api/v1/engine/core/modules", get(list_modules::<S>))
58 .route(
59 "/api/v1/engine/core/modules/{name}/toggle",
60 post(toggle_module::<S>),
61 )
62 .route("/api/v1/engine/core/instances", get(list_instances::<S>))
63 .route("/api/v1/engine/core/audit", get(list_audit::<S>))
64 .route("/api/v1/engine/core/config", get(get_config::<S>))
65}
66
67async fn engine_health<S: WorkflowStore + Clone + 'static>(
76 State(s): State<EngineState<S>>,
77) -> Json<Value> {
78 Json(json!({
79 "status": "ok",
80 "engine_version": s.engine_version,
81 "instance_id": s.instance_id.to_string(),
82 "modules": &*s.modules,
83 "leader": true,
88 }))
89}
90
91async fn active_modules<S: WorkflowStore + Clone + 'static>(
99 State(s): State<EngineState<S>>,
100) -> Json<Value> {
101 Json(json!({
102 "modules": &*s.modules,
103 }))
104}
105
106#[derive(Debug, Clone, Serialize)]
111pub struct EngineInfo {
112 pub version: &'static str,
113 pub instance_id: String,
114 pub started_at: f64,
115 pub leader: bool,
116 pub modules: Vec<String>,
117 pub backend_kind: &'static str,
118 pub backend_data_dir: Option<String>,
120 pub backend_url_redacted: Option<String>,
123 pub bind_addr: String,
124 pub public_url: String,
125}
126
127async fn engine_info<S: WorkflowStore + Clone + 'static>(
128 State(s): State<EngineState<S>>,
129) -> Json<EngineInfo> {
130 let cfg: &EngineConfig = &s.engine_config;
131 let (kind, data_dir, url_redacted) = match &cfg.backend {
132 BackendConfig::Sqlite { data_dir, .. } => ("sqlite", Some(data_dir.clone()), None),
133 BackendConfig::Postgres { url } => ("postgres", None, Some(redact_pg_url(url))),
134 };
135 Json(EngineInfo {
136 version: s.engine_version,
137 instance_id: s.instance_id.to_string(),
138 started_at: s.started_at,
139 leader: true,
140 modules: (*s.modules).clone(),
141 backend_kind: kind,
142 backend_data_dir: data_dir,
143 backend_url_redacted: url_redacted,
144 bind_addr: cfg.server.bind_addr.clone(),
145 public_url: cfg.server.public_url.clone(),
146 })
147}
148
149#[derive(Debug, Clone, Serialize)]
154pub struct ModuleEntry {
155 pub name: String,
156 pub enabled: bool,
157 pub enabled_at: Option<f64>,
158 pub enabled_by: Option<String>,
159 pub version: Option<String>,
160 pub config: Value,
161}
162
163#[derive(Debug, Clone, Serialize)]
164pub struct ListModulesResponse {
165 pub items: Vec<ModuleEntry>,
166}
167
168async fn list_modules<S: WorkflowStore + Clone + 'static>(
169 State(s): State<EngineState<S>>,
170 headers: HeaderMap,
171) -> Response {
172 if let Err(r) = require_admin(&headers, &s).await {
173 return *r;
174 }
175 let items = match list_module_records(&s.engine_config).await {
176 Ok(v) => v,
177 Err(e) => return server_error(&format!("list modules: {e}")),
178 };
179 let entries = items
180 .into_iter()
181 .map(|m| ModuleEntry {
182 name: m.name,
183 enabled: m.enabled,
184 enabled_at: m.enabled_at,
185 enabled_by: m.enabled_by,
186 version: m.version,
187 config: m.config,
188 })
189 .collect();
190 (StatusCode::OK, Json(ListModulesResponse { items: entries })).into_response()
191}
192
193#[derive(Debug, Clone, Deserialize)]
194pub struct ToggleBody {
195 pub enabled: Option<bool>,
198}
199
200#[derive(Debug, Clone, Serialize)]
201pub struct ToggleResponse {
202 pub enabled: bool,
203 pub restart_required: bool,
204 pub message: String,
205}
206
207async fn toggle_module<S: WorkflowStore + Clone + 'static>(
208 State(s): State<EngineState<S>>,
209 headers: HeaderMap,
210 Path(name): Path<String>,
211 body: Option<Json<ToggleBody>>,
212) -> Response {
213 if let Err(r) = require_admin(&headers, &s).await {
214 return *r;
215 }
216 let modules = match list_module_records(&s.engine_config).await {
218 Ok(v) => v,
219 Err(e) => return server_error(&format!("list modules: {e}")),
220 };
221 let Some(current) = modules.iter().find(|m| m.name == name) else {
222 return (
223 StatusCode::NOT_FOUND,
224 Json(json!({"error": "unknown module name"})),
225 )
226 .into_response();
227 };
228 let target = body
229 .and_then(|Json(b)| b.enabled)
230 .unwrap_or(!current.enabled);
231 let actor = bearer_token(&headers).map(short_actor);
232 if let Err(e) = set_module_enabled(&s.engine_config, &name, target, actor.as_deref()).await {
233 return server_error(&format!("set enabled: {e}"));
234 }
235 if let Err(e) = audit_module_toggle(&s.engine_config, &name, target, actor.as_deref()).await {
236 tracing::warn!(?e, "engine.audit insert failed for module toggle");
238 }
239 let msg = if target {
240 format!("module {name} marked enabled — restart engine to load")
241 } else {
242 format!("module {name} marked disabled — restart engine to unload")
243 };
244 (
245 StatusCode::OK,
246 Json(ToggleResponse {
247 enabled: target,
248 restart_required: true,
249 message: msg,
250 }),
251 )
252 .into_response()
253}
254
255#[derive(Debug, Clone, Serialize)]
260pub struct InstanceEntry {
261 pub id: String,
262 pub started_at: f64,
263 pub last_heartbeat: f64,
264 pub namespaces: Vec<String>,
265 pub version: Option<String>,
266}
267
268#[derive(Debug, Clone, Serialize)]
269pub struct ListInstancesResponse {
270 pub items: Vec<InstanceEntry>,
271}
272
273#[derive(Debug, Clone, Default, Deserialize)]
274pub struct PageQuery {
275 #[serde(default)]
276 pub limit: Option<i64>,
277 #[serde(default)]
278 pub offset: Option<i64>,
279}
280
281async fn list_instances<S: WorkflowStore + Clone + 'static>(
282 State(s): State<EngineState<S>>,
283 headers: HeaderMap,
284 Query(_q): Query<PageQuery>,
285) -> Response {
286 if let Err(r) = require_admin(&headers, &s).await {
287 return *r;
288 }
289 let items = match list_instance_records(&s.engine_config).await {
290 Ok(v) => v,
291 Err(e) => return server_error(&format!("list instances: {e}")),
292 };
293 let entries = items
294 .into_iter()
295 .map(|i| InstanceEntry {
296 id: i.id,
297 started_at: i.started_at,
298 last_heartbeat: i.last_heartbeat,
299 namespaces: i.namespaces,
300 version: i.version,
301 })
302 .collect();
303 (
304 StatusCode::OK,
305 Json(ListInstancesResponse { items: entries }),
306 )
307 .into_response()
308}
309
310#[derive(Debug, Clone, Default, Deserialize)]
315pub struct AuditQuery {
316 #[serde(default)]
317 pub limit: Option<i64>,
318 #[serde(default)]
319 pub offset: Option<i64>,
320 #[serde(default)]
321 pub actor: Option<String>,
322 #[serde(default)]
323 pub action: Option<String>,
324 #[serde(default)]
325 pub since: Option<f64>,
326 #[serde(default)]
327 pub until: Option<f64>,
328}
329
330#[derive(Debug, Clone, Serialize)]
331pub struct AuditEntry {
332 pub id: String,
333 pub ts: f64,
334 pub actor: Option<String>,
335 pub action: String,
336 pub details: Value,
337}
338
339#[derive(Debug, Clone, Serialize)]
340pub struct ListAuditResponse {
341 pub items: Vec<AuditEntry>,
342 pub total: i64,
343 pub limit: i64,
344 pub offset: i64,
345}
346
347async fn list_audit<S: WorkflowStore + Clone + 'static>(
348 State(s): State<EngineState<S>>,
349 headers: HeaderMap,
350 Query(q): Query<AuditQuery>,
351) -> Response {
352 if let Err(r) = require_admin(&headers, &s).await {
353 return *r;
354 }
355 let limit = q.limit.unwrap_or(50).clamp(1, 500);
356 let offset = q.offset.unwrap_or(0).max(0);
357 let (rows, total) = match list_audit_records(
358 &s.engine_config,
359 limit,
360 offset,
361 q.actor.as_deref(),
362 q.action.as_deref(),
363 q.since,
364 q.until,
365 )
366 .await
367 {
368 Ok(v) => v,
369 Err(e) => return server_error(&format!("list audit: {e}")),
370 };
371 let items = rows
372 .into_iter()
373 .map(|a| AuditEntry {
374 id: a.id,
375 ts: a.ts,
376 actor: a.actor,
377 action: a.action,
378 details: a.details,
379 })
380 .collect();
381 (
382 StatusCode::OK,
383 Json(ListAuditResponse {
384 items,
385 total,
386 limit,
387 offset,
388 }),
389 )
390 .into_response()
391}
392
393async fn get_config<S: WorkflowStore + Clone + 'static>(
398 State(s): State<EngineState<S>>,
399 headers: HeaderMap,
400) -> Response {
401 if let Err(r) = require_admin(&headers, &s).await {
402 return *r;
403 }
404 let mut value = match serde_json::to_value(&*s.engine_config) {
405 Ok(v) => v,
406 Err(e) => return server_error(&format!("serialise config: {e}")),
407 };
408 redact_secrets(&mut value);
409 (StatusCode::OK, Json(value)).into_response()
410}
411
412fn redact_secrets(v: &mut Value) {
418 let placeholder = Value::String("[REDACTED]".to_string());
419 match v {
420 Value::Object(map) => {
421 let keys: Vec<String> = map.keys().cloned().collect();
424 for k in keys {
425 let lk = k.to_lowercase();
426 if k == "admin_api_keys" {
427 if let Some(Value::Array(arr)) = map.get_mut(&k) {
428 for entry in arr {
429 *entry = placeholder.clone();
430 }
431 }
432 continue;
433 }
434 if (lk.contains("password")
435 || lk.contains("secret")
436 || lk.contains("api_key")
437 || lk.contains("api-key"))
438 && let Some(slot) = map.get_mut(&k)
439 {
440 match slot {
441 Value::String(_) => *slot = placeholder.clone(),
442 Value::Array(arr) => {
443 for entry in arr {
444 if let Value::String(_) = entry {
445 *entry = placeholder.clone();
446 } else {
447 redact_secrets(entry);
448 }
449 }
450 }
451 other => redact_secrets(other),
452 }
453 continue;
454 }
455 if let Some(slot) = map.get_mut(&k) {
456 redact_secrets(slot);
457 }
458 }
459 }
460 Value::Array(arr) => {
461 for entry in arr {
462 redact_secrets(entry);
463 }
464 }
465 _ => {}
466 }
467}
468
469async fn require_admin<S: WorkflowStore + Clone + 'static>(
480 headers: &HeaderMap,
481 state: &EngineState<S>,
482) -> Result<(), Box<Response>> {
483 let auth = state
484 .auth
485 .as_ref()
486 .ok_or_else(|| svc_unavailable_box("auth not configured on this engine instance"))?;
487 let keys = crate::state::AdminApiKeys(std::sync::Arc::clone(&state.admin_api_keys));
488 assay_auth::gate::require_role_for(headers, auth, &keys, "engine", "core", "admin")
489 .await
490 .map(|_| ())
491}
492
493fn svc_unavailable_box(msg: &str) -> Box<Response> {
494 Box::new(
495 (
496 StatusCode::SERVICE_UNAVAILABLE,
497 Json(json!({"error": "service_unavailable", "error_description": msg})),
498 )
499 .into_response(),
500 )
501}
502
503fn bearer_token(headers: &HeaderMap) -> Option<String> {
504 let raw = headers
505 .get(header::AUTHORIZATION)
506 .and_then(|v| v.to_str().ok())?;
507 raw.strip_prefix("Bearer ")
508 .or_else(|| raw.strip_prefix("bearer "))
509 .map(|s| s.trim().to_string())
510}
511
512fn short_actor(token: String) -> String {
517 let t = token.trim();
518 if t.len() <= 6 {
519 return format!("admin:****{t}");
520 }
521 let tail = &t[t.len() - 6..];
522 format!("admin:****{tail}")
523}
524
525fn server_error(msg: &str) -> Response {
526 (
527 StatusCode::INTERNAL_SERVER_ERROR,
528 Json(json!({"error": "server_error", "error_description": msg})),
529 )
530 .into_response()
531}
532
533async fn list_module_records(
542 cfg: &EngineConfig,
543) -> anyhow::Result<Vec<assay_domain::engine::ModuleRecord>> {
544 match &cfg.backend {
545 #[cfg(feature = "backend-postgres")]
546 BackendConfig::Postgres { url } => {
547 let pool = sqlx::PgPool::connect(url)
548 .await
549 .map_err(|e| anyhow::anyhow!("connect pg: {e}"))?;
550 let schema = assay_domain::engine::PgEngineSchema::new(pool);
551 let rows = schema
552 .list_modules()
553 .await
554 .map_err(|e| anyhow::anyhow!("list modules (pg): {e}"))?;
555 Ok(rows)
556 }
557 #[cfg(feature = "backend-sqlite")]
558 BackendConfig::Sqlite { .. } => {
559 let pool = open_sqlite_engine_pool(cfg).await?;
560 let schema = assay_domain::engine::SqliteEngineSchema::new(pool);
561 let rows = schema
562 .list_modules()
563 .await
564 .map_err(|e| anyhow::anyhow!("list modules (sqlite): {e}"))?;
565 Ok(rows)
566 }
567 #[allow(unreachable_patterns)]
568 _ => anyhow::bail!("backend not enabled at compile time"),
569 }
570}
571
572async fn set_module_enabled(
573 cfg: &EngineConfig,
574 name: &str,
575 enabled: bool,
576 actor: Option<&str>,
577) -> anyhow::Result<bool> {
578 match &cfg.backend {
579 #[cfg(feature = "backend-postgres")]
580 BackendConfig::Postgres { url } => {
581 let pool = sqlx::PgPool::connect(url).await?;
582 let schema = assay_domain::engine::PgEngineSchema::new(pool);
583 schema.set_module_enabled(name, enabled, actor).await
584 }
585 #[cfg(feature = "backend-sqlite")]
586 BackendConfig::Sqlite { .. } => {
587 let pool = open_sqlite_engine_pool(cfg).await?;
588 let schema = assay_domain::engine::SqliteEngineSchema::new(pool);
589 schema.set_module_enabled(name, enabled, actor).await
590 }
591 #[allow(unreachable_patterns)]
592 _ => anyhow::bail!("backend not enabled at compile time"),
593 }
594}
595
596async fn audit_module_toggle(
597 cfg: &EngineConfig,
598 name: &str,
599 enabled: bool,
600 actor: Option<&str>,
601) -> anyhow::Result<()> {
602 let details = json!({"module": name, "enabled": enabled});
603 match &cfg.backend {
604 #[cfg(feature = "backend-postgres")]
605 BackendConfig::Postgres { url } => {
606 let pool = sqlx::PgPool::connect(url).await?;
607 let schema = assay_domain::engine::PgEngineSchema::new(pool);
608 schema.audit(actor, "engine.module.toggle", &details).await
609 }
610 #[cfg(feature = "backend-sqlite")]
611 BackendConfig::Sqlite { .. } => {
612 let pool = open_sqlite_engine_pool(cfg).await?;
613 let schema = assay_domain::engine::SqliteEngineSchema::new(pool);
614 schema.audit(actor, "engine.module.toggle", &details).await
615 }
616 #[allow(unreachable_patterns)]
617 _ => anyhow::bail!("backend not enabled at compile time"),
618 }
619}
620
621async fn list_instance_records(
622 cfg: &EngineConfig,
623) -> anyhow::Result<Vec<assay_domain::engine::InstanceRecord>> {
624 match &cfg.backend {
625 #[cfg(feature = "backend-postgres")]
626 BackendConfig::Postgres { url } => {
627 let pool = sqlx::PgPool::connect(url).await?;
628 let schema = assay_domain::engine::PgEngineSchema::new(pool);
629 schema.list_instances().await
630 }
631 #[cfg(feature = "backend-sqlite")]
632 BackendConfig::Sqlite { .. } => {
633 let pool = open_sqlite_engine_pool(cfg).await?;
634 let schema = assay_domain::engine::SqliteEngineSchema::new(pool);
635 schema.list_instances().await
636 }
637 #[allow(unreachable_patterns)]
638 _ => anyhow::bail!("backend not enabled at compile time"),
639 }
640}
641
642async fn list_audit_records(
643 cfg: &EngineConfig,
644 limit: i64,
645 offset: i64,
646 actor: Option<&str>,
647 action: Option<&str>,
648 since: Option<f64>,
649 until: Option<f64>,
650) -> anyhow::Result<(Vec<assay_domain::engine::AuditRecord>, i64)> {
651 match &cfg.backend {
652 #[cfg(feature = "backend-postgres")]
653 BackendConfig::Postgres { url } => {
654 let pool = sqlx::PgPool::connect(url).await?;
655 let schema = assay_domain::engine::PgEngineSchema::new(pool);
656 schema
657 .list_audit(limit, offset, actor, action, since, until)
658 .await
659 }
660 #[cfg(feature = "backend-sqlite")]
661 BackendConfig::Sqlite { .. } => {
662 let pool = open_sqlite_engine_pool(cfg).await?;
663 let schema = assay_domain::engine::SqliteEngineSchema::new(pool);
664 schema
665 .list_audit(limit, offset, actor, action, since, until)
666 .await
667 }
668 #[allow(unreachable_patterns)]
669 _ => anyhow::bail!("backend not enabled at compile time"),
670 }
671}
672
673#[cfg(feature = "backend-sqlite")]
679async fn open_sqlite_engine_pool(cfg: &EngineConfig) -> anyhow::Result<sqlx::SqlitePool> {
680 use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
681 use std::str::FromStr;
682
683 let data_dir = cfg
684 .backend
685 .sqlite_data_dir()
686 .ok_or_else(|| anyhow::anyhow!("sqlite_data_dir missing on non-sqlite backend"))?;
687 let path = format!("file:{data_dir}/engine.db?mode=rw");
688 let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.create_if_missing(true);
689 let pool = SqlitePoolOptions::new()
690 .max_connections(1)
691 .after_connect(move |conn, _meta| {
692 let path = path.clone();
693 Box::pin(async move {
694 use sqlx::Executor;
695 conn.execute(format!("ATTACH DATABASE '{path}' AS engine").as_str())
696 .await?;
697 Ok(())
698 })
699 })
700 .connect_with(opts)
701 .await
702 .map_err(|e| anyhow::anyhow!("connect engine.db: {e}"))?;
703 Ok(pool)
704}
705
706fn redact_pg_url(raw: &str) -> String {
713 let Ok(u) = url::Url::parse(raw) else {
714 return "[REDACTED]".to_string();
715 };
716 let scheme = u.scheme();
717 let host = u.host_str().unwrap_or("");
718 let port = u.port().map(|p| format!(":{p}")).unwrap_or_default();
719 let path = u.path();
720 let query = match u.query() {
721 Some(q) => format!("?{q}"),
722 None => String::new(),
723 };
724 let userinfo = if !u.username().is_empty() || u.password().is_some() {
725 "[REDACTED]@"
726 } else {
727 ""
728 };
729 format!("{scheme}://{userinfo}{host}{port}{path}{query}")
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735
736 #[test]
737 fn redact_secrets_replaces_admin_api_keys_array() {
738 let mut v = json!({
739 "auth": {
740 "admin_api_keys": ["secret1", "secret2"],
741 "issuer": "https://issuer.example",
742 }
743 });
744 redact_secrets(&mut v);
745 let arr = v["auth"]["admin_api_keys"].as_array().unwrap();
746 assert!(arr.iter().all(|x| x.as_str() == Some("[REDACTED]")));
747 assert_eq!(v["auth"]["issuer"], json!("https://issuer.example"));
749 }
750
751 #[test]
752 fn redact_secrets_replaces_password_fields() {
753 let mut v = json!({"backend": {"password": "hunter2"}});
754 redact_secrets(&mut v);
755 assert_eq!(v["backend"]["password"], json!("[REDACTED]"));
756 }
757
758 #[test]
759 fn redact_pg_url_strips_userinfo() {
760 let raw = "postgres://alice:hunter2@db.example.com:5432/assay";
761 let red = redact_pg_url(raw);
762 assert!(!red.contains("hunter2"), "password leak: {red}");
763 assert!(red.contains("[REDACTED]"));
764 assert!(red.contains("db.example.com:5432"));
765 assert!(red.contains("/assay"));
766 }
767
768 #[test]
773 fn short_actor_safely_truncates() {
774 let s = short_actor("abcdef0123456789".to_string());
775 assert_eq!(s, "admin:****456789");
776 let s = short_actor("xyz".to_string());
778 assert!(s.starts_with("admin:****"));
779 }
780}