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}
246
247#[cfg(feature = "oidc")]
251fn auth_error(msg: &str) -> (String, String, String) {
252 (
253 "400 Bad Request".into(),
254 "Content-Type: text/plain; charset=utf-8\r\nCache-Control: no-store\r\n".into(),
255 format!("login failed: {msg}\n\nStart again at /auth/login\n"),
256 )
257}
258
259#[cfg(feature = "oidc")]
260fn not_found_auth() -> (String, String, String) {
261 (
262 "404 Not Found".into(),
263 "Content-Type: text/plain; charset=utf-8\r\n".into(),
264 "no such auth endpoint\n".into(),
265 )
266}
267
268#[cfg(feature = "oidc")]
274fn cookie_value(header: &str, name: &str) -> Option<String> {
275 header.split(';').find_map(|pair| {
276 let (k, v) = pair.split_once('=')?;
277 (k.trim() == name).then(|| v.trim().to_string())
278 })
279}
280
281fn sanitize_sso_identity(raw: &str) -> Option<String> {
294 let id = raw.trim();
295 if id.is_empty() || id.len() > MAX_SSO_IDENTITY_BYTES {
296 return None;
297 }
298 if id.chars().any(|c| c.is_control() || c.is_whitespace()) {
302 return None;
303 }
304 if RESERVED_PRINCIPALS.contains(&id) {
305 return None;
306 }
307 Some(id.to_string())
308}
309
310struct RequestBinding<'a> {
316 facade: &'a AreevFacade,
317}
318
319impl<'a> RequestBinding<'a> {
320 fn bind(
321 facade: &'a AreevFacade,
322 map: &areev_core::authz::CredentialMap,
323 bearer: Option<&str>,
324 memory: &str,
325 ) -> Self {
326 let principal =
329 bearer.and_then(|t| map.resolve_for_memory(t, memory).ok().map(str::to_string));
330 match principal {
331 Some(p) => {
332 if facade.bind_principal(&p).is_err() {
333 Self::bind_anonymous(facade);
336 }
337 }
338 None => Self::bind_anonymous(facade),
339 }
340 RequestBinding { facade }
341 }
342
343 fn bind_identity(facade: &'a AreevFacade, principal: &str) -> Self {
349 if facade.bind_principal(principal).is_err() {
350 Self::bind_anonymous(facade);
351 }
352 RequestBinding { facade }
353 }
354
355 fn bind_anonymous(facade: &AreevFacade) {
356 use areev_core::authz::{AuthzSet, Grant, Verb};
357 let mut grants = facade
358 .with_store(|m| m.authz_grants("anonymous"))
359 .unwrap_or_default();
360 grants.push(Grant { verbs: vec![Verb::Read], namespaces: vec!["*".to_string()] });
361 facade.bind(AuthzSet::restricted("anonymous", grants));
362 }
363}
364
365impl Drop for RequestBinding<'_> {
366 fn drop(&mut self) {
367 self.facade
368 .bind(areev_core::authz::AuthzSet::owner("user:console"));
369 }
370}
371
372impl UiServer {
373 pub fn new(facade: AreevFacade, db_label: String) -> Self {
374 let db_label_display = redact_dsn(&db_label);
375 UiServer {
376 facade: std::sync::Arc::new(facade),
377 executor: CalExecutor::new(CalExecutorConfig::default())
378 .with_governance(std::sync::Arc::new(areev_loop_adapter::LoopGovernance::new())),
379 db_label,
380 db_label_display,
381 token: None,
382 auth_all: false,
383 allow_remote: false,
384 allowed_origins: Vec::new(),
385 loop_policy: None,
386 credentials: None,
387 allow_destructive_ops: CalExecutorConfig::default().allow_destructive_ops,
388 #[cfg(feature = "tls")]
389 tls_config: None,
390 sso_header: None,
391 sso_secrets: Vec::new(),
392 sso_groups_header: None,
393 sso_principal_prefix: None,
394 #[cfg(feature = "oidc")]
395 oidc: None,
396 sso_approvals: false,
397 auth_failures: std::sync::Mutex::new(std::collections::HashMap::new()),
398 }
399 }
400
401 pub fn with_sso(self, header: impl Into<String>, secret: impl Into<String>) -> Self {
405 self.with_sso_rotating(header, secret, None::<String>)
406 }
407
408 pub fn with_sso_rotating(
423 mut self,
424 header: impl Into<String>,
425 secret: impl Into<String>,
426 next: Option<impl Into<String>>,
427 ) -> Self {
428 let header = header.into();
429 let secret = secret.into();
430 let next = next.map(Into::into).filter(|s| !s.trim().is_empty());
431 assert!(
435 !header.trim().is_empty() && !secret.trim().is_empty(),
436 "with_sso requires a non-empty header name and proxy secret"
437 );
438 assert!(
442 next.as_deref() != Some(secret.as_str()),
443 "the rotating proxy secret must differ from the current one"
444 );
445 self.sso_header = Some(header.to_ascii_lowercase());
446 self.sso_secrets = match next {
447 Some(n) => vec![secret, n],
448 None => vec![secret],
449 };
450 self
451 }
452
453 #[cfg(feature = "oidc")]
455 pub fn with_oidc(mut self, provider: oidc::OidcProvider) -> Self {
456 self.oidc = Some(std::sync::Arc::new(provider));
457 self
458 }
459
460 #[cfg(feature = "oidc")]
462 fn oidc_principal(&self, cookie_header: Option<&str>) -> Option<String> {
463 let provider = self.oidc.as_ref()?;
464 let sid = cookie_value(cookie_header?, oidc::SESSION_COOKIE)?;
465 provider.principal_for_session(&sid)
466 }
467
468 #[cfg(feature = "oidc")]
473 fn oidc_route(
474 &self,
475 method: &str,
476 path: &str,
477 cookie_header: Option<&str>,
478 ) -> (String, String, String) {
479 let provider = match &self.oidc {
480 Some(p) => p,
481 None => return not_found_auth(),
482 };
483 let (base, query) = match path.split_once('?') {
484 Some((p, q)) => (p, q),
485 None => (path, ""),
486 };
487 let q = |key: &str| -> Option<String> {
488 query.split('&').find_map(|kv| {
489 let (k, v) = kv.split_once('=')?;
490 (k == key).then(|| urldecode(v))
491 })
492 };
493 let secure = if provider.is_secure() { "; Secure" } else { "" };
498
499 match (method, base) {
500 ("GET", "/auth/login") => match provider.authorize_url() {
501 Ok(url) => (
502 "302 Found".into(),
503 format!("Location: {url}\r\nCache-Control: no-store\r\n"),
504 String::new(),
505 ),
506 Err(e) => auth_error(&e.to_string()),
507 },
508 ("GET", "/auth/callback") => {
509 let (Some(code), Some(state)) = (q("code"), q("state")) else {
510 return auth_error(&match q("error") {
513 Some(e) => format!("the identity provider refused the login: {e}"),
514 None => "callback missing `code` or `state`".to_string(),
515 });
516 };
517 match provider.complete_login(&code, &state) {
518 Ok(sid) => (
519 "302 Found".into(),
520 format!(
521 "Location: /\r\nCache-Control: no-store\r\n\
522 Set-Cookie: {}={sid}; HttpOnly; SameSite=Strict; Path=/{secure}\r\n",
523 oidc::SESSION_COOKIE
524 ),
525 String::new(),
526 ),
527 Err(e) => auth_error(&e.to_string()),
528 }
529 }
530 ("POST", "/auth/logout") | ("GET", "/auth/logout") => {
535 if let Some(sid) = cookie_header.and_then(|c| cookie_value(c, oidc::SESSION_COOKIE))
536 {
537 provider.logout(&sid);
540 }
541 (
542 "302 Found".into(),
543 format!(
544 "Location: /\r\nCache-Control: no-store\r\n\
545 Set-Cookie: {}=; HttpOnly; SameSite=Strict; Path=/{secure}; Max-Age=0\r\n",
546 oidc::SESSION_COOKIE
547 ),
548 String::new(),
549 )
550 }
551 _ => not_found_auth(),
552 }
553 }
554
555 pub fn with_sso_groups_header(mut self, header: impl Into<String>) -> Self {
559 let header = header.into();
560 assert!(
561 !header.trim().is_empty(),
562 "with_sso_groups_header requires a non-empty header name"
563 );
564 self.sso_groups_header = Some(header.to_ascii_lowercase());
565 self
566 }
567
568 pub fn with_sso_principal_prefix(mut self, prefix: impl Into<String>) -> Self {
572 let prefix = prefix.into();
573 assert!(
574 !prefix.trim().is_empty(),
575 "with_sso_principal_prefix requires a non-empty prefix"
576 );
577 self.sso_principal_prefix = Some(prefix);
578 self
579 }
580
581 fn resolve_sso_principal(
601 &self,
602 identity: &str,
603 groups_raw: Option<&str>,
604 ) -> Option<(String, bool)> {
605 let stamp = |p: &str| match &self.sso_principal_prefix {
606 Some(prefix) if !p.starts_with(prefix.as_str()) => format!("{prefix}{p}"),
607 _ => p.to_string(),
608 };
609 let identity = stamp(identity);
610
611 let identity_granted = self
616 .facade
617 .with_store(|m| m.authz_grants(&identity))
618 .map(|g| !g.is_empty())
619 .unwrap_or(false);
620 if identity_granted {
621 return Some((identity, false));
622 }
623
624 if let (Some(map), Some(raw)) = (&self.credentials, groups_raw) {
625 for group in raw.split(',') {
626 if let Some(principal) = map.principal_for_group(group) {
627 return Some((principal.to_string(), true));
628 }
629 }
630 }
631
632 Some((identity, false))
637 }
638
639 pub fn with_sso_approvals(mut self, allow: bool) -> Self {
651 self.sso_approvals = allow;
652 self
653 }
654
655 fn rebuild_executor(&mut self) {
662 self.executor = CalExecutor::new(CalExecutorConfig {
663 allow_destructive_ops: self.allow_destructive_ops,
664 ..CalExecutorConfig::default()
665 })
666 .with_governance(std::sync::Arc::new(match &self.loop_policy {
667 Some(p) => areev_loop_adapter::LoopGovernance::with_policy(p.clone()),
668 None => areev_loop_adapter::LoopGovernance::new(),
669 }));
670 }
671
672 pub fn with_credentials(mut self, map: areev_core::authz::CredentialMap) -> Self {
677 self.credentials = Some(map);
678 self
679 }
680
681 pub fn with_loop_policy(mut self, policy: areev_loop::Policy) -> Self {
685 self.loop_policy = Some(policy);
686 self.rebuild_executor();
688 self
689 }
690
691 fn engine(&self) -> Engine {
694 match &self.loop_policy {
695 Some(p) => Engine::with_builtins().with_policy(p.clone()),
696 None => Engine::with_builtins(),
697 }
698 }
699
700 pub fn allow_remote(mut self, yes: bool) -> Self {
704 self.allow_remote = yes;
705 self
706 }
707
708 pub fn allow_origin(mut self, origin: impl Into<String>) -> Self {
723 self.allowed_origins.push(normalize_origin(&origin.into()));
724 self
725 }
726
727 fn origin_is_allowed(&self, origin: &str) -> bool {
731 let normalized = normalize_origin(origin);
732 self.allowed_origins.contains(&normalized)
733 }
734
735 pub fn with_auth(mut self, token: String) -> Self {
742 assert!(
746 !token.trim().is_empty(),
747 "with_auth requires a non-empty token"
748 );
749 self.token = Some(token);
750 self.auth_all = true;
751 self
752 }
753
754 pub fn allow_destructive_ops(mut self, allow: bool) -> Self {
759 self.allow_destructive_ops = allow;
760 self.rebuild_executor();
761 self
762 }
763
764 pub fn bind(addr: &str) -> std::io::Result<TcpListener> {
766 TcpListener::bind(addr)
767 }
768
769 pub fn serve(&self, listener: TcpListener) -> std::io::Result<()> {
771 for stream in listener.incoming() {
772 match stream {
773 Ok(s) => {
774 if let Err(e) = self.handle(s) {
775 eprintln!("areev ui: request error: {e}");
776 }
777 }
778 Err(e) => eprintln!("areev ui: accept error: {e}"),
779 }
780 }
781 Ok(())
782 }
783
784 #[cfg(feature = "tls")]
788 pub fn with_tls(mut self, cert_pem: &str, key_pem: &str) -> std::io::Result<Self> {
789 use rustls::pki_types::pem::PemObject;
790 let pem_err =
791 |e: rustls::pki_types::pem::Error| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string());
792 let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
793 rustls::pki_types::CertificateDer::pem_file_iter(cert_pem)
794 .map_err(pem_err)?
795 .collect::<Result<_, _>>()
796 .map_err(pem_err)?;
797 let key =
798 rustls::pki_types::PrivateKeyDer::from_pem_file(key_pem).map_err(pem_err)?;
799 let config = rustls::ServerConfig::builder()
800 .with_no_client_auth()
801 .with_single_cert(certs, key)
802 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
803 self.tls_config = Some(std::sync::Arc::new(config));
804 Ok(self)
805 }
806
807 fn note_auth_failure(&self, ip: std::net::IpAddr) -> u32 {
832 let now = std::time::Instant::now();
833 let idle = Duration::from_secs(AUTH_FAILURE_IDLE_SECS);
834 let mut map = self
835 .auth_failures
836 .lock()
837 .unwrap_or_else(std::sync::PoisonError::into_inner);
838 match map.get_mut(&ip) {
839 Some((n, last)) if now.duration_since(*last) < idle => {
840 *n = n.saturating_add(1);
841 *last = now;
842 *n
843 }
844 Some(entry) => {
845 *entry = (1, now);
846 1
847 }
848 None => {
849 if map.len() >= MAX_AUTH_FAILURE_IPS {
850 if let Some(stalest) =
851 map.iter().min_by_key(|(_, (_, last))| *last).map(|(k, _)| *k)
852 {
853 map.remove(&stalest);
854 }
855 }
856 map.insert(ip, (1, now));
857 1
858 }
859 }
860 }
861
862 fn reset_auth_failures(&self, ip: std::net::IpAddr) {
864 let mut map = self
865 .auth_failures
866 .lock()
867 .unwrap_or_else(std::sync::PoisonError::into_inner);
868 map.remove(&ip);
869 }
870
871 fn auth_failure_count(&self, ip: std::net::IpAddr) -> u32 {
879 let idle = Duration::from_secs(AUTH_FAILURE_IDLE_SECS);
880 self.auth_failures
881 .lock()
882 .unwrap_or_else(std::sync::PoisonError::into_inner)
883 .get(&ip)
884 .filter(|(_, last)| last.elapsed() < idle)
885 .map(|(n, _)| *n)
886 .unwrap_or(0)
887 }
888
889 #[cfg(test)]
892 fn auth_failure_count_for_test(&self, ip: std::net::IpAddr) -> u32 {
893 self.auth_failure_count(ip)
894 }
895
896 fn wrap(&self, stream: TcpStream) -> std::io::Result<Conn> {
899 #[cfg(feature = "tls")]
900 if let Some(config) = &self.tls_config {
901 let session = rustls::ServerConnection::new(std::sync::Arc::clone(config))
902 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
903 return Ok(Conn::Tls(Box::new(rustls::StreamOwned::new(session, stream))));
904 }
905 Ok(Conn::Plain(stream))
906 }
907
908 fn handle(&self, stream: TcpStream) -> std::io::Result<()> {
909 let watchdog_stream = stream.try_clone()?;
915 let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
916 let watchdog_done = std::sync::Arc::clone(&done);
917 let watchdog = std::thread::spawn(move || {
918 std::thread::park_timeout(Duration::from_secs(REQUEST_DEADLINE_SECS));
919 if !watchdog_done.load(std::sync::atomic::Ordering::Acquire) {
920 let _ = watchdog_stream.shutdown(std::net::Shutdown::Both);
921 }
922 });
923 let raw = stream.try_clone()?;
924 let conn = self.wrap(stream)?;
925 let result = self.handle_request(&raw, conn);
926 done.store(true, std::sync::atomic::Ordering::Release);
927 watchdog.thread().unpark();
928 let _ = watchdog.join();
929 result
930 }
931
932 fn handle_request(&self, raw: &TcpStream, mut conn: Conn) -> std::io::Result<()> {
933 raw.set_read_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
938 raw.set_write_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
939 let mut reader = BufReader::new((&mut conn).take(MAX_CONN_BYTES));
940 let mut request_line = String::new();
941 reader.read_line(&mut request_line)?;
942 let mut parts = request_line.split_whitespace();
943 let method = parts.next().unwrap_or("").to_string();
944 let path = parts.next().unwrap_or("/").to_string();
945
946 let mut content_length = 0usize;
948 let mut bearer: Option<String> = None;
949 let mut origin: Option<String> = None;
950 let mut host: Option<String> = None;
951 let mut sso_identity_raw: Option<String> = None;
952 let mut sso_groups_raw: Option<String> = None;
953 #[cfg(feature = "oidc")]
954 let mut cookie_header: Option<String> = None;
955 let mut proxy_secret: Option<String> = None;
956 let mut header_bytes = 0usize;
957 let mut header_count = 0usize;
958 loop {
959 let mut line = String::new();
960 let n = reader.read_line(&mut line)?;
961 if n == 0 {
962 break; }
964 header_bytes += n;
965 header_count += 1;
966 if header_bytes > MAX_HEADER_BYTES || header_count > MAX_HEADERS {
967 return Err(std::io::Error::new(
968 std::io::ErrorKind::InvalidData,
969 "request headers too large",
970 ));
971 }
972 let l = line.trim();
973 if l.is_empty() {
974 break;
975 }
976 let low = l.to_ascii_lowercase();
977 if let Some(v) = low.strip_prefix("content-length:") {
978 content_length = v.trim().parse().unwrap_or(0);
979 }
980 if let Some(v) = low.strip_prefix("origin:") {
981 origin = Some(v.trim().to_string());
982 }
983 if let Some(v) = low.strip_prefix("host:") {
984 host = Some(v.trim().to_string());
985 }
986 #[cfg(feature = "oidc")]
987 if low.starts_with("cookie:") {
988 if let Some((_, orig)) = l.split_once(':') {
991 cookie_header = Some(orig.trim().to_string());
992 }
993 }
994 if let Some(v) = low.strip_prefix("x-areev-proxy-secret:") {
995 let _ = v;
998 if let Some((_, orig)) = l.split_once(':') {
999 proxy_secret = Some(orig.trim().to_string());
1000 }
1001 }
1002 if let Some(h) = &self.sso_header {
1003 if low.starts_with(&format!("{h}:")) {
1004 if let Some((_, orig)) = l.split_once(':') {
1005 sso_identity_raw = Some(orig.trim().to_string());
1006 }
1007 }
1008 }
1009 if let Some(h) = &self.sso_groups_header {
1010 if low.starts_with(&format!("{h}:")) {
1011 if let Some((_, orig)) = l.split_once(':') {
1012 sso_groups_raw = Some(orig.trim().to_string());
1013 }
1014 }
1015 }
1016 if low.starts_with("authorization:") {
1017 if let Some((_, v)) = l.split_once(':') {
1018 let v = v.trim();
1019 bearer = if let Some(t) =
1024 v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))
1025 {
1026 Some(t.trim().to_string())
1027 } else if let Some(b64) =
1028 v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))
1029 {
1030 basic_auth_password(b64.trim())
1031 } else {
1032 Some(v.to_string())
1033 };
1034 }
1035 }
1036 }
1037 if !self.allow_remote && host.as_deref().is_some_and(|h| !host_is_local(h)) {
1044 let payload = br#"{"ok":false,"error":"non-loopback Host rejected (use --allow-remote to serve remotely)"}"#;
1045 drop(reader);
1046 let mut out = conn;
1047 write!(
1048 out,
1049 "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1050 payload.len()
1051 )?;
1052 out.write_all(payload)?;
1053 return out.flush();
1054 }
1055
1056 let mut body = vec![0u8; content_length.min(1 << 20)];
1057 if content_length > 0 {
1058 reader.read_exact(&mut body)?;
1059 }
1060
1061 if method == "POST"
1068 && origin
1069 .as_deref()
1070 .is_some_and(|o| !origin_is_local(o) && !self.origin_is_allowed(o))
1071 {
1072 let payload = br#"{"ok":false,"error":"cross-origin request rejected"}"#;
1073 drop(reader);
1074 let mut out = conn;
1075 write!(
1076 out,
1077 "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1078 payload.len()
1079 )?;
1080 out.write_all(payload)?;
1081 return out.flush();
1082 }
1083
1084 let proxy_secret_presented = proxy_secret.is_some();
1089 let proxy_proved = match &proxy_secret {
1099 Some(presented) => {
1100 self.sso_secrets
1105 .iter()
1106 .fold(false, |acc, s| ct_eq(s.as_bytes(), presented.as_bytes()) | acc)
1107 }
1108 None => false,
1109 };
1110 let sso_identity: Option<String> = sso_identity_raw
1111 .filter(|_| proxy_proved)
1112 .as_deref()
1113 .and_then(sanitize_sso_identity);
1114 let sso_groups: Option<String> = sso_groups_raw.filter(|_| proxy_proved);
1115 #[cfg(feature = "oidc")]
1120 if self.oidc.is_some() && path.starts_with("/auth/") {
1121 let (status, extra, payload) =
1122 self.oidc_route(&method, &path, cookie_header.as_deref());
1123 drop(reader);
1124 let mut out = conn;
1125 write!(
1126 out,
1127 "HTTP/1.1 {status}\r\n{extra}Content-Length: {}\r\nConnection: close\r\n\r\n",
1128 payload.len()
1129 )?;
1130 out.write_all(payload.as_bytes())?;
1131 return out.flush();
1132 }
1133 #[cfg(feature = "oidc")]
1134 let oidc_principal = self.oidc_principal(cookie_header.as_deref());
1135 #[cfg(not(feature = "oidc"))]
1136 let oidc_principal: Option<String> = None;
1137
1138 let auth_configured =
1165 self.token.is_some() || self.credentials.is_some() || !self.sso_secrets.is_empty();
1166 let credential_presented = bearer.is_some() || proxy_secret_presented;
1167 if auth_configured && credential_presented && !proxy_proved {
1168 if let Ok(peer) = raw.peer_addr() {
1169 if self.auth_failure_count(peer.ip()) >= MAX_CONSECUTIVE_AUTH_FAILURES {
1170 let payload = format!(
1171 r#"{{"ok":false,"error":"too many failed authentications from this address - wait {} minutes, or restart the console to clear the counter"}}"#,
1172 AUTH_FAILURE_IDLE_SECS / 60
1173 );
1174 drop(reader);
1175 let mut out = conn;
1176 write!(
1177 out,
1178 "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",
1179 AUTH_FAILURE_IDLE_SECS,
1180 payload.len()
1181 )?;
1182 out.write_all(payload.as_bytes())?;
1183 return out.flush();
1184 }
1185 }
1186 }
1187
1188 let (status, ctype, payload) = self.route_full(
1189 &method,
1190 &path,
1191 &body,
1192 bearer.as_deref(),
1193 sso_identity.as_deref(),
1194 sso_groups.as_deref(),
1195 oidc_principal.as_deref(),
1196 );
1197
1198 if auth_configured && credential_presented {
1211 if let Ok(peer) = raw.peer_addr() {
1212 let ip = peer.ip();
1213 if status.starts_with("401") {
1214 let consecutive = self.note_auth_failure(ip);
1215 eprintln!("areev: console auth FAILED from {ip} ({consecutive} consecutive)");
1216 } else {
1217 self.reset_auth_failures(ip);
1218 }
1219 }
1220 }
1221
1222 let auth_challenge = if self.auth_all && status.starts_with("401") {
1225 "WWW-Authenticate: Basic realm=\"Areev console\", charset=\"UTF-8\"\r\n"
1226 } else {
1227 ""
1228 };
1229 drop(reader);
1230 let mut out = conn;
1231 write!(
1232 out,
1233 "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\n{auth_challenge}Content-Length: {}\r\nConnection: close\r\n\r\n",
1234 payload.len()
1235 )?;
1236 out.write_all(&payload)?;
1237 out.flush()
1238 }
1239
1240 fn runner(&self, principal: &str) -> areev_run::Runner {
1245 struct NoExec;
1246 impl areev_run::HostToolExecutor for NoExec {
1247 fn execute(
1248 &self,
1249 tool_name: &str,
1250 _h: &str,
1251 _i: &Value,
1252 _k: &str,
1253 ) -> areev_run::ExecResult {
1254 areev_run::ExecResult::Err {
1255 cause: areev_run::FailCause::ExecutorError,
1256 detail: format!("the console does not execute tools ('{tool_name}')"),
1257 }
1258 }
1259 }
1260 areev_run::Runner {
1261 facade: std::sync::Arc::clone(&self.facade),
1262 clock: std::sync::Arc::new(areev_run::SystemClock),
1263 executor: std::sync::Arc::new(NoExec),
1264 llm: None,
1265 observer: None,
1266 ns: self
1267 .facade
1268 .default_namespace()
1269 .unwrap_or("shared")
1270 .to_string(),
1271 principal: principal.to_string(),
1272 }
1273 }
1274
1275 #[cfg(test)]
1281 fn route(
1282 &self,
1283 method: &str,
1284 path: &str,
1285 body: &[u8],
1286 bearer: Option<&str>,
1287 sso_identity: Option<&str>,
1288 ) -> (&'static str, &'static str, Vec<u8>) {
1289 self.route_full(method, path, body, bearer, sso_identity, None, None)
1290 }
1291
1292 #[cfg(test)]
1295 fn route_with_groups(
1296 &self,
1297 method: &str,
1298 path: &str,
1299 body: &[u8],
1300 bearer: Option<&str>,
1301 sso_identity: Option<&str>,
1302 sso_groups: Option<&str>,
1303 ) -> (&'static str, &'static str, Vec<u8>) {
1304 self.route_full(method, path, body, bearer, sso_identity, sso_groups, None)
1305 }
1306
1307 #[allow(clippy::too_many_arguments)]
1314 fn route_full(
1315 &self,
1316 method: &str,
1317 path: &str,
1318 body: &[u8],
1319 bearer: Option<&str>,
1320 sso_identity: Option<&str>,
1321 sso_groups: Option<&str>,
1322 oidc_principal: Option<&str>,
1323 ) -> (&'static str, &'static str, Vec<u8>) {
1324 let mut shared_secret_ok = false;
1336 if let Some(tok) = &self.token {
1337 let guarded = self.auth_all || method == "POST";
1338 shared_secret_ok = bearer.is_some_and(|b| ct_eq(b.as_bytes(), tok.as_bytes()));
1339 let known = shared_secret_ok
1340 || sso_identity.is_some()
1341 || oidc_principal.is_some()
1342 || match (&self.credentials, bearer) {
1343 (Some(map), Some(b)) => map.resolve_for_memory(b, &self.db_label).is_ok(),
1344 _ => false,
1345 };
1346 if guarded && !known {
1347 return ("401 Unauthorized", "application/json",
1348 br#"{"ok":false,"error":"authentication required"}"#.to_vec());
1349 }
1350 } else if self.credentials.is_none()
1351 && sso_identity.is_none()
1352 && oidc_principal.is_none()
1353 && method == "POST"
1354 {
1355 let base = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
1361 let allowed = base == "/api/cal" && cal_body_is_read_only(body);
1362 if !allowed {
1363 return ("401 Unauthorized", "application/json",
1364 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());
1365 }
1366 }
1367
1368 let _session = match (&self.credentials, shared_secret_ok) {
1375 (Some(map), false) => Some(RequestBinding::bind(&self.facade, map, bearer, &self.db_label)),
1376 _ => None,
1377 };
1378 let mut request_principal: Option<String> = match (&self.credentials, shared_secret_ok, bearer)
1383 {
1384 (Some(map), false, Some(b)) => map.resolve_for_memory(b, &self.db_label).ok().map(str::to_string),
1385 _ => None,
1386 };
1387 let mut principal_from_sso = false;
1397 let mut principal_from_group = false;
1401 let mut principal_from_oidc = false;
1407 let _oidc_session = match (&request_principal, oidc_principal, shared_secret_ok) {
1408 (None, Some(principal), false) => {
1409 principal_from_oidc = true;
1410 let binding = RequestBinding::bind_identity(&self.facade, principal);
1411 request_principal = Some(principal.to_string());
1412 Some(binding)
1413 }
1414 _ => None,
1415 };
1416 let _sso_session = match (&request_principal, sso_identity, shared_secret_ok) {
1417 (None, Some(identity), false) => {
1418 match self.resolve_sso_principal(identity, sso_groups) {
1419 Some((principal, from_group)) => {
1420 principal_from_sso = true;
1421 principal_from_group = from_group;
1422 let binding = RequestBinding::bind_identity(&self.facade, &principal);
1423 request_principal = Some(principal);
1424 Some(binding)
1425 }
1426 None => None,
1427 }
1428 }
1429 _ => None,
1430 };
1431
1432 let (path, query) = match path.split_once('?') {
1433 Some((p, q)) => (p, q),
1434 None => (path, ""),
1435 };
1436 let q = |key: &str| -> Option<String> {
1437 query.split('&').find_map(|kv| {
1438 let (k, v) = kv.split_once('=')?;
1439 (k == key).then(|| urldecode(v))
1440 })
1441 };
1442 match (method, path) {
1443 ("GET", "/") => (
1444 "200 OK",
1445 "text/html; charset=utf-8",
1446 CONSOLE_HTML
1447 .replace("{{DB}}", &html_escape(&self.db_label_display))
1448 .into_bytes(),
1449 ),
1450 ("POST", "/api/cal") => {
1451 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1452 let queryt = req.get("query").and_then(|v| v.as_str()).unwrap_or("");
1453 match self.executor.execute(queryt, &*self.facade) {
1454 Ok(res) => ok_json(json!({
1455 "ok": true,
1456 "result": res.result,
1457 "warnings": res.warnings,
1458 "statement": res.metadata.statement_type,
1459 "elapsed_ms": res.metadata.execution_time_ms,
1460 })),
1461 Err(e) => {
1462 let mut err = json!({
1465 "ok": false,
1466 "error": e.sanitize_for_client(),
1467 "code": e.code(),
1468 });
1469 if let Some(sp) = e.span() {
1470 err["span"] = json!({
1471 "start": sp.start, "end": sp.end,
1472 "line": sp.line, "col": sp.col,
1473 });
1474 }
1475 if let Some(hint) = e.suggestion() {
1476 err["suggestion"] = json!(hint);
1477 }
1478 if let Some(sc) = e.store_code() {
1481 err["store_code"] = json!(sc);
1482 }
1483 ok_json(err)
1484 }
1485 }
1486 }
1487 ("GET", "/api/stats") => {
1488 let stats = self.facade.with_store(|m| m.stats());
1489 match stats {
1490 Ok(s) => ok_json(json!({
1491 "db": self.db_label_display,
1492 "grains": s.grains, "current": s.current,
1493 "triples": s.triples, "terms": s.terms,
1494 "ops": s.ops, "events_indexed": s.events_indexed,
1495 })),
1496 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1497 }
1498 }
1499 ("GET", "/api/log") => {
1500 let since: i64 = q("since").and_then(|v| v.parse().ok()).unwrap_or(0);
1501 let limit: usize = q("limit").and_then(|v| v.parse().ok()).unwrap_or(100);
1502 match self.facade.with_store(|m| m.changes_since(since, limit)) {
1503 Ok(ops) => {
1504 let rows: Vec<Value> = ops
1505 .iter()
1506 .map(|o| {
1507 json!({
1508 "op_seq": o.op_seq, "hlc": o.hlc,
1509 "op": match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" },
1510 "hash": o.hash.to_hex(),
1511 })
1512 })
1513 .collect();
1514 ok_json(json!(rows))
1515 }
1516 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1517 }
1518 }
1519 ("GET", "/api/whoami") => {
1523 let a = self.facade.authz();
1524 ok_json(json!({
1525 "ok": true,
1526 "principal": a.principal(),
1527 "owner": a.is_owner(),
1528 "mode": if self.credentials.is_some() { "credentials" }
1529 else if self.token.is_some() { "token" }
1530 else { "open" },
1531 "identity_source": if principal_from_oidc { "oidc" }
1535 else if principal_from_group { "sso-group" }
1536 else if principal_from_sso { "sso" }
1537 else if request_principal.is_some() { "credential" }
1538 else { "none" },
1539 "may_approve": request_principal.is_some()
1540 && !principal_from_group
1541 && (!principal_from_sso || self.sso_approvals),
1542 }))
1543 }
1544 ("GET", "/api/config") => {
1545 let cfg = self.executor.config();
1550 let (index_text, embedder_dim, declared_embed, mut warnings) =
1551 self.facade.with_store(|m| {
1552 (
1553 m.index_text_enabled(),
1554 m.embedder_dim(),
1555 m.declared_embedding().map(|(mm, d)| (mm.to_string(), d)),
1556 m.open_warnings().to_vec(),
1557 )
1558 });
1559 if let Some((m, d)) = &declared_embed {
1560 if embedder_dim.is_none() {
1561 warnings.push(format!(
1562 "vector leg dormant: file expects {m}@{d}, no embedding backend installed"
1563 ));
1564 }
1565 }
1566 warnings.extend(self.facade.meta_warnings());
1570 let (anon_declared, anon_floor, anon_audit) = self.facade.with_store(|m| {
1574 (
1575 m.anon_declared(),
1576 m.anonymize_egress_floor(),
1577 m.anon_audit_counts(),
1578 )
1579 });
1580 let anonymization = json!({
1581 "policies": anon_declared
1582 .iter()
1583 .map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
1584 .collect::<Vec<_>>(),
1585 "floor": anon_floor,
1586 "audit_counts": anon_audit
1587 .iter()
1588 .map(|(ns, cat, n)| json!({"ns": ns, "category": cat, "count": n}))
1589 .collect::<Vec<_>>(),
1590 });
1591 ok_json(json!({
1592 "ok": true,
1593 "db": self.db_label_display,
1594 "warnings": warnings,
1595 "anonymization": anonymization,
1596 "file": {
1597 "text_index": index_text,
1598 "embedding": declared_embed.map(|(m, d)| json!({"model": m, "dim": d})),
1599 },
1600 "session": {
1601 "namespace": self.facade.session_namespace(),
1602 "mounts": self.facade.mount_aliases(),
1603 "is_owner": self.facade.authz().is_owner(),
1606 },
1607 "store": {
1608 "index_text": index_text,
1609 "embedder": embedder_dim.map(|d| json!({"dim": d})),
1610 },
1611 "recall": {
1612 "fusion": "rrf",
1613 "rrf_k0": areev_store::RRF_K0,
1614 "overfetch_factor": AreevFacade::RECALL_OVERFETCH,
1615 "legs": {
1616 "structural": true,
1617 "bm25": index_text,
1618 "vector": embedder_dim.is_some(),
1619 },
1620 },
1621 "executor": {
1622 "max_limit": cfg.max_limit,
1623 "default_limit": cfg.default_limit,
1624 "tier1_writes": cfg.tier1_enabled,
1625 "namespace_override": cfg.namespace_override,
1626 "user_id_override": cfg.user_id_override,
1627 },
1628 "server": {
1629 "auth_required": self.token.is_some(),
1630 "auth_all": self.auth_all,
1633 },
1634 "persistence": "per-process (host-supplied at open) — not stored in the .db",
1635 }))
1636 }
1637 ("GET", "/api/browse") => {
1638 let limit: i64 = q("limit")
1644 .and_then(|v| v.parse().ok())
1645 .unwrap_or(500)
1646 .clamp(1, 2000);
1647 let built = self.facade.with_store(|m| -> Result<(i64, Vec<Value>), String> {
1648 let total = m.stats().map_err(|e| e.to_string())?.ops as i64;
1649 let after = (total - limit).max(0);
1650 let ops = m.changes_since(after, limit as usize).map_err(|e| e.to_string())?;
1651 let mut rows: Vec<Value> = Vec::with_capacity(ops.len());
1652 let mut idx: std::collections::HashMap<String, usize> =
1653 std::collections::HashMap::new();
1654 for o in &ops {
1655 let hex = o.hash.to_hex();
1656 let op_name = match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" };
1657 if o.op == 3 {
1658 match idx.get(&hex) {
1661 Some(&i) => { rows[i]["forgotten"] = json!(true); }
1662 None => rows.push(json!({
1663 "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc,
1664 "op": op_name, "forgotten": true,
1665 })),
1666 }
1667 continue;
1668 }
1669 if let Some(&i) = idx.get(&hex) {
1673 rows[i]["op"] = json!(op_name);
1674 rows[i]["op_seq"] = json!(o.op_seq);
1675 rows[i]["hlc"] = json!(o.hlc);
1676 continue;
1677 }
1678 let mut row = json!({
1679 "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc, "op": op_name,
1680 });
1681 match m.get(&o.hash) {
1682 Ok(g) => {
1683 row["type"] = json!(format!("{:?}", g.grain_type).to_lowercase());
1684 row["fields"] = serde_json::to_value(&g.fields).unwrap_or(Value::Null);
1685 }
1686 Err(_) => { row["missing"] = json!(true); }
1688 }
1689 idx.insert(hex, rows.len());
1690 rows.push(row);
1691 }
1692 let marks: Vec<(String, String)> = rows.iter()
1695 .filter(|r| r["op"] == "supersede")
1696 .filter_map(|r| {
1697 let old = r["fields"].get("derived_from")?.as_str()?;
1698 Some((old.to_string(), r["hash"].as_str()?.to_string()))
1699 })
1700 .collect();
1701 for (old, newer) in marks {
1702 if let Some(&i) = idx.get(&old) {
1703 rows[i]["superseded_by"] = json!(newer);
1704 }
1705 }
1706 rows.reverse();
1707 Ok((total, rows))
1708 });
1709 let ns_param = q("ns");
1726 let built = built.and_then(|(total, rows)| {
1727 let authz = self.facade.authz();
1728 let filter_ns = match ns_param.as_deref() {
1729 Some("*") => {
1730 if !authz.is_owner() {
1731 return Err("not authorized to browse all namespaces".to_string());
1732 }
1733 None
1734 }
1735 Some(ns) => {
1736 authz
1737 .check(areev_core::authz::Verb::Read, ns)
1738 .map_err(|e| e.to_string())?;
1739 Some(ns.to_string())
1740 }
1741 None => self.facade.session_namespace().map(|s| s.to_string()),
1742 };
1743 let Some(ns) = filter_ns else {
1744 return Ok((total, rows));
1745 };
1746 let kept = rows
1747 .into_iter()
1748 .filter(|r| {
1749 r["fields"]
1750 .get("namespace")
1751 .and_then(Value::as_str)
1752 .is_none_or(|got| got == ns)
1753 })
1754 .collect();
1755 Ok((total, kept))
1756 });
1757 match built {
1758 Ok((total, rows)) => ok_json(json!({"ok": true, "total_ops": total, "grains": rows})),
1759 Err(e) => ok_json(json!({"ok": false, "error": e})),
1760 }
1761 }
1762 ("GET", "/api/namespaces") => {
1763 let result = self.facade.with_store(|m| m.namespaces());
1768 match result {
1769 Ok(list) => {
1770 let authz = self.facade.authz();
1771 let owner = authz.is_owner();
1772 let arr: Vec<Value> = list
1773 .into_iter()
1774 .filter(|(ns, _)| {
1775 owner || authz.check(areev_core::authz::Verb::Read, ns).is_ok()
1776 })
1777 .map(|(ns, n)| json!({"namespace": ns, "count": n}))
1778 .collect();
1779 ok_json(json!({"ok": true, "namespaces": arr}))
1780 }
1781 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1782 }
1783 }
1784 ("GET", "/api/grain") => {
1785 let hash = q("hash").unwrap_or_default();
1786 match areev_core::error::Hash::from_hex(&hash)
1787 .and_then(|h| self.facade.get(&h))
1788 {
1789 Ok(g) => ok_json(json!({
1790 "hash": g.hash.to_hex(),
1791 "type": format!("{:?}", g.grain_type).to_lowercase(),
1792 "fields": g.fields,
1793 })),
1794 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1795 }
1796 }
1797 ("GET", "/api/anon/preview") => {
1798 let hash = q("hash").unwrap_or_default();
1801 match areev_core::error::Hash::from_hex(&hash)
1802 .and_then(|h| self.facade.with_store(|m| m.anon_preview(&h)))
1803 {
1804 Ok(g) => ok_json(json!({
1805 "hash": g.hash.to_hex(),
1806 "type": format!("{:?}", g.grain_type).to_lowercase(),
1807 "fields": g.fields,
1808 })),
1809 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1810 }
1811 }
1812 ("GET", "/api/verify") => match self.facade.with_store(|m| m.verify()) {
1813 Ok(r) => ok_json(json!({
1814 "integrity": r.integrity, "grains": r.grains,
1815 "hash_mismatches": r.hash_mismatches, "undecodable": r.undecodable,
1816 })),
1817 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1818 },
1819 ("GET", "/api/run/list") => {
1823 let runner = self.runner("console:read");
1831 let ns = q("ns").filter(|s| !s.is_empty() && s.as_str() != "*");
1832 let limit = q("limit")
1833 .and_then(|v| v.parse::<usize>().ok())
1834 .unwrap_or(50)
1835 .clamp(1, 500);
1836 let offset = q("offset").and_then(|v| v.parse::<usize>().ok()).unwrap_or(0);
1837 match runner.list_runs(ns.as_deref(), offset, limit) {
1838 Ok(page) => {
1839 let rows: Vec<Value> = page
1840 .runs
1841 .iter()
1842 .map(|r| {
1843 serde_json::json!({
1844 "run_id": r.run_id,
1845 "ns": r.ns,
1846 "outcome": r.outcome,
1847 })
1848 })
1849 .collect();
1850 ok_json(serde_json::json!({
1851 "ok": true,
1852 "runs": rows,
1853 "total": page.total,
1854 "truncated": page.truncated,
1855 "offset": page.offset,
1856 "limit": page.limit,
1857 "unattributed": page.unattributed,
1858 }))
1859 }
1860 Err(e) => run_api_err(&e.to_string()),
1861 }
1862 }
1863 ("GET", "/api/run/inspect") => {
1864 let Some(run_id) = q("run_id") else {
1865 return run_api_err("run_id required");
1866 };
1867 match self.runner("console:read").inspect(&run_id) {
1871 Ok(report) => {
1872 let mut v = serde_json::to_value(&report).unwrap_or(Value::Null);
1873 if let Some(o) = v.as_object_mut() {
1874 o.insert("ok".into(), Value::Bool(true));
1875 }
1876 ok_json(v)
1877 }
1878 Err(e) => run_api_err(&e.to_string()),
1879 }
1880 }
1881 ("GET", "/api/run/shadow") => {
1886 let Some(runs) = q("runs") else { return run_api_err("runs required (comma-separated)") };
1887 let Some(plan) = q("plan") else { return run_api_err("plan required (a Workflow hash)") };
1888 let ids: Vec<String> = runs.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
1889 let h = match areev_core::error::Hash::from_hex(&plan) {
1890 Ok(h) => h,
1891 Err(e) => return run_api_err(&e.to_string()),
1892 };
1893 match self.runner("console:read").shadow_plan(&ids, &areev_run::PlanCandidate::Hash(h)) {
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 ("POST", "/api/run/shadow") => {
1905 let v: Value = match serde_json::from_slice(body) {
1906 Ok(v) => v,
1907 Err(e) => return run_api_err(&e.to_string()),
1908 };
1909 let ids: Vec<String> = match v.get("runs") {
1910 Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(),
1911 Some(Value::String(csv)) => csv.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
1912 _ => return run_api_err("runs required (array or comma-separated)"),
1913 };
1914 let candidate = match (v.get("plan").and_then(Value::as_str), v.get("plan_body").and_then(Value::as_object)) {
1915 (Some(_), Some(_)) => return run_api_err("give plan or plan_body, not both"),
1916 (Some(h), None) => match areev_core::error::Hash::from_hex(h) {
1917 Ok(h) => areev_run::PlanCandidate::Hash(h),
1918 Err(e) => return run_api_err(&e.to_string()),
1919 },
1920 (None, Some(body)) => areev_run::PlanCandidate::Body(body.clone()),
1921 (None, None) => return run_api_err("plan (hash) or plan_body (object) required"),
1922 };
1923 match self.runner("console:read").shadow_plan(&ids, &candidate) {
1924 Ok(report) => {
1925 let mut v = serde_json::to_value(&report).unwrap_or(Value::Null);
1926 if let Some(o) = v.as_object_mut() {
1927 o.insert("ok".into(), Value::Bool(true));
1928 }
1929 ok_json(v)
1930 }
1931 Err(e) => run_api_err(&e.to_string()),
1932 }
1933 }
1934 ("POST", "/api/run/respond") => {
1935 let Some(responder) = request_principal.clone() else {
1940 return ("403 Forbidden", "application/json",
1941 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());
1942 };
1943 if principal_from_sso && !self.sso_approvals {
1951 return ("403 Forbidden", "application/json",
1952 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());
1953 }
1954 if principal_from_group {
1959 return ("403 Forbidden", "application/json",
1960 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());
1961 }
1962 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1963 let (Some(run_id), Some(ask)) = (
1964 req.get("run_id").and_then(Value::as_str),
1965 req.get("tool_call_id").and_then(Value::as_str),
1966 ) else {
1967 return run_api_err("run_id and tool_call_id required");
1968 };
1969 let result = req.get("result").cloned().unwrap_or(Value::Null);
1970 let is_error = req.get("is_error").and_then(Value::as_bool).unwrap_or(false);
1971 let runner = self.runner(&responder);
1972 match runner.respond(run_id, ask, result, is_error, &responder) {
1973 Ok(()) => ok_json(serde_json::json!({
1974 "ok": true, "responded": ask, "responder": responder,
1975 "note": "resume the run to spend the compute",
1976 })),
1977 Err(e) => run_api_err(&e.to_string()),
1978 }
1979 }
1980 ("POST", "/api/run/cancel") => {
1981 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1984 let Some(run_id) = req.get("run_id").and_then(Value::as_str) else {
1985 return run_api_err("run_id required");
1986 };
1987 let because = req.get("because").and_then(Value::as_str).unwrap_or("canceled via console");
1988 let who = request_principal.clone().unwrap_or_else(|| "console:shared".into());
1989 let runner = self.runner(&who);
1990 match runner.cancel(run_id, &who, because) {
1991 Ok(()) => ok_json(serde_json::json!({"ok": true, "canceled": run_id})),
1992 Err(e) => run_api_err(&e.to_string()),
1993 }
1994 }
1995 ("GET", "/api/loop/recommendations") => {
1996 let status = q("status").and_then(|s| status_from_str(&s));
1997 let sub = BorrowedSubstrate::new(&self.facade);
1998 match areev_loop_adapter::visible_recommendations(
2000 &self.engine(),
2001 &sub,
2002 &self.facade.authz(),
2003 status,
2004 ) {
2005 Ok(recs) => ok_json(json!({
2006 "ok": true,
2007 "recommendations": recs.iter().map(rec_json).collect::<Vec<_>>(),
2008 })),
2009 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2010 }
2011 }
2012 ("GET", "/api/loop/health") => {
2013 let sub = BorrowedSubstrate::new(&self.facade);
2014 match self.engine().health(&sub, now_ms()) {
2015 Ok(h) => ok_json(json!({"ok": true, "health": h})),
2016 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2017 }
2018 }
2019 ("GET", "/api/loop/outcomes") => {
2020 let sub = BorrowedSubstrate::new(&self.facade);
2021 match self.engine().outcomes(&sub) {
2022 Ok(o) => ok_json(json!({"ok": true, "outcomes": o})),
2023 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2024 }
2025 }
2026 ("GET", "/api/loop/analyzers") => {
2027 let sub = BorrowedSubstrate::new(&self.facade);
2030 match self.engine().analyzer_settings(&sub) {
2031 Ok(list) => ok_json(json!({"ok": true, "analyzers": list})),
2032 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2033 }
2034 }
2035 ("GET", "/api/loop/telemetry") => {
2036 let mode = self.facade.with_store(|m| m.telemetry_mode());
2039 if mode == areev_store::TelemetryMode::Off {
2040 ok_json(json!({"ok": true, "enabled": false}))
2041 } else {
2042 let access = self.facade.with_store(|m| m.telemetry_access_stats(None));
2043 let queries = self.facade.with_store(|m| m.telemetry_query_stats(None));
2044 let budget = self.facade.with_store(|m| m.telemetry_budget_stats());
2045 match (access, queries, budget) {
2046 (Ok(mut a), Ok(mut q), Ok(b)) => {
2047 a.sort_by_key(|x| std::cmp::Reverse(x.recall_count));
2049 a.truncate(200);
2050 q.sort_by_key(|x| std::cmp::Reverse(x.run_count));
2051 q.truncate(200);
2052 ok_json(json!({
2053 "ok": true,
2054 "enabled": true,
2055 "mode": mode.as_str(),
2056 "access": a.iter().map(|x| json!({
2057 "hash": x.hash, "recall_count": x.recall_count, "last_ms": x.last_ms,
2058 })).collect::<Vec<_>>(),
2059 "queries": q.iter().map(|x| json!({
2060 "sample": x.sample, "run_count": x.run_count,
2061 "empty_count": x.empty_count,
2062 })).collect::<Vec<_>>(),
2063 "budget": {
2064 "sample_count": b.sample_count,
2065 "overflow_count": b.overflow_count,
2066 },
2067 }))
2068 }
2069 _ => ok_json(json!({"ok": false, "error": "telemetry read failed"})),
2070 }
2071 }
2072 }
2073 ("POST", "/api/loop/run") => {
2074 let authz = self.facade.authz();
2075 if !authz.is_owner()
2076 && !authz.allows(areev_core::authz::Verb::LoopRun, areev_loop::LOOP_NS)
2077 {
2078 return ("403 Forbidden", "application/json",
2079 br#"{"ok":false,"error":"AUT-E001: this session lacks loop.run"}"#.to_vec());
2080 }
2081 let mut sub = BorrowedSubstrate::new(&self.facade);
2082 let actor = if self.credentials.is_some() {
2087 authz.principal().to_string()
2088 } else {
2089 serde_json::from_slice::<Value>(body)
2090 .ok()
2091 .and_then(|v| v.get("actor").and_then(Value::as_str).map(str::to_string))
2092 .unwrap_or_else(|| "user:console".to_string())
2093 };
2094 let opts = RunOptions { triggering_actor: Some(actor), ..Default::default() };
2095 match self.engine().run(&mut sub, &opts, now_ms()) {
2096 Ok(res) => ok_json(json!({"ok": true, "run": res})),
2097 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2098 }
2099 }
2100 ("POST", "/api/loop/review") => self.loop_review(body),
2101 ("POST", "/api/loop/apply") => self.loop_apply(body),
2102 ("POST", "/api/loop/rollback") => self.loop_rollback(body),
2103 ("POST", "/api/loop/config") => self.loop_config(body),
2104 ("POST", "/api/loop/replay") => self.loop_replay(body),
2105 ("POST", "/api/anon/config") => self.anon_config(body),
2106 _ => (
2107 "404 Not Found",
2108 "application/json",
2109 br#"{"ok":false,"error":"not found"}"#.to_vec(),
2110 ),
2111 }
2112 }
2113
2114 fn loop_review(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2119 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2120 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
2121 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
2122 let bound = self.facade.authz();
2123 let actor = if self.credentials.is_some() {
2124 bound.principal().to_string()
2125 } else {
2126 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
2127 };
2128 let actor = actor.as_str();
2129 let decision = if req.get("decision").and_then(Value::as_str) == Some("reject") {
2130 Decision::Reject
2131 } else {
2132 Decision::Approve
2133 };
2134 let mut sub = BorrowedSubstrate::new(&self.facade);
2135 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
2136 let observer = areev_loop_adapter::observer_for_principal(actor);
2137 match self.engine().review(
2138 &mut sub, hash, decision, actor, observer, &scopes, because, now_ms(),
2139 ) {
2140 Ok(()) => ok_json(json!({"ok": true})),
2141 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2142 }
2143 }
2144
2145 fn loop_apply(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2146 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2147 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
2148 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
2149 let bound = self.facade.authz();
2150 let actor = if self.credentials.is_some() {
2151 bound.principal().to_string()
2152 } else {
2153 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
2154 };
2155 let actor = actor.as_str();
2156 let allow_destructive = req.get("allow_destructive").and_then(Value::as_bool).unwrap_or(false);
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 let engine = self.engine();
2161 let gating = match req.get("gating_run").and_then(Value::as_str) {
2165 Some(run_id) => match engine.gating_evidence(&sub, hash, run_id) {
2166 Ok(g) => Some(g),
2167 Err(e) => {
2168 return ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()}))
2169 }
2170 },
2171 None => None,
2172 };
2173 let applied = match &gating {
2174 Some(g) => engine.apply_gated(
2175 &mut sub, hash, actor, observer, &scopes, because, allow_destructive, g, now_ms(),
2176 ),
2177 None => engine.apply(
2178 &mut sub, hash, actor, observer, &scopes, because, allow_destructive, now_ms(),
2179 ),
2180 };
2181 match applied {
2182 Ok(applied) => ok_json(json!({"ok": true, "rollbackable": applied.rollbackable})),
2183 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2184 }
2185 }
2186
2187 fn loop_rollback(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2188 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2189 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
2190 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
2191 let bound = self.facade.authz();
2192 let actor = if self.credentials.is_some() {
2193 bound.principal().to_string()
2194 } else {
2195 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
2196 };
2197 let actor = actor.as_str();
2198 let mut sub = BorrowedSubstrate::new(&self.facade);
2199 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
2200 let observer = areev_loop_adapter::observer_for_principal(actor);
2201 match self.engine().rollback(
2202 &mut sub, hash, actor, observer, &scopes, because, now_ms(),
2203 ) {
2204 Ok(()) => ok_json(json!({"ok": true})),
2205 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2206 }
2207 }
2208
2209 fn loop_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2215 let id = serde_json::from_slice::<Value>(body)
2216 .ok()
2217 .and_then(|v| v.get("analyzer_id").and_then(Value::as_str).map(str::to_string))
2218 .unwrap_or_default();
2219 let update: areev_loop::AnalyzerConfigUpdate = serde_json::from_slice(body).unwrap_or_default();
2221 let mut sub = BorrowedSubstrate::new(&self.facade);
2222 match self.engine().set_analyzer_config(&mut sub, &id, update, &areev_loop_adapter::scopes_for(&self.facade.authz())) {
2223 Ok(cfg) => ok_json(json!({"ok": true, "config": cfg})),
2224 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2225 }
2226 }
2227}
2228
2229impl UiServer {
2230 fn loop_replay(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2236 let req = match serde_json::from_slice::<areev_loop::replay::ReplayRequest>(body) {
2237 Ok(r) => r,
2238 Err(e) => return ok_json(json!({"ok": false, "error": format!("replay request: {e}")})),
2239 };
2240 let (candidate, opts) = match req.resolve(now_ms()) {
2241 Ok(v) => v,
2242 Err(e) => return ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2243 };
2244 let sub = BorrowedSubstrate::new(&self.facade);
2245 match self.engine().replay(&sub, &candidate, &opts) {
2246 Ok(report) => ok_json(json!({"ok": true, "replay": report})),
2247 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2248 }
2249 }
2250
2251 fn anon_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2256 let v: Value = match serde_json::from_slice(body) {
2257 Ok(v) => v,
2258 Err(e) => return ok_json(json!({"ok": false, "error": e.to_string()})),
2259 };
2260 let Some(ns) = v.get("ns").and_then(Value::as_str).filter(|n| !n.trim().is_empty())
2261 else {
2262 return ok_json(json!({"ok": false, "error": "ns is required"}));
2263 };
2264 let result = if v.get("clear").and_then(Value::as_bool) == Some(true) {
2265 self.facade.with_store(|m| m.clear_anon_policy(ns))
2266 } else {
2267 match v.get("policy") {
2268 Some(policy) => {
2269 let policy_json = policy.to_string();
2270 self.facade.with_store(|m| m.set_anon_policy(ns, &policy_json))
2271 }
2272 None => {
2273 return ok_json(json!({"ok": false, "error": "policy (object) or clear: true is required"}));
2274 }
2275 }
2276 };
2277 match result {
2278 Ok(()) => {
2279 let declared = self.facade.with_store(|m| m.anon_declared());
2280 ok_json(json!({"ok": true, "policies": declared
2281 .iter()
2282 .map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
2283 .collect::<Vec<_>>()}))
2284 }
2285 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
2286 }
2287 }
2288}
2289
2290fn host_is_local(host_port: &str) -> bool {
2294 let host = if let Some(h) = host_port.strip_prefix('[') {
2295 h.split(']').next().unwrap_or("")
2296 } else {
2297 host_port.split(':').next().unwrap_or("")
2298 };
2299 matches!(host, "127.0.0.1" | "localhost" | "::1")
2300}
2301
2302fn origin_is_local(origin: &str) -> bool {
2304 let rest = origin
2305 .strip_prefix("http://")
2306 .or_else(|| origin.strip_prefix("https://"));
2307 let Some(rest) = rest else { return false };
2308 let host_port = rest.split('/').next().unwrap_or("");
2309 host_is_local(host_port)
2310}
2311
2312fn normalize_origin(origin: &str) -> String {
2318 let trimmed = origin.trim();
2319 trimmed.strip_suffix('/').unwrap_or(trimmed).to_ascii_lowercase()
2320}
2321
2322pub fn redact_dsn(label: &str) -> String {
2343 let Some(scheme_end) = label.find("://") else {
2344 return label.to_string();
2345 };
2346 let authority_start = scheme_end + 3;
2347 let rest = &label[authority_start..];
2348 let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
2349 let authority = &rest[..authority_len];
2350 let Some(at) = authority.rfind('@') else {
2351 return label.to_string();
2352 };
2353 let userinfo = &authority[..at];
2354 let Some(colon) = userinfo.find(':') else {
2355 return label.to_string();
2356 };
2357 let user = &userinfo[..colon];
2358 let mut out = String::with_capacity(label.len());
2359 out.push_str(&label[..authority_start]);
2360 out.push_str(user);
2361 out.push_str(":***@");
2362 out.push_str(&authority[at + 1..]);
2363 out.push_str(&rest[authority_len..]);
2364 out
2365}
2366
2367fn ok_json(v: Value) -> (&'static str, &'static str, Vec<u8>) {
2368 ("200 OK", "application/json", v.to_string().into_bytes())
2369}
2370
2371fn run_api_err(msg: &str) -> (&'static str, &'static str, Vec<u8>) {
2372 (
2373 "400 Bad Request",
2374 "application/json",
2375 serde_json::json!({"ok": false, "error": msg}).to_string().into_bytes(),
2376 )
2377}
2378
2379fn cal_body_is_read_only(body: &[u8]) -> bool {
2386 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2387 let q = req.get("query").and_then(Value::as_str).unwrap_or("");
2388 areev_cal::classify::query_is_read_only(q)
2389}
2390
2391fn status_from_str(s: &str) -> Option<RecStatus> {
2392 match s {
2393 "pending" => Some(RecStatus::Pending),
2394 "approved" => Some(RecStatus::Approved),
2395 "rejected" => Some(RecStatus::Rejected),
2396 "applied" => Some(RecStatus::Applied),
2397 "rolled_back" => Some(RecStatus::RolledBack),
2398 "expired" => Some(RecStatus::Expired),
2399 "withdrawn" => Some(RecStatus::Withdrawn),
2400 _ => None, }
2402}
2403
2404fn rec_json(r: &areev_loop::Recommendation) -> Value {
2405 let mut v = json!({
2406 "hash": r.hash,
2407 "status": r.status.as_str(),
2408 "severity": r.severity.as_str(),
2409 "analyzer": r.analyzer,
2410 "summary": r.summary.render(),
2411 "target_ref": r.target_ref,
2412 "destructive": r.destructive,
2413 "rollbackable": r.rollbackable,
2414 "evidence": r.evidence,
2415 "evalset_hash": r.evalset_hash,
2418 });
2419 if !r.near_duplicate_of.is_empty() {
2423 v["near_duplicate_of"] = json!(r.near_duplicate_of);
2424 }
2425 if let Some(replay) = &r.replay {
2427 v["replay"] = replay.clone();
2428 }
2429 v
2430}
2431
2432fn urldecode(s: &str) -> String {
2433 let mut out = Vec::new();
2434 let b = s.as_bytes();
2435 let mut i = 0;
2436 while i < b.len() {
2437 match b[i] {
2438 b'%' if i + 2 < b.len() + 1 && i + 2 < b.len() + 1 => {
2439 if i + 2 < b.len() + 1 && i + 2 < b.len() {
2440 let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
2441 if let Ok(v) = u8::from_str_radix(hex, 16) {
2442 out.push(v);
2443 i += 3;
2444 continue;
2445 }
2446 }
2447 out.push(b[i]);
2448 i += 1;
2449 }
2450 b'+' => {
2451 out.push(b' ');
2452 i += 1;
2453 }
2454 c => {
2455 out.push(c);
2456 i += 1;
2457 }
2458 }
2459 }
2460 String::from_utf8_lossy(&out).to_string()
2461}
2462
2463fn html_escape(s: &str) -> String {
2464 s.replace('&', "&").replace('<', "<").replace('>', ">")
2465}
2466
2467fn ct_eq(a: &[u8], b: &[u8]) -> bool {
2470 if a.len() != b.len() {
2471 return false;
2472 }
2473 let mut diff = 0u8;
2474 for (x, y) in a.iter().zip(b.iter()) {
2475 diff |= x ^ y;
2476 }
2477 diff == 0
2478}
2479
2480fn base64_decode(s: &str) -> Option<Vec<u8>> {
2484 areev_core::b64::decode(s)
2485}
2486
2487fn basic_auth_password(b64: &str) -> Option<String> {
2491 let text = String::from_utf8(base64_decode(b64)?).ok()?;
2492 text.split_once(':').map(|(_user, pass)| pass.to_string())
2493}
2494
2495#[cfg(test)]
2496mod loop_route_tests {
2497 use super::UiServer;
2498 use areev_cal::AreevFacade;
2499 use areev_core::types::{Fact, Grain};
2500 use areev_store::Areev;
2501
2502 fn server(auth: Option<&str>) -> UiServer {
2503 let dir = tempfile::tempdir().unwrap();
2504 let path = dir.path().join("t.db");
2505 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2506 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
2508 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
2509 std::mem::forget(dir); let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2511 let s = UiServer::new(facade, "test".into());
2512 match auth {
2513 Some(t) => s.with_auth(t.to_string()),
2514 None => s,
2515 }
2516 }
2517
2518 fn text(r: &(&str, &str, Vec<u8>)) -> String {
2519 String::from_utf8_lossy(&r.2).to_string()
2520 }
2521
2522 #[test]
2523 fn token_less_console_is_read_only() {
2524 let s = server(None);
2525 assert!(s.route("POST", "/api/loop/run", b"{}", None, None).0.starts_with("401"));
2527 let w = s.route("POST", "/api/cal", br#"{"query":"ADD fact SET subject=\"x\""}"#, None, None);
2529 assert!(w.0.starts_with("401"), "write CAL must be 401: {}", text(&w));
2530 let r = s.route("POST", "/api/cal", br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#, None, None);
2532 assert!(r.0.starts_with("200"), "read CAL allowed: {}", text(&r));
2533 assert!(s.route("GET", "/api/loop/recommendations", b"", None, None).0.starts_with("200"));
2535 }
2536
2537 #[test]
2538 fn telemetry_endpoint_is_an_open_read() {
2539 let s = server(None);
2540 let r = s.route("GET", "/api/loop/telemetry", b"", None, None);
2541 assert!(r.0.starts_with("200"), "telemetry read open: {}", text(&r));
2542 let body = text(&r);
2543 assert!(body.contains("\"ok\":true"), "{body}");
2544 assert!(body.contains("\"enabled\":false"), "{body}");
2546 }
2547
2548 #[test]
2549 fn authenticated_run_review_apply_roundtrip() {
2550 let s = server(Some("tok"));
2551 let run = s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
2552 assert!(run.0.starts_with("200"), "run: {}", text(&run));
2553 assert!(text(&run).contains("\"ran\""));
2554
2555 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
2556 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
2557 let recs = v["recommendations"].as_array().unwrap();
2558 assert!(!recs.is_empty(), "at least one recommendation");
2559 let hash = recs[0]["hash"].as_str().unwrap().to_string();
2560
2561 let rev = s.route(
2562 "POST",
2563 "/api/loop/review",
2564 format!(r#"{{"hash":"{hash}","decision":"approve","because":"ok"}}"#).as_bytes(),
2565 Some("tok"),
2566 None,
2567 );
2568 assert!(text(&rev).contains("\"ok\":true"), "review: {}", text(&rev));
2569
2570 let ap = s.route(
2571 "POST",
2572 "/api/loop/apply",
2573 format!(r#"{{"hash":"{hash}","because":"go"}}"#).as_bytes(),
2574 Some("tok"),
2575 None,
2576 );
2577 assert!(text(&ap).contains("\"ok\":true"), "apply: {}", text(&ap));
2578 }
2579
2580 #[test]
2581 fn config_edit_toggles_analyzer_via_console() {
2582 let s = server(Some("tok"));
2583 assert!(s.route("POST", "/api/loop/config", b"{}", None, None).0.starts_with("401"));
2585 assert!(s.route("POST", "/api/loop/replay", b"{}", None, None).0.starts_with("401"));
2588 let (status, _ct, body) = s.route("POST", "/api/loop/replay", br#"{"window":"7d","step":"1d"}"#, Some("tok"), None);
2589 assert!(status.starts_with("200"), "{status}");
2590 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
2591 assert_eq!(v["ok"], true, "{v}");
2592 for key in ["incumbent", "candidate", "steps", "not_replayed", "matching"] {
2593 assert!(v["replay"].get(key).is_some(), "replay report lacks {key}: {v}");
2594 }
2595 assert!(v["replay"]["incumbent"]["total"]["findings"].is_u64(), "{v}");
2596 let (_status, _ct, body) = s.route("POST", "/api/loop/replay", br#"{"analyzers": {}}"#, Some("tok"), None);
2597 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
2598 assert_eq!(v["ok"], false, "unknown keys are refused: {v}");
2599
2600 let list = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
2602 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
2603 let id = v["analyzers"]
2604 .as_array()
2605 .unwrap()
2606 .iter()
2607 .find(|x| x["enabled"] == true)
2608 .expect("some analyzer is on")["id"]
2609 .as_str()
2610 .unwrap()
2611 .to_string();
2612
2613 let post = s.route(
2615 "POST",
2616 "/api/loop/config",
2617 format!(r#"{{"analyzer_id":"{id}","enabled":false}}"#).as_bytes(),
2618 Some("tok"),
2619 None,
2620 );
2621 assert!(text(&post).contains("\"ok\":true"), "config: {}", text(&post));
2622
2623 let list2 = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
2625 let v2: serde_json::Value = serde_json::from_slice(&list2.2).unwrap();
2626 let now = v2["analyzers"]
2627 .as_array()
2628 .unwrap()
2629 .iter()
2630 .find(|x| x["id"] == id.as_str())
2631 .unwrap();
2632 assert_eq!(now["enabled"], false, "toggled off and persisted");
2633 }
2634
2635 #[test]
2636 fn self_approval_surfaces_lop_code() {
2637 let s = server(Some("tok"));
2638 s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
2639 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
2640 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
2641 let rec = &v["recommendations"].as_array().unwrap()[0];
2642 let hash = rec["hash"].as_str().unwrap();
2643 let analyzer = rec["analyzer"].as_str().unwrap();
2644 let creator = format!("engine:{analyzer}");
2645 let rev = s.route(
2647 "POST",
2648 "/api/loop/review",
2649 format!(r#"{{"hash":"{hash}","decision":"approve","because":"self","actor":"{creator}"}}"#).as_bytes(),
2650 Some("tok"),
2651 None,
2652 );
2653 assert!(text(&rev).contains("LOP-E021"), "self-approval blocked: {}", text(&rev));
2654 }
2655}
2656
2657#[cfg(test)]
2658mod security_tests {
2659 use super::{base64_decode, basic_auth_password, ct_eq, normalize_origin, redact_dsn};
2660
2661 #[test]
2662 fn ct_eq_matches_only_equal() {
2663 assert!(ct_eq(b"secret-token", b"secret-token"));
2664 assert!(!ct_eq(b"secret-token", b"secret-toker"));
2665 assert!(!ct_eq(b"secret", b"secret-token")); assert!(ct_eq(b"", b""));
2667 }
2668
2669 #[test]
2670 fn base64_decode_roundtrips_known_vectors() {
2671 assert_eq!(base64_decode("").unwrap(), b"");
2673 assert_eq!(base64_decode("Zg==").unwrap(), b"f");
2674 assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
2675 assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
2676 assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
2677 assert_eq!(base64_decode("Zg").unwrap(), b"f");
2679 assert!(base64_decode("****").is_none());
2681 }
2682
2683 #[test]
2684 fn basic_auth_extracts_password_ignoring_username() {
2685 assert_eq!(basic_auth_password("ZGVqYTpzM2NyZXQ=").as_deref(), Some("s3cret"));
2687 assert_eq!(basic_auth_password("OnMzY3JldA==").as_deref(), Some("s3cret"));
2688 assert_eq!(basic_auth_password("dTphOmI=").as_deref(), Some("a:b"));
2691 assert_eq!(basic_auth_password("bm9jb2xvbg=="), None); assert_eq!(basic_auth_password("****"), None); }
2695
2696 #[test]
2699 fn redact_dsn_hides_a_password() {
2700 assert_eq!(
2701 redact_dsn("postgresql://rounic:SUPERSECRET@pg-x:5432/rounic?sslmode=verify-full&schema=desk_invoice"),
2702 "postgresql://rounic:***@pg-x:5432/rounic?sslmode=verify-full&schema=desk_invoice",
2703 );
2704 }
2705
2706 #[test]
2707 fn redact_dsn_leaves_no_password_alone() {
2708 assert_eq!(redact_dsn("postgres://user@host:5432/db"), "postgres://user@host:5432/db");
2709 }
2710
2711 #[test]
2712 fn redact_dsn_leaves_no_userinfo_alone() {
2713 assert_eq!(redact_dsn("postgres://host:5432/db"), "postgres://host:5432/db");
2714 }
2715
2716 #[test]
2717 fn redact_dsn_leaves_a_plain_file_path_alone() {
2718 assert_eq!(redact_dsn("demo.db"), "demo.db");
2719 assert_eq!(redact_dsn("/var/lib/areev/demo.db"), "/var/lib/areev/demo.db");
2720 }
2721
2722 #[test]
2723 fn redact_dsn_hides_a_percent_encoded_password() {
2724 assert_eq!(
2727 redact_dsn("postgres://user:pa%40ss@host:5432/db"),
2728 "postgres://user:***@host:5432/db",
2729 );
2730 }
2731
2732 #[test]
2733 fn redact_dsn_hides_a_password_with_a_literal_at_sign() {
2734 assert_eq!(
2737 redact_dsn("postgres://user:p@ss@host:5432/db"),
2738 "postgres://user:***@host:5432/db",
2739 );
2740 }
2741
2742 #[test]
2743 fn redact_dsn_ignores_colon_and_at_in_the_query_string() {
2744 assert_eq!(
2745 redact_dsn("postgres://user:pass@host:5432/db?options=-c%20foo:bar@baz"),
2746 "postgres://user:***@host:5432/db?options=-c%20foo:bar@baz",
2747 );
2748 }
2749
2750 #[test]
2751 fn redact_dsn_handles_empty_string() {
2752 assert_eq!(redact_dsn(""), "");
2753 }
2754
2755 #[test]
2758 fn normalize_origin_lowercases_and_drops_one_trailing_slash() {
2759 assert_eq!(normalize_origin("https://Console.Example.com"), "https://console.example.com");
2760 assert_eq!(normalize_origin("https://console.example.com/"), "https://console.example.com");
2761 assert_eq!(
2762 normalize_origin("HTTPS://Console.Example.com/"),
2763 "https://console.example.com",
2764 );
2765 }
2766}
2767
2768#[cfg(test)]
2769mod credential_route_tests {
2770 use super::UiServer;
2771 use areev_cal::AreevFacade;
2772 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2773 use areev_core::types::{Fact, Grain};
2774 use areev_store::Areev;
2775
2776 #[test]
2781 fn loop_apply_honors_a_gating_run() {
2782 let dir = tempfile::tempdir().unwrap();
2783 let path = dir.path().join("g.db");
2784 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2785 let evalset = store
2787 .add(
2788 &Fact::new("evalset:gate", "mg:evalset", "{\"name\":\"gate\",\"cases\":[]}")
2789 .namespace("agent:harness")
2790 .created_at(1_000),
2791 )
2792 .unwrap()
2793 .to_hex();
2794 store
2795 .add(
2796 &Fact::new(
2797 &format!("evalset:{evalset}"),
2798 "mg:eval_run",
2799 "{\"run_id\":\"eval-1\",\"passed\":3,\"failed\":0}",
2800 )
2801 .namespace("agent:harness")
2802 .created_at(2_000),
2803 )
2804 .unwrap();
2805 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
2806 std::mem::forget(dir);
2807 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2808 let manifest = facade
2810 .record_corpus_export("RECALL facts", "train.jsonl", None, 4_000, &[], &[])
2811 .unwrap()
2812 .to_hex();
2813 facade
2814 .record_adapter(
2815 r#"{"adapter":{"uri":"file:///tmp/a","sha256":"feed"},"base_model":"qwen3-4b","serves_as":"acme-support"}"#,
2816 &manifest,
2817 &evalset,
2818 5_000,
2819 )
2820 .unwrap();
2821 let s = UiServer::new(facade, "test".into()).with_auth("gate-token".into());
2824 let tok = Some("gate-token");
2825
2826 let run = s.route("POST", "/api/loop/run", b"{}", tok, None);
2828 assert!(run.0.starts_with("200"), "{}", text(&run));
2829 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", tok, None);
2830 let rows: serde_json::Value = serde_json::from_str(&text(&list)).unwrap();
2831 let rec = rows["recommendations"]
2832 .as_array()
2833 .unwrap()
2834 .iter()
2835 .find(|r| r["analyzer"].as_str().unwrap_or("").contains("adapter_intake"))
2836 .unwrap_or_else(|| panic!("adapter_intake must propose: {rows}"));
2837 assert_eq!(rec["evalset_hash"], evalset.as_str(), "the row must carry the pin");
2838 let hash = rec["hash"].as_str().unwrap().to_string();
2839
2840 let approve = format!(r#"{{"hash":"{hash}","decision":"approve","because":"reviewed"}}"#);
2843 let r = s.route("POST", "/api/loop/review", approve.as_bytes(), tok, None);
2844 assert!(text(&r).contains("\"ok\":true"), "{}", text(&r));
2845 let bare = format!(r#"{{"hash":"{hash}","because":"ship it"}}"#);
2846 let r = s.route("POST", "/api/loop/apply", bare.as_bytes(), tok, None);
2847 assert!(text(&r).contains("gating run"), "ungated must refuse: {}", text(&r));
2848 let bad = format!(r#"{{"hash":"{hash}","because":"ship","gating_run":"eval-nope"}}"#);
2849 let r = s.route("POST", "/api/loop/apply", bad.as_bytes(), tok, None);
2850 assert!(text(&r).contains("no recorded gate run"), "{}", text(&r));
2851 let good = format!(r#"{{"hash":"{hash}","because":"gated and green","gating_run":"eval-1"}}"#);
2852 let r = s.route("POST", "/api/loop/apply", good.as_bytes(), tok, None);
2853 assert!(text(&r).contains("\"ok\":true"), "gated apply failed: {}", text(&r));
2854 }
2855
2856 fn server() -> UiServer {
2859 std::env::set_var("AREEV_TEST_READER_TOK", "reader-secret");
2860 std::env::set_var("AREEV_TEST_WRITER_TOK", "writer-secret");
2861 let dir = tempfile::tempdir().unwrap();
2862 let path = dir.path().join("t.db");
2863 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2864 store
2865 .add(&Fact::new("user:reader", REL_PERMITS, "read ON *").namespace(AUTHZ_NS).created_at(1_000))
2866 .unwrap();
2867 store
2868 .add(
2869 &Fact::new("agent:writer", REL_PERMITS, "read,write ON caller")
2870 .namespace(AUTHZ_NS)
2871 .created_at(2_000),
2872 )
2873 .unwrap();
2874 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
2875 std::mem::forget(dir);
2876 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2877 let map = CredentialMap::from_json(
2878 r#"{"version":1,"tokens":[
2879 {"env":"AREEV_TEST_READER_TOK","principal":"user:reader"},
2880 {"env":"AREEV_TEST_WRITER_TOK","principal":"agent:writer"}
2881 ]}"#,
2882 )
2883 .unwrap();
2884 UiServer::new(facade, "test".into()).with_credentials(map)
2885 }
2886
2887 fn text(r: &(&str, &str, Vec<u8>)) -> String {
2888 String::from_utf8_lossy(&r.2).to_string()
2889 }
2890
2891 const READ: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
2892 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2893
2894 #[test]
2895 fn tokens_bind_principals_and_verbs_decide() {
2896 let s = server();
2897
2898 let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
2900 assert!(r.0.starts_with("200"), "{}", text(&r));
2901 assert!(text(&r).contains("grains"));
2902 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2904 assert!(text(&w).contains("AUT-E001"), "reader write must be refused: {}", text(&w));
2905
2906 let w = s.route("POST", "/api/cal", WRITE, Some("writer-secret"), None);
2908 assert!(text(&w).contains("added"), "writer write must land: {}", text(&w));
2909
2910 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2913 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2914 }
2915
2916 #[test]
2917 fn anonymous_is_read_only_and_loop_run_is_verb_gated() {
2918 let s = server();
2919 let r = s.route("POST", "/api/cal", READ, None, None);
2921 assert!(r.0.starts_with("200"), "{}", text(&r));
2922 assert!(text(&r).contains("grains"));
2923 let w = s.route("POST", "/api/cal", WRITE, None, None);
2925 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2926 let w = s.route("POST", "/api/cal", WRITE, Some("stolen-guess"), None);
2928 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2929
2930 let run = s.route("POST", "/api/loop/run", b"{}", None, None);
2932 assert!(run.0.starts_with("403"), "{}", text(&run));
2933 }
2934
2935 #[test]
2936 fn whoami_reports_the_bound_principal() {
2937 let s = server();
2938 let r = s.route("GET", "/api/whoami", b"", Some("reader-secret"), None);
2939 assert!(text(&r).contains("user:reader"), "{}", text(&r));
2940 assert!(text(&r).contains("credentials"));
2941 let r = s.route("GET", "/api/whoami", b"", None, None);
2942 assert!(text(&r).contains("anonymous"), "{}", text(&r));
2943 }
2944
2945 #[test]
2949 fn a_credential_map_does_not_disable_the_shared_secret() {
2950 let s = server().with_auth("console-token".into());
2951
2952 let r = s.route("POST", "/api/cal", READ, None, None);
2954 assert!(r.0.starts_with("401"), "{}", text(&r));
2955 let page = s.route("GET", "/", b"", None, None);
2956 assert!(page.0.starts_with("401"), "the console page is guarded too");
2957 let r = s.route("POST", "/api/cal", READ, Some("stolen-guess"), None);
2959 assert!(r.0.starts_with("401"), "{}", text(&r));
2960
2961 let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
2963 assert!(r.0.starts_with("200"), "{}", text(&r));
2964 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2965 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2966
2967 let w = s.route("POST", "/api/cal", WRITE, Some("console-token"), None);
2970 assert!(text(&w).contains("added"), "{}", text(&w));
2971 }
2972
2973 #[test]
2976 fn run_loop_via_cal_is_verb_gated_like_the_route() {
2977 let s = server();
2978 let run = s.route("POST", "/api/loop/run", b"{}", None, None);
2979 assert!(run.0.starts_with("403"), "{}", text(&run));
2980 let cal = s.route("POST", "/api/cal", br#"{"query":"RUN LOOP"}"#, None, None);
2981 assert!(
2982 text(&cal).contains("AUT-E001"),
2983 "anonymous must not run the loop through CAL either: {}",
2984 text(&cal)
2985 );
2986 }
2987}
2988
2989#[cfg(test)]
2990mod namespace_route_tests {
2991 use super::UiServer;
2992 use areev_cal::AreevFacade;
2993 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2994 use areev_core::types::{Fact, Grain};
2995 use areev_store::Areev;
2996
2997 fn text(r: &(&str, &str, Vec<u8>)) -> String {
2998 String::from_utf8_lossy(&r.2).to_string()
2999 }
3000
3001 fn restricted_server() -> UiServer {
3004 std::env::set_var("AREEV_TEST_NS_WRITER_TOK", "writer-secret");
3005 let dir = tempfile::tempdir().unwrap();
3006 let path = dir.path().join("t.db");
3007 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
3008 store
3009 .add(&Fact::new("agent:writer", REL_PERMITS, "read,write ON caller").namespace(AUTHZ_NS).created_at(1_000))
3010 .unwrap();
3011 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000)).unwrap();
3012 store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(3_000)).unwrap();
3013 std::mem::forget(dir);
3014 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3015 let map = CredentialMap::from_json(
3016 r#"{"version":1,"tokens":[{"env":"AREEV_TEST_NS_WRITER_TOK","principal":"agent:writer"}]}"#,
3017 )
3018 .unwrap();
3019 UiServer::new(facade, "test".into()).with_credentials(map)
3020 }
3021
3022 fn owner_server() -> UiServer {
3025 let dir = tempfile::tempdir().unwrap();
3026 let path = dir.path().join("t.db");
3027 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
3028 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(1_000)).unwrap();
3029 store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(2_000)).unwrap();
3030 std::mem::forget(dir);
3031 UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
3032 }
3033
3034 #[test]
3035 fn namespaces_route_hides_what_the_principal_cannot_read() {
3036 let s = restricted_server();
3037 let r = s.route("GET", "/api/namespaces", b"", Some("writer-secret"), None);
3038 let body = text(&r);
3039 assert!(body.contains("\"caller\""), "granted namespace must be listed: {body}");
3040 assert!(!body.contains("\"other\""), "ungranted namespace must not be listed: {body}");
3041 assert!(!body.contains(AUTHZ_NS), "the audit namespace must not leak either: {body}");
3042 }
3043
3044 #[test]
3045 fn namespaces_route_lists_everything_for_the_owner() {
3046 let s = owner_server();
3047 let r = s.route("GET", "/api/namespaces", b"", None, None);
3048 let body = text(&r);
3049 assert!(body.contains("\"caller\"") && body.contains("\"other\""), "{body}");
3050 }
3051
3052 #[test]
3053 fn browse_ns_param_is_read_gated_for_a_restricted_principal() {
3054 let s = restricted_server();
3055 let ok = s.route("GET", "/api/browse?ns=caller", b"", Some("writer-secret"), None);
3056 assert!(text(&ok).contains("\"ok\":true"), "their own granted namespace must work: {}", text(&ok));
3057
3058 let denied = s.route("GET", "/api/browse?ns=other", b"", Some("writer-secret"), None);
3059 let body = text(&denied);
3060 assert!(body.contains("\"ok\":false"), "an ungranted namespace must be refused, not silently emptied: {body}");
3061 }
3062
3063 #[test]
3064 fn browse_ns_star_requires_owner() {
3065 let s = restricted_server();
3066 let r = s.route("GET", "/api/browse?ns=*", b"", Some("writer-secret"), None);
3067 assert!(text(&r).contains("\"ok\":false"), "a restricted principal must not get the unfiltered view: {}", text(&r));
3068
3069 let owner = owner_server();
3070 let r2 = owner.route("GET", "/api/browse?ns=*", b"", None, None);
3071 let body2 = text(&r2);
3072 assert!(body2.contains("\"ok\":true"), "the owner session may lift the filter: {body2}");
3073 assert!(body2.contains("\"caller\"") && body2.contains("\"other\""), "{body2}");
3074 }
3075
3076 #[test]
3077 fn browse_without_ns_param_keeps_original_session_default_behavior() {
3078 let s = owner_server();
3079 let r = s.route("GET", "/api/browse", b"", None, None);
3080 let body = text(&r);
3081 assert!(body.contains("\"caller\""), "{body}");
3082 assert!(!body.contains("\"other\""), "omitting ?ns must behave exactly as before this feature existed: {body}");
3083 }
3084}
3085
3086#[cfg(test)]
3087mod builder_tests {
3088 use super::UiServer;
3089 use areev_cal::AreevFacade;
3090 use areev_store::Areev;
3091
3092 fn server() -> UiServer {
3093 let dir = tempfile::tempdir().unwrap();
3094 let path = dir.path().join("t.db");
3095 let store = Areev::open(path.to_str().unwrap()).unwrap();
3096 std::mem::forget(dir);
3097 UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
3098 }
3099
3100 #[test]
3104 fn attaching_a_loop_policy_preserves_the_destructive_cap() {
3105 let s = server()
3106 .allow_destructive_ops(false)
3107 .with_loop_policy(areev_loop::Policy::default());
3108 let r = s.route(
3109 "POST",
3110 "/api/cal",
3111 br#"{"query":"FORGET sha256:684c6c9bda818630a870119d0726e4d242ed537af061658ef6f3acb158a2c67d"}"#,
3112 None,
3113 None,
3114 );
3115 let body = String::from_utf8_lossy(&r.2).to_string();
3116 assert!(
3117 body.contains("--no-destructive-ops") || r.0.starts_with("401"),
3118 "destruction must stay disabled: {body}"
3119 );
3120 }
3121}
3122
3123#[cfg(test)]
3124mod run_api_tests {
3125 use super::UiServer;
3126 use areev_cal::AreevFacade;
3127 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
3128 use areev_core::types::{Fact, Grain, Tool, ToolKind, Workflow};
3129 use areev_store::Areev;
3130 use std::sync::Arc;
3131
3132 fn parked_server() -> (UiServer, String) {
3135 std::env::set_var("AREEV_RUN_OFFICER_TOK", "officer-secret");
3136 std::env::set_var("AREEV_RUN_STARTER_TOK", "starter-secret");
3137 let dir = tempfile::tempdir().unwrap();
3138 let path = dir.path().join("t.db");
3139 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
3140 for (who, what) in [
3141 ("user:officer", "read,run.respond ON ops"),
3142 ("user:starter", "read ON ops"),
3143 ] {
3144 store
3145 .add(&Fact::new(who, REL_PERMITS, what).namespace(AUTHZ_NS).created_at(900))
3146 .unwrap();
3147 }
3148 std::mem::forget(dir);
3149 let facade = Arc::new(AreevFacade::with_session(store, Some("ops".into()), None));
3150
3151 let (wf, _g, _a) = facade
3153 .with_store(|m| {
3154 let greet = Tool::new("greet")
3155 .kind(ToolKind::Definition)
3156 .tool_description("t")
3157 .created_at(500)
3158 .namespace("ops");
3159 let gh = m.add(&greet)?;
3160 let approve = Tool::new("approve")
3161 .kind(ToolKind::Definition)
3162 .tool_description("human approves")
3163 .executor_kind(areev_core::types::ExecutorKind::Client)
3164 .created_at(501)
3165 .namespace("ops");
3166 let ah = m.add(&approve)?;
3167 let wf = Workflow::new(vec!["greet".into(), "approve".into()])
3168 .edge("greet", "approve")
3169 .bind("greet", &gh.to_hex())
3170 .bind("approve", &ah.to_hex())
3171 .created_at(502)
3172 .namespace("ops");
3173 let wh = m.add(&wf)?;
3174 Ok::<_, areev_core::error::AreevError>((wh, gh, ah))
3175 })
3176 .unwrap();
3177 struct Ok1;
3178 impl areev_run::HostToolExecutor for Ok1 {
3179 fn execute(
3180 &self,
3181 t: &str,
3182 _h: &str,
3183 _i: &serde_json::Value,
3184 _k: &str,
3185 ) -> areev_run::ExecResult {
3186 areev_run::ExecResult::Ok(serde_json::json!({t.to_string(): true}))
3187 }
3188 }
3189 let runner = areev_run::Runner {
3190 facade: Arc::clone(&facade),
3191 clock: Arc::new(areev_run::ScriptedClock::new(
3192 (0..100).map(|i| 1_758_000_000_000 + i * 10).collect(),
3193 )),
3194 executor: Arc::new(Ok1),
3195 llm: None,
3196 observer: None,
3197 ns: "ops".into(),
3198 principal: "user:starter".into(),
3199 };
3200 let session = runner
3201 .start(&wf, "hitl-1", serde_json::json!({}), &areev_run::RunOptions {
3202 workers: 1,
3203 ..Default::default()
3204 })
3205 .unwrap();
3206 let areev_run::RunSession::Parked { envelope, .. } = session else {
3207 panic!("expected park")
3208 };
3209 let ask = envelope["asks"][0]["tool_call_id"].as_str().unwrap().to_string();
3210
3211 drop(runner);
3218 let facade = Arc::try_unwrap(facade).unwrap_or_else(|_| panic!("facade still shared"));
3219 let map = CredentialMap::from_json(
3220 r#"{"version":1,"tokens":[
3221 {"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer"},
3222 {"env":"AREEV_RUN_STARTER_TOK","principal":"user:starter"}
3223 ]}"#,
3224 )
3225 .unwrap();
3226 let server = UiServer::new(facade, "test".into())
3227 .with_auth("shared-console-secret".into())
3228 .with_credentials(map);
3229 (server, ask)
3230 }
3231
3232 fn body(run_id: &str, ask: &str) -> Vec<u8> {
3233 serde_json::json!({"run_id": run_id, "tool_call_id": ask, "result": {"approved": true}})
3234 .to_string()
3235 .into_bytes()
3236 }
3237
3238 #[test]
3244 fn run_list_is_scoped_and_paged_server_side() {
3245 let dir = tempfile::tempdir().unwrap();
3246 let path = dir.path().join("t.db");
3247 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
3248 for i in 0..60u32 {
3249 let (id, ns) = if i < 20 {
3250 (format!("quiet-{i}"), "tenant.quiet")
3251 } else {
3252 (format!("busy-{i}"), "tenant.busy")
3253 };
3254 let mut f = Fact::new(&format!("run:{id}"), "mg:harness", "00")
3255 .namespace(areev_core::authz::HARNESS_NS)
3256 .created_at(1_000 + i64::from(i));
3257 f.common.extra_fields.insert("run_id".into(), serde_json::json!(id));
3258 f.common.extra_fields.insert("run_ns".into(), serde_json::json!(ns));
3259 store.add(&f).unwrap();
3260 }
3261 std::mem::forget(dir);
3262 let s = UiServer::new(AreevFacade::with_session(store, Some("ops".into()), None), "test".into());
3263 let page = |query: &str| -> serde_json::Value {
3264 let r = s.route("GET", &format!("/api/run/list{query}"), b"", None, None);
3265 assert!(r.0.starts_with("200"), "{} {}", r.0, String::from_utf8_lossy(&r.2));
3266 serde_json::from_slice(&r.2).unwrap()
3267 };
3268 let rows = |v: &serde_json::Value| v["runs"].as_array().unwrap().clone();
3269
3270 let all = page("");
3272 assert_eq!(rows(&all).len(), 50);
3273 assert_eq!(all["total"], 60);
3274 assert_eq!(all["truncated"], true);
3275 assert_eq!(rows(&all).iter().filter(|r| r["ns"] == "tenant.quiet").count(), 10);
3276
3277 let quiet = page("?ns=tenant.quiet");
3279 assert_eq!(quiet["total"], 20);
3280 assert_eq!(quiet["truncated"], false);
3281 assert_eq!(rows(&quiet).len(), 20);
3282 assert!(rows(&quiet).iter().all(|r| r["ns"] == "tenant.quiet"), "{quiet}");
3283
3284 let tail = page("?ns=tenant.*&limit=10&offset=55");
3286 assert_eq!(tail["total"], 60);
3287 assert_eq!(rows(&tail).len(), 5);
3288 assert_eq!(tail["truncated"], false);
3289 assert_eq!((tail["offset"].as_u64(), tail["limit"].as_u64()), (Some(55), Some(10)));
3290
3291 assert_eq!(page("?ns=*")["total"], 60);
3293 assert_eq!(page("?limit=100000")["limit"], 500);
3294
3295 s.facade
3298 .with_store(|m| {
3299 let mut f = Fact::new("run:old", "mg:harness", "00")
3300 .namespace(areev_core::authz::HARNESS_NS)
3301 .created_at(1);
3302 f.common.extra_fields.insert("run_id".into(), serde_json::json!("old"));
3303 m.add(&f)
3304 })
3305 .unwrap();
3306 let quiet = page("?ns=tenant.quiet");
3307 assert_eq!(quiet["total"], 20, "scoping stays exact: {quiet}");
3308 assert_eq!(quiet["unattributed"], 1, "and says what it left out: {quiet}");
3309 let unscoped = page("");
3310 assert_eq!(unscoped["total"], 61);
3311 assert_eq!(unscoped["unattributed"], 0);
3312 }
3313
3314 #[test]
3318 fn run_shadow_route_rehearses_a_run_under_a_candidate_plan() {
3319 let (s, _ask) = parked_server();
3320 let (_st, _ct, body) = s.route("GET", "/api/run/inspect?run_id=hitl-1", b"", Some("officer-secret"), None);
3321 let inspect: serde_json::Value = serde_json::from_slice(&body).unwrap();
3322 let plan = inspect["plan_hash"].as_str().expect("the run's plan").to_string();
3323
3324 let (status, _ct, body) = s.route("GET", &format!("/api/run/shadow?runs=hitl-1&plan={plan}"), b"", Some("officer-secret"), None);
3325 assert!(status.starts_with("200"), "{status}");
3326 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3327 assert_eq!(v["ok"], true, "{v}");
3328 assert_eq!(v["candidate_plan"], plan.as_str());
3329 assert_eq!((v["effect_dispatches"].as_u64(), v["writes"].as_u64()), (Some(0), Some(0)));
3330 let row = &v["runs"][0];
3331 assert_eq!(row["run_id"], "hitl-1");
3332 assert!(row["identity"].is_object(), "the incumbent as candidate is a verify: {row}");
3333
3334 let draft = serde_json::json!({"runs": ["hitl-1"], "plan_body": {"nodes": ["greet"], "edges": []}});
3336 let (status, _ct, body) = s.route("POST", "/api/run/shadow", draft.to_string().as_bytes(), Some("officer-secret"), None);
3337 assert!(status.starts_with("200"), "{status}");
3338 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3339 assert_eq!(v["ok"], true, "{v}");
3340 assert_eq!(v["candidate_is_draft"], true);
3341 let (_st, _ct, body) = s.route("POST", "/api/run/shadow", br#"{"runs": ["hitl-1"]}"#, Some("officer-secret"), None);
3342 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3343 assert_eq!(v["ok"], false, "{v}");
3344 let (_st, _ct, body) = s.route("GET", "/api/run/shadow?runs=hitl-1", b"", Some("officer-secret"), None);
3345 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3346 assert_eq!(v["ok"], false, "plan required: {v}");
3347 }
3348
3349 #[test]
3350 fn shared_token_approvals_are_refused_and_principals_approve() {
3351 let (s, ask) = parked_server();
3352
3353 let r = s.route("GET", "/api/run/list", b"", Some("shared-console-secret"), None);
3355 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3356 let r = s.route(
3357 "GET",
3358 "/api/run/inspect?run_id=hitl-1",
3359 b"",
3360 Some("shared-console-secret"),
3361 None,
3362 );
3363 assert!(String::from_utf8_lossy(&r.2).contains(&ask), "ask visible in inspect");
3364
3365 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("shared-console-secret"), None);
3367 assert!(r.0.starts_with("403"), "shared-token approval must refuse: {}", r.0);
3368
3369 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("starter-secret"), None);
3372 let text = String::from_utf8_lossy(&r.2).to_string();
3373 assert!(text.contains("responder") || text.contains("RUN-E012"), "{text}");
3374
3375 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("officer-secret"), None);
3377 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3378 assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
3379
3380 let r = s.route(
3382 "POST",
3383 "/api/run/cancel",
3384 br#"{"run_id":"hitl-1","because":"test brake"}"#,
3385 Some("shared-console-secret"),
3386 None,
3387 );
3388 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3389 }
3390
3391 #[test]
3398 fn proxy_asserted_identities_cannot_approve_by_default() {
3399 let (s, ask) = parked_server();
3400 let s = s.with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>);
3401
3402 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), None, Some("user:officer"));
3404 assert!(
3405 r.0.starts_with("403"),
3406 "proxy-asserted approval must refuse by default: {} {}",
3407 r.0,
3408 String::from_utf8_lossy(&r.2)
3409 );
3410 let text = String::from_utf8_lossy(&r.2).to_string();
3411 assert!(
3412 text.contains("--sso-approvals"),
3413 "the refusal must name the flag that lifts it: {text}"
3414 );
3415
3416 let r = s.route("GET", "/api/run/list", b"", None, Some("user:officer"));
3418 assert!(r.0.starts_with("200"), "SSO identities may still review: {}", r.0);
3419
3420 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("officer-secret"), None);
3422 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3423 }
3424
3425 #[test]
3429 fn sso_approvals_allow_opts_in_to_proxy_asserted_approvals() {
3430 let (s, ask) = parked_server();
3431 let s = s
3432 .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>)
3433 .with_sso_approvals(true);
3434
3435 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), None, Some("user:officer"));
3436 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3437 assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
3438 }
3439
3440 #[test]
3445 fn groups_resolve_to_principals_but_never_outrank_an_identity() {
3446 let (s, _ask) = parked_server();
3447 let map = CredentialMap::from_json(
3450 r#"{"version":1,
3451 "tokens":[{"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer","id":"off"}],
3452 "groups":{"Engineering":"user:officer"}}"#,
3453 )
3454 .unwrap();
3455 let s = s
3456 .with_credentials(map)
3457 .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>)
3458 .with_sso_groups_header("x-forwarded-groups");
3459
3460 let r = s.route_with_groups(
3463 "GET",
3464 "/api/whoami",
3465 b"",
3466 None,
3467 Some("user:newhire@example.com"),
3468 Some("platform,Engineering"),
3469 );
3470 let text = String::from_utf8_lossy(&r.2).to_string();
3471 assert!(text.contains("user:officer"), "group should resolve: {text}");
3472 assert!(text.contains(r#""identity_source":"sso-group""#), "{text}");
3473
3474 let r = s.route_with_groups(
3478 "GET", "/api/whoami", b"", None, Some("user:newhire@example.com"), Some("ENGINEERING"),
3479 );
3480 assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
3481
3482 let r = s.route_with_groups(
3484 "GET", "/api/whoami", b"", None, Some("user:officer"), Some("Engineering"),
3485 );
3486 let text = String::from_utf8_lossy(&r.2).to_string();
3487 assert!(text.contains(r#""identity_source":"sso""#), "identity outranks group: {text}");
3488
3489 let r = s.route_with_groups(
3491 "GET", "/api/whoami", b"", None, Some("user:newhire@example.com"), Some("sales"),
3492 );
3493 assert!(String::from_utf8_lossy(&r.2).contains("user:newhire@example.com"));
3494 }
3495
3496 #[test]
3500 fn group_derived_principals_can_never_approve() {
3501 let (s, ask) = parked_server();
3502 let map = CredentialMap::from_json(
3503 r#"{"version":1,
3504 "tokens":[{"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer","id":"off"}],
3505 "groups":{"approvers":"user:officer"}}"#,
3506 )
3507 .unwrap();
3508 let s = s
3509 .with_credentials(map)
3510 .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>)
3511 .with_sso_groups_header("x-forwarded-groups")
3512 .with_sso_approvals(true);
3514
3515 let r = s.route_with_groups(
3516 "POST",
3517 "/api/run/respond",
3518 &body("hitl-1", &ask),
3519 None,
3520 Some("user:newhire@example.com"),
3521 Some("approvers"),
3522 );
3523 assert!(r.0.starts_with("403"), "{} {}", r.0, String::from_utf8_lossy(&r.2));
3524 let text = String::from_utf8_lossy(&r.2).to_string();
3525 assert!(text.contains("role"), "the refusal must say why: {text}");
3526 }
3527
3528 #[test]
3531 fn the_principal_prefix_is_stamped_on_proxy_asserted_identities() {
3532 let (s, _ask) = parked_server();
3533 let s = s
3534 .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>)
3535 .with_sso_principal_prefix("sso:");
3536
3537 let r = s.route("GET", "/api/whoami", b"", None, Some("pat@example.com"));
3538 assert!(String::from_utf8_lossy(&r.2).contains("sso:pat@example.com"), "{}", String::from_utf8_lossy(&r.2));
3539
3540 let r = s.route("GET", "/api/whoami", b"", None, Some("sso:pat@example.com"));
3543 let text = String::from_utf8_lossy(&r.2).to_string();
3544 assert!(text.contains("sso:pat@example.com"), "{text}");
3545 assert!(!text.contains("sso:sso:"), "must not double-stamp: {text}");
3546 }
3547
3548 #[test]
3552 fn whoami_reports_identity_provenance_and_approval_capability() {
3553 let (s, _ask) = parked_server();
3554 let s = s.with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>);
3555
3556 let r = s.route("GET", "/api/whoami", b"", None, Some("user:officer"));
3557 let text = String::from_utf8_lossy(&r.2).to_string();
3558 assert!(text.contains(r#""identity_source":"sso""#), "{text}");
3559 assert!(text.contains(r#""may_approve":false"#), "{text}");
3560
3561 let r = s.route("GET", "/api/whoami", b"", Some("officer-secret"), None);
3562 let text = String::from_utf8_lossy(&r.2).to_string();
3563 assert!(text.contains(r#""identity_source":"credential""#), "{text}");
3564 assert!(text.contains(r#""may_approve":true"#), "{text}");
3565 }
3566}
3567
3568#[cfg(test)]
3569mod memory_scoped_token_tests {
3570 use super::UiServer;
3571 use areev_cal::AreevFacade;
3572 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
3573 use areev_core::types::{Fact, Grain};
3574 use areev_store::Areev;
3575
3576 #[test]
3580 fn memory_scoped_tokens_do_not_cross_memories() {
3581 std::env::set_var("AREEV_SCOPED_TOK", "scoped-secret");
3582 let map_json = r#"{"version":1,"tokens":[
3583 {"env":"AREEV_SCOPED_TOK","principal":"agent:sync","memories":["memory-a"]}
3584 ]}"#;
3585 let server_for = |label: &str| {
3586 let dir = tempfile::tempdir().unwrap();
3587 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3588 store
3589 .add(
3590 &Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
3591 .namespace(AUTHZ_NS)
3592 .created_at(1_000),
3593 )
3594 .unwrap();
3595 store
3596 .add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000))
3597 .unwrap();
3598 std::mem::forget(dir);
3599 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3600 UiServer::new(facade, label.into())
3601 .with_credentials(CredentialMap::from_json(map_json).unwrap())
3602 };
3603 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
3604
3605 let a = server_for("memory-a");
3608 let r = a.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
3609 let text = String::from_utf8_lossy(&r.2).to_string();
3610 assert!(r.0.starts_with("200") && !text.contains("AUT-E001"), "{text}");
3611
3612 let b = server_for("memory-b");
3616 let r = b.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
3617 let text = String::from_utf8_lossy(&r.2).to_string();
3618 assert!(
3619 text.contains("AUT-E001") && text.contains("anonymous"),
3620 "scoped token must not cross memories: {text}"
3621 );
3622
3623 assert!(CredentialMap::from_json(
3625 r#"{"version":1,"tokens":[{"env":"AREEV_SCOPED_TOK","principal":"x","memories":[]}]}"#
3626 )
3627 .is_err());
3628 }
3629}
3630
3631#[cfg(all(test, feature = "tls"))]
3632mod tls_tests {
3633 use super::UiServer;
3634 use areev_cal::AreevFacade;
3635 use areev_store::Areev;
3636 use std::io::{Read, Write};
3637
3638 fn self_signed(dir: &std::path::Path) -> Option<(String, String)> {
3639 let cert = dir.join("cert.pem");
3640 let key = dir.join("key.pem");
3641 let status = std::process::Command::new("openssl")
3642 .args([
3643 "req", "-x509", "-newkey", "rsa:2048", "-nodes",
3644 "-keyout", key.to_str().unwrap(), "-out", cert.to_str().unwrap(),
3645 "-days", "1", "-subj", "/CN=localhost",
3646 "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1",
3647 "-addext", "basicConstraints=critical,CA:FALSE",
3648 ])
3649 .stdout(std::process::Stdio::null())
3650 .stderr(std::process::Stdio::null())
3651 .status();
3652 match status {
3653 Ok(s) if s.success() => Some((
3654 cert.to_str().unwrap().to_string(),
3655 key.to_str().unwrap().to_string(),
3656 )),
3657 _ => None, }
3659 }
3660
3661 #[test]
3664 fn tls_round_trip_and_no_plaintext_downgrade() {
3665 let dir = tempfile::tempdir().unwrap();
3666 let Some((cert, key)) = self_signed(dir.path()) else {
3667 eprintln!("skipping: no openssl binary");
3668 return;
3669 };
3670 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3671 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3672 let server = UiServer::new(facade, "tls-test".into())
3673 .with_tls(&cert, &key)
3674 .unwrap();
3675 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3676 let addr = listener.local_addr().unwrap();
3677 let handle = std::thread::spawn(move || {
3678 for _ in 0..2 {
3680 if let Ok((s, _)) = listener.accept() {
3681 let _ = server.handle(s);
3682 }
3683 }
3684 });
3685
3686 let mut roots = rustls::RootCertStore::empty();
3688 {
3689 use rustls::pki_types::pem::PemObject;
3690 for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
3691 roots.add(c.unwrap()).unwrap();
3692 }
3693 }
3694 let config = std::sync::Arc::new(
3695 rustls::ClientConfig::builder()
3696 .with_root_certificates(roots)
3697 .with_no_client_auth(),
3698 );
3699 let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
3700 let session = rustls::ClientConnection::new(config, name).unwrap();
3701 let tcp = std::net::TcpStream::connect(addr).unwrap();
3702 let mut tls = rustls::StreamOwned::new(session, tcp);
3703 tls.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
3704 .unwrap();
3705 let mut response = String::new();
3706 let _ = tls.read_to_string(&mut response);
3707 assert!(response.starts_with("HTTP/1.1 200"), "{}", &response[..response.len().min(120)]);
3708 assert!(response.contains("Areev"), "console served over TLS");
3709
3710 let mut plain = std::net::TcpStream::connect(addr).unwrap();
3713 plain
3714 .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
3715 .unwrap();
3716 let mut buf = Vec::new();
3717 let _ = plain.read_to_end(&mut buf);
3718 let text = String::from_utf8_lossy(&buf);
3719 assert!(
3720 !text.contains("HTTP/1.1 200"),
3721 "plaintext must not be served on a TLS listener: {text}"
3722 );
3723 handle.join().unwrap();
3724 }
3725
3726 #[test]
3732 fn an_authenticated_console_terminates_tls_the_same_way() {
3733 let dir = tempfile::tempdir().unwrap();
3734 let Some((cert, key)) = self_signed(dir.path()) else {
3735 eprintln!("skipping: no openssl binary");
3736 return;
3737 };
3738 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3739 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3740 let server = UiServer::new(facade, "auth-tls-test".into())
3741 .with_auth("console-token".into())
3742 .with_tls(&cert, &key)
3743 .unwrap();
3744 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3745 let addr = listener.local_addr().unwrap();
3746 let handle = std::thread::spawn(move || {
3747 if let Ok((s, _)) = listener.accept() {
3748 let _ = server.handle(s);
3749 }
3750 });
3751
3752 let mut roots = rustls::RootCertStore::empty();
3753 {
3754 use rustls::pki_types::pem::PemObject;
3755 for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
3756 roots.add(c.unwrap()).unwrap();
3757 }
3758 }
3759 let config = std::sync::Arc::new(
3760 rustls::ClientConfig::builder()
3761 .with_root_certificates(roots)
3762 .with_no_client_auth(),
3763 );
3764 let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
3765 let session = rustls::ClientConnection::new(config, name).unwrap();
3766 let tcp = std::net::TcpStream::connect(addr).unwrap();
3767 let mut tls = rustls::StreamOwned::new(session, tcp);
3768 tls.write_all(
3769 b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\
3770 Authorization: Bearer console-token\r\nConnection: close\r\n\r\n",
3771 )
3772 .unwrap();
3773 let mut response = String::new();
3774 let _ = tls.read_to_string(&mut response);
3775 assert!(
3776 response.starts_with("HTTP/1.1 200"),
3777 "an authenticated console should terminate TLS like an open one: {}",
3778 &response[..response.len().min(120)]
3779 );
3780 handle.join().unwrap();
3781 }
3782}
3783
3784#[cfg(test)]
3785mod sso_tests {
3786 use super::UiServer;
3787 use areev_cal::AreevFacade;
3788 use areev_core::authz::{AUTHZ_NS, REL_PERMITS};
3789 use areev_core::types::{Fact, Grain};
3790 use areev_store::Areev;
3791 use std::io::{Read, Write};
3792
3793 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
3794
3795 fn sso_server() -> UiServer {
3796 sso_server_with("proxy-secret", None)
3797 }
3798
3799 fn sso_server_rotating(next: Option<&str>) -> UiServer {
3801 sso_server_with("proxy-secret", next)
3802 }
3803
3804 fn sso_server_with(secret: &str, next: Option<&str>) -> UiServer {
3805 let dir = tempfile::tempdir().unwrap();
3806 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3807 store
3808 .add(
3809 &Fact::new("user:pat@example.com", REL_PERMITS, "read,write ON caller")
3810 .namespace(AUTHZ_NS)
3811 .created_at(1_000),
3812 )
3813 .unwrap();
3814 std::mem::forget(dir);
3815 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3816 UiServer::new(facade, "sso-test".into())
3817 .with_sso_rotating("x-forwarded-user", secret, next)
3818 }
3819
3820 fn post(server: &UiServer, extra_headers: &str) -> String {
3821 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3822 let addr = listener.local_addr().unwrap();
3823 std::thread::scope(|scope| {
3824 scope.spawn(|| {
3825 if let Ok((s, _)) = listener.accept() {
3826 let _ = server.handle(s);
3827 }
3828 });
3829 let mut c = std::net::TcpStream::connect(addr).unwrap();
3830 let req = format!(
3831 "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",
3832 WRITE.len()
3833 );
3834 c.write_all(req.as_bytes()).unwrap();
3835 c.write_all(WRITE).unwrap();
3836 let mut out = String::new();
3837 let _ = c.read_to_string(&mut out);
3838 out
3839 })
3840 }
3841
3842 #[test]
3846 fn proxied_identity_works_and_forged_headers_are_ignored() {
3847 let server = sso_server();
3848
3849 let ok = post(
3851 &server,
3852 "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
3853 );
3854 assert!(ok.contains("HTTP/1.1 200"), "{}", &ok[..ok.len().min(200)]);
3855 assert!(!ok.contains("AUT-E001"), "granted principal must write: {ok}");
3856
3857 let forged = post(&server, "X-Forwarded-User: user:pat@example.com\r\n");
3860 assert!(
3861 forged.contains("anonymous") || forged.contains("401"),
3862 "forged identity must not authenticate: {forged}"
3863 );
3864
3865 let wrong = post(
3867 &server,
3868 "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: guess\r\n",
3869 );
3870 assert!(
3871 wrong.contains("anonymous") || wrong.contains("401"),
3872 "wrong proxy secret must not authenticate: {wrong}"
3873 );
3874 }
3875
3876 #[test]
3880 fn malformed_identities_are_rejected_even_with_a_valid_proxy_secret() {
3881 assert_eq!(
3882 super::sanitize_sso_identity(" user:pat@example.com ").as_deref(),
3883 Some("user:pat@example.com"),
3884 "surrounding whitespace is trimmed, not rejected"
3885 );
3886
3887 assert!(super::sanitize_sso_identity("user:a\r\nX-Admin: true").is_none());
3889 assert!(super::sanitize_sso_identity("user:a\tb").is_none());
3890 assert!(super::sanitize_sso_identity("user a").is_none());
3893 assert!(super::sanitize_sso_identity("").is_none());
3894 assert!(super::sanitize_sso_identity(&"x".repeat(129)).is_none());
3895 assert!(super::sanitize_sso_identity("anonymous").is_none());
3898 assert!(super::sanitize_sso_identity("user:console").is_none());
3899 }
3900
3901 #[test]
3905 fn a_rejected_identity_degrades_to_anonymous_rather_than_erroring() {
3906 let server = sso_server();
3907 let out = post(
3908 &server,
3909 "X-Forwarded-User: anonymous\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
3910 );
3911 assert!(
3912 out.contains("anonymous") || out.contains("401"),
3913 "a reserved name must not authenticate: {out}"
3914 );
3915 assert!(!out.contains("HTTP/1.1 500"), "and must not error: {out}");
3916 }
3917
3918 #[test]
3923 fn both_secrets_authenticate_during_a_rotation_window() {
3924 let server = sso_server_rotating(Some("new-secret"));
3925 let id = "X-Forwarded-User: user:pat@example.com\r\n";
3926
3927 for secret in ["proxy-secret", "new-secret"] {
3928 let out = post(&server, &format!("{id}X-Areev-Proxy-Secret: {secret}\r\n"));
3929 assert!(out.contains("HTTP/1.1 200"), "{secret} rejected: {out}");
3930 assert!(!out.contains("AUT-E001"), "{secret} must authenticate: {out}");
3931 }
3932
3933 let wrong = post(&server, &format!("{id}X-Areev-Proxy-Secret: guess\r\n"));
3936 assert!(
3937 wrong.contains("anonymous") || wrong.contains("401"),
3938 "an unrelated secret must still be refused mid-rotation: {wrong}"
3939 );
3940 }
3941
3942 #[test]
3945 fn the_retired_secret_stops_working_once_the_window_closes() {
3946 let server = sso_server_with("new-secret", None);
3948 let id = "X-Forwarded-User: user:pat@example.com\r\n";
3949
3950 let ok = post(&server, &format!("{id}X-Areev-Proxy-Secret: new-secret\r\n"));
3951 assert!(ok.contains("HTTP/1.1 200"), "{ok}");
3952 assert!(!ok.contains("AUT-E001"), "the promoted secret must authenticate: {ok}");
3953
3954 let old = post(&server, &format!("{id}X-Areev-Proxy-Secret: proxy-secret\r\n"));
3955 assert!(
3956 old.contains("anonymous") || old.contains("401"),
3957 "the retired secret must stop working: {old}"
3958 );
3959 }
3960
3961 #[test]
3962 #[should_panic(expected = "must differ")]
3963 fn rotating_to_the_same_value_is_refused() {
3964 let _ = sso_server_rotating(Some("proxy-secret"));
3967 }
3968
3969 use crate::CONSOLE_HTML;
3970 use std::collections::BTreeSet;
3971
3972 #[test]
3984 fn every_console_page_is_in_the_visibility_list() {
3985 fn all(hay: &str, open: &str) -> BTreeSet<String> {
3986 let mut out = BTreeSet::new();
3987 let mut rest = hay;
3988 while let Some(i) = rest.find(open) {
3989 rest = &rest[i + open.len()..];
3990 let end = rest.find('"').expect("unterminated attribute");
3991 out.insert(rest[..end].to_string());
3992 rest = &rest[end..];
3993 }
3994 out
3995 }
3996
3997 let sections = all(CONSOLE_HTML, "id=\"page-");
3998 let nav = all(CONSOLE_HTML, "data-page=\"");
3999 for want in ["workflows", "runs", "tools"] {
4000 assert!(sections.contains(want), "the {want} section itself went missing");
4001 }
4002 assert!(!sections.contains("triggers"), "triggers belong on the canvas, not on a page");
4006 assert!(
4007 CONSOLE_HTML.contains("if (head === 'triggers')"),
4008 "#triggers must still route somewhere — a removed page is not a dead bookmark"
4009 );
4010
4011 let head = "for (const p of [";
4013 let i = CONSOLE_HTML.find(head).expect("render()'s page-visibility loop");
4014 let tail = &CONSOLE_HTML[i + head.len()..];
4015 let j = tail.find(']').expect("unterminated page array");
4016 let listed: BTreeSet<String> = tail[..j]
4017 .split(',')
4018 .map(|t| t.trim().trim_matches('\'').to_string())
4019 .filter(|t| !t.is_empty())
4020 .collect();
4021
4022 assert_eq!(
4023 sections, listed,
4024 "page sections and render()'s visibility list disagree; a section missing \
4025 from the list renders into a pane that never un-hides"
4026 );
4027 let orphan: Vec<_> = nav.difference(§ions).collect();
4028 assert!(orphan.is_empty(), "nav items with no page section: {orphan:?}");
4029 }
4030}
4031
4032#[cfg(test)]
4036mod dsn_redaction_tests {
4037 use super::UiServer;
4038 use areev_cal::AreevFacade;
4039 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
4040 use areev_core::types::{Fact, Grain};
4041 use areev_store::Areev;
4042
4043 const DSN: &str = "postgresql://u:SUPERSECRET@h:5432/d?sslmode=require";
4044
4045 fn server() -> UiServer {
4046 let dir = tempfile::tempdir().unwrap();
4047 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4048 std::mem::forget(dir);
4049 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4050 UiServer::new(facade, DSN.to_string())
4051 }
4052
4053 fn text(r: &(&str, &str, Vec<u8>)) -> String {
4054 String::from_utf8_lossy(&r.2).to_string()
4055 }
4056
4057 #[test]
4058 fn stats_config_and_html_redact_the_dsn_password_but_keep_the_host() {
4059 let s = server();
4060 for (method, path) in [("GET", "/api/stats"), ("GET", "/api/config"), ("GET", "/")] {
4061 let r = s.route(method, path, b"", None, None);
4062 let body = text(&r);
4063 assert!(!body.contains("SUPERSECRET"), "{path} leaked the password: {body}");
4064 assert!(body.contains("h:5432"), "{path} redacted more than the password: {body}");
4065 }
4066 }
4067
4068 #[test]
4073 fn credential_map_memories_entry_still_matches_the_raw_dsn() {
4074 std::env::set_var("AREEV_DSN_TOK", "dsn-scoped-secret");
4075 let map_json = format!(
4076 r#"{{"version":1,"tokens":[{{"env":"AREEV_DSN_TOK","principal":"agent:sync","memories":["{DSN}"]}}]}}"#
4077 );
4078 let dir = tempfile::tempdir().unwrap();
4079 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4080 store
4081 .add(
4082 &Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
4083 .namespace(AUTHZ_NS)
4084 .created_at(1_000),
4085 )
4086 .unwrap();
4087 std::mem::forget(dir);
4088 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4089 let s = UiServer::new(facade, DSN.to_string())
4090 .with_credentials(CredentialMap::from_json(&map_json).unwrap());
4091
4092 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
4093 let r = s.route("POST", "/api/cal", WRITE, Some("dsn-scoped-secret"), None);
4094 let body = text(&r);
4095 assert!(r.0.starts_with("200") && !body.contains("AUT-E001"), "{body}");
4096 }
4097}
4098
4099#[cfg(test)]
4104mod origin_tests {
4105 use super::UiServer;
4106 use areev_cal::AreevFacade;
4107 use areev_store::Areev;
4108 use std::io::{Read, Write};
4109
4110 fn server() -> UiServer {
4111 let dir = tempfile::tempdir().unwrap();
4112 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4113 std::mem::forget(dir);
4114 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4115 UiServer::new(facade, "origin-test".into())
4116 }
4117
4118 fn post_with_origin(server: &UiServer, origin: Option<&str>) -> String {
4121 let body: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
4122 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
4123 let addr = listener.local_addr().unwrap();
4124 std::thread::scope(|scope| {
4125 scope.spawn(|| {
4126 if let Ok((s, _)) = listener.accept() {
4127 let _ = server.handle(s);
4128 }
4129 });
4130 let mut c = std::net::TcpStream::connect(addr).unwrap();
4131 let origin_header = origin.map(|o| format!("Origin: {o}\r\n")).unwrap_or_default();
4132 let req = format!(
4133 "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",
4134 body.len()
4135 );
4136 c.write_all(req.as_bytes()).unwrap();
4137 c.write_all(body).unwrap();
4138 let mut out = String::new();
4139 let _ = c.read_to_string(&mut out);
4140 out
4141 })
4142 }
4143
4144 #[test]
4145 fn matching_allowed_origin_is_accepted() {
4146 let s = server().allow_origin("https://console.example.com");
4147 let r = post_with_origin(&s, Some("https://console.example.com"));
4148 assert!(r.contains("HTTP/1.1 200"), "{r}");
4149 }
4150
4151 #[test]
4152 fn non_matching_origin_is_still_rejected() {
4153 let s = server().allow_origin("https://console.example.com");
4154 let r = post_with_origin(&s, Some("https://not-allowed.example.com"));
4155 assert!(r.contains("403"), "{r}");
4156 assert!(r.contains("cross-origin request rejected"), "{r}");
4157 }
4158
4159 #[test]
4160 fn a_subdomain_of_an_allowed_origin_is_rejected_not_matched() {
4161 let s = server().allow_origin("https://console.example.com");
4164 let r = post_with_origin(&s, Some("https://evil-console.example.com"));
4165 assert!(r.contains("403"), "{r}");
4166 }
4167
4168 #[test]
4169 fn loopback_origin_still_works_with_no_allowlist_set() {
4170 let s = server();
4171 let r = post_with_origin(&s, Some("http://127.0.0.1:7437"));
4172 assert!(r.contains("HTTP/1.1 200"), "{r}");
4173 }
4174
4175 #[test]
4176 fn no_origin_header_still_works() {
4177 let s = server();
4178 let r = post_with_origin(&s, None);
4179 assert!(r.contains("HTTP/1.1 200"), "{r}");
4180 }
4181
4182 #[test]
4183 fn matching_origin_differing_only_in_case_and_trailing_slash_is_accepted() {
4184 let s = server().allow_origin("https://console.example.com");
4185 let r = post_with_origin(&s, Some("HTTPS://Console.Example.com/"));
4186 assert!(r.contains("HTTP/1.1 200"), "{r}");
4187 }
4188}
4189
4190#[cfg(all(test, feature = "oidc"))]
4199mod oidc_route_tests {
4200 use super::{cookie_value, oidc, UiServer};
4201 use areev_cal::AreevFacade;
4202 use areev_core::authz::{AUTHZ_NS, REL_PERMITS};
4203 use areev_core::types::{Fact, Grain};
4204 use areev_store::Areev;
4205
4206 fn cfg() -> oidc::OidcConfig {
4207 oidc::OidcConfig {
4208 issuer: "https://idp.example.com".into(),
4209 client_id: "console".into(),
4210 client_secret: "s3cr3t".into(),
4211 redirect_uri: "https://console.example.com/auth/callback".into(),
4212 scopes: "openid email".into(),
4213 principal_claim: "email".into(),
4214 principal_prefix: None,
4215 }
4216 }
4217
4218 #[test]
4222 fn cookie_lookup_is_exact_on_the_name() {
4223 let h = "theme=dark; areev_session=real; other=x";
4224 assert_eq!(cookie_value(h, "areev_session").as_deref(), Some("real"));
4225 let decoy = "areev_session_decoy=evil; areev_session=real";
4227 assert_eq!(cookie_value(decoy, "areev_session").as_deref(), Some("real"));
4228 let only_decoy = "xareev_session=evil; areev_sessionx=evil2";
4229 assert!(cookie_value(only_decoy, "areev_session").is_none());
4230 assert!(cookie_value("", "areev_session").is_none());
4231 }
4232
4233 #[test]
4236 fn the_auth_endpoints_redirect_and_manage_the_cookie() {
4237 let dir = tempfile::tempdir().unwrap();
4238 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4239 std::mem::forget(dir);
4240 let facade = AreevFacade::with_session(store, Some("ops".into()), None);
4241 let provider = oidc::OidcProvider::with_test_session(cfg(), "sid-1", "user:pat");
4242 let s = UiServer::new(facade, "oidc-test".into()).with_oidc(provider);
4243
4244 let (status, extra, _) = s.oidc_route("GET", "/auth/login", None);
4246 assert!(status.starts_with("302"), "{status}");
4247 assert!(extra.contains("Location: https://idp.example.com/authorize"), "{extra}");
4248 assert!(!extra.contains("Set-Cookie"), "{extra}");
4249 assert!(extra.contains("Cache-Control: no-store"), "a redirect carrying state must not cache: {extra}");
4250
4251 assert!(s.oidc_principal(Some("areev_session=sid-1")).is_some());
4253
4254 let (status, extra, _) =
4256 s.oidc_route("POST", "/auth/logout", Some("areev_session=sid-1"));
4257 assert!(status.starts_with("302"), "{status}");
4258 assert!(extra.contains("Max-Age=0"), "{extra}");
4259 assert!(extra.contains("HttpOnly"), "{extra}");
4260 assert!(extra.contains("SameSite=Strict"), "{extra}");
4261 assert!(
4262 s.oidc_principal(Some("areev_session=sid-1")).is_none(),
4263 "logout must invalidate the session, not just the browser's copy"
4264 );
4265
4266 let (status, _, body) =
4268 s.oidc_route("GET", "/auth/callback?error=access_denied", None);
4269 assert!(status.starts_with("400"), "{status}");
4270 assert!(body.contains("access_denied"), "{body}");
4271 }
4272
4273 #[test]
4277 fn the_session_cookie_is_httponly_strict_and_secure_only_over_https() {
4278 let mk = |redirect: &str| {
4279 let dir = tempfile::tempdir().unwrap();
4280 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4281 std::mem::forget(dir);
4282 let facade = AreevFacade::with_session(store, Some("ops".into()), None);
4283 let mut c = cfg();
4284 c.redirect_uri = redirect.into();
4285 let p = oidc::OidcProvider::with_test_session(c, "sid", "user:pat");
4286 UiServer::new(facade, "t".into()).with_oidc(p)
4287 };
4288
4289 let https = mk("https://console.example.com/auth/callback");
4290 let (_, extra, _) = https.oidc_route("GET", "/auth/logout", None);
4291 assert!(extra.contains("; Secure"), "https deployment must set Secure: {extra}");
4292
4293 let http = mk("http://127.0.0.1:7437/auth/callback");
4294 let (_, extra, _) = http.oidc_route("GET", "/auth/logout", None);
4295 assert!(!extra.contains("; Secure"), "loopback http must not set Secure: {extra}");
4296 assert!(extra.contains("HttpOnly") && extra.contains("SameSite=Strict"), "{extra}");
4298 }
4299
4300 #[test]
4305 fn an_oidc_principal_may_approve_regardless_of_sso_approvals() {
4306 let dir = tempfile::tempdir().unwrap();
4307 let path = dir.path().join("t.db");
4308 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
4309 store
4310 .add(
4311 &Fact::new("user:pat", REL_PERMITS, "read,run.respond ON ops")
4312 .namespace(AUTHZ_NS)
4313 .created_at(900),
4314 )
4315 .unwrap();
4316 std::mem::forget(dir);
4317 let facade = AreevFacade::with_session(store, Some("ops".into()), None);
4318 let provider = oidc::OidcProvider::with_test_session(cfg(), "sid-1", "user:pat");
4319 let s = UiServer::new(facade, "oidc-test".into())
4320 .with_oidc(provider)
4321 .with_sso_approvals(false);
4323
4324 let r = s.route_full("GET", "/api/whoami", b"", None, None, None, Some("user:pat"));
4325 let text = String::from_utf8_lossy(&r.2).to_string();
4326 assert!(text.contains(r#""identity_source":"oidc""#), "{text}");
4327 assert!(
4328 text.contains(r#""may_approve":true"#),
4329 "an IdP-proven identity is exactly what --sso-approvals exists to \
4330 substitute for; it must not be caught by that refusal: {text}"
4331 );
4332 assert!(text.contains("user:pat"), "{text}");
4333 }
4334}
4335
4336#[cfg(test)]
4337mod auth_failure_tracking_tests {
4338 use super::UiServer;
4339 use areev_cal::AreevFacade;
4340 use areev_store::Areev;
4341 use std::io::{Read, Write};
4342 use std::net::IpAddr;
4343
4344 const LOCALHOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
4345
4346 fn server() -> UiServer {
4347 let dir = tempfile::tempdir().unwrap();
4348 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4349 std::mem::forget(dir);
4350 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4351 UiServer::new(facade, "auth-failure-test".into()).with_auth("right-token".into())
4352 }
4353
4354 fn get_root(server: &UiServer, bearer: Option<&str>) -> String {
4357 let auth_header = bearer
4358 .map(|t| format!("Authorization: Bearer {t}\r\n"))
4359 .unwrap_or_default();
4360 get_root_with(server, &auth_header)
4361 }
4362
4363 fn get_root_with(server: &UiServer, extra_headers: &str) -> String {
4366 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
4367 let addr = listener.local_addr().unwrap();
4368 std::thread::scope(|scope| {
4369 scope.spawn(|| {
4370 if let Ok((s, _)) = listener.accept() {
4371 let _ = server.handle(s);
4372 }
4373 });
4374 let mut c = std::net::TcpStream::connect(addr).unwrap();
4375 let req = format!("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n{extra_headers}Connection: close\r\n\r\n");
4376 c.write_all(req.as_bytes()).unwrap();
4377 let mut out = String::new();
4378 let _ = c.read_to_string(&mut out);
4379 out
4380 })
4381 }
4382
4383 #[test]
4384 fn a_bare_request_is_401_but_not_counted() {
4385 let s = server();
4386 let bare = get_root(&s, None);
4390 assert!(bare.contains("HTTP/1.1 401"), "{bare}");
4391 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 0, "a bare request has nothing to fail");
4392 }
4393
4394 #[test]
4395 fn a_wrong_token_is_401_and_is_counted() {
4396 let s = server();
4397 let wrong = get_root(&s, Some("guess"));
4398 assert!(wrong.contains("HTTP/1.1 401"), "{wrong}");
4399 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 1);
4400
4401 let wrong_again = get_root(&s, Some("guess"));
4402 assert!(wrong_again.contains("HTTP/1.1 401"), "{wrong_again}");
4403 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 2, "consecutive failures accumulate");
4404 }
4405
4406 #[test]
4407 fn a_right_token_after_a_streak_succeeds_immediately_and_resets_it() {
4408 let s = server();
4409 for _ in 0..5 {
4410 let r = get_root(&s, Some("guess"));
4411 assert!(r.contains("HTTP/1.1 401"), "{r}");
4412 }
4413 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 5);
4414
4415 let ok = get_root(&s, Some("right-token"));
4420 assert!(ok.contains("HTTP/1.1 200"), "{ok}");
4421 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 0, "success resets the streak");
4422
4423 let r = get_root(&s, Some("guess"));
4425 assert!(r.contains("HTTP/1.1 401"), "{r}");
4426 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 1);
4427 }
4428
4429 #[test]
4435 fn a_proxy_proven_request_is_never_locked_out() {
4436 let dir = tempfile::tempdir().unwrap();
4437 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4438 std::mem::forget(dir);
4439 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4440 let s = UiServer::new(facade, "proxy-test".into())
4441 .with_auth("right-token".into())
4442 .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>);
4443
4444 for _ in 0..(super::MAX_CONSECUTIVE_AUTH_FAILURES + 5) {
4447 let _ = get_root_with(
4448 &s,
4449 "Authorization: Bearer guess\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
4450 );
4451 }
4452 let ok = get_root_with(
4454 &s,
4455 "Authorization: Bearer right-token\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
4456 );
4457 assert!(ok.contains("HTTP/1.1 200"), "proxy-borne traffic must not be locked out: {ok}");
4458 }
4459
4460 #[test]
4464 fn a_long_streak_is_refused_with_429_until_it_goes_idle() {
4465 let s = server();
4466 for i in 0..super::MAX_CONSECUTIVE_AUTH_FAILURES {
4467 let r = get_root(&s, Some("guess"));
4468 assert!(r.contains("HTTP/1.1 401"), "attempt {i} should still be a 401: {r}");
4469 }
4470
4471 let blocked = get_root(&s, Some("guess"));
4473 assert!(blocked.contains("HTTP/1.1 429"), "{blocked}");
4474 assert!(blocked.contains("Retry-After:"), "a 429 must say when: {blocked}");
4475
4476 let right = get_root(&s, Some("right-token"));
4481 assert!(right.contains("HTTP/1.1 429"), "{right}");
4482
4483 let bare = get_root(&s, None);
4487 assert!(bare.contains("HTTP/1.1 401"), "{bare}");
4488 }
4489}