1use std::io::{BufRead, BufReader, Read, Write};
9use std::net::{TcpListener, TcpStream};
10use std::time::Duration;
11
12use areev_cal::{CalExecutor, CalExecutorConfig, CalStoreFacade, AreevFacade};
13use areev_loop_adapter::{now_ms, BorrowedSubstrate};
14use serde_json::{json, Value};
15use areev_loop::{Decision, Engine, RecStatus, RunOptions};
16
17#[cfg(feature = "oidc")]
21pub mod oidc;
22
23const CONSOLE_HTML: &str = include_str!("console.html");
24
25const READ_TIMEOUT_SECS: u64 = 15;
27const MAX_CONN_BYTES: u64 = (1 << 20) + (128 << 10);
30const MAX_HEADER_BYTES: usize = 64 * 1024;
32const MAX_HEADERS: usize = 100;
33const REQUEST_DEADLINE_SECS: u64 = 30;
37const AUTH_FAILURE_IDLE_SECS: u64 = 15 * 60;
40const MAX_AUTH_FAILURE_IPS: usize = 4096;
44const MAX_SSO_IDENTITY_BYTES: usize = 128;
48const RESERVED_PRINCIPALS: [&str; 2] = ["anonymous", "user:console"];
59
60const MAX_CONSECUTIVE_AUTH_FAILURES: u32 = 10;
70
71
72enum Conn {
79 Plain(TcpStream),
80 #[cfg(feature = "tls")]
81 Tls(Box<rustls::StreamOwned<rustls::ServerConnection, TcpStream>>),
82}
83
84impl Read for Conn {
85 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
86 match self {
87 Conn::Plain(s) => s.read(buf),
88 #[cfg(feature = "tls")]
89 Conn::Tls(s) => s.read(buf),
90 }
91 }
92}
93
94impl Write for Conn {
95 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
96 match self {
97 Conn::Plain(s) => s.write(buf),
98 #[cfg(feature = "tls")]
99 Conn::Tls(s) => s.write(buf),
100 }
101 }
102 fn flush(&mut self) -> std::io::Result<()> {
103 match self {
104 Conn::Plain(s) => s.flush(),
105 #[cfg(feature = "tls")]
106 Conn::Tls(s) => s.flush(),
107 }
108 }
109}
110
111pub struct UiServer {
112 facade: std::sync::Arc<AreevFacade>,
113 executor: CalExecutor,
114 db_label: String,
115 db_label_display: String,
123 token: Option<String>,
127 auth_all: bool,
131 allow_remote: bool,
136 allowed_origins: Vec<String>,
146 loop_policy: Option<areev_loop::Policy>,
151 credentials: Option<areev_core::authz::CredentialMap>,
160 allow_destructive_ops: bool,
163 #[cfg(feature = "tls")]
166 tls_config: Option<std::sync::Arc<rustls::ServerConfig>>,
167 sso_header: Option<String>,
174 sso_secrets: Vec<String>,
191 sso_groups_header: Option<String>,
197 sso_principal_prefix: Option<String>,
203 #[cfg(feature = "oidc")]
210 oidc: Option<std::sync::Arc<oidc::OidcProvider>>,
211 sso_approvals: bool,
229 auth_failures: std::sync::Mutex<std::collections::HashMap<std::net::IpAddr, (u32, std::time::Instant)>>,
245 decider: Option<(String, bool)>,
251}
252
253#[cfg(feature = "oidc")]
257fn auth_error(msg: &str) -> (String, String, String) {
258 (
259 "400 Bad Request".into(),
260 "Content-Type: text/plain; charset=utf-8\r\nCache-Control: no-store\r\n".into(),
261 format!("login failed: {msg}\n\nStart again at /auth/login\n"),
262 )
263}
264
265#[cfg(feature = "oidc")]
266fn not_found_auth() -> (String, String, String) {
267 (
268 "404 Not Found".into(),
269 "Content-Type: text/plain; charset=utf-8\r\n".into(),
270 "no such auth endpoint\n".into(),
271 )
272}
273
274#[cfg(feature = "oidc")]
280fn cookie_value(header: &str, name: &str) -> Option<String> {
281 header.split(';').find_map(|pair| {
282 let (k, v) = pair.split_once('=')?;
283 (k.trim() == name).then(|| v.trim().to_string())
284 })
285}
286
287fn sanitize_sso_identity(raw: &str) -> Option<String> {
300 let id = raw.trim();
301 if id.is_empty() || id.len() > MAX_SSO_IDENTITY_BYTES {
302 return None;
303 }
304 if id.chars().any(|c| c.is_control() || c.is_whitespace()) {
308 return None;
309 }
310 if RESERVED_PRINCIPALS.contains(&id) {
311 return None;
312 }
313 Some(id.to_string())
314}
315
316struct RequestBinding<'a> {
322 facade: &'a AreevFacade,
323}
324
325impl<'a> RequestBinding<'a> {
326 fn bind(
327 facade: &'a AreevFacade,
328 map: &areev_core::authz::CredentialMap,
329 bearer: Option<&str>,
330 memory: &str,
331 ) -> Self {
332 let principal =
335 bearer.and_then(|t| map.resolve_for_memory(t, memory).ok().map(str::to_string));
336 match principal {
337 Some(p) => {
338 if facade.bind_principal(&p).is_err() {
339 Self::bind_anonymous(facade);
342 }
343 }
344 None => Self::bind_anonymous(facade),
345 }
346 RequestBinding { facade }
347 }
348
349 fn bind_identity(facade: &'a AreevFacade, principal: &str) -> Self {
355 if facade.bind_principal(principal).is_err() {
356 Self::bind_anonymous(facade);
357 }
358 RequestBinding { facade }
359 }
360
361 fn bind_anonymous(facade: &AreevFacade) {
362 use areev_core::authz::{AuthzSet, Grant, Verb};
363 let mut grants = facade
364 .with_store(|m| m.authz_grants("anonymous"))
365 .unwrap_or_default();
366 grants.push(Grant { verbs: vec![Verb::Read], namespaces: vec!["*".to_string()] });
367 facade.bind(AuthzSet::restricted("anonymous", grants));
368 }
369}
370
371impl Drop for RequestBinding<'_> {
372 fn drop(&mut self) {
373 self.facade
374 .bind(areev_core::authz::AuthzSet::owner("user:console"));
375 }
376}
377
378impl UiServer {
379 pub fn new(facade: AreevFacade, db_label: String) -> Self {
380 let db_label_display = redact_dsn(&db_label);
381 UiServer {
382 facade: std::sync::Arc::new(facade),
383 executor: CalExecutor::new(CalExecutorConfig::default())
384 .with_governance(std::sync::Arc::new(areev_loop_adapter::LoopGovernance::new())),
385 db_label,
386 db_label_display,
387 token: None,
388 auth_all: false,
389 allow_remote: false,
390 allowed_origins: Vec::new(),
391 loop_policy: None,
392 credentials: None,
393 allow_destructive_ops: CalExecutorConfig::default().allow_destructive_ops,
394 #[cfg(feature = "tls")]
395 tls_config: None,
396 sso_header: None,
397 sso_secrets: Vec::new(),
398 sso_groups_header: None,
399 sso_principal_prefix: None,
400 #[cfg(feature = "oidc")]
401 oidc: None,
402 sso_approvals: false,
403 auth_failures: std::sync::Mutex::new(std::collections::HashMap::new()),
404 decider: None,
405 }
406 }
407
408 pub fn with_decider(mut self, chain: impl Into<String>, calibrated: bool) -> Self {
413 self.decider = Some((chain.into(), calibrated));
414 self
415 }
416
417 pub fn with_sso(self, header: impl Into<String>, secret: impl Into<String>) -> Self {
421 self.with_sso_rotating(header, secret, None::<String>)
422 }
423
424 pub fn with_sso_rotating(
439 mut self,
440 header: impl Into<String>,
441 secret: impl Into<String>,
442 next: Option<impl Into<String>>,
443 ) -> Self {
444 let header = header.into();
445 let secret = secret.into();
446 let next = next.map(Into::into).filter(|s| !s.trim().is_empty());
447 assert!(
451 !header.trim().is_empty() && !secret.trim().is_empty(),
452 "with_sso requires a non-empty header name and proxy secret"
453 );
454 assert!(
458 next.as_deref() != Some(secret.as_str()),
459 "the rotating proxy secret must differ from the current one"
460 );
461 self.sso_header = Some(header.to_ascii_lowercase());
462 self.sso_secrets = match next {
463 Some(n) => vec![secret, n],
464 None => vec![secret],
465 };
466 self
467 }
468
469 #[cfg(feature = "oidc")]
471 pub fn with_oidc(mut self, provider: oidc::OidcProvider) -> Self {
472 self.oidc = Some(std::sync::Arc::new(provider));
473 self
474 }
475
476 #[cfg(feature = "oidc")]
478 fn oidc_principal(&self, cookie_header: Option<&str>) -> Option<String> {
479 let provider = self.oidc.as_ref()?;
480 let sid = cookie_value(cookie_header?, oidc::SESSION_COOKIE)?;
481 provider.principal_for_session(&sid)
482 }
483
484 #[cfg(feature = "oidc")]
489 fn oidc_route(
490 &self,
491 method: &str,
492 path: &str,
493 cookie_header: Option<&str>,
494 ) -> (String, String, String) {
495 let provider = match &self.oidc {
496 Some(p) => p,
497 None => return not_found_auth(),
498 };
499 let (base, query) = match path.split_once('?') {
500 Some((p, q)) => (p, q),
501 None => (path, ""),
502 };
503 let q = |key: &str| -> Option<String> {
504 query.split('&').find_map(|kv| {
505 let (k, v) = kv.split_once('=')?;
506 (k == key).then(|| urldecode(v))
507 })
508 };
509 let secure = if provider.is_secure() { "; Secure" } else { "" };
514
515 match (method, base) {
516 ("GET", "/auth/login") => match provider.authorize_url() {
517 Ok(url) => (
518 "302 Found".into(),
519 format!("Location: {url}\r\nCache-Control: no-store\r\n"),
520 String::new(),
521 ),
522 Err(e) => auth_error(&e.to_string()),
523 },
524 ("GET", "/auth/callback") => {
525 let (Some(code), Some(state)) = (q("code"), q("state")) else {
526 return auth_error(&match q("error") {
529 Some(e) => format!("the identity provider refused the login: {e}"),
530 None => "callback missing `code` or `state`".to_string(),
531 });
532 };
533 match provider.complete_login(&code, &state) {
534 Ok(sid) => (
535 "302 Found".into(),
536 format!(
537 "Location: /\r\nCache-Control: no-store\r\n\
538 Set-Cookie: {}={sid}; HttpOnly; SameSite=Strict; Path=/{secure}\r\n",
539 oidc::SESSION_COOKIE
540 ),
541 String::new(),
542 ),
543 Err(e) => auth_error(&e.to_string()),
544 }
545 }
546 ("POST", "/auth/logout") | ("GET", "/auth/logout") => {
551 if let Some(sid) = cookie_header.and_then(|c| cookie_value(c, oidc::SESSION_COOKIE))
552 {
553 provider.logout(&sid);
556 }
557 (
558 "302 Found".into(),
559 format!(
560 "Location: /\r\nCache-Control: no-store\r\n\
561 Set-Cookie: {}=; HttpOnly; SameSite=Strict; Path=/{secure}; Max-Age=0\r\n",
562 oidc::SESSION_COOKIE
563 ),
564 String::new(),
565 )
566 }
567 _ => not_found_auth(),
568 }
569 }
570
571 pub fn with_sso_groups_header(mut self, header: impl Into<String>) -> Self {
575 let header = header.into();
576 assert!(
577 !header.trim().is_empty(),
578 "with_sso_groups_header requires a non-empty header name"
579 );
580 self.sso_groups_header = Some(header.to_ascii_lowercase());
581 self
582 }
583
584 pub fn with_sso_principal_prefix(mut self, prefix: impl Into<String>) -> Self {
588 let prefix = prefix.into();
589 assert!(
590 !prefix.trim().is_empty(),
591 "with_sso_principal_prefix requires a non-empty prefix"
592 );
593 self.sso_principal_prefix = Some(prefix);
594 self
595 }
596
597 fn resolve_sso_principal(
617 &self,
618 identity: &str,
619 groups_raw: Option<&str>,
620 ) -> Option<(String, bool)> {
621 let stamp = |p: &str| match &self.sso_principal_prefix {
622 Some(prefix) if !p.starts_with(prefix.as_str()) => format!("{prefix}{p}"),
623 _ => p.to_string(),
624 };
625 let identity = stamp(identity);
626
627 let identity_granted = self
632 .facade
633 .with_store(|m| m.authz_grants(&identity))
634 .map(|g| !g.is_empty())
635 .unwrap_or(false);
636 if identity_granted {
637 return Some((identity, false));
638 }
639
640 if let (Some(map), Some(raw)) = (&self.credentials, groups_raw) {
641 for group in raw.split(',') {
642 if let Some(principal) = map.principal_for_group(group) {
643 return Some((principal.to_string(), true));
644 }
645 }
646 }
647
648 Some((identity, false))
653 }
654
655 pub fn with_sso_approvals(mut self, allow: bool) -> Self {
667 self.sso_approvals = allow;
668 self
669 }
670
671 fn rebuild_executor(&mut self) {
678 self.executor = CalExecutor::new(CalExecutorConfig {
679 allow_destructive_ops: self.allow_destructive_ops,
680 ..CalExecutorConfig::default()
681 })
682 .with_governance(std::sync::Arc::new(match &self.loop_policy {
683 Some(p) => areev_loop_adapter::LoopGovernance::with_policy(p.clone()),
684 None => areev_loop_adapter::LoopGovernance::new(),
685 }));
686 }
687
688 pub fn with_credentials(mut self, map: areev_core::authz::CredentialMap) -> Self {
693 self.credentials = Some(map);
694 self
695 }
696
697 pub fn with_loop_policy(mut self, policy: areev_loop::Policy) -> Self {
701 self.loop_policy = Some(policy);
702 self.rebuild_executor();
704 self
705 }
706
707 fn engine(&self) -> Engine {
710 match &self.loop_policy {
711 Some(p) => Engine::with_builtins().with_policy(p.clone()),
712 None => Engine::with_builtins(),
713 }
714 }
715
716 pub fn allow_remote(mut self, yes: bool) -> Self {
720 self.allow_remote = yes;
721 self
722 }
723
724 pub fn allow_origin(mut self, origin: impl Into<String>) -> Self {
739 self.allowed_origins.push(normalize_origin(&origin.into()));
740 self
741 }
742
743 fn origin_is_allowed(&self, origin: &str) -> bool {
747 let normalized = normalize_origin(origin);
748 self.allowed_origins.contains(&normalized)
749 }
750
751 pub fn with_auth(mut self, token: String) -> Self {
758 assert!(
762 !token.trim().is_empty(),
763 "with_auth requires a non-empty token"
764 );
765 self.token = Some(token);
766 self.auth_all = true;
767 self
768 }
769
770 pub fn allow_destructive_ops(mut self, allow: bool) -> Self {
775 self.allow_destructive_ops = allow;
776 self.rebuild_executor();
777 self
778 }
779
780 pub fn bind(addr: &str) -> std::io::Result<TcpListener> {
782 TcpListener::bind(addr)
783 }
784
785 pub fn serve(&self, listener: TcpListener) -> std::io::Result<()> {
787 for stream in listener.incoming() {
788 match stream {
789 Ok(s) => {
790 if let Err(e) = self.handle(s) {
791 eprintln!("areev ui: request error: {e}");
792 }
793 }
794 Err(e) => eprintln!("areev ui: accept error: {e}"),
795 }
796 }
797 Ok(())
798 }
799
800 #[cfg(feature = "tls")]
804 pub fn with_tls(mut self, cert_pem: &str, key_pem: &str) -> std::io::Result<Self> {
805 use rustls::pki_types::pem::PemObject;
806 let pem_err =
807 |e: rustls::pki_types::pem::Error| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string());
808 let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
809 rustls::pki_types::CertificateDer::pem_file_iter(cert_pem)
810 .map_err(pem_err)?
811 .collect::<Result<_, _>>()
812 .map_err(pem_err)?;
813 let key =
814 rustls::pki_types::PrivateKeyDer::from_pem_file(key_pem).map_err(pem_err)?;
815 let config = rustls::ServerConfig::builder()
816 .with_no_client_auth()
817 .with_single_cert(certs, key)
818 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
819 self.tls_config = Some(std::sync::Arc::new(config));
820 Ok(self)
821 }
822
823 fn note_auth_failure(&self, ip: std::net::IpAddr) -> u32 {
848 let now = std::time::Instant::now();
849 let idle = Duration::from_secs(AUTH_FAILURE_IDLE_SECS);
850 let mut map = self
851 .auth_failures
852 .lock()
853 .unwrap_or_else(std::sync::PoisonError::into_inner);
854 match map.get_mut(&ip) {
855 Some((n, last)) if now.duration_since(*last) < idle => {
856 *n = n.saturating_add(1);
857 *last = now;
858 *n
859 }
860 Some(entry) => {
861 *entry = (1, now);
862 1
863 }
864 None => {
865 if map.len() >= MAX_AUTH_FAILURE_IPS {
866 if let Some(stalest) =
867 map.iter().min_by_key(|(_, (_, last))| *last).map(|(k, _)| *k)
868 {
869 map.remove(&stalest);
870 }
871 }
872 map.insert(ip, (1, now));
873 1
874 }
875 }
876 }
877
878 fn reset_auth_failures(&self, ip: std::net::IpAddr) {
880 let mut map = self
881 .auth_failures
882 .lock()
883 .unwrap_or_else(std::sync::PoisonError::into_inner);
884 map.remove(&ip);
885 }
886
887 fn auth_failure_count(&self, ip: std::net::IpAddr) -> u32 {
895 let idle = Duration::from_secs(AUTH_FAILURE_IDLE_SECS);
896 self.auth_failures
897 .lock()
898 .unwrap_or_else(std::sync::PoisonError::into_inner)
899 .get(&ip)
900 .filter(|(_, last)| last.elapsed() < idle)
901 .map(|(n, _)| *n)
902 .unwrap_or(0)
903 }
904
905 #[cfg(test)]
908 fn auth_failure_count_for_test(&self, ip: std::net::IpAddr) -> u32 {
909 self.auth_failure_count(ip)
910 }
911
912 fn wrap(&self, stream: TcpStream) -> std::io::Result<Conn> {
915 #[cfg(feature = "tls")]
916 if let Some(config) = &self.tls_config {
917 let session = rustls::ServerConnection::new(std::sync::Arc::clone(config))
918 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
919 return Ok(Conn::Tls(Box::new(rustls::StreamOwned::new(session, stream))));
920 }
921 Ok(Conn::Plain(stream))
922 }
923
924 fn handle(&self, stream: TcpStream) -> std::io::Result<()> {
925 let watchdog_stream = stream.try_clone()?;
931 let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
932 let watchdog_done = std::sync::Arc::clone(&done);
933 let watchdog = std::thread::spawn(move || {
934 std::thread::park_timeout(Duration::from_secs(REQUEST_DEADLINE_SECS));
935 if !watchdog_done.load(std::sync::atomic::Ordering::Acquire) {
936 let _ = watchdog_stream.shutdown(std::net::Shutdown::Both);
937 }
938 });
939 let raw = stream.try_clone()?;
940 let conn = self.wrap(stream)?;
941 let result = self.handle_request(&raw, conn);
942 done.store(true, std::sync::atomic::Ordering::Release);
943 watchdog.thread().unpark();
944 let _ = watchdog.join();
945 result
946 }
947
948 fn handle_request(&self, raw: &TcpStream, mut conn: Conn) -> std::io::Result<()> {
949 raw.set_read_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
954 raw.set_write_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
955 let mut reader = BufReader::new((&mut conn).take(MAX_CONN_BYTES));
956 let mut request_line = String::new();
957 reader.read_line(&mut request_line)?;
958 let mut parts = request_line.split_whitespace();
959 let method = parts.next().unwrap_or("").to_string();
960 let path = parts.next().unwrap_or("/").to_string();
961
962 let mut content_length = 0usize;
964 let mut bearer: Option<String> = None;
965 let mut origin: Option<String> = None;
966 let mut host: Option<String> = None;
967 let mut sso_identity_raw: Option<String> = None;
968 let mut sso_groups_raw: Option<String> = None;
969 #[cfg(feature = "oidc")]
970 let mut cookie_header: Option<String> = None;
971 let mut proxy_secret: Option<String> = None;
972 let mut header_bytes = 0usize;
973 let mut header_count = 0usize;
974 loop {
975 let mut line = String::new();
976 let n = reader.read_line(&mut line)?;
977 if n == 0 {
978 break; }
980 header_bytes += n;
981 header_count += 1;
982 if header_bytes > MAX_HEADER_BYTES || header_count > MAX_HEADERS {
983 return Err(std::io::Error::new(
984 std::io::ErrorKind::InvalidData,
985 "request headers too large",
986 ));
987 }
988 let l = line.trim();
989 if l.is_empty() {
990 break;
991 }
992 let low = l.to_ascii_lowercase();
993 if let Some(v) = low.strip_prefix("content-length:") {
994 content_length = v.trim().parse().unwrap_or(0);
995 }
996 if let Some(v) = low.strip_prefix("origin:") {
997 origin = Some(v.trim().to_string());
998 }
999 if let Some(v) = low.strip_prefix("host:") {
1000 host = Some(v.trim().to_string());
1001 }
1002 #[cfg(feature = "oidc")]
1003 if low.starts_with("cookie:") {
1004 if let Some((_, orig)) = l.split_once(':') {
1007 cookie_header = Some(orig.trim().to_string());
1008 }
1009 }
1010 if let Some(v) = low.strip_prefix("x-areev-proxy-secret:") {
1011 let _ = v;
1014 if let Some((_, orig)) = l.split_once(':') {
1015 proxy_secret = Some(orig.trim().to_string());
1016 }
1017 }
1018 if let Some(h) = &self.sso_header {
1019 if low.starts_with(&format!("{h}:")) {
1020 if let Some((_, orig)) = l.split_once(':') {
1021 sso_identity_raw = Some(orig.trim().to_string());
1022 }
1023 }
1024 }
1025 if let Some(h) = &self.sso_groups_header {
1026 if low.starts_with(&format!("{h}:")) {
1027 if let Some((_, orig)) = l.split_once(':') {
1028 sso_groups_raw = Some(orig.trim().to_string());
1029 }
1030 }
1031 }
1032 if low.starts_with("authorization:") {
1033 if let Some((_, v)) = l.split_once(':') {
1034 let v = v.trim();
1035 bearer = if let Some(t) =
1040 v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))
1041 {
1042 Some(t.trim().to_string())
1043 } else if let Some(b64) =
1044 v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))
1045 {
1046 basic_auth_password(b64.trim())
1047 } else {
1048 Some(v.to_string())
1049 };
1050 }
1051 }
1052 }
1053 if !self.allow_remote && host.as_deref().is_some_and(|h| !host_is_local(h)) {
1060 let payload = br#"{"ok":false,"error":"non-loopback Host rejected (use --allow-remote to serve remotely)"}"#;
1061 drop(reader);
1062 let mut out = conn;
1063 write!(
1064 out,
1065 "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1066 payload.len()
1067 )?;
1068 out.write_all(payload)?;
1069 return out.flush();
1070 }
1071
1072 let mut body = vec![0u8; content_length.min(1 << 20)];
1073 if content_length > 0 {
1074 reader.read_exact(&mut body)?;
1075 }
1076
1077 if method == "POST"
1084 && origin
1085 .as_deref()
1086 .is_some_and(|o| !origin_is_local(o) && !self.origin_is_allowed(o))
1087 {
1088 let payload = br#"{"ok":false,"error":"cross-origin request rejected"}"#;
1089 drop(reader);
1090 let mut out = conn;
1091 write!(
1092 out,
1093 "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1094 payload.len()
1095 )?;
1096 out.write_all(payload)?;
1097 return out.flush();
1098 }
1099
1100 let proxy_secret_presented = proxy_secret.is_some();
1105 let proxy_proved = match &proxy_secret {
1115 Some(presented) => {
1116 self.sso_secrets
1121 .iter()
1122 .fold(false, |acc, s| ct_eq(s.as_bytes(), presented.as_bytes()) | acc)
1123 }
1124 None => false,
1125 };
1126 let sso_identity: Option<String> = sso_identity_raw
1127 .filter(|_| proxy_proved)
1128 .as_deref()
1129 .and_then(sanitize_sso_identity);
1130 let sso_groups: Option<String> = sso_groups_raw.filter(|_| proxy_proved);
1131 #[cfg(feature = "oidc")]
1136 if self.oidc.is_some() && path.starts_with("/auth/") {
1137 let (status, extra, payload) =
1138 self.oidc_route(&method, &path, cookie_header.as_deref());
1139 drop(reader);
1140 let mut out = conn;
1141 write!(
1142 out,
1143 "HTTP/1.1 {status}\r\n{extra}Content-Length: {}\r\nConnection: close\r\n\r\n",
1144 payload.len()
1145 )?;
1146 out.write_all(payload.as_bytes())?;
1147 return out.flush();
1148 }
1149 #[cfg(feature = "oidc")]
1150 let oidc_principal = self.oidc_principal(cookie_header.as_deref());
1151 #[cfg(not(feature = "oidc"))]
1152 let oidc_principal: Option<String> = None;
1153
1154 let auth_configured =
1181 self.token.is_some() || self.credentials.is_some() || !self.sso_secrets.is_empty();
1182 let credential_presented = bearer.is_some() || proxy_secret_presented;
1183 if auth_configured && credential_presented && !proxy_proved {
1184 if let Ok(peer) = raw.peer_addr() {
1185 if self.auth_failure_count(peer.ip()) >= MAX_CONSECUTIVE_AUTH_FAILURES {
1186 let payload = format!(
1187 r#"{{"ok":false,"error":"too many failed authentications from this address - wait {} minutes, or restart the console to clear the counter"}}"#,
1188 AUTH_FAILURE_IDLE_SECS / 60
1189 );
1190 drop(reader);
1191 let mut out = conn;
1192 write!(
1193 out,
1194 "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nRetry-After: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1195 AUTH_FAILURE_IDLE_SECS,
1196 payload.len()
1197 )?;
1198 out.write_all(payload.as_bytes())?;
1199 return out.flush();
1200 }
1201 }
1202 }
1203
1204 let (status, ctype, payload) = self.route_full(
1205 &method,
1206 &path,
1207 &body,
1208 bearer.as_deref(),
1209 sso_identity.as_deref(),
1210 sso_groups.as_deref(),
1211 oidc_principal.as_deref(),
1212 );
1213
1214 if auth_configured && credential_presented {
1227 if let Ok(peer) = raw.peer_addr() {
1228 let ip = peer.ip();
1229 if status.starts_with("401") {
1230 let consecutive = self.note_auth_failure(ip);
1231 eprintln!("areev: console auth FAILED from {ip} ({consecutive} consecutive)");
1232 } else {
1233 self.reset_auth_failures(ip);
1234 }
1235 }
1236 }
1237
1238 let auth_challenge = if self.auth_all && status.starts_with("401") {
1241 "WWW-Authenticate: Basic realm=\"Areev console\", charset=\"UTF-8\"\r\n"
1242 } else {
1243 ""
1244 };
1245 drop(reader);
1246 let mut out = conn;
1247 write!(
1248 out,
1249 "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\n{auth_challenge}Content-Length: {}\r\nConnection: close\r\n\r\n",
1250 payload.len()
1251 )?;
1252 out.write_all(&payload)?;
1253 out.flush()
1254 }
1255
1256 fn runner(&self, principal: &str) -> areev_run::Runner {
1261 struct NoExec;
1262 impl areev_run::HostToolExecutor for NoExec {
1263 fn execute(
1264 &self,
1265 tool_name: &str,
1266 _h: &str,
1267 _i: &Value,
1268 _k: &str,
1269 ) -> areev_run::ExecResult {
1270 areev_run::ExecResult::Err {
1271 cause: areev_run::FailCause::ExecutorError,
1272 detail: format!("the console does not execute tools ('{tool_name}')"),
1273 }
1274 }
1275 }
1276 areev_run::Runner {
1277 facade: std::sync::Arc::clone(&self.facade),
1278 clock: std::sync::Arc::new(areev_run::SystemClock),
1279 executor: std::sync::Arc::new(NoExec),
1280 llm: None,
1281 observer: None,
1282 ns: self
1283 .facade
1284 .default_namespace()
1285 .unwrap_or("shared")
1286 .to_string(),
1287 principal: principal.to_string(),
1288 }
1289 }
1290
1291 #[cfg(test)]
1297 fn route(
1298 &self,
1299 method: &str,
1300 path: &str,
1301 body: &[u8],
1302 bearer: Option<&str>,
1303 sso_identity: Option<&str>,
1304 ) -> (&'static str, &'static str, Vec<u8>) {
1305 self.route_full(method, path, body, bearer, sso_identity, None, None)
1306 }
1307
1308 #[cfg(test)]
1311 fn route_with_groups(
1312 &self,
1313 method: &str,
1314 path: &str,
1315 body: &[u8],
1316 bearer: Option<&str>,
1317 sso_identity: Option<&str>,
1318 sso_groups: Option<&str>,
1319 ) -> (&'static str, &'static str, Vec<u8>) {
1320 self.route_full(method, path, body, bearer, sso_identity, sso_groups, None)
1321 }
1322
1323 #[allow(clippy::too_many_arguments)]
1330 fn route_full(
1331 &self,
1332 method: &str,
1333 path: &str,
1334 body: &[u8],
1335 bearer: Option<&str>,
1336 sso_identity: Option<&str>,
1337 sso_groups: Option<&str>,
1338 oidc_principal: Option<&str>,
1339 ) -> (&'static str, &'static str, Vec<u8>) {
1340 let mut shared_secret_ok = false;
1352 if let Some(tok) = &self.token {
1353 let guarded = self.auth_all || method == "POST";
1354 shared_secret_ok = bearer.is_some_and(|b| ct_eq(b.as_bytes(), tok.as_bytes()));
1355 let known = shared_secret_ok
1356 || sso_identity.is_some()
1357 || oidc_principal.is_some()
1358 || match (&self.credentials, bearer) {
1359 (Some(map), Some(b)) => map.resolve_for_memory(b, &self.db_label).is_ok(),
1360 _ => false,
1361 };
1362 if guarded && !known {
1363 return ("401 Unauthorized", "application/json",
1364 br#"{"ok":false,"error":"authentication required"}"#.to_vec());
1365 }
1366 } else if self.credentials.is_none()
1367 && sso_identity.is_none()
1368 && oidc_principal.is_none()
1369 && method == "POST"
1370 {
1371 let base = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
1377 let allowed = base == "/api/cal" && cal_body_is_read_only(body);
1378 if !allowed {
1379 return ("401 Unauthorized", "application/json",
1380 br#"{"ok":false,"error":"read-only console: restart areev ui with --auth <map> (per-principal credentials, attributable writes and approvals) or --token-env VAR (one shared secret, writes only) to enable writes"}"#.to_vec());
1381 }
1382 }
1383
1384 let _session = match (&self.credentials, shared_secret_ok) {
1391 (Some(map), false) => Some(RequestBinding::bind(&self.facade, map, bearer, &self.db_label)),
1392 _ => None,
1393 };
1394 let mut request_principal: Option<String> = match (&self.credentials, shared_secret_ok, bearer)
1399 {
1400 (Some(map), false, Some(b)) => map.resolve_for_memory(b, &self.db_label).ok().map(str::to_string),
1401 _ => None,
1402 };
1403 let mut principal_from_sso = false;
1413 let mut principal_from_group = false;
1417 let mut principal_from_oidc = false;
1423 let _oidc_session = match (&request_principal, oidc_principal, shared_secret_ok) {
1424 (None, Some(principal), false) => {
1425 principal_from_oidc = true;
1426 let binding = RequestBinding::bind_identity(&self.facade, principal);
1427 request_principal = Some(principal.to_string());
1428 Some(binding)
1429 }
1430 _ => None,
1431 };
1432 let _sso_session = match (&request_principal, sso_identity, shared_secret_ok) {
1433 (None, Some(identity), false) => {
1434 match self.resolve_sso_principal(identity, sso_groups) {
1435 Some((principal, from_group)) => {
1436 principal_from_sso = true;
1437 principal_from_group = from_group;
1438 let binding = RequestBinding::bind_identity(&self.facade, &principal);
1439 request_principal = Some(principal);
1440 Some(binding)
1441 }
1442 None => None,
1443 }
1444 }
1445 _ => None,
1446 };
1447
1448 let (path, query) = match path.split_once('?') {
1449 Some((p, q)) => (p, q),
1450 None => (path, ""),
1451 };
1452 let q = |key: &str| -> Option<String> {
1453 query.split('&').find_map(|kv| {
1454 let (k, v) = kv.split_once('=')?;
1455 (k == key).then(|| urldecode(v))
1456 })
1457 };
1458 match (method, path) {
1459 ("GET", "/") => (
1460 "200 OK",
1461 "text/html; charset=utf-8",
1462 CONSOLE_HTML
1463 .replace("{{DB}}", &html_escape(&self.db_label_display))
1464 .into_bytes(),
1465 ),
1466 ("POST", "/api/cal") => {
1467 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1468 let queryt = req.get("query").and_then(|v| v.as_str()).unwrap_or("");
1469 match self.executor.execute(queryt, &*self.facade) {
1470 Ok(res) => ok_json(json!({
1471 "ok": true,
1472 "result": res.result,
1473 "warnings": res.warnings,
1474 "statement": res.metadata.statement_type,
1475 "elapsed_ms": res.metadata.execution_time_ms,
1476 })),
1477 Err(e) => {
1478 let mut err = json!({
1481 "ok": false,
1482 "error": e.sanitize_for_client(),
1483 "code": e.code(),
1484 });
1485 if let Some(sp) = e.span() {
1486 err["span"] = json!({
1487 "start": sp.start, "end": sp.end,
1488 "line": sp.line, "col": sp.col,
1489 });
1490 }
1491 if let Some(hint) = e.suggestion() {
1492 err["suggestion"] = json!(hint);
1493 }
1494 if let Some(sc) = e.store_code() {
1497 err["store_code"] = json!(sc);
1498 }
1499 ok_json(err)
1500 }
1501 }
1502 }
1503 ("GET", "/api/stats") => {
1504 let stats = self.facade.with_store(|m| m.stats());
1505 match stats {
1506 Ok(s) => ok_json(json!({
1507 "db": self.db_label_display,
1508 "grains": s.grains, "current": s.current,
1509 "triples": s.triples, "terms": s.terms,
1510 "ops": s.ops, "events_indexed": s.events_indexed,
1511 })),
1512 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1513 }
1514 }
1515 ("GET", "/api/log") => {
1516 let since: i64 = q("since").and_then(|v| v.parse().ok()).unwrap_or(0);
1517 let limit: usize = q("limit").and_then(|v| v.parse().ok()).unwrap_or(100);
1518 match self.facade.with_store(|m| m.changes_since(since, limit)) {
1519 Ok(ops) => {
1520 let rows: Vec<Value> = ops
1521 .iter()
1522 .map(|o| {
1523 json!({
1524 "op_seq": o.op_seq, "hlc": o.hlc,
1525 "op": match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" },
1526 "hash": o.hash.to_hex(),
1527 })
1528 })
1529 .collect();
1530 ok_json(json!(rows))
1531 }
1532 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1533 }
1534 }
1535 ("GET", "/api/whoami") => {
1539 let a = self.facade.authz();
1540 ok_json(json!({
1541 "ok": true,
1542 "principal": a.principal(),
1543 "owner": a.is_owner(),
1544 "mode": if self.credentials.is_some() { "credentials" }
1545 else if self.token.is_some() { "token" }
1546 else { "open" },
1547 "identity_source": if principal_from_oidc { "oidc" }
1551 else if principal_from_group { "sso-group" }
1552 else if principal_from_sso { "sso" }
1553 else if request_principal.is_some() { "credential" }
1554 else { "none" },
1555 "may_approve": request_principal.is_some()
1556 && !principal_from_group
1557 && (!principal_from_sso || self.sso_approvals),
1558 }))
1559 }
1560 ("GET", "/api/config") => {
1561 let cfg = self.executor.config();
1566 let (index_text, embedder_dim, declared_embed, mut warnings) =
1567 self.facade.with_store(|m| {
1568 (
1569 m.index_text_enabled(),
1570 m.embedder_dim(),
1571 m.declared_embedding().map(|(mm, d)| (mm.to_string(), d)),
1572 m.open_warnings().to_vec(),
1573 )
1574 });
1575 if let Some((m, d)) = &declared_embed {
1576 if embedder_dim.is_none() {
1577 warnings.push(format!(
1578 "vector leg dormant: file expects {m}@{d}, no embedding backend installed"
1579 ));
1580 }
1581 }
1582 warnings.extend(self.facade.meta_warnings());
1586 let (anon_declared, anon_floor, anon_audit) = self.facade.with_store(|m| {
1590 (
1591 m.anon_declared(),
1592 m.anonymize_egress_floor(),
1593 m.anon_audit_counts(),
1594 )
1595 });
1596 let anonymization = json!({
1597 "policies": anon_declared
1598 .iter()
1599 .map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
1600 .collect::<Vec<_>>(),
1601 "floor": anon_floor,
1602 "audit_counts": anon_audit
1603 .iter()
1604 .map(|(ns, cat, n)| json!({"ns": ns, "category": cat, "count": n}))
1605 .collect::<Vec<_>>(),
1606 });
1607 ok_json(json!({
1608 "ok": true,
1609 "db": self.db_label_display,
1610 "warnings": warnings,
1611 "anonymization": anonymization,
1612 "file": {
1613 "text_index": index_text,
1614 "embedding": declared_embed.map(|(m, d)| json!({"model": m, "dim": d})),
1615 },
1616 "session": {
1617 "namespace": self.facade.session_namespace(),
1618 "mounts": self.facade.mount_aliases(),
1619 "is_owner": self.facade.authz().is_owner(),
1622 },
1623 "store": {
1624 "index_text": index_text,
1625 "embedder": embedder_dim.map(|d| json!({"dim": d})),
1626 },
1627 "recall": {
1628 "fusion": "rrf",
1629 "rrf_k0": areev_store::RRF_K0,
1630 "overfetch_factor": AreevFacade::RECALL_OVERFETCH,
1631 "legs": {
1632 "structural": true,
1633 "bm25": index_text,
1634 "vector": embedder_dim.is_some(),
1635 },
1636 },
1637 "executor": {
1638 "max_limit": cfg.max_limit,
1639 "default_limit": cfg.default_limit,
1640 "tier1_writes": cfg.tier1_enabled,
1641 "namespace_override": cfg.namespace_override,
1642 "user_id_override": cfg.user_id_override,
1643 },
1644 "server": {
1645 "auth_required": self.token.is_some(),
1646 "auth_all": self.auth_all,
1649 },
1650 "decide": self.decider.as_ref().map(|(chain, calibrated)| {
1655 json!({"chain": chain, "calibrated": calibrated})
1656 }),
1657 "persistence": "per-process (host-supplied at open) — not stored in the .db",
1658 }))
1659 }
1660 ("GET", "/api/browse") => {
1661 let limit: i64 = q("limit")
1667 .and_then(|v| v.parse().ok())
1668 .unwrap_or(500)
1669 .clamp(1, 2000);
1670 let built = self.facade.with_store(|m| -> Result<(i64, Vec<Value>), String> {
1671 let total = m.stats().map_err(|e| e.to_string())?.ops as i64;
1672 let after = (total - limit).max(0);
1673 let ops = m.changes_since(after, limit as usize).map_err(|e| e.to_string())?;
1674 let mut rows: Vec<Value> = Vec::with_capacity(ops.len());
1675 let mut idx: std::collections::HashMap<String, usize> =
1676 std::collections::HashMap::new();
1677 for o in &ops {
1678 let hex = o.hash.to_hex();
1679 let op_name = match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" };
1680 if o.op == 3 {
1681 match idx.get(&hex) {
1684 Some(&i) => { rows[i]["forgotten"] = json!(true); }
1685 None => rows.push(json!({
1686 "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc,
1687 "op": op_name, "forgotten": true,
1688 })),
1689 }
1690 continue;
1691 }
1692 if let Some(&i) = idx.get(&hex) {
1696 rows[i]["op"] = json!(op_name);
1697 rows[i]["op_seq"] = json!(o.op_seq);
1698 rows[i]["hlc"] = json!(o.hlc);
1699 continue;
1700 }
1701 let mut row = json!({
1702 "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc, "op": op_name,
1703 });
1704 match m.get(&o.hash) {
1705 Ok(g) => {
1706 row["type"] = json!(format!("{:?}", g.grain_type).to_lowercase());
1707 row["fields"] = serde_json::to_value(&g.fields).unwrap_or(Value::Null);
1708 }
1709 Err(_) => { row["missing"] = json!(true); }
1711 }
1712 idx.insert(hex, rows.len());
1713 rows.push(row);
1714 }
1715 let marks: Vec<(String, String)> = rows.iter()
1718 .filter(|r| r["op"] == "supersede")
1719 .filter_map(|r| {
1720 let old = r["fields"].get("derived_from")?.as_str()?;
1721 Some((old.to_string(), r["hash"].as_str()?.to_string()))
1722 })
1723 .collect();
1724 for (old, newer) in marks {
1725 if let Some(&i) = idx.get(&old) {
1726 rows[i]["superseded_by"] = json!(newer);
1727 }
1728 }
1729 rows.reverse();
1730 Ok((total, rows))
1731 });
1732 let ns_param = q("ns");
1749 let built = built.and_then(|(total, rows)| {
1750 let authz = self.facade.authz();
1751 let filter_ns = match ns_param.as_deref() {
1752 Some("*") => {
1753 if !authz.is_owner() {
1754 return Err("not authorized to browse all namespaces".to_string());
1755 }
1756 None
1757 }
1758 Some(ns) => {
1759 authz
1760 .check(areev_core::authz::Verb::Read, ns)
1761 .map_err(|e| e.to_string())?;
1762 Some(ns.to_string())
1763 }
1764 None => self.facade.session_namespace().map(|s| s.to_string()),
1765 };
1766 let Some(ns) = filter_ns else {
1767 return Ok((total, rows));
1768 };
1769 let kept = rows
1770 .into_iter()
1771 .filter(|r| {
1772 r["fields"]
1773 .get("namespace")
1774 .and_then(Value::as_str)
1775 .is_none_or(|got| got == ns)
1776 })
1777 .collect();
1778 Ok((total, kept))
1779 });
1780 match built {
1781 Ok((total, rows)) => ok_json(json!({"ok": true, "total_ops": total, "grains": rows})),
1782 Err(e) => ok_json(json!({"ok": false, "error": e})),
1783 }
1784 }
1785 ("GET", "/api/namespaces") => {
1786 let result = self.facade.with_store(|m| m.namespaces());
1791 match result {
1792 Ok(list) => {
1793 let authz = self.facade.authz();
1794 let owner = authz.is_owner();
1795 let arr: Vec<Value> = list
1796 .into_iter()
1797 .filter(|(ns, _)| {
1798 owner || authz.check(areev_core::authz::Verb::Read, ns).is_ok()
1799 })
1800 .map(|(ns, n)| json!({"namespace": ns, "count": n}))
1801 .collect();
1802 ok_json(json!({"ok": true, "namespaces": arr}))
1803 }
1804 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1805 }
1806 }
1807 ("GET", "/api/grain") => {
1808 let hash = q("hash").unwrap_or_default();
1809 match areev_core::error::Hash::from_hex(&hash)
1810 .and_then(|h| self.facade.get(&h))
1811 {
1812 Ok(g) => ok_json(json!({
1813 "hash": g.hash.to_hex(),
1814 "type": format!("{:?}", g.grain_type).to_lowercase(),
1815 "fields": g.fields,
1816 })),
1817 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1818 }
1819 }
1820 ("GET", "/api/anon/preview") => {
1821 let hash = q("hash").unwrap_or_default();
1824 match areev_core::error::Hash::from_hex(&hash)
1825 .and_then(|h| self.facade.with_store(|m| m.anon_preview(&h)))
1826 {
1827 Ok(g) => ok_json(json!({
1828 "hash": g.hash.to_hex(),
1829 "type": format!("{:?}", g.grain_type).to_lowercase(),
1830 "fields": g.fields,
1831 })),
1832 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1833 }
1834 }
1835 ("GET", "/api/verify") => match self.facade.with_store(|m| m.verify()) {
1836 Ok(r) => ok_json(json!({
1837 "integrity": r.integrity, "grains": r.grains,
1838 "hash_mismatches": r.hash_mismatches, "undecodable": r.undecodable,
1839 })),
1840 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1841 },
1842 ("GET", "/api/run/list") => {
1846 let runner = self.runner("console:read");
1854 let ns = q("ns").filter(|s| !s.is_empty() && s.as_str() != "*");
1855 let limit = q("limit")
1856 .and_then(|v| v.parse::<usize>().ok())
1857 .unwrap_or(50)
1858 .clamp(1, 500);
1859 let offset = q("offset").and_then(|v| v.parse::<usize>().ok()).unwrap_or(0);
1860 match runner.list_runs(ns.as_deref(), offset, limit) {
1861 Ok(page) => {
1862 let rows: Vec<Value> = page
1863 .runs
1864 .iter()
1865 .map(|r| {
1866 serde_json::json!({
1867 "run_id": r.run_id,
1868 "ns": r.ns,
1869 "outcome": r.outcome,
1870 })
1871 })
1872 .collect();
1873 ok_json(serde_json::json!({
1874 "ok": true,
1875 "runs": rows,
1876 "total": page.total,
1877 "truncated": page.truncated,
1878 "offset": page.offset,
1879 "limit": page.limit,
1880 "unattributed": page.unattributed,
1881 }))
1882 }
1883 Err(e) => run_api_err(&e.to_string()),
1884 }
1885 }
1886 ("GET", "/api/run/inspect") => {
1887 let Some(run_id) = q("run_id") else {
1888 return run_api_err("run_id required");
1889 };
1890 match self.runner("console:read").inspect(&run_id) {
1894 Ok(report) => {
1895 let mut v = serde_json::to_value(&report).unwrap_or(Value::Null);
1896 if let Some(o) = v.as_object_mut() {
1897 o.insert("ok".into(), Value::Bool(true));
1898 }
1899 ok_json(v)
1900 }
1901 Err(e) => run_api_err(&e.to_string()),
1902 }
1903 }
1904 ("GET", "/api/run/shadow") => {
1909 let Some(runs) = q("runs") else { return run_api_err("runs required (comma-separated)") };
1910 let Some(plan) = q("plan") else { return run_api_err("plan required (a Workflow hash)") };
1911 let ids: Vec<String> = runs.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
1912 let h = match areev_core::error::Hash::from_hex(&plan) {
1913 Ok(h) => h,
1914 Err(e) => return run_api_err(&e.to_string()),
1915 };
1916 match self.runner("console:read").shadow_plan(&ids, &areev_run::PlanCandidate::Hash(h)) {
1917 Ok(report) => {
1918 let mut v = serde_json::to_value(&report).unwrap_or(Value::Null);
1919 if let Some(o) = v.as_object_mut() {
1920 o.insert("ok".into(), Value::Bool(true));
1921 }
1922 ok_json(v)
1923 }
1924 Err(e) => run_api_err(&e.to_string()),
1925 }
1926 }
1927 ("POST", "/api/run/shadow") => {
1928 let v: Value = match serde_json::from_slice(body) {
1929 Ok(v) => v,
1930 Err(e) => return run_api_err(&e.to_string()),
1931 };
1932 let ids: Vec<String> = match v.get("runs") {
1933 Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(),
1934 Some(Value::String(csv)) => csv.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
1935 _ => return run_api_err("runs required (array or comma-separated)"),
1936 };
1937 let candidate = match (v.get("plan").and_then(Value::as_str), v.get("plan_body").and_then(Value::as_object)) {
1938 (Some(_), Some(_)) => return run_api_err("give plan or plan_body, not both"),
1939 (Some(h), None) => match areev_core::error::Hash::from_hex(h) {
1940 Ok(h) => areev_run::PlanCandidate::Hash(h),
1941 Err(e) => return run_api_err(&e.to_string()),
1942 },
1943 (None, Some(body)) => areev_run::PlanCandidate::Body(body.clone()),
1944 (None, None) => return run_api_err("plan (hash) or plan_body (object) required"),
1945 };
1946 match self.runner("console:read").shadow_plan(&ids, &candidate) {
1947 Ok(report) => {
1948 let mut v = serde_json::to_value(&report).unwrap_or(Value::Null);
1949 if let Some(o) = v.as_object_mut() {
1950 o.insert("ok".into(), Value::Bool(true));
1951 }
1952 ok_json(v)
1953 }
1954 Err(e) => run_api_err(&e.to_string()),
1955 }
1956 }
1957 ("POST", "/api/run/respond") => {
1958 let Some(responder) = request_principal.clone() else {
1963 return ("403 Forbidden", "application/json",
1964 br#"{"ok":false,"error":"run.respond requires a per-principal credential (areev ui --auth <map>); shared-token or anonymous approvals are refused - the approver's identity IS the audit record"}"#.to_vec());
1965 };
1966 if principal_from_sso && !self.sso_approvals {
1974 return ("403 Forbidden", "application/json",
1975 br#"{"ok":false,"error":"run.respond refuses proxy-asserted SSO identities: the identity header is trusted only via a shared proxy secret, which whoever holds it can use to assert any approver. Approve with a per-principal credential (areev ui --auth <map>), or accept the trade-off explicitly with --sso-approvals allow"}"#.to_vec());
1976 }
1977 if principal_from_group {
1982 return ("403 Forbidden", "application/json",
1983 br#"{"ok":false,"error":"run.respond refuses group-derived principals: a role is not an approver, and an audit record naming one identifies nobody. Grant this person a principal of their own, or give them a per-principal credential"}"#.to_vec());
1984 }
1985 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1986 let (Some(run_id), Some(ask)) = (
1987 req.get("run_id").and_then(Value::as_str),
1988 req.get("tool_call_id").and_then(Value::as_str),
1989 ) else {
1990 return run_api_err("run_id and tool_call_id required");
1991 };
1992 let result = req.get("result").cloned().unwrap_or(Value::Null);
1993 let is_error = req.get("is_error").and_then(Value::as_bool).unwrap_or(false);
1994 let runner = self.runner(&responder);
1995 match runner.respond(run_id, ask, result, is_error, &responder) {
1996 Ok(()) => ok_json(serde_json::json!({
1997 "ok": true, "responded": ask, "responder": responder,
1998 "note": "resume the run to spend the compute",
1999 })),
2000 Err(e) => run_api_err(&e.to_string()),
2001 }
2002 }
2003 ("POST", "/api/run/cancel") => {
2004 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2007 let Some(run_id) = req.get("run_id").and_then(Value::as_str) else {
2008 return run_api_err("run_id required");
2009 };
2010 let because = req.get("because").and_then(Value::as_str).unwrap_or("canceled via console");
2011 let who = request_principal.clone().unwrap_or_else(|| "console:shared".into());
2012 let runner = self.runner(&who);
2013 match runner.cancel(run_id, &who, because) {
2014 Ok(()) => ok_json(serde_json::json!({"ok": true, "canceled": run_id})),
2015 Err(e) => run_api_err(&e.to_string()),
2016 }
2017 }
2018 ("GET", "/api/loop/recommendations") => {
2019 let status = q("status").and_then(|s| status_from_str(&s));
2020 let sub = BorrowedSubstrate::new(&self.facade);
2021 match areev_loop_adapter::visible_recommendations(
2023 &self.engine(),
2024 &sub,
2025 &self.facade.authz(),
2026 status,
2027 ) {
2028 Ok(recs) => ok_json(json!({
2029 "ok": true,
2030 "recommendations": recs.iter().map(rec_json).collect::<Vec<_>>(),
2031 })),
2032 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2033 }
2034 }
2035 ("GET", "/api/loop/health") => {
2036 let sub = BorrowedSubstrate::new(&self.facade);
2037 match self.engine().health(&sub, now_ms()) {
2038 Ok(h) => ok_json(json!({"ok": true, "health": h})),
2039 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2040 }
2041 }
2042 ("GET", "/api/loop/outcomes") => {
2043 let sub = BorrowedSubstrate::new(&self.facade);
2044 match self.engine().outcomes(&sub) {
2045 Ok(o) => ok_json(json!({"ok": true, "outcomes": o})),
2046 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2047 }
2048 }
2049 ("GET", "/api/loop/analyzers") => {
2050 let sub = BorrowedSubstrate::new(&self.facade);
2053 match self.engine().analyzer_settings(&sub) {
2054 Ok(list) => ok_json(json!({"ok": true, "analyzers": list})),
2055 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2056 }
2057 }
2058 ("GET", "/api/loop/telemetry") => {
2059 let mode = self.facade.with_store(|m| m.telemetry_mode());
2062 if mode == areev_store::TelemetryMode::Off {
2063 ok_json(json!({"ok": true, "enabled": false}))
2064 } else {
2065 let access = self.facade.with_store(|m| m.telemetry_access_stats(None));
2066 let queries = self.facade.with_store(|m| m.telemetry_query_stats(None));
2067 let budget = self.facade.with_store(|m| m.telemetry_budget_stats());
2068 match (access, queries, budget) {
2069 (Ok(mut a), Ok(mut q), Ok(b)) => {
2070 a.sort_by_key(|x| std::cmp::Reverse(x.recall_count));
2072 a.truncate(200);
2073 q.sort_by_key(|x| std::cmp::Reverse(x.run_count));
2074 q.truncate(200);
2075 ok_json(json!({
2076 "ok": true,
2077 "enabled": true,
2078 "mode": mode.as_str(),
2079 "access": a.iter().map(|x| json!({
2080 "hash": x.hash, "recall_count": x.recall_count, "last_ms": x.last_ms,
2081 })).collect::<Vec<_>>(),
2082 "queries": q.iter().map(|x| json!({
2083 "sample": x.sample, "run_count": x.run_count,
2084 "empty_count": x.empty_count,
2085 })).collect::<Vec<_>>(),
2086 "budget": {
2087 "sample_count": b.sample_count,
2088 "overflow_count": b.overflow_count,
2089 },
2090 }))
2091 }
2092 _ => ok_json(json!({"ok": false, "error": "telemetry read failed"})),
2093 }
2094 }
2095 }
2096 ("POST", "/api/loop/run") => {
2097 let authz = self.facade.authz();
2098 if !authz.is_owner()
2099 && !authz.allows(areev_core::authz::Verb::LoopRun, areev_loop::LOOP_NS)
2100 {
2101 return ("403 Forbidden", "application/json",
2102 br#"{"ok":false,"error":"AUT-E001: this session lacks loop.run"}"#.to_vec());
2103 }
2104 let mut sub = BorrowedSubstrate::new(&self.facade);
2105 let actor = if self.credentials.is_some() {
2110 authz.principal().to_string()
2111 } else {
2112 serde_json::from_slice::<Value>(body)
2113 .ok()
2114 .and_then(|v| v.get("actor").and_then(Value::as_str).map(str::to_string))
2115 .unwrap_or_else(|| "user:console".to_string())
2116 };
2117 let opts = RunOptions { triggering_actor: Some(actor), ..Default::default() };
2118 match self.engine().run(&mut sub, &opts, now_ms()) {
2119 Ok(res) => ok_json(json!({"ok": true, "run": res})),
2120 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2121 }
2122 }
2123 ("POST", "/api/loop/review") => self.loop_review(body),
2124 ("POST", "/api/loop/apply") => self.loop_apply(body),
2125 ("POST", "/api/loop/rollback") => self.loop_rollback(body),
2126 ("POST", "/api/loop/config") => self.loop_config(body),
2127 ("POST", "/api/loop/replay") => self.loop_replay(body),
2128 ("POST", "/api/anon/config") => self.anon_config(body),
2129 _ => (
2130 "404 Not Found",
2131 "application/json",
2132 br#"{"ok":false,"error":"not found"}"#.to_vec(),
2133 ),
2134 }
2135 }
2136
2137 fn loop_review(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2142 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2143 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
2144 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
2145 let bound = self.facade.authz();
2146 let actor = if self.credentials.is_some() {
2147 bound.principal().to_string()
2148 } else {
2149 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
2150 };
2151 let actor = actor.as_str();
2152 let decision = if req.get("decision").and_then(Value::as_str) == Some("reject") {
2153 Decision::Reject
2154 } else {
2155 Decision::Approve
2156 };
2157 let mut sub = BorrowedSubstrate::new(&self.facade);
2158 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
2159 let observer = areev_loop_adapter::observer_for_principal(actor);
2160 match self.engine().review(
2161 &mut sub, hash, decision, actor, observer, &scopes, because, now_ms(),
2162 ) {
2163 Ok(()) => ok_json(json!({"ok": true})),
2164 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2165 }
2166 }
2167
2168 fn loop_apply(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2169 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2170 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
2171 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
2172 let bound = self.facade.authz();
2173 let actor = if self.credentials.is_some() {
2174 bound.principal().to_string()
2175 } else {
2176 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
2177 };
2178 let actor = actor.as_str();
2179 let allow_destructive = req.get("allow_destructive").and_then(Value::as_bool).unwrap_or(false);
2180 let mut sub = BorrowedSubstrate::new(&self.facade);
2181 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
2182 let observer = areev_loop_adapter::observer_for_principal(actor);
2183 let engine = self.engine();
2184 let gating = match req.get("gating_run").and_then(Value::as_str) {
2188 Some(run_id) => match engine.gating_evidence(&sub, hash, run_id) {
2189 Ok(g) => Some(g),
2190 Err(e) => {
2191 return ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()}))
2192 }
2193 },
2194 None => None,
2195 };
2196 let applied = match &gating {
2197 Some(g) => engine.apply_gated(
2198 &mut sub, hash, actor, observer, &scopes, because, allow_destructive, g, now_ms(),
2199 ),
2200 None => engine.apply(
2201 &mut sub, hash, actor, observer, &scopes, because, allow_destructive, now_ms(),
2202 ),
2203 };
2204 match applied {
2205 Ok(applied) => ok_json(json!({"ok": true, "rollbackable": applied.rollbackable})),
2206 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2207 }
2208 }
2209
2210 fn loop_rollback(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2211 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2212 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
2213 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
2214 let bound = self.facade.authz();
2215 let actor = if self.credentials.is_some() {
2216 bound.principal().to_string()
2217 } else {
2218 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
2219 };
2220 let actor = actor.as_str();
2221 let mut sub = BorrowedSubstrate::new(&self.facade);
2222 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
2223 let observer = areev_loop_adapter::observer_for_principal(actor);
2224 match self.engine().rollback(
2225 &mut sub, hash, actor, observer, &scopes, because, now_ms(),
2226 ) {
2227 Ok(()) => ok_json(json!({"ok": true})),
2228 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2229 }
2230 }
2231
2232 fn loop_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2238 let id = serde_json::from_slice::<Value>(body)
2239 .ok()
2240 .and_then(|v| v.get("analyzer_id").and_then(Value::as_str).map(str::to_string))
2241 .unwrap_or_default();
2242 let update: areev_loop::AnalyzerConfigUpdate = serde_json::from_slice(body).unwrap_or_default();
2244 let mut sub = BorrowedSubstrate::new(&self.facade);
2245 match self.engine().set_analyzer_config(&mut sub, &id, update, &areev_loop_adapter::scopes_for(&self.facade.authz())) {
2246 Ok(cfg) => ok_json(json!({"ok": true, "config": cfg})),
2247 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2248 }
2249 }
2250}
2251
2252impl UiServer {
2253 fn loop_replay(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2259 let req = match serde_json::from_slice::<areev_loop::replay::ReplayRequest>(body) {
2260 Ok(r) => r,
2261 Err(e) => return ok_json(json!({"ok": false, "error": format!("replay request: {e}")})),
2262 };
2263 let (candidate, opts) = match req.resolve(now_ms()) {
2264 Ok(v) => v,
2265 Err(e) => return ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2266 };
2267 let sub = BorrowedSubstrate::new(&self.facade);
2268 match self.engine().replay(&sub, &candidate, &opts) {
2269 Ok(report) => ok_json(json!({"ok": true, "replay": report})),
2270 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2271 }
2272 }
2273
2274 fn anon_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2279 let v: Value = match serde_json::from_slice(body) {
2280 Ok(v) => v,
2281 Err(e) => return ok_json(json!({"ok": false, "error": e.to_string()})),
2282 };
2283 let Some(ns) = v.get("ns").and_then(Value::as_str).filter(|n| !n.trim().is_empty())
2284 else {
2285 return ok_json(json!({"ok": false, "error": "ns is required"}));
2286 };
2287 let result = if v.get("clear").and_then(Value::as_bool) == Some(true) {
2288 self.facade.with_store(|m| m.clear_anon_policy(ns))
2289 } else {
2290 match v.get("policy") {
2291 Some(policy) => {
2292 let policy_json = policy.to_string();
2293 self.facade.with_store(|m| m.set_anon_policy(ns, &policy_json))
2294 }
2295 None => {
2296 return ok_json(json!({"ok": false, "error": "policy (object) or clear: true is required"}));
2297 }
2298 }
2299 };
2300 match result {
2301 Ok(()) => {
2302 let declared = self.facade.with_store(|m| m.anon_declared());
2303 ok_json(json!({"ok": true, "policies": declared
2304 .iter()
2305 .map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
2306 .collect::<Vec<_>>()}))
2307 }
2308 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
2309 }
2310 }
2311}
2312
2313fn host_is_local(host_port: &str) -> bool {
2317 let host = if let Some(h) = host_port.strip_prefix('[') {
2318 h.split(']').next().unwrap_or("")
2319 } else {
2320 host_port.split(':').next().unwrap_or("")
2321 };
2322 matches!(host, "127.0.0.1" | "localhost" | "::1")
2323}
2324
2325fn origin_is_local(origin: &str) -> bool {
2327 let rest = origin
2328 .strip_prefix("http://")
2329 .or_else(|| origin.strip_prefix("https://"));
2330 let Some(rest) = rest else { return false };
2331 let host_port = rest.split('/').next().unwrap_or("");
2332 host_is_local(host_port)
2333}
2334
2335fn normalize_origin(origin: &str) -> String {
2341 let trimmed = origin.trim();
2342 trimmed.strip_suffix('/').unwrap_or(trimmed).to_ascii_lowercase()
2343}
2344
2345pub fn redact_dsn(label: &str) -> String {
2366 let Some(scheme_end) = label.find("://") else {
2367 return label.to_string();
2368 };
2369 let authority_start = scheme_end + 3;
2370 let rest = &label[authority_start..];
2371 let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
2372 let authority = &rest[..authority_len];
2373 let Some(at) = authority.rfind('@') else {
2374 return label.to_string();
2375 };
2376 let userinfo = &authority[..at];
2377 let Some(colon) = userinfo.find(':') else {
2378 return label.to_string();
2379 };
2380 let user = &userinfo[..colon];
2381 let mut out = String::with_capacity(label.len());
2382 out.push_str(&label[..authority_start]);
2383 out.push_str(user);
2384 out.push_str(":***@");
2385 out.push_str(&authority[at + 1..]);
2386 out.push_str(&rest[authority_len..]);
2387 out
2388}
2389
2390fn ok_json(v: Value) -> (&'static str, &'static str, Vec<u8>) {
2391 ("200 OK", "application/json", v.to_string().into_bytes())
2392}
2393
2394fn run_api_err(msg: &str) -> (&'static str, &'static str, Vec<u8>) {
2395 (
2396 "400 Bad Request",
2397 "application/json",
2398 serde_json::json!({"ok": false, "error": msg}).to_string().into_bytes(),
2399 )
2400}
2401
2402fn cal_body_is_read_only(body: &[u8]) -> bool {
2409 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2410 let q = req.get("query").and_then(Value::as_str).unwrap_or("");
2411 areev_cal::classify::query_is_read_only(q)
2412}
2413
2414fn status_from_str(s: &str) -> Option<RecStatus> {
2415 match s {
2416 "pending" => Some(RecStatus::Pending),
2417 "approved" => Some(RecStatus::Approved),
2418 "rejected" => Some(RecStatus::Rejected),
2419 "applied" => Some(RecStatus::Applied),
2420 "rolled_back" => Some(RecStatus::RolledBack),
2421 "expired" => Some(RecStatus::Expired),
2422 "withdrawn" => Some(RecStatus::Withdrawn),
2423 _ => None, }
2425}
2426
2427fn rec_json(r: &areev_loop::Recommendation) -> Value {
2428 let mut v = json!({
2429 "hash": r.hash,
2430 "status": r.status.as_str(),
2431 "severity": r.severity.as_str(),
2432 "analyzer": r.analyzer,
2433 "summary": r.summary.render(),
2434 "target_ref": r.target_ref,
2435 "destructive": r.destructive,
2436 "rollbackable": r.rollbackable,
2437 "evidence": r.evidence,
2438 "evalset_hash": r.evalset_hash,
2441 });
2442 if !r.near_duplicate_of.is_empty() {
2446 v["near_duplicate_of"] = json!(r.near_duplicate_of);
2447 }
2448 if let Some(replay) = &r.replay {
2450 v["replay"] = replay.clone();
2451 }
2452 v
2453}
2454
2455fn urldecode(s: &str) -> String {
2456 let mut out = Vec::new();
2457 let b = s.as_bytes();
2458 let mut i = 0;
2459 while i < b.len() {
2460 match b[i] {
2461 b'%' if i + 2 < b.len() + 1 && i + 2 < b.len() + 1 => {
2462 if i + 2 < b.len() + 1 && i + 2 < b.len() {
2463 let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
2464 if let Ok(v) = u8::from_str_radix(hex, 16) {
2465 out.push(v);
2466 i += 3;
2467 continue;
2468 }
2469 }
2470 out.push(b[i]);
2471 i += 1;
2472 }
2473 b'+' => {
2474 out.push(b' ');
2475 i += 1;
2476 }
2477 c => {
2478 out.push(c);
2479 i += 1;
2480 }
2481 }
2482 }
2483 String::from_utf8_lossy(&out).to_string()
2484}
2485
2486fn html_escape(s: &str) -> String {
2487 s.replace('&', "&").replace('<', "<").replace('>', ">")
2488}
2489
2490fn ct_eq(a: &[u8], b: &[u8]) -> bool {
2493 if a.len() != b.len() {
2494 return false;
2495 }
2496 let mut diff = 0u8;
2497 for (x, y) in a.iter().zip(b.iter()) {
2498 diff |= x ^ y;
2499 }
2500 diff == 0
2501}
2502
2503fn base64_decode(s: &str) -> Option<Vec<u8>> {
2507 areev_core::b64::decode(s)
2508}
2509
2510fn basic_auth_password(b64: &str) -> Option<String> {
2514 let text = String::from_utf8(base64_decode(b64)?).ok()?;
2515 text.split_once(':').map(|(_user, pass)| pass.to_string())
2516}
2517
2518#[cfg(test)]
2519mod loop_route_tests {
2520 use super::UiServer;
2521 use areev_cal::AreevFacade;
2522 use areev_core::types::{Fact, Grain};
2523 use areev_store::Areev;
2524
2525 fn server(auth: Option<&str>) -> UiServer {
2526 let dir = tempfile::tempdir().unwrap();
2527 let path = dir.path().join("t.db");
2528 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2529 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
2531 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
2532 std::mem::forget(dir); let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2534 let s = UiServer::new(facade, "test".into());
2535 match auth {
2536 Some(t) => s.with_auth(t.to_string()),
2537 None => s,
2538 }
2539 }
2540
2541 fn text(r: &(&str, &str, Vec<u8>)) -> String {
2542 String::from_utf8_lossy(&r.2).to_string()
2543 }
2544
2545 #[test]
2546 fn token_less_console_is_read_only() {
2547 let s = server(None);
2548 assert!(s.route("POST", "/api/loop/run", b"{}", None, None).0.starts_with("401"));
2550 let w = s.route("POST", "/api/cal", br#"{"query":"ADD fact SET subject=\"x\""}"#, None, None);
2552 assert!(w.0.starts_with("401"), "write CAL must be 401: {}", text(&w));
2553 let r = s.route("POST", "/api/cal", br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#, None, None);
2555 assert!(r.0.starts_with("200"), "read CAL allowed: {}", text(&r));
2556 assert!(s.route("GET", "/api/loop/recommendations", b"", None, None).0.starts_with("200"));
2558 }
2559
2560 #[test]
2561 fn telemetry_endpoint_is_an_open_read() {
2562 let s = server(None);
2563 let r = s.route("GET", "/api/loop/telemetry", b"", None, None);
2564 assert!(r.0.starts_with("200"), "telemetry read open: {}", text(&r));
2565 let body = text(&r);
2566 assert!(body.contains("\"ok\":true"), "{body}");
2567 assert!(body.contains("\"enabled\":false"), "{body}");
2569 }
2570
2571 #[test]
2572 fn authenticated_run_review_apply_roundtrip() {
2573 let s = server(Some("tok"));
2574 let run = s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
2575 assert!(run.0.starts_with("200"), "run: {}", text(&run));
2576 assert!(text(&run).contains("\"ran\""));
2577
2578 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
2579 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
2580 let recs = v["recommendations"].as_array().unwrap();
2581 assert!(!recs.is_empty(), "at least one recommendation");
2582 let hash = recs[0]["hash"].as_str().unwrap().to_string();
2583
2584 let rev = s.route(
2585 "POST",
2586 "/api/loop/review",
2587 format!(r#"{{"hash":"{hash}","decision":"approve","because":"ok"}}"#).as_bytes(),
2588 Some("tok"),
2589 None,
2590 );
2591 assert!(text(&rev).contains("\"ok\":true"), "review: {}", text(&rev));
2592
2593 let ap = s.route(
2594 "POST",
2595 "/api/loop/apply",
2596 format!(r#"{{"hash":"{hash}","because":"go"}}"#).as_bytes(),
2597 Some("tok"),
2598 None,
2599 );
2600 assert!(text(&ap).contains("\"ok\":true"), "apply: {}", text(&ap));
2601 }
2602
2603 #[test]
2604 fn config_edit_toggles_analyzer_via_console() {
2605 let s = server(Some("tok"));
2606 assert!(s.route("POST", "/api/loop/config", b"{}", None, None).0.starts_with("401"));
2608 assert!(s.route("POST", "/api/loop/replay", b"{}", None, None).0.starts_with("401"));
2611 let (status, _ct, body) = s.route("POST", "/api/loop/replay", br#"{"window":"7d","step":"1d"}"#, Some("tok"), None);
2612 assert!(status.starts_with("200"), "{status}");
2613 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
2614 assert_eq!(v["ok"], true, "{v}");
2615 for key in ["incumbent", "candidate", "steps", "not_replayed", "matching"] {
2616 assert!(v["replay"].get(key).is_some(), "replay report lacks {key}: {v}");
2617 }
2618 assert!(v["replay"]["incumbent"]["total"]["findings"].is_u64(), "{v}");
2619 let (_status, _ct, body) = s.route("POST", "/api/loop/replay", br#"{"analyzers": {}}"#, Some("tok"), None);
2620 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
2621 assert_eq!(v["ok"], false, "unknown keys are refused: {v}");
2622
2623 let list = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
2625 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
2626 let id = v["analyzers"]
2627 .as_array()
2628 .unwrap()
2629 .iter()
2630 .find(|x| x["enabled"] == true)
2631 .expect("some analyzer is on")["id"]
2632 .as_str()
2633 .unwrap()
2634 .to_string();
2635
2636 let post = s.route(
2638 "POST",
2639 "/api/loop/config",
2640 format!(r#"{{"analyzer_id":"{id}","enabled":false}}"#).as_bytes(),
2641 Some("tok"),
2642 None,
2643 );
2644 assert!(text(&post).contains("\"ok\":true"), "config: {}", text(&post));
2645
2646 let list2 = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
2648 let v2: serde_json::Value = serde_json::from_slice(&list2.2).unwrap();
2649 let now = v2["analyzers"]
2650 .as_array()
2651 .unwrap()
2652 .iter()
2653 .find(|x| x["id"] == id.as_str())
2654 .unwrap();
2655 assert_eq!(now["enabled"], false, "toggled off and persisted");
2656 }
2657
2658 #[test]
2659 fn self_approval_surfaces_lop_code() {
2660 let s = server(Some("tok"));
2661 s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
2662 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
2663 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
2664 let rec = &v["recommendations"].as_array().unwrap()[0];
2665 let hash = rec["hash"].as_str().unwrap();
2666 let analyzer = rec["analyzer"].as_str().unwrap();
2667 let creator = format!("engine:{analyzer}");
2668 let rev = s.route(
2670 "POST",
2671 "/api/loop/review",
2672 format!(r#"{{"hash":"{hash}","decision":"approve","because":"self","actor":"{creator}"}}"#).as_bytes(),
2673 Some("tok"),
2674 None,
2675 );
2676 assert!(text(&rev).contains("LOP-E021"), "self-approval blocked: {}", text(&rev));
2677 }
2678}
2679
2680#[cfg(test)]
2681mod security_tests {
2682 use super::{base64_decode, basic_auth_password, ct_eq, normalize_origin, redact_dsn};
2683
2684 #[test]
2685 fn ct_eq_matches_only_equal() {
2686 assert!(ct_eq(b"secret-token", b"secret-token"));
2687 assert!(!ct_eq(b"secret-token", b"secret-toker"));
2688 assert!(!ct_eq(b"secret", b"secret-token")); assert!(ct_eq(b"", b""));
2690 }
2691
2692 #[test]
2693 fn base64_decode_roundtrips_known_vectors() {
2694 assert_eq!(base64_decode("").unwrap(), b"");
2696 assert_eq!(base64_decode("Zg==").unwrap(), b"f");
2697 assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
2698 assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
2699 assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
2700 assert_eq!(base64_decode("Zg").unwrap(), b"f");
2702 assert!(base64_decode("****").is_none());
2704 }
2705
2706 #[test]
2707 fn basic_auth_extracts_password_ignoring_username() {
2708 assert_eq!(basic_auth_password("ZGVqYTpzM2NyZXQ=").as_deref(), Some("s3cret"));
2710 assert_eq!(basic_auth_password("OnMzY3JldA==").as_deref(), Some("s3cret"));
2711 assert_eq!(basic_auth_password("dTphOmI=").as_deref(), Some("a:b"));
2714 assert_eq!(basic_auth_password("bm9jb2xvbg=="), None); assert_eq!(basic_auth_password("****"), None); }
2718
2719 #[test]
2722 fn redact_dsn_hides_a_password() {
2723 assert_eq!(
2724 redact_dsn("postgresql://rounic:SUPERSECRET@pg-x:5432/rounic?sslmode=verify-full&schema=desk_invoice"),
2725 "postgresql://rounic:***@pg-x:5432/rounic?sslmode=verify-full&schema=desk_invoice",
2726 );
2727 }
2728
2729 #[test]
2730 fn redact_dsn_leaves_no_password_alone() {
2731 assert_eq!(redact_dsn("postgres://user@host:5432/db"), "postgres://user@host:5432/db");
2732 }
2733
2734 #[test]
2735 fn redact_dsn_leaves_no_userinfo_alone() {
2736 assert_eq!(redact_dsn("postgres://host:5432/db"), "postgres://host:5432/db");
2737 }
2738
2739 #[test]
2740 fn redact_dsn_leaves_a_plain_file_path_alone() {
2741 assert_eq!(redact_dsn("demo.db"), "demo.db");
2742 assert_eq!(redact_dsn("/var/lib/areev/demo.db"), "/var/lib/areev/demo.db");
2743 }
2744
2745 #[test]
2746 fn redact_dsn_hides_a_percent_encoded_password() {
2747 assert_eq!(
2750 redact_dsn("postgres://user:pa%40ss@host:5432/db"),
2751 "postgres://user:***@host:5432/db",
2752 );
2753 }
2754
2755 #[test]
2756 fn redact_dsn_hides_a_password_with_a_literal_at_sign() {
2757 assert_eq!(
2760 redact_dsn("postgres://user:p@ss@host:5432/db"),
2761 "postgres://user:***@host:5432/db",
2762 );
2763 }
2764
2765 #[test]
2766 fn redact_dsn_ignores_colon_and_at_in_the_query_string() {
2767 assert_eq!(
2768 redact_dsn("postgres://user:pass@host:5432/db?options=-c%20foo:bar@baz"),
2769 "postgres://user:***@host:5432/db?options=-c%20foo:bar@baz",
2770 );
2771 }
2772
2773 #[test]
2774 fn redact_dsn_handles_empty_string() {
2775 assert_eq!(redact_dsn(""), "");
2776 }
2777
2778 #[test]
2781 fn normalize_origin_lowercases_and_drops_one_trailing_slash() {
2782 assert_eq!(normalize_origin("https://Console.Example.com"), "https://console.example.com");
2783 assert_eq!(normalize_origin("https://console.example.com/"), "https://console.example.com");
2784 assert_eq!(
2785 normalize_origin("HTTPS://Console.Example.com/"),
2786 "https://console.example.com",
2787 );
2788 }
2789}
2790
2791#[cfg(test)]
2792mod credential_route_tests {
2793 use super::UiServer;
2794 use areev_cal::AreevFacade;
2795 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2796 use areev_core::types::{Fact, Grain};
2797 use areev_store::Areev;
2798
2799 #[test]
2804 fn loop_apply_honors_a_gating_run() {
2805 let dir = tempfile::tempdir().unwrap();
2806 let path = dir.path().join("g.db");
2807 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2808 let evalset = store
2810 .add(
2811 &Fact::new("evalset:gate", "mg:evalset", "{\"name\":\"gate\",\"cases\":[]}")
2812 .namespace("agent:harness")
2813 .created_at(1_000),
2814 )
2815 .unwrap()
2816 .to_hex();
2817 store
2818 .add(
2819 &Fact::new(
2820 &format!("evalset:{evalset}"),
2821 "mg:eval_run",
2822 "{\"run_id\":\"eval-1\",\"passed\":3,\"failed\":0}",
2823 )
2824 .namespace("agent:harness")
2825 .created_at(2_000),
2826 )
2827 .unwrap();
2828 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
2829 std::mem::forget(dir);
2830 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2831 let manifest = facade
2833 .record_corpus_export("RECALL facts", "train.jsonl", None, 4_000, &[], &[])
2834 .unwrap()
2835 .to_hex();
2836 facade
2837 .record_adapter(
2838 r#"{"adapter":{"uri":"file:///tmp/a","sha256":"feed"},"base_model":"qwen3-4b","serves_as":"acme-support"}"#,
2839 &manifest,
2840 &evalset,
2841 5_000,
2842 )
2843 .unwrap();
2844 let s = UiServer::new(facade, "test".into()).with_auth("gate-token".into());
2847 let tok = Some("gate-token");
2848
2849 let run = s.route("POST", "/api/loop/run", b"{}", tok, None);
2851 assert!(run.0.starts_with("200"), "{}", text(&run));
2852 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", tok, None);
2853 let rows: serde_json::Value = serde_json::from_str(&text(&list)).unwrap();
2854 let rec = rows["recommendations"]
2855 .as_array()
2856 .unwrap()
2857 .iter()
2858 .find(|r| r["analyzer"].as_str().unwrap_or("").contains("adapter_intake"))
2859 .unwrap_or_else(|| panic!("adapter_intake must propose: {rows}"));
2860 assert_eq!(rec["evalset_hash"], evalset.as_str(), "the row must carry the pin");
2861 let hash = rec["hash"].as_str().unwrap().to_string();
2862
2863 let approve = format!(r#"{{"hash":"{hash}","decision":"approve","because":"reviewed"}}"#);
2866 let r = s.route("POST", "/api/loop/review", approve.as_bytes(), tok, None);
2867 assert!(text(&r).contains("\"ok\":true"), "{}", text(&r));
2868 let bare = format!(r#"{{"hash":"{hash}","because":"ship it"}}"#);
2869 let r = s.route("POST", "/api/loop/apply", bare.as_bytes(), tok, None);
2870 assert!(text(&r).contains("gating run"), "ungated must refuse: {}", text(&r));
2871 let bad = format!(r#"{{"hash":"{hash}","because":"ship","gating_run":"eval-nope"}}"#);
2872 let r = s.route("POST", "/api/loop/apply", bad.as_bytes(), tok, None);
2873 assert!(text(&r).contains("no recorded gate run"), "{}", text(&r));
2874 let good = format!(r#"{{"hash":"{hash}","because":"gated and green","gating_run":"eval-1"}}"#);
2875 let r = s.route("POST", "/api/loop/apply", good.as_bytes(), tok, None);
2876 assert!(text(&r).contains("\"ok\":true"), "gated apply failed: {}", text(&r));
2877 }
2878
2879 fn server() -> UiServer {
2882 std::env::set_var("AREEV_TEST_READER_TOK", "reader-secret");
2883 std::env::set_var("AREEV_TEST_WRITER_TOK", "writer-secret");
2884 let dir = tempfile::tempdir().unwrap();
2885 let path = dir.path().join("t.db");
2886 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2887 store
2888 .add(&Fact::new("user:reader", REL_PERMITS, "read ON *").namespace(AUTHZ_NS).created_at(1_000))
2889 .unwrap();
2890 store
2891 .add(
2892 &Fact::new("agent:writer", REL_PERMITS, "read,write ON caller")
2893 .namespace(AUTHZ_NS)
2894 .created_at(2_000),
2895 )
2896 .unwrap();
2897 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
2898 std::mem::forget(dir);
2899 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2900 let map = CredentialMap::from_json(
2901 r#"{"version":1,"tokens":[
2902 {"env":"AREEV_TEST_READER_TOK","principal":"user:reader"},
2903 {"env":"AREEV_TEST_WRITER_TOK","principal":"agent:writer"}
2904 ]}"#,
2905 )
2906 .unwrap();
2907 UiServer::new(facade, "test".into()).with_credentials(map)
2908 }
2909
2910 fn text(r: &(&str, &str, Vec<u8>)) -> String {
2911 String::from_utf8_lossy(&r.2).to_string()
2912 }
2913
2914 const READ: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
2915 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2916
2917 #[test]
2918 fn tokens_bind_principals_and_verbs_decide() {
2919 let s = server();
2920
2921 let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
2923 assert!(r.0.starts_with("200"), "{}", text(&r));
2924 assert!(text(&r).contains("grains"));
2925 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2927 assert!(text(&w).contains("AUT-E001"), "reader write must be refused: {}", text(&w));
2928
2929 let w = s.route("POST", "/api/cal", WRITE, Some("writer-secret"), None);
2931 assert!(text(&w).contains("added"), "writer write must land: {}", text(&w));
2932
2933 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2936 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2937 }
2938
2939 #[test]
2940 fn anonymous_is_read_only_and_loop_run_is_verb_gated() {
2941 let s = server();
2942 let r = s.route("POST", "/api/cal", READ, None, None);
2944 assert!(r.0.starts_with("200"), "{}", text(&r));
2945 assert!(text(&r).contains("grains"));
2946 let w = s.route("POST", "/api/cal", WRITE, None, None);
2948 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2949 let w = s.route("POST", "/api/cal", WRITE, Some("stolen-guess"), None);
2951 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2952
2953 let run = s.route("POST", "/api/loop/run", b"{}", None, None);
2955 assert!(run.0.starts_with("403"), "{}", text(&run));
2956 }
2957
2958 #[test]
2959 fn whoami_reports_the_bound_principal() {
2960 let s = server();
2961 let r = s.route("GET", "/api/whoami", b"", Some("reader-secret"), None);
2962 assert!(text(&r).contains("user:reader"), "{}", text(&r));
2963 assert!(text(&r).contains("credentials"));
2964 let r = s.route("GET", "/api/whoami", b"", None, None);
2965 assert!(text(&r).contains("anonymous"), "{}", text(&r));
2966 }
2967
2968 #[test]
2972 fn a_credential_map_does_not_disable_the_shared_secret() {
2973 let s = server().with_auth("console-token".into());
2974
2975 let r = s.route("POST", "/api/cal", READ, None, None);
2977 assert!(r.0.starts_with("401"), "{}", text(&r));
2978 let page = s.route("GET", "/", b"", None, None);
2979 assert!(page.0.starts_with("401"), "the console page is guarded too");
2980 let r = s.route("POST", "/api/cal", READ, Some("stolen-guess"), None);
2982 assert!(r.0.starts_with("401"), "{}", text(&r));
2983
2984 let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
2986 assert!(r.0.starts_with("200"), "{}", text(&r));
2987 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2988 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2989
2990 let w = s.route("POST", "/api/cal", WRITE, Some("console-token"), None);
2993 assert!(text(&w).contains("added"), "{}", text(&w));
2994 }
2995
2996 #[test]
2999 fn run_loop_via_cal_is_verb_gated_like_the_route() {
3000 let s = server();
3001 let run = s.route("POST", "/api/loop/run", b"{}", None, None);
3002 assert!(run.0.starts_with("403"), "{}", text(&run));
3003 let cal = s.route("POST", "/api/cal", br#"{"query":"RUN LOOP"}"#, None, None);
3004 assert!(
3005 text(&cal).contains("AUT-E001"),
3006 "anonymous must not run the loop through CAL either: {}",
3007 text(&cal)
3008 );
3009 }
3010}
3011
3012#[cfg(test)]
3013mod namespace_route_tests {
3014 use super::UiServer;
3015 use areev_cal::AreevFacade;
3016 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
3017 use areev_core::types::{Fact, Grain};
3018 use areev_store::Areev;
3019
3020 fn text(r: &(&str, &str, Vec<u8>)) -> String {
3021 String::from_utf8_lossy(&r.2).to_string()
3022 }
3023
3024 fn restricted_server() -> UiServer {
3027 std::env::set_var("AREEV_TEST_NS_WRITER_TOK", "writer-secret");
3028 let dir = tempfile::tempdir().unwrap();
3029 let path = dir.path().join("t.db");
3030 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
3031 store
3032 .add(&Fact::new("agent:writer", REL_PERMITS, "read,write ON caller").namespace(AUTHZ_NS).created_at(1_000))
3033 .unwrap();
3034 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000)).unwrap();
3035 store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(3_000)).unwrap();
3036 std::mem::forget(dir);
3037 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3038 let map = CredentialMap::from_json(
3039 r#"{"version":1,"tokens":[{"env":"AREEV_TEST_NS_WRITER_TOK","principal":"agent:writer"}]}"#,
3040 )
3041 .unwrap();
3042 UiServer::new(facade, "test".into()).with_credentials(map)
3043 }
3044
3045 fn owner_server() -> UiServer {
3048 let dir = tempfile::tempdir().unwrap();
3049 let path = dir.path().join("t.db");
3050 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
3051 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(1_000)).unwrap();
3052 store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(2_000)).unwrap();
3053 std::mem::forget(dir);
3054 UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
3055 }
3056
3057 #[test]
3058 fn namespaces_route_hides_what_the_principal_cannot_read() {
3059 let s = restricted_server();
3060 let r = s.route("GET", "/api/namespaces", b"", Some("writer-secret"), None);
3061 let body = text(&r);
3062 assert!(body.contains("\"caller\""), "granted namespace must be listed: {body}");
3063 assert!(!body.contains("\"other\""), "ungranted namespace must not be listed: {body}");
3064 assert!(!body.contains(AUTHZ_NS), "the audit namespace must not leak either: {body}");
3065 }
3066
3067 #[test]
3068 fn namespaces_route_lists_everything_for_the_owner() {
3069 let s = owner_server();
3070 let r = s.route("GET", "/api/namespaces", b"", None, None);
3071 let body = text(&r);
3072 assert!(body.contains("\"caller\"") && body.contains("\"other\""), "{body}");
3073 }
3074
3075 #[test]
3076 fn browse_ns_param_is_read_gated_for_a_restricted_principal() {
3077 let s = restricted_server();
3078 let ok = s.route("GET", "/api/browse?ns=caller", b"", Some("writer-secret"), None);
3079 assert!(text(&ok).contains("\"ok\":true"), "their own granted namespace must work: {}", text(&ok));
3080
3081 let denied = s.route("GET", "/api/browse?ns=other", b"", Some("writer-secret"), None);
3082 let body = text(&denied);
3083 assert!(body.contains("\"ok\":false"), "an ungranted namespace must be refused, not silently emptied: {body}");
3084 }
3085
3086 #[test]
3087 fn browse_ns_star_requires_owner() {
3088 let s = restricted_server();
3089 let r = s.route("GET", "/api/browse?ns=*", b"", Some("writer-secret"), None);
3090 assert!(text(&r).contains("\"ok\":false"), "a restricted principal must not get the unfiltered view: {}", text(&r));
3091
3092 let owner = owner_server();
3093 let r2 = owner.route("GET", "/api/browse?ns=*", b"", None, None);
3094 let body2 = text(&r2);
3095 assert!(body2.contains("\"ok\":true"), "the owner session may lift the filter: {body2}");
3096 assert!(body2.contains("\"caller\"") && body2.contains("\"other\""), "{body2}");
3097 }
3098
3099 #[test]
3100 fn browse_without_ns_param_keeps_original_session_default_behavior() {
3101 let s = owner_server();
3102 let r = s.route("GET", "/api/browse", b"", None, None);
3103 let body = text(&r);
3104 assert!(body.contains("\"caller\""), "{body}");
3105 assert!(!body.contains("\"other\""), "omitting ?ns must behave exactly as before this feature existed: {body}");
3106 }
3107}
3108
3109#[cfg(test)]
3110mod builder_tests {
3111 use super::UiServer;
3112 use areev_cal::AreevFacade;
3113 use areev_store::Areev;
3114
3115 fn server() -> UiServer {
3116 let dir = tempfile::tempdir().unwrap();
3117 let path = dir.path().join("t.db");
3118 let store = Areev::open(path.to_str().unwrap()).unwrap();
3119 std::mem::forget(dir);
3120 UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
3121 }
3122
3123 #[test]
3127 fn attaching_a_loop_policy_preserves_the_destructive_cap() {
3128 let s = server()
3129 .allow_destructive_ops(false)
3130 .with_loop_policy(areev_loop::Policy::default());
3131 let r = s.route(
3132 "POST",
3133 "/api/cal",
3134 br#"{"query":"FORGET sha256:684c6c9bda818630a870119d0726e4d242ed537af061658ef6f3acb158a2c67d"}"#,
3135 None,
3136 None,
3137 );
3138 let body = String::from_utf8_lossy(&r.2).to_string();
3139 assert!(
3140 body.contains("--no-destructive-ops") || r.0.starts_with("401"),
3141 "destruction must stay disabled: {body}"
3142 );
3143 }
3144}
3145
3146#[cfg(test)]
3147mod run_api_tests {
3148 use super::UiServer;
3149 use areev_cal::AreevFacade;
3150 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
3151 use areev_core::types::{Fact, Grain, Tool, ToolKind, Workflow};
3152 use areev_store::Areev;
3153 use std::sync::Arc;
3154
3155 fn parked_server() -> (UiServer, String) {
3158 std::env::set_var("AREEV_RUN_OFFICER_TOK", "officer-secret");
3159 std::env::set_var("AREEV_RUN_STARTER_TOK", "starter-secret");
3160 let dir = tempfile::tempdir().unwrap();
3161 let path = dir.path().join("t.db");
3162 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
3163 for (who, what) in [
3164 ("user:officer", "read,run.respond ON ops"),
3165 ("user:starter", "read ON ops"),
3166 ] {
3167 store
3168 .add(&Fact::new(who, REL_PERMITS, what).namespace(AUTHZ_NS).created_at(900))
3169 .unwrap();
3170 }
3171 std::mem::forget(dir);
3172 let facade = Arc::new(AreevFacade::with_session(store, Some("ops".into()), None));
3173
3174 let (wf, _g, _a) = facade
3176 .with_store(|m| {
3177 let greet = Tool::new("greet")
3178 .kind(ToolKind::Definition)
3179 .tool_description("t")
3180 .created_at(500)
3181 .namespace("ops");
3182 let gh = m.add(&greet)?;
3183 let approve = Tool::new("approve")
3184 .kind(ToolKind::Definition)
3185 .tool_description("human approves")
3186 .executor_kind(areev_core::types::ExecutorKind::Client)
3187 .created_at(501)
3188 .namespace("ops");
3189 let ah = m.add(&approve)?;
3190 let wf = Workflow::new(vec!["greet".into(), "approve".into()])
3191 .edge("greet", "approve")
3192 .bind("greet", &gh.to_hex())
3193 .bind("approve", &ah.to_hex())
3194 .created_at(502)
3195 .namespace("ops");
3196 let wh = m.add(&wf)?;
3197 Ok::<_, areev_core::error::AreevError>((wh, gh, ah))
3198 })
3199 .unwrap();
3200 struct Ok1;
3201 impl areev_run::HostToolExecutor for Ok1 {
3202 fn execute(
3203 &self,
3204 t: &str,
3205 _h: &str,
3206 _i: &serde_json::Value,
3207 _k: &str,
3208 ) -> areev_run::ExecResult {
3209 areev_run::ExecResult::Ok(serde_json::json!({t.to_string(): true}))
3210 }
3211 }
3212 let runner = areev_run::Runner {
3213 facade: Arc::clone(&facade),
3214 clock: Arc::new(areev_run::ScriptedClock::new(
3215 (0..100).map(|i| 1_758_000_000_000 + i * 10).collect(),
3216 )),
3217 executor: Arc::new(Ok1),
3218 llm: None,
3219 observer: None,
3220 ns: "ops".into(),
3221 principal: "user:starter".into(),
3222 };
3223 let session = runner
3224 .start(&wf, "hitl-1", serde_json::json!({}), &areev_run::RunOptions {
3225 workers: 1,
3226 ..Default::default()
3227 })
3228 .unwrap();
3229 let areev_run::RunSession::Parked { envelope, .. } = session else {
3230 panic!("expected park")
3231 };
3232 let ask = envelope["asks"][0]["tool_call_id"].as_str().unwrap().to_string();
3233
3234 drop(runner);
3241 let facade = Arc::try_unwrap(facade).unwrap_or_else(|_| panic!("facade still shared"));
3242 let map = CredentialMap::from_json(
3243 r#"{"version":1,"tokens":[
3244 {"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer"},
3245 {"env":"AREEV_RUN_STARTER_TOK","principal":"user:starter"}
3246 ]}"#,
3247 )
3248 .unwrap();
3249 let server = UiServer::new(facade, "test".into())
3250 .with_auth("shared-console-secret".into())
3251 .with_credentials(map);
3252 (server, ask)
3253 }
3254
3255 fn body(run_id: &str, ask: &str) -> Vec<u8> {
3256 serde_json::json!({"run_id": run_id, "tool_call_id": ask, "result": {"approved": true}})
3257 .to_string()
3258 .into_bytes()
3259 }
3260
3261 #[test]
3267 fn run_list_is_scoped_and_paged_server_side() {
3268 let dir = tempfile::tempdir().unwrap();
3269 let path = dir.path().join("t.db");
3270 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
3271 for i in 0..60u32 {
3272 let (id, ns) = if i < 20 {
3273 (format!("quiet-{i}"), "tenant.quiet")
3274 } else {
3275 (format!("busy-{i}"), "tenant.busy")
3276 };
3277 let mut f = Fact::new(&format!("run:{id}"), "mg:harness", "00")
3278 .namespace(areev_core::authz::HARNESS_NS)
3279 .created_at(1_000 + i64::from(i));
3280 f.common.extra_fields.insert("run_id".into(), serde_json::json!(id));
3281 f.common.extra_fields.insert("run_ns".into(), serde_json::json!(ns));
3282 store.add(&f).unwrap();
3283 }
3284 std::mem::forget(dir);
3285 let s = UiServer::new(AreevFacade::with_session(store, Some("ops".into()), None), "test".into());
3286 let page = |query: &str| -> serde_json::Value {
3287 let r = s.route("GET", &format!("/api/run/list{query}"), b"", None, None);
3288 assert!(r.0.starts_with("200"), "{} {}", r.0, String::from_utf8_lossy(&r.2));
3289 serde_json::from_slice(&r.2).unwrap()
3290 };
3291 let rows = |v: &serde_json::Value| v["runs"].as_array().unwrap().clone();
3292
3293 let all = page("");
3295 assert_eq!(rows(&all).len(), 50);
3296 assert_eq!(all["total"], 60);
3297 assert_eq!(all["truncated"], true);
3298 assert_eq!(rows(&all).iter().filter(|r| r["ns"] == "tenant.quiet").count(), 10);
3299
3300 let quiet = page("?ns=tenant.quiet");
3302 assert_eq!(quiet["total"], 20);
3303 assert_eq!(quiet["truncated"], false);
3304 assert_eq!(rows(&quiet).len(), 20);
3305 assert!(rows(&quiet).iter().all(|r| r["ns"] == "tenant.quiet"), "{quiet}");
3306
3307 let tail = page("?ns=tenant.*&limit=10&offset=55");
3309 assert_eq!(tail["total"], 60);
3310 assert_eq!(rows(&tail).len(), 5);
3311 assert_eq!(tail["truncated"], false);
3312 assert_eq!((tail["offset"].as_u64(), tail["limit"].as_u64()), (Some(55), Some(10)));
3313
3314 assert_eq!(page("?ns=*")["total"], 60);
3316 assert_eq!(page("?limit=100000")["limit"], 500);
3317
3318 s.facade
3321 .with_store(|m| {
3322 let mut f = Fact::new("run:old", "mg:harness", "00")
3323 .namespace(areev_core::authz::HARNESS_NS)
3324 .created_at(1);
3325 f.common.extra_fields.insert("run_id".into(), serde_json::json!("old"));
3326 m.add(&f)
3327 })
3328 .unwrap();
3329 let quiet = page("?ns=tenant.quiet");
3330 assert_eq!(quiet["total"], 20, "scoping stays exact: {quiet}");
3331 assert_eq!(quiet["unattributed"], 1, "and says what it left out: {quiet}");
3332 let unscoped = page("");
3333 assert_eq!(unscoped["total"], 61);
3334 assert_eq!(unscoped["unattributed"], 0);
3335 }
3336
3337 #[test]
3341 fn run_shadow_route_rehearses_a_run_under_a_candidate_plan() {
3342 let (s, _ask) = parked_server();
3343 let (_st, _ct, body) = s.route("GET", "/api/run/inspect?run_id=hitl-1", b"", Some("officer-secret"), None);
3344 let inspect: serde_json::Value = serde_json::from_slice(&body).unwrap();
3345 let plan = inspect["plan_hash"].as_str().expect("the run's plan").to_string();
3346
3347 let (status, _ct, body) = s.route("GET", &format!("/api/run/shadow?runs=hitl-1&plan={plan}"), b"", Some("officer-secret"), None);
3348 assert!(status.starts_with("200"), "{status}");
3349 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3350 assert_eq!(v["ok"], true, "{v}");
3351 assert_eq!(v["candidate_plan"], plan.as_str());
3352 assert_eq!((v["effect_dispatches"].as_u64(), v["writes"].as_u64()), (Some(0), Some(0)));
3353 let row = &v["runs"][0];
3354 assert_eq!(row["run_id"], "hitl-1");
3355 assert!(row["identity"].is_object(), "the incumbent as candidate is a verify: {row}");
3356
3357 let draft = serde_json::json!({"runs": ["hitl-1"], "plan_body": {"nodes": ["greet"], "edges": []}});
3359 let (status, _ct, body) = s.route("POST", "/api/run/shadow", draft.to_string().as_bytes(), Some("officer-secret"), None);
3360 assert!(status.starts_with("200"), "{status}");
3361 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3362 assert_eq!(v["ok"], true, "{v}");
3363 assert_eq!(v["candidate_is_draft"], true);
3364 let (_st, _ct, body) = s.route("POST", "/api/run/shadow", br#"{"runs": ["hitl-1"]}"#, Some("officer-secret"), None);
3365 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3366 assert_eq!(v["ok"], false, "{v}");
3367 let (_st, _ct, body) = s.route("GET", "/api/run/shadow?runs=hitl-1", b"", Some("officer-secret"), None);
3368 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3369 assert_eq!(v["ok"], false, "plan required: {v}");
3370 }
3371
3372 #[test]
3373 fn shared_token_approvals_are_refused_and_principals_approve() {
3374 let (s, ask) = parked_server();
3375
3376 let r = s.route("GET", "/api/run/list", b"", Some("shared-console-secret"), None);
3378 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3379 let r = s.route(
3380 "GET",
3381 "/api/run/inspect?run_id=hitl-1",
3382 b"",
3383 Some("shared-console-secret"),
3384 None,
3385 );
3386 assert!(String::from_utf8_lossy(&r.2).contains(&ask), "ask visible in inspect");
3387
3388 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("shared-console-secret"), None);
3390 assert!(r.0.starts_with("403"), "shared-token approval must refuse: {}", r.0);
3391
3392 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("starter-secret"), None);
3395 let text = String::from_utf8_lossy(&r.2).to_string();
3396 assert!(text.contains("responder") || text.contains("RUN-E012"), "{text}");
3397
3398 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("officer-secret"), None);
3400 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3401 assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
3402
3403 let r = s.route(
3405 "POST",
3406 "/api/run/cancel",
3407 br#"{"run_id":"hitl-1","because":"test brake"}"#,
3408 Some("shared-console-secret"),
3409 None,
3410 );
3411 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3412 }
3413
3414 #[test]
3421 fn proxy_asserted_identities_cannot_approve_by_default() {
3422 let (s, ask) = parked_server();
3423 let s = s.with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>);
3424
3425 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), None, Some("user:officer"));
3427 assert!(
3428 r.0.starts_with("403"),
3429 "proxy-asserted approval must refuse by default: {} {}",
3430 r.0,
3431 String::from_utf8_lossy(&r.2)
3432 );
3433 let text = String::from_utf8_lossy(&r.2).to_string();
3434 assert!(
3435 text.contains("--sso-approvals"),
3436 "the refusal must name the flag that lifts it: {text}"
3437 );
3438
3439 let r = s.route("GET", "/api/run/list", b"", None, Some("user:officer"));
3441 assert!(r.0.starts_with("200"), "SSO identities may still review: {}", r.0);
3442
3443 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("officer-secret"), None);
3445 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3446 }
3447
3448 #[test]
3452 fn sso_approvals_allow_opts_in_to_proxy_asserted_approvals() {
3453 let (s, ask) = parked_server();
3454 let s = s
3455 .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>)
3456 .with_sso_approvals(true);
3457
3458 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), None, Some("user:officer"));
3459 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3460 assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
3461 }
3462
3463 #[test]
3468 fn groups_resolve_to_principals_but_never_outrank_an_identity() {
3469 let (s, _ask) = parked_server();
3470 let map = CredentialMap::from_json(
3473 r#"{"version":1,
3474 "tokens":[{"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer","id":"off"}],
3475 "groups":{"Engineering":"user:officer"}}"#,
3476 )
3477 .unwrap();
3478 let s = s
3479 .with_credentials(map)
3480 .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>)
3481 .with_sso_groups_header("x-forwarded-groups");
3482
3483 let r = s.route_with_groups(
3486 "GET",
3487 "/api/whoami",
3488 b"",
3489 None,
3490 Some("user:newhire@example.com"),
3491 Some("platform,Engineering"),
3492 );
3493 let text = String::from_utf8_lossy(&r.2).to_string();
3494 assert!(text.contains("user:officer"), "group should resolve: {text}");
3495 assert!(text.contains(r#""identity_source":"sso-group""#), "{text}");
3496
3497 let r = s.route_with_groups(
3501 "GET", "/api/whoami", b"", None, Some("user:newhire@example.com"), Some("ENGINEERING"),
3502 );
3503 assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
3504
3505 let r = s.route_with_groups(
3507 "GET", "/api/whoami", b"", None, Some("user:officer"), Some("Engineering"),
3508 );
3509 let text = String::from_utf8_lossy(&r.2).to_string();
3510 assert!(text.contains(r#""identity_source":"sso""#), "identity outranks group: {text}");
3511
3512 let r = s.route_with_groups(
3514 "GET", "/api/whoami", b"", None, Some("user:newhire@example.com"), Some("sales"),
3515 );
3516 assert!(String::from_utf8_lossy(&r.2).contains("user:newhire@example.com"));
3517 }
3518
3519 #[test]
3523 fn group_derived_principals_can_never_approve() {
3524 let (s, ask) = parked_server();
3525 let map = CredentialMap::from_json(
3526 r#"{"version":1,
3527 "tokens":[{"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer","id":"off"}],
3528 "groups":{"approvers":"user:officer"}}"#,
3529 )
3530 .unwrap();
3531 let s = s
3532 .with_credentials(map)
3533 .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>)
3534 .with_sso_groups_header("x-forwarded-groups")
3535 .with_sso_approvals(true);
3537
3538 let r = s.route_with_groups(
3539 "POST",
3540 "/api/run/respond",
3541 &body("hitl-1", &ask),
3542 None,
3543 Some("user:newhire@example.com"),
3544 Some("approvers"),
3545 );
3546 assert!(r.0.starts_with("403"), "{} {}", r.0, String::from_utf8_lossy(&r.2));
3547 let text = String::from_utf8_lossy(&r.2).to_string();
3548 assert!(text.contains("role"), "the refusal must say why: {text}");
3549 }
3550
3551 #[test]
3554 fn the_principal_prefix_is_stamped_on_proxy_asserted_identities() {
3555 let (s, _ask) = parked_server();
3556 let s = s
3557 .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>)
3558 .with_sso_principal_prefix("sso:");
3559
3560 let r = s.route("GET", "/api/whoami", b"", None, Some("pat@example.com"));
3561 assert!(String::from_utf8_lossy(&r.2).contains("sso:pat@example.com"), "{}", String::from_utf8_lossy(&r.2));
3562
3563 let r = s.route("GET", "/api/whoami", b"", None, Some("sso:pat@example.com"));
3566 let text = String::from_utf8_lossy(&r.2).to_string();
3567 assert!(text.contains("sso:pat@example.com"), "{text}");
3568 assert!(!text.contains("sso:sso:"), "must not double-stamp: {text}");
3569 }
3570
3571 #[test]
3575 fn whoami_reports_identity_provenance_and_approval_capability() {
3576 let (s, _ask) = parked_server();
3577 let s = s.with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>);
3578
3579 let r = s.route("GET", "/api/whoami", b"", None, Some("user:officer"));
3580 let text = String::from_utf8_lossy(&r.2).to_string();
3581 assert!(text.contains(r#""identity_source":"sso""#), "{text}");
3582 assert!(text.contains(r#""may_approve":false"#), "{text}");
3583
3584 let r = s.route("GET", "/api/whoami", b"", Some("officer-secret"), None);
3585 let text = String::from_utf8_lossy(&r.2).to_string();
3586 assert!(text.contains(r#""identity_source":"credential""#), "{text}");
3587 assert!(text.contains(r#""may_approve":true"#), "{text}");
3588 }
3589}
3590
3591#[cfg(test)]
3592mod memory_scoped_token_tests {
3593 use super::UiServer;
3594 use areev_cal::AreevFacade;
3595 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
3596 use areev_core::types::{Fact, Grain};
3597 use areev_store::Areev;
3598
3599 #[test]
3603 fn memory_scoped_tokens_do_not_cross_memories() {
3604 std::env::set_var("AREEV_SCOPED_TOK", "scoped-secret");
3605 let map_json = r#"{"version":1,"tokens":[
3606 {"env":"AREEV_SCOPED_TOK","principal":"agent:sync","memories":["memory-a"]}
3607 ]}"#;
3608 let server_for = |label: &str| {
3609 let dir = tempfile::tempdir().unwrap();
3610 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3611 store
3612 .add(
3613 &Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
3614 .namespace(AUTHZ_NS)
3615 .created_at(1_000),
3616 )
3617 .unwrap();
3618 store
3619 .add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000))
3620 .unwrap();
3621 std::mem::forget(dir);
3622 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3623 UiServer::new(facade, label.into())
3624 .with_credentials(CredentialMap::from_json(map_json).unwrap())
3625 };
3626 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
3627
3628 let a = server_for("memory-a");
3631 let r = a.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
3632 let text = String::from_utf8_lossy(&r.2).to_string();
3633 assert!(r.0.starts_with("200") && !text.contains("AUT-E001"), "{text}");
3634
3635 let b = server_for("memory-b");
3639 let r = b.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
3640 let text = String::from_utf8_lossy(&r.2).to_string();
3641 assert!(
3642 text.contains("AUT-E001") && text.contains("anonymous"),
3643 "scoped token must not cross memories: {text}"
3644 );
3645
3646 assert!(CredentialMap::from_json(
3648 r#"{"version":1,"tokens":[{"env":"AREEV_SCOPED_TOK","principal":"x","memories":[]}]}"#
3649 )
3650 .is_err());
3651 }
3652}
3653
3654#[cfg(all(test, feature = "tls"))]
3655mod tls_tests {
3656 use super::UiServer;
3657 use areev_cal::AreevFacade;
3658 use areev_store::Areev;
3659 use std::io::{Read, Write};
3660
3661 fn self_signed(dir: &std::path::Path) -> Option<(String, String)> {
3662 let cert = dir.join("cert.pem");
3663 let key = dir.join("key.pem");
3664 let status = std::process::Command::new("openssl")
3665 .args([
3666 "req", "-x509", "-newkey", "rsa:2048", "-nodes",
3667 "-keyout", key.to_str().unwrap(), "-out", cert.to_str().unwrap(),
3668 "-days", "1", "-subj", "/CN=localhost",
3669 "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1",
3670 "-addext", "basicConstraints=critical,CA:FALSE",
3671 ])
3672 .stdout(std::process::Stdio::null())
3673 .stderr(std::process::Stdio::null())
3674 .status();
3675 match status {
3676 Ok(s) if s.success() => Some((
3677 cert.to_str().unwrap().to_string(),
3678 key.to_str().unwrap().to_string(),
3679 )),
3680 _ => None, }
3682 }
3683
3684 #[test]
3687 fn tls_round_trip_and_no_plaintext_downgrade() {
3688 let dir = tempfile::tempdir().unwrap();
3689 let Some((cert, key)) = self_signed(dir.path()) else {
3690 eprintln!("skipping: no openssl binary");
3691 return;
3692 };
3693 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3694 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3695 let server = UiServer::new(facade, "tls-test".into())
3696 .with_tls(&cert, &key)
3697 .unwrap();
3698 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3699 let addr = listener.local_addr().unwrap();
3700 let handle = std::thread::spawn(move || {
3701 for _ in 0..2 {
3703 if let Ok((s, _)) = listener.accept() {
3704 let _ = server.handle(s);
3705 }
3706 }
3707 });
3708
3709 let mut roots = rustls::RootCertStore::empty();
3711 {
3712 use rustls::pki_types::pem::PemObject;
3713 for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
3714 roots.add(c.unwrap()).unwrap();
3715 }
3716 }
3717 let config = std::sync::Arc::new(
3718 rustls::ClientConfig::builder()
3719 .with_root_certificates(roots)
3720 .with_no_client_auth(),
3721 );
3722 let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
3723 let session = rustls::ClientConnection::new(config, name).unwrap();
3724 let tcp = std::net::TcpStream::connect(addr).unwrap();
3725 let mut tls = rustls::StreamOwned::new(session, tcp);
3726 tls.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
3727 .unwrap();
3728 let mut response = String::new();
3729 let _ = tls.read_to_string(&mut response);
3730 assert!(response.starts_with("HTTP/1.1 200"), "{}", &response[..response.len().min(120)]);
3731 assert!(response.contains("Areev"), "console served over TLS");
3732
3733 let mut plain = std::net::TcpStream::connect(addr).unwrap();
3736 plain
3737 .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
3738 .unwrap();
3739 let mut buf = Vec::new();
3740 let _ = plain.read_to_end(&mut buf);
3741 let text = String::from_utf8_lossy(&buf);
3742 assert!(
3743 !text.contains("HTTP/1.1 200"),
3744 "plaintext must not be served on a TLS listener: {text}"
3745 );
3746 handle.join().unwrap();
3747 }
3748
3749 #[test]
3755 fn an_authenticated_console_terminates_tls_the_same_way() {
3756 let dir = tempfile::tempdir().unwrap();
3757 let Some((cert, key)) = self_signed(dir.path()) else {
3758 eprintln!("skipping: no openssl binary");
3759 return;
3760 };
3761 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3762 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3763 let server = UiServer::new(facade, "auth-tls-test".into())
3764 .with_auth("console-token".into())
3765 .with_tls(&cert, &key)
3766 .unwrap();
3767 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3768 let addr = listener.local_addr().unwrap();
3769 let handle = std::thread::spawn(move || {
3770 if let Ok((s, _)) = listener.accept() {
3771 let _ = server.handle(s);
3772 }
3773 });
3774
3775 let mut roots = rustls::RootCertStore::empty();
3776 {
3777 use rustls::pki_types::pem::PemObject;
3778 for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
3779 roots.add(c.unwrap()).unwrap();
3780 }
3781 }
3782 let config = std::sync::Arc::new(
3783 rustls::ClientConfig::builder()
3784 .with_root_certificates(roots)
3785 .with_no_client_auth(),
3786 );
3787 let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
3788 let session = rustls::ClientConnection::new(config, name).unwrap();
3789 let tcp = std::net::TcpStream::connect(addr).unwrap();
3790 let mut tls = rustls::StreamOwned::new(session, tcp);
3791 tls.write_all(
3792 b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\
3793 Authorization: Bearer console-token\r\nConnection: close\r\n\r\n",
3794 )
3795 .unwrap();
3796 let mut response = String::new();
3797 let _ = tls.read_to_string(&mut response);
3798 assert!(
3799 response.starts_with("HTTP/1.1 200"),
3800 "an authenticated console should terminate TLS like an open one: {}",
3801 &response[..response.len().min(120)]
3802 );
3803 handle.join().unwrap();
3804 }
3805}
3806
3807#[cfg(test)]
3808mod sso_tests {
3809 use super::UiServer;
3810 use areev_cal::AreevFacade;
3811 use areev_core::authz::{AUTHZ_NS, REL_PERMITS};
3812 use areev_core::types::{Fact, Grain};
3813 use areev_store::Areev;
3814 use std::io::{Read, Write};
3815
3816 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
3817
3818 fn sso_server() -> UiServer {
3819 sso_server_with("proxy-secret", None)
3820 }
3821
3822 fn sso_server_rotating(next: Option<&str>) -> UiServer {
3824 sso_server_with("proxy-secret", next)
3825 }
3826
3827 fn sso_server_with(secret: &str, next: Option<&str>) -> UiServer {
3828 let dir = tempfile::tempdir().unwrap();
3829 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3830 store
3831 .add(
3832 &Fact::new("user:pat@example.com", REL_PERMITS, "read,write ON caller")
3833 .namespace(AUTHZ_NS)
3834 .created_at(1_000),
3835 )
3836 .unwrap();
3837 std::mem::forget(dir);
3838 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3839 UiServer::new(facade, "sso-test".into())
3840 .with_sso_rotating("x-forwarded-user", secret, next)
3841 }
3842
3843 fn post(server: &UiServer, extra_headers: &str) -> String {
3844 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3845 let addr = listener.local_addr().unwrap();
3846 std::thread::scope(|scope| {
3847 scope.spawn(|| {
3848 if let Ok((s, _)) = listener.accept() {
3849 let _ = server.handle(s);
3850 }
3851 });
3852 let mut c = std::net::TcpStream::connect(addr).unwrap();
3853 let req = format!(
3854 "POST /api/cal HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n{extra_headers}Content-Length: {}\r\nConnection: close\r\n\r\n",
3855 WRITE.len()
3856 );
3857 c.write_all(req.as_bytes()).unwrap();
3858 c.write_all(WRITE).unwrap();
3859 let mut out = String::new();
3860 let _ = c.read_to_string(&mut out);
3861 out
3862 })
3863 }
3864
3865 #[test]
3869 fn proxied_identity_works_and_forged_headers_are_ignored() {
3870 let server = sso_server();
3871
3872 let ok = post(
3874 &server,
3875 "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
3876 );
3877 assert!(ok.contains("HTTP/1.1 200"), "{}", &ok[..ok.len().min(200)]);
3878 assert!(!ok.contains("AUT-E001"), "granted principal must write: {ok}");
3879
3880 let forged = post(&server, "X-Forwarded-User: user:pat@example.com\r\n");
3883 assert!(
3884 forged.contains("anonymous") || forged.contains("401"),
3885 "forged identity must not authenticate: {forged}"
3886 );
3887
3888 let wrong = post(
3890 &server,
3891 "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: guess\r\n",
3892 );
3893 assert!(
3894 wrong.contains("anonymous") || wrong.contains("401"),
3895 "wrong proxy secret must not authenticate: {wrong}"
3896 );
3897 }
3898
3899 #[test]
3903 fn malformed_identities_are_rejected_even_with_a_valid_proxy_secret() {
3904 assert_eq!(
3905 super::sanitize_sso_identity(" user:pat@example.com ").as_deref(),
3906 Some("user:pat@example.com"),
3907 "surrounding whitespace is trimmed, not rejected"
3908 );
3909
3910 assert!(super::sanitize_sso_identity("user:a\r\nX-Admin: true").is_none());
3912 assert!(super::sanitize_sso_identity("user:a\tb").is_none());
3913 assert!(super::sanitize_sso_identity("user a").is_none());
3916 assert!(super::sanitize_sso_identity("").is_none());
3917 assert!(super::sanitize_sso_identity(&"x".repeat(129)).is_none());
3918 assert!(super::sanitize_sso_identity("anonymous").is_none());
3921 assert!(super::sanitize_sso_identity("user:console").is_none());
3922 }
3923
3924 #[test]
3928 fn a_rejected_identity_degrades_to_anonymous_rather_than_erroring() {
3929 let server = sso_server();
3930 let out = post(
3931 &server,
3932 "X-Forwarded-User: anonymous\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
3933 );
3934 assert!(
3935 out.contains("anonymous") || out.contains("401"),
3936 "a reserved name must not authenticate: {out}"
3937 );
3938 assert!(!out.contains("HTTP/1.1 500"), "and must not error: {out}");
3939 }
3940
3941 #[test]
3946 fn both_secrets_authenticate_during_a_rotation_window() {
3947 let server = sso_server_rotating(Some("new-secret"));
3948 let id = "X-Forwarded-User: user:pat@example.com\r\n";
3949
3950 for secret in ["proxy-secret", "new-secret"] {
3951 let out = post(&server, &format!("{id}X-Areev-Proxy-Secret: {secret}\r\n"));
3952 assert!(out.contains("HTTP/1.1 200"), "{secret} rejected: {out}");
3953 assert!(!out.contains("AUT-E001"), "{secret} must authenticate: {out}");
3954 }
3955
3956 let wrong = post(&server, &format!("{id}X-Areev-Proxy-Secret: guess\r\n"));
3959 assert!(
3960 wrong.contains("anonymous") || wrong.contains("401"),
3961 "an unrelated secret must still be refused mid-rotation: {wrong}"
3962 );
3963 }
3964
3965 #[test]
3968 fn the_retired_secret_stops_working_once_the_window_closes() {
3969 let server = sso_server_with("new-secret", None);
3971 let id = "X-Forwarded-User: user:pat@example.com\r\n";
3972
3973 let ok = post(&server, &format!("{id}X-Areev-Proxy-Secret: new-secret\r\n"));
3974 assert!(ok.contains("HTTP/1.1 200"), "{ok}");
3975 assert!(!ok.contains("AUT-E001"), "the promoted secret must authenticate: {ok}");
3976
3977 let old = post(&server, &format!("{id}X-Areev-Proxy-Secret: proxy-secret\r\n"));
3978 assert!(
3979 old.contains("anonymous") || old.contains("401"),
3980 "the retired secret must stop working: {old}"
3981 );
3982 }
3983
3984 #[test]
3985 #[should_panic(expected = "must differ")]
3986 fn rotating_to_the_same_value_is_refused() {
3987 let _ = sso_server_rotating(Some("proxy-secret"));
3990 }
3991
3992 use crate::CONSOLE_HTML;
3993 use std::collections::BTreeSet;
3994
3995 #[test]
4007 fn every_console_page_is_in_the_visibility_list() {
4008 fn all(hay: &str, open: &str) -> BTreeSet<String> {
4009 let mut out = BTreeSet::new();
4010 let mut rest = hay;
4011 while let Some(i) = rest.find(open) {
4012 rest = &rest[i + open.len()..];
4013 let end = rest.find('"').expect("unterminated attribute");
4014 out.insert(rest[..end].to_string());
4015 rest = &rest[end..];
4016 }
4017 out
4018 }
4019
4020 let sections = all(CONSOLE_HTML, "id=\"page-");
4021 let nav = all(CONSOLE_HTML, "data-page=\"");
4022 for want in ["workflows", "runs", "tools"] {
4023 assert!(sections.contains(want), "the {want} section itself went missing");
4024 }
4025 assert!(!sections.contains("triggers"), "triggers belong on the canvas, not on a page");
4029 assert!(
4030 CONSOLE_HTML.contains("if (head === 'triggers')"),
4031 "#triggers must still route somewhere — a removed page is not a dead bookmark"
4032 );
4033
4034 let head = "for (const p of [";
4036 let i = CONSOLE_HTML.find(head).expect("render()'s page-visibility loop");
4037 let tail = &CONSOLE_HTML[i + head.len()..];
4038 let j = tail.find(']').expect("unterminated page array");
4039 let listed: BTreeSet<String> = tail[..j]
4040 .split(',')
4041 .map(|t| t.trim().trim_matches('\'').to_string())
4042 .filter(|t| !t.is_empty())
4043 .collect();
4044
4045 assert_eq!(
4046 sections, listed,
4047 "page sections and render()'s visibility list disagree; a section missing \
4048 from the list renders into a pane that never un-hides"
4049 );
4050 let orphan: Vec<_> = nav.difference(§ions).collect();
4051 assert!(orphan.is_empty(), "nav items with no page section: {orphan:?}");
4052 }
4053}
4054
4055#[cfg(test)]
4059mod dsn_redaction_tests {
4060 use super::UiServer;
4061 use areev_cal::AreevFacade;
4062 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
4063 use areev_core::types::{Fact, Grain};
4064 use areev_store::Areev;
4065
4066 const DSN: &str = "postgresql://u:SUPERSECRET@h:5432/d?sslmode=require";
4067
4068 fn server() -> UiServer {
4069 let dir = tempfile::tempdir().unwrap();
4070 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4071 std::mem::forget(dir);
4072 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4073 UiServer::new(facade, DSN.to_string())
4074 }
4075
4076 fn text(r: &(&str, &str, Vec<u8>)) -> String {
4077 String::from_utf8_lossy(&r.2).to_string()
4078 }
4079
4080 #[test]
4083 fn config_reports_the_decision_chain_or_null() {
4084 let cfg = |s: &UiServer| -> serde_json::Value {
4085 serde_json::from_slice(&s.route("GET", "/api/config", b"", None, None).2).unwrap()
4086 };
4087 let none = cfg(&server());
4088 assert!(none.get("decide").is_some_and(serde_json::Value::is_null), "{none}");
4089
4090 let with = cfg(&server().with_decider("systemone:jev-latest,llm:ollama:qwen3.5:4b", false));
4091 assert_eq!(
4092 with["decide"],
4093 serde_json::json!({"chain": "systemone:jev-latest,llm:ollama:qwen3.5:4b", "calibrated": false}),
4094 "{with}"
4095 );
4096 let calibrated = cfg(&server().with_decider("typesafe:jev-latest", true));
4097 assert_eq!(calibrated["decide"]["calibrated"], serde_json::json!(true), "{calibrated}");
4098 }
4099
4100 #[test]
4101 fn stats_config_and_html_redact_the_dsn_password_but_keep_the_host() {
4102 let s = server();
4103 for (method, path) in [("GET", "/api/stats"), ("GET", "/api/config"), ("GET", "/")] {
4104 let r = s.route(method, path, b"", None, None);
4105 let body = text(&r);
4106 assert!(!body.contains("SUPERSECRET"), "{path} leaked the password: {body}");
4107 assert!(body.contains("h:5432"), "{path} redacted more than the password: {body}");
4108 }
4109 }
4110
4111 #[test]
4116 fn credential_map_memories_entry_still_matches_the_raw_dsn() {
4117 std::env::set_var("AREEV_DSN_TOK", "dsn-scoped-secret");
4118 let map_json = format!(
4119 r#"{{"version":1,"tokens":[{{"env":"AREEV_DSN_TOK","principal":"agent:sync","memories":["{DSN}"]}}]}}"#
4120 );
4121 let dir = tempfile::tempdir().unwrap();
4122 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4123 store
4124 .add(
4125 &Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
4126 .namespace(AUTHZ_NS)
4127 .created_at(1_000),
4128 )
4129 .unwrap();
4130 std::mem::forget(dir);
4131 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4132 let s = UiServer::new(facade, DSN.to_string())
4133 .with_credentials(CredentialMap::from_json(&map_json).unwrap());
4134
4135 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
4136 let r = s.route("POST", "/api/cal", WRITE, Some("dsn-scoped-secret"), None);
4137 let body = text(&r);
4138 assert!(r.0.starts_with("200") && !body.contains("AUT-E001"), "{body}");
4139 }
4140}
4141
4142#[cfg(test)]
4147mod origin_tests {
4148 use super::UiServer;
4149 use areev_cal::AreevFacade;
4150 use areev_store::Areev;
4151 use std::io::{Read, Write};
4152
4153 fn server() -> UiServer {
4154 let dir = tempfile::tempdir().unwrap();
4155 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4156 std::mem::forget(dir);
4157 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4158 UiServer::new(facade, "origin-test".into())
4159 }
4160
4161 fn post_with_origin(server: &UiServer, origin: Option<&str>) -> String {
4164 let body: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
4165 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
4166 let addr = listener.local_addr().unwrap();
4167 std::thread::scope(|scope| {
4168 scope.spawn(|| {
4169 if let Ok((s, _)) = listener.accept() {
4170 let _ = server.handle(s);
4171 }
4172 });
4173 let mut c = std::net::TcpStream::connect(addr).unwrap();
4174 let origin_header = origin.map(|o| format!("Origin: {o}\r\n")).unwrap_or_default();
4175 let req = format!(
4176 "POST /api/cal HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n{origin_header}Content-Length: {}\r\nConnection: close\r\n\r\n",
4177 body.len()
4178 );
4179 c.write_all(req.as_bytes()).unwrap();
4180 c.write_all(body).unwrap();
4181 let mut out = String::new();
4182 let _ = c.read_to_string(&mut out);
4183 out
4184 })
4185 }
4186
4187 #[test]
4188 fn matching_allowed_origin_is_accepted() {
4189 let s = server().allow_origin("https://console.example.com");
4190 let r = post_with_origin(&s, Some("https://console.example.com"));
4191 assert!(r.contains("HTTP/1.1 200"), "{r}");
4192 }
4193
4194 #[test]
4195 fn non_matching_origin_is_still_rejected() {
4196 let s = server().allow_origin("https://console.example.com");
4197 let r = post_with_origin(&s, Some("https://not-allowed.example.com"));
4198 assert!(r.contains("403"), "{r}");
4199 assert!(r.contains("cross-origin request rejected"), "{r}");
4200 }
4201
4202 #[test]
4203 fn a_subdomain_of_an_allowed_origin_is_rejected_not_matched() {
4204 let s = server().allow_origin("https://console.example.com");
4207 let r = post_with_origin(&s, Some("https://evil-console.example.com"));
4208 assert!(r.contains("403"), "{r}");
4209 }
4210
4211 #[test]
4212 fn loopback_origin_still_works_with_no_allowlist_set() {
4213 let s = server();
4214 let r = post_with_origin(&s, Some("http://127.0.0.1:7437"));
4215 assert!(r.contains("HTTP/1.1 200"), "{r}");
4216 }
4217
4218 #[test]
4219 fn no_origin_header_still_works() {
4220 let s = server();
4221 let r = post_with_origin(&s, None);
4222 assert!(r.contains("HTTP/1.1 200"), "{r}");
4223 }
4224
4225 #[test]
4226 fn matching_origin_differing_only_in_case_and_trailing_slash_is_accepted() {
4227 let s = server().allow_origin("https://console.example.com");
4228 let r = post_with_origin(&s, Some("HTTPS://Console.Example.com/"));
4229 assert!(r.contains("HTTP/1.1 200"), "{r}");
4230 }
4231}
4232
4233#[cfg(all(test, feature = "oidc"))]
4242mod oidc_route_tests {
4243 use super::{cookie_value, oidc, UiServer};
4244 use areev_cal::AreevFacade;
4245 use areev_core::authz::{AUTHZ_NS, REL_PERMITS};
4246 use areev_core::types::{Fact, Grain};
4247 use areev_store::Areev;
4248
4249 fn cfg() -> oidc::OidcConfig {
4250 oidc::OidcConfig {
4251 issuer: "https://idp.example.com".into(),
4252 client_id: "console".into(),
4253 client_secret: "s3cr3t".into(),
4254 redirect_uri: "https://console.example.com/auth/callback".into(),
4255 scopes: "openid email".into(),
4256 principal_claim: "email".into(),
4257 principal_prefix: None,
4258 }
4259 }
4260
4261 #[test]
4265 fn cookie_lookup_is_exact_on_the_name() {
4266 let h = "theme=dark; areev_session=real; other=x";
4267 assert_eq!(cookie_value(h, "areev_session").as_deref(), Some("real"));
4268 let decoy = "areev_session_decoy=evil; areev_session=real";
4270 assert_eq!(cookie_value(decoy, "areev_session").as_deref(), Some("real"));
4271 let only_decoy = "xareev_session=evil; areev_sessionx=evil2";
4272 assert!(cookie_value(only_decoy, "areev_session").is_none());
4273 assert!(cookie_value("", "areev_session").is_none());
4274 }
4275
4276 #[test]
4279 fn the_auth_endpoints_redirect_and_manage_the_cookie() {
4280 let dir = tempfile::tempdir().unwrap();
4281 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4282 std::mem::forget(dir);
4283 let facade = AreevFacade::with_session(store, Some("ops".into()), None);
4284 let provider = oidc::OidcProvider::with_test_session(cfg(), "sid-1", "user:pat");
4285 let s = UiServer::new(facade, "oidc-test".into()).with_oidc(provider);
4286
4287 let (status, extra, _) = s.oidc_route("GET", "/auth/login", None);
4289 assert!(status.starts_with("302"), "{status}");
4290 assert!(extra.contains("Location: https://idp.example.com/authorize"), "{extra}");
4291 assert!(!extra.contains("Set-Cookie"), "{extra}");
4292 assert!(extra.contains("Cache-Control: no-store"), "a redirect carrying state must not cache: {extra}");
4293
4294 assert!(s.oidc_principal(Some("areev_session=sid-1")).is_some());
4296
4297 let (status, extra, _) =
4299 s.oidc_route("POST", "/auth/logout", Some("areev_session=sid-1"));
4300 assert!(status.starts_with("302"), "{status}");
4301 assert!(extra.contains("Max-Age=0"), "{extra}");
4302 assert!(extra.contains("HttpOnly"), "{extra}");
4303 assert!(extra.contains("SameSite=Strict"), "{extra}");
4304 assert!(
4305 s.oidc_principal(Some("areev_session=sid-1")).is_none(),
4306 "logout must invalidate the session, not just the browser's copy"
4307 );
4308
4309 let (status, _, body) =
4311 s.oidc_route("GET", "/auth/callback?error=access_denied", None);
4312 assert!(status.starts_with("400"), "{status}");
4313 assert!(body.contains("access_denied"), "{body}");
4314 }
4315
4316 #[test]
4320 fn the_session_cookie_is_httponly_strict_and_secure_only_over_https() {
4321 let mk = |redirect: &str| {
4322 let dir = tempfile::tempdir().unwrap();
4323 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4324 std::mem::forget(dir);
4325 let facade = AreevFacade::with_session(store, Some("ops".into()), None);
4326 let mut c = cfg();
4327 c.redirect_uri = redirect.into();
4328 let p = oidc::OidcProvider::with_test_session(c, "sid", "user:pat");
4329 UiServer::new(facade, "t".into()).with_oidc(p)
4330 };
4331
4332 let https = mk("https://console.example.com/auth/callback");
4333 let (_, extra, _) = https.oidc_route("GET", "/auth/logout", None);
4334 assert!(extra.contains("; Secure"), "https deployment must set Secure: {extra}");
4335
4336 let http = mk("http://127.0.0.1:7437/auth/callback");
4337 let (_, extra, _) = http.oidc_route("GET", "/auth/logout", None);
4338 assert!(!extra.contains("; Secure"), "loopback http must not set Secure: {extra}");
4339 assert!(extra.contains("HttpOnly") && extra.contains("SameSite=Strict"), "{extra}");
4341 }
4342
4343 #[test]
4348 fn an_oidc_principal_may_approve_regardless_of_sso_approvals() {
4349 let dir = tempfile::tempdir().unwrap();
4350 let path = dir.path().join("t.db");
4351 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
4352 store
4353 .add(
4354 &Fact::new("user:pat", REL_PERMITS, "read,run.respond ON ops")
4355 .namespace(AUTHZ_NS)
4356 .created_at(900),
4357 )
4358 .unwrap();
4359 std::mem::forget(dir);
4360 let facade = AreevFacade::with_session(store, Some("ops".into()), None);
4361 let provider = oidc::OidcProvider::with_test_session(cfg(), "sid-1", "user:pat");
4362 let s = UiServer::new(facade, "oidc-test".into())
4363 .with_oidc(provider)
4364 .with_sso_approvals(false);
4366
4367 let r = s.route_full("GET", "/api/whoami", b"", None, None, None, Some("user:pat"));
4368 let text = String::from_utf8_lossy(&r.2).to_string();
4369 assert!(text.contains(r#""identity_source":"oidc""#), "{text}");
4370 assert!(
4371 text.contains(r#""may_approve":true"#),
4372 "an IdP-proven identity is exactly what --sso-approvals exists to \
4373 substitute for; it must not be caught by that refusal: {text}"
4374 );
4375 assert!(text.contains("user:pat"), "{text}");
4376 }
4377}
4378
4379#[cfg(test)]
4380mod auth_failure_tracking_tests {
4381 use super::UiServer;
4382 use areev_cal::AreevFacade;
4383 use areev_store::Areev;
4384 use std::io::{Read, Write};
4385 use std::net::IpAddr;
4386
4387 const LOCALHOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
4388
4389 fn server() -> UiServer {
4390 let dir = tempfile::tempdir().unwrap();
4391 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4392 std::mem::forget(dir);
4393 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4394 UiServer::new(facade, "auth-failure-test".into()).with_auth("right-token".into())
4395 }
4396
4397 fn get_root(server: &UiServer, bearer: Option<&str>) -> String {
4400 let auth_header = bearer
4401 .map(|t| format!("Authorization: Bearer {t}\r\n"))
4402 .unwrap_or_default();
4403 get_root_with(server, &auth_header)
4404 }
4405
4406 fn get_root_with(server: &UiServer, extra_headers: &str) -> String {
4409 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
4410 let addr = listener.local_addr().unwrap();
4411 std::thread::scope(|scope| {
4412 scope.spawn(|| {
4413 if let Ok((s, _)) = listener.accept() {
4414 let _ = server.handle(s);
4415 }
4416 });
4417 let mut c = std::net::TcpStream::connect(addr).unwrap();
4418 let req = format!("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n{extra_headers}Connection: close\r\n\r\n");
4419 c.write_all(req.as_bytes()).unwrap();
4420 let mut out = String::new();
4421 let _ = c.read_to_string(&mut out);
4422 out
4423 })
4424 }
4425
4426 #[test]
4427 fn a_bare_request_is_401_but_not_counted() {
4428 let s = server();
4429 let bare = get_root(&s, None);
4433 assert!(bare.contains("HTTP/1.1 401"), "{bare}");
4434 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 0, "a bare request has nothing to fail");
4435 }
4436
4437 #[test]
4438 fn a_wrong_token_is_401_and_is_counted() {
4439 let s = server();
4440 let wrong = get_root(&s, Some("guess"));
4441 assert!(wrong.contains("HTTP/1.1 401"), "{wrong}");
4442 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 1);
4443
4444 let wrong_again = get_root(&s, Some("guess"));
4445 assert!(wrong_again.contains("HTTP/1.1 401"), "{wrong_again}");
4446 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 2, "consecutive failures accumulate");
4447 }
4448
4449 #[test]
4450 fn a_right_token_after_a_streak_succeeds_immediately_and_resets_it() {
4451 let s = server();
4452 for _ in 0..5 {
4453 let r = get_root(&s, Some("guess"));
4454 assert!(r.contains("HTTP/1.1 401"), "{r}");
4455 }
4456 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 5);
4457
4458 let ok = get_root(&s, Some("right-token"));
4463 assert!(ok.contains("HTTP/1.1 200"), "{ok}");
4464 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 0, "success resets the streak");
4465
4466 let r = get_root(&s, Some("guess"));
4468 assert!(r.contains("HTTP/1.1 401"), "{r}");
4469 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 1);
4470 }
4471
4472 #[test]
4478 fn a_proxy_proven_request_is_never_locked_out() {
4479 let dir = tempfile::tempdir().unwrap();
4480 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4481 std::mem::forget(dir);
4482 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4483 let s = UiServer::new(facade, "proxy-test".into())
4484 .with_auth("right-token".into())
4485 .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>);
4486
4487 for _ in 0..(super::MAX_CONSECUTIVE_AUTH_FAILURES + 5) {
4490 let _ = get_root_with(
4491 &s,
4492 "Authorization: Bearer guess\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
4493 );
4494 }
4495 let ok = get_root_with(
4497 &s,
4498 "Authorization: Bearer right-token\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
4499 );
4500 assert!(ok.contains("HTTP/1.1 200"), "proxy-borne traffic must not be locked out: {ok}");
4501 }
4502
4503 #[test]
4507 fn a_long_streak_is_refused_with_429_until_it_goes_idle() {
4508 let s = server();
4509 for i in 0..super::MAX_CONSECUTIVE_AUTH_FAILURES {
4510 let r = get_root(&s, Some("guess"));
4511 assert!(r.contains("HTTP/1.1 401"), "attempt {i} should still be a 401: {r}");
4512 }
4513
4514 let blocked = get_root(&s, Some("guess"));
4516 assert!(blocked.contains("HTTP/1.1 429"), "{blocked}");
4517 assert!(blocked.contains("Retry-After:"), "a 429 must say when: {blocked}");
4518
4519 let right = get_root(&s, Some("right-token"));
4524 assert!(right.contains("HTTP/1.1 429"), "{right}");
4525
4526 let bare = get_root(&s, None);
4530 assert!(bare.contains("HTTP/1.1 401"), "{bare}");
4531 }
4532}