1pub mod auth;
11pub mod identity;
12mod materialize;
13mod routes;
14mod state;
15mod thq_dispatch;
16mod thq_register;
17pub mod tls;
18pub mod xagent;
19
20static BIN_VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
25
26pub fn set_bin_version(v: &str) {
30 let _ = BIN_VERSION.set(v.to_string());
31}
32
33pub fn bin_version() -> &'static str {
36 BIN_VERSION
37 .get()
38 .map(|s| s.as_str())
39 .unwrap_or(env!("CARGO_PKG_VERSION"))
40}
41
42const EMBEDDED_CEDAR_POLICY: &str = include_str!("../policies/trustee_default.cedar");
44const EMBEDDED_CEDAR_SCHEMA: &str = include_str!("../policies/trustee_schema.cedarschema");
45
46use std::net::SocketAddr;
47use std::sync::Arc;
48
49use anyhow::Result;
50use axum::routing::{get, post};
51use tower_http::cors::CorsLayer;
52
53pub use auth::{AuthConfig, AuthState};
54pub use state::ServerState;
55
56pub async fn run(
68 config_toml: String,
69 secrets: std::collections::HashMap<String, String>,
70 build_info: trustee_core::types::BuildInfo,
71 addr: SocketAddr,
72 use_tls: bool,
73) -> Result<()> {
74 let auth_state = if let Some(cfg) = AuthConfig::from_toml(&config_toml) {
76 let is_dev = cfg.dev_config.local_dev_mode;
77 tracing::info!(
78 "Auth enabled: {} mode, issuer={}",
79 if is_dev { "development" } else { "production" },
80 cfg.issuer_url
81 );
82
83 let cedar_boot = parse_cedar_config(&config_toml)
85 .await
86 .map_err(|e| anyhow::anyhow!("{e}"))?;
87 cedar_boot_decision(
88 true,
89 cedar_boot.authorizer.is_some(),
90 cedar_boot.allow_disabled,
91 )
92 .map_err(|e| anyhow::anyhow!("{e}"))?;
93
94 let mut issuer_fallbacks = crate::state::service_issuers_from_config(&config_toml);
97 for si in crate::thq_dispatch::discover_service_issuers() {
98 if !issuer_fallbacks.contains(&si) {
99 issuer_fallbacks.push(si);
100 }
101 }
102 issuer_fallbacks.retain(|si| *si != cfg.issuer_url);
103 if !issuer_fallbacks.is_empty() {
104 tracing::info!("Auth issuer fallbacks armed: {:?}", issuer_fallbacks);
105 }
106 Some(Arc::new(
107 AuthState::with_cedar(cfg, cedar_boot.authorizer)
108 .with_issuer_fallbacks(issuer_fallbacks),
109 ))
110 } else {
111 tracing::warn!("AUTH NOT CONFIGURED: trustee-web is running WITHOUT authentication or Cedar authorization (no [oidc]/[dev] section). Never expose this to a network.");
113 None
114 };
115
116 let thq_enrollment = match thq_register::ThqConfig::from_toml(&config_toml) {
120 Ok(None) => None,
121 Err(e) => {
122 tracing::error!("THQ enrollment DISABLED — [thq] is malformed: {e}");
123 None
124 }
125 Ok(Some(cfg)) => Some(thq_register::Enrollment::new(cfg)),
126 };
127
128 let config_toml_for_state = config_toml.clone();
130 let secrets_for_state = secrets.clone();
131 let build_info_for_state = build_info.clone();
132 let (mut session, workflow_rx) = trustee_core::session::Session::new();
133 session.config_toml = Some(config_toml);
134 session.secrets = Some(secrets);
135 session.build_info = Some(build_info);
136 session.parse_auto_handoff_config();
137
138 if let Some(ref config_toml_str) = session.config_toml {
140 if let Ok(table) = config_toml_str.parse::<toml::Value>() {
141 if let Some(name) = table
142 .get("agent")
143 .and_then(|a| a.get("name"))
144 .and_then(|n| n.as_str())
145 {
146 session.agent_name = name.to_string();
147 }
148 }
149 }
150
151 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(256);
153
154 let (max_sessions, allow_llm_overlay) = {
157 let config_str: &str = &config_toml_for_state;
158 match toml::from_str::<toml::Value>(config_str) {
159 Ok(v) => {
160 let max_sessions = v
161 .get("web")
162 .and_then(|w| w.as_table())
163 .and_then(|w| w.get("max_sessions_per_user").and_then(|v| v.as_integer()))
164 .map(|v| v as usize)
165 .unwrap_or(4);
166 let allow_llm_overlay = v
167 .get("users")
168 .and_then(|u| u.as_table())
169 .and_then(|u| u.get("allow_llm_overlay").and_then(|v| v.as_bool()))
170 .unwrap_or(false);
171 (max_sessions, allow_llm_overlay)
172 }
173 Err(_) => (4, false),
174 }
175 };
176
177 let state = ServerState::new(session, ws_tx, auth_state)
178 .with_config_toml(config_toml_for_state)
179 .with_secrets(secrets_for_state)
180 .with_build_info(build_info_for_state)
181 .with_max_sessions_per_user(max_sessions)
182 .with_allow_llm_overlay(allow_llm_overlay);
183
184 state.clone().spawn_drain_task(workflow_rx);
186
187 thq_dispatch::populate(state.clone());
191
192 let app = axum::Router::new()
200 .route("/api/v1/health", get(routes::health))
202 .nest("/auth", auth::auth_routes())
203 .route("/api/v1/models", get(routes::list_models))
205 .route(
216 "/api/v1/sessions",
217 get(routes::list_sessions).post(routes::create_session),
218 )
219 .route("/api/v1/sessions/live", get(routes::list_live_sessions))
220 .route(
221 "/api/v1/sessions/{id}",
222 get(routes::get_session_detail).delete(routes::destroy_session),
223 )
224 .route("/api/v1/sessions/{id}/live", get(routes::get_live_session))
225 .route("/api/v1/sessions/{id}/resume", post(routes::resume_session))
226 .route("/api/v1/sessions/{id}/fork", post(routes::fork_session))
227 .route(
228 "/api/v1/sessions/{id}/history",
229 get(routes::get_session_history),
230 )
231 .route(
233 "/api/v1/sessions/{id}/command",
234 post(routes::post_command_session).layer(axum::extract::DefaultBodyLimit::max(
235 routes::ATTACH_BODY_LIMIT,
236 )),
237 )
238 .route(
239 "/api/v1/sessions/{id}/cancel",
240 post(routes::post_cancel_session),
241 )
242 .route(
243 "/api/v1/sessions/{id}/handoff",
244 post(routes::post_handoff_session),
245 )
246 .route(
247 "/api/v1/sessions/{id}/name",
248 post(routes::set_session_name_session),
249 )
250 .route(
251 "/api/v1/sessions/{id}/stream",
252 get(routes::ws_session_handler),
253 )
254 .route("/", get(routes::serve_index))
256 .route("/{file}", get(routes::serve_static))
257 .merge(crate::xagent::router());
259 let app = match &thq_enrollment {
263 Some(enr) => app.merge(thq_register::enroll_route(enr.clone())),
264 None => app,
265 };
266 let enrollment_state = state.clone();
268 let app = app
269 .layer(CorsLayer::permissive())
270 .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
271 .with_state(state);
272
273 let listener = tokio::net::TcpListener::bind(addr).await?;
277 if let Some(enr) = thq_enrollment {
278 enr.spawn(enrollment_state);
279 }
280
281 if use_tls {
282 let _ = rustls::crypto::ring::default_provider().install_default();
286
287 let cert_dir = tls::default_cert_dir();
289 let (cert_path, key_path) = tls::ensure_certs(&cert_dir)?;
290
291 let tls_config = tls::load_tls_config(&cert_path, &key_path)?;
293 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_config));
294
295 tracing::info!("Trustee API listening on https://{}", addr);
296
297 loop {
299 let (tcp_stream, peer_addr) = match listener.accept().await {
300 Ok(stream) => stream,
301 Err(e) => {
302 tracing::warn!("TCP accept failed: {}", e);
303 continue;
304 }
305 };
306
307 let acceptor = acceptor.clone();
308 let app = app.clone();
309
310 tokio::spawn(async move {
311 let tls_stream = match acceptor.accept(tcp_stream).await {
312 Ok(s) => s,
313 Err(e) => {
314 tracing::debug!("TLS accept failed from {}: {}", peer_addr, e);
315 return;
316 }
317 };
318
319 let io = hyper_util::rt::TokioIo::new(tls_stream);
322 let svc = hyper_util::service::TowerToHyperService::new(app);
323
324 let _ = hyper_util::server::conn::auto::Builder::new(
325 hyper_util::rt::TokioExecutor::new(),
326 )
327 .serve_connection_with_upgrades(io, svc)
328 .await;
329 });
330 }
331 } else {
332 tracing::info!("Trustee API listening on http://{}", addr);
333 axum::serve(listener, app).await?;
334 }
335
336 Ok(())
337}
338
339struct CedarBoot {
350 authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
351 allow_disabled: bool,
356}
357
358pub(crate) fn cedar_boot_decision(
360 auth_configured: bool,
361 cedar_present: bool,
362 allow_disabled: bool,
363) -> Result<(), String> {
364 if !auth_configured {
365 return Ok(());
368 }
369 if cedar_present || allow_disabled {
370 Ok(())
371 } else {
372 Err(
373 "Cedar authorization is REQUIRED in web mode (fail-closed, nghr 645809c3). \
374 Either configure it: [cedar] enabled = true (policies ship embedded), \
375 or explicitly opt out per environment: [cedar] allow_disabled = true."
376 .to_string(),
377 )
378 }
379}
380
381pub(crate) fn audit_policy_action_skew(policy_text: &str, schema_text: &str) -> Vec<String> {
391 let referenced = policy_action_refs(policy_text);
392 let declared = schema_action_decls(schema_text);
393 referenced.difference(&declared).cloned().collect()
394}
395
396fn policy_action_refs(policy_text: &str) -> std::collections::BTreeSet<String> {
401 let code: String = policy_text
402 .lines()
403 .map(|l| match l.find("//") {
404 Some(pos) => &l[..pos],
405 None => l,
406 })
407 .collect::<Vec<_>>()
408 .join("\n");
409 let mut out = std::collections::BTreeSet::new();
410 let mut rest = code.as_str();
411 while let Some(pos) = rest.find("Action::\"") {
412 rest = &rest[pos + "Action::\"".len()..];
413 match rest.find('"') {
414 Some(end) => {
415 out.insert(rest[..end].to_string());
416 rest = &rest[end..];
417 }
418 None => break,
419 }
420 }
421 out
422}
423
424fn schema_action_decls(schema_text: &str) -> std::collections::BTreeSet<String> {
427 let mut out = std::collections::BTreeSet::new();
428 for line in schema_text.lines() {
429 let line = line.trim_start();
430 if let Some(rest) = line.strip_prefix("action ") {
431 let name: String = rest
432 .chars()
433 .take_while(|c| !c.is_whitespace() && *c != ';')
434 .collect();
435 if !name.is_empty() {
436 out.insert(name);
437 }
438 }
439 }
440 out
441}
442
443pub(crate) fn log_policy_action_skew(source: &str, policy_text: &str, schema_text: &str) -> Vec<String> {
447 let unknown = audit_policy_action_skew(policy_text, schema_text);
448 if !unknown.is_empty() {
449 for action in &unknown {
454 tracing::error!(
455 "Cedar POLICY/BINARY SKEW ({source}): policy references action Action::\"{action}\" which this binary's schema does NOT declare — the deployed policy files predate (or postdate) this binary's action set. Fix: align the policy files with the binary's schema (the embedded pair is authoritative), or update the binary."
456 );
457 }
458 tracing::error!(
459 "Cedar POLICY/BINARY SKEW ({source}): {} unknown action(s) total: {:?}",
460 unknown.len(),
461 unknown
462 );
463 }
464 unknown
465}
466
467async fn parse_cedar_config(config_toml: &str) -> Result<CedarBoot, String> {
468 let parsed: Option<toml::Table> = toml::from_str(config_toml).ok();
469 let cedar_table = parsed.as_ref().and_then(|t| t.get("cedar"));
470 let allow_disabled = cedar_table
471 .and_then(|c| c.get("allow_disabled"))
472 .and_then(|v| v.as_bool())
473 .unwrap_or(false);
474
475 let Some(cedar_section) = cedar_table.and_then(|c| c.as_table().cloned()) else {
476 return Ok(CedarBoot {
477 authorizer: None,
478 allow_disabled,
479 });
480 };
481 let enabled = cedar_section
482 .get("enabled")
483 .and_then(|v| v.as_bool())
484 .unwrap_or(false);
485
486 if !enabled {
487 tracing::debug!("Cedar authorization disabled (default)");
488 return Ok(CedarBoot {
489 authorizer: None,
490 allow_disabled,
491 });
492 }
493
494 tracing::info!("Cedar authorization enabled — initializing authorizer");
495
496 let agent_name = parsed
499 .as_ref()
500 .and_then(|t| t.get("agent"))
501 .and_then(|a| a.as_table())
502 .and_then(|a| a.get("name"))
503 .and_then(|n| n.as_str())
504 .unwrap_or("trustee");
505
506 let home_policies_dir = dirs::home_dir()
507 .map(|h| h.join(format!(".{}", agent_name)).join("policies"))
508 .unwrap_or_else(|| std::path::PathBuf::from("/nonexistent"));
509
510 let default_policy_path = home_policies_dir.join("trustee_default.cedar");
511 let default_schema_path = home_policies_dir.join("trustee_schema.cedarschema");
512
513 let policy_path = cedar_section
514 .get("policy_path")
515 .and_then(|v| v.as_str())
516 .filter(|s| !s.is_empty())
517 .map(std::path::PathBuf::from)
518 .unwrap_or(default_policy_path);
519
520 let schema_path = cedar_section
521 .get("schema_path")
522 .and_then(|v| v.as_str())
523 .filter(|s| !s.is_empty())
524 .map(std::path::PathBuf::from)
525 .or_else(|| Some(default_schema_path));
526
527 let policy_store_url = cedar_section
528 .get("policy_store_url")
529 .and_then(|v| v.as_str())
530 .map(String::from);
531
532 let policy_store_token = cedar_section
533 .get("policy_store_token")
534 .and_then(|v| v.as_str())
535 .map(String::from);
536
537 log_policy_action_skew("embedded", EMBEDDED_CEDAR_POLICY, EMBEDDED_CEDAR_SCHEMA);
544 if policy_path.exists() && schema_path.as_ref().is_some_and(|p| p.exists()) {
545 match (
546 std::fs::read_to_string(&policy_path),
547 schema_path.as_ref().and_then(|p| std::fs::read_to_string(p).ok()),
548 ) {
549 (Ok(policy_text), Some(schema_text)) => {
550 log_policy_action_skew(
551 &format!("filesystem {}", policy_path.display()),
552 &policy_text,
553 &schema_text,
554 );
555 }
556 _ => {
557 tracing::warn!(
558 "Cedar skew guard: policy file {} exists but the schema file is unreadable — the audit ran on the embedded pair only",
559 policy_path.display()
560 );
561 }
562 }
563 }
564
565 let cedar_config = pep::cedar::CedarConfig {
566 policy_path,
567 schema_path,
568 entities_path: None,
569 default_decision: pep::cedar::DefaultDecision::Deny,
570 validate_on_load: true,
571 policy_store_url,
572 policy_store_token,
573 embedded_policy: Some(EMBEDDED_CEDAR_POLICY),
574 embedded_schema: Some(EMBEDDED_CEDAR_SCHEMA),
575 };
576
577 match pep::cedar::CedarAuthorizer::new_with_policy_store(cedar_config).await {
578 Ok(auth) => {
579 tracing::info!("Cedar authorizer initialized successfully");
580 Ok(CedarBoot {
581 authorizer: Some(Arc::new(auth)),
582 allow_disabled,
583 })
584 }
585 Err(e) => {
589 let msg = format!(
590 "Cedar authorization is enabled but FAILED to initialize: {e}. \
591 Refusing to boot (fail-closed). Fix the policy/schema configuration \
592 or explicitly set [cedar] allow_disabled = true to run identity-only."
593 );
594 tracing::error!("{msg}");
595 Err(msg)
596 }
597 }
598}