diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index 0f8b6061..876d63f2 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -451,7 +451,10 @@ impl Http { let cors_origins = if use_permissive_cors { Vec::new() } else { - crate::manager::first_party::front_end_origins() + let stored = inbuxa_features::security::front_ends::get(&bp.data_store) + .await + .unwrap_or_default(); + crate::manager::first_party::front_end_origins(&stored) .into_iter() .filter_map(|origin| hyper::header::HeaderValue::from_str(&origin).ok()) .collect() diff --git a/crates/common/src/manager/first_party.rs b/crates/common/src/manager/first_party.rs index e97a341c..dc91dc67 100644 --- a/crates/common/src/manager/first_party.rs +++ b/crates/common/src/manager/first_party.rs @@ -17,15 +17,19 @@ //! - inbuxa-webmail, as the confidential client `ihasmail-inbuxa`, when //! `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` are set. //! -//! inbuxa: the environment variables stand in for `x:FrontEnds` (C-4) until -//! that object exists; the installer and INBUXA Admin's setup wizard will set -//! it instead. +//! inbuxa: `inbuxa:FrontEnds` (C-4) holds the same addresses, set over JMAP +//! by the installer (`inbuxa join`) or INBUXA Admin; the environment +//! variables keep working beside it, and both are registered. Where both +//! give the webmail a secret, the environment's wins, so a server configured +//! through its environment is never changed by what was stored. //! //! A missing client is created. An existing one gains any redirect URI it //! lacks and, for inbuxa-webmail, the configured secret; nothing an operator -//! added is removed. +//! added is removed. This runs on every start, and again whenever +//! `inbuxa:FrontEnds` is set. use directory::core::secret::{hash_secret, verify_secret_hash}; +use inbuxa_features::security::front_ends::{self, FrontEnds}; use registry::{ schema::{ enums::{PasswordHashAlgorithm, ServiceProtocol}, @@ -52,16 +56,27 @@ pub struct FirstPartyClient { pub client_id: String, pub description: String, pub redirect_uris: Vec, - pub secret: Option, + pub secret: Option, +} + +/// A confidential client's secret: given in the clear (the environment), or +/// already hashed (`inbuxa:FrontEnds`, which never keeps it in the clear). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientSecret { + Plain(String), + Hash(String), } /// The first-party clients this server should have, from its applications and -/// the front-end addresses it was given. +/// the front-end addresses it was given. Each front end may have several +/// addresses (the environment's and `inbuxa:FrontEnds`'), each a redirect URI +/// of the same client. pub fn first_party_clients( base_url: &str, applications: &[Application], - admin_url: Option<&str>, - webmail: Option<(&str, &str)>, + admin_urls: &[&str], + webmail_urls: &[&str], + webmail_secret: Option, ) -> Vec { let base_url = base_url.trim_end_matches('/'); let mut clients: Vec = Vec::new(); @@ -102,25 +117,41 @@ pub fn first_party_clients( } } - if let Some(url) = admin_url.map(|url| url.trim().trim_end_matches('/')).filter(|url| !url.is_empty()) { + let uris = |urls: &[&str], path: &str| { + let mut uris: Vec = Vec::new(); + for url in urls { + let url = url.trim().trim_end_matches('/'); + let uri = format!("{url}{path}"); + if !url.is_empty() && !uris.contains(&uri) { + uris.push(uri); + } + } + uris + }; + + let admin = uris(admin_urls, "/oauth/callback"); + if !admin.is_empty() { clients.push(FirstPartyClient { client_id: ADMIN_CLIENT_ID.to_string(), description: "inbuxa Admin".to_string(), - redirect_uris: vec![format!("{url}/oauth/callback")], + redirect_uris: admin, secret: None, }); } - if let Some((url, secret)) = webmail { - let url = url.trim().trim_end_matches('/'); - if !url.is_empty() && !secret.is_empty() { - clients.push(FirstPartyClient { - client_id: WEBMAIL_CLIENT_ID.to_string(), - description: "ihasmail webmail".to_string(), - redirect_uris: vec![format!("{url}/api/auth/callback")], - secret: Some(secret.to_string()), - }); - } + let webmail = uris(webmail_urls, "/api/auth/callback"); + let webmail_secret = webmail_secret.filter(|secret| match secret { + ClientSecret::Plain(s) | ClientSecret::Hash(s) => !s.is_empty(), + }); + if !webmail.is_empty() + && let Some(secret) = webmail_secret + { + clients.push(FirstPartyClient { + client_id: WEBMAIL_CLIENT_ID.to_string(), + description: "ihasmail webmail".to_string(), + redirect_uris: webmail, + secret: Some(secret), + }); } clients @@ -166,12 +197,17 @@ pub fn origin_of(url: &str) -> Option { } /// The origins allowed to make cross-origin requests (contract C-14): INBUXA -/// Admin's, the webmail's, and `INBUXA_CORS_EXTRA_ORIGINS` (comma-separated). -/// -/// inbuxa: read from the environment until `x:FrontEnds` exists (C-4). -pub fn front_end_origins() -> Vec { +/// Admin's, the webmail's, and `INBUXA_CORS_EXTRA_ORIGINS` (comma-separated), +/// from the environment and from `inbuxa:FrontEnds` alike. +pub fn front_end_origins(stored: &FrontEnds) -> Vec { let mut origins = Vec::new(); - for url in [env("ADMIN_URL"), env("WEBMAIL_URL")].into_iter().flatten() { + for url in [env("ADMIN_URL"), env("WEBMAIL_URL")] + .into_iter() + .flatten() + .chain(stored.admin_url.clone()) + .chain(stored.webmail_url.clone()) + .chain(stored.extra_origins.iter().cloned()) + { origins.extend(origin_of(&url)); } if let Some(extra) = env("CORS_EXTRA_ORIGINS") { @@ -182,6 +218,12 @@ pub fn front_end_origins() -> Vec { origins } +/// Whether the environment gives the webmail its secret. `inbuxa:FrontEnds` +/// can't set one then: the environment's would be put back at every start. +pub fn webmail_secret_from_environment() -> bool { + env("WEBMAIL_CLIENT_SECRET").is_some() +} + fn env(name: &str) -> Option { types::branding::env_var(name) .ok() @@ -189,7 +231,9 @@ fn env(name: &str) -> Option { .filter(|value| !value.is_empty()) } -pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> { +/// Registers the first-party clients: at every start, and again whenever +/// `inbuxa:FrontEnds` is set. +pub async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> { retire_legacy_web_interface_client(bp).await?; let system = bp.setting_infallible::().await; let base_url = base_url(bp, &system); @@ -199,18 +243,22 @@ pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Resul .into_iter() .map(|app| app.object) .collect::>(); - let admin_url = env("ADMIN_URL"); - let webmail_url = env("WEBMAIL_URL"); - let webmail_secret = env("WEBMAIL_CLIENT_SECRET"); - if webmail_url.is_some() && webmail_secret.is_none() { + let stored = front_ends::get(&bp.data_store).await?; + let admin_urls: Vec = env("ADMIN_URL").into_iter().chain(stored.admin_url.clone()).collect(); + let webmail_urls: Vec = env("WEBMAIL_URL").into_iter().chain(stored.webmail_url.clone()).collect(); + let webmail_secret = env("WEBMAIL_CLIENT_SECRET") + .map(ClientSecret::Plain) + .or_else(|| stored.webmail_secret_hash.clone().map(ClientSecret::Hash)); + if !webmail_urls.is_empty() && webmail_secret.is_none() { trc::event!( Auth(trc::AuthEvent::Error), - Details = "INBUXA_WEBMAIL_URL is set without INBUXA_WEBMAIL_CLIENT_SECRET; the webmail client was not registered." + Details = "The webmail's address is set without its client secret; the webmail client was not registered." ); } - let webmail = webmail_url.as_deref().zip(webmail_secret.as_deref()); + let admin_urls: Vec<&str> = admin_urls.iter().map(String::as_str).collect(); + let webmail_urls: Vec<&str> = webmail_urls.iter().map(String::as_str).collect(); - for client in first_party_clients(&base_url, &applications, admin_url.as_deref(), webmail) { + for client in first_party_clients(&base_url, &applications, &admin_urls, &webmail_urls, webmail_secret) { ensure_client(bp, client).await?; } @@ -295,19 +343,27 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res updated.redirect_uris.push(uri.clone()); } } - if let Some(secret) = &client.secret { - let matches = match updated.secret.as_deref() { - Some(hash) if !hash.is_empty() => { - verify_secret_hash(hash, secret.as_bytes()).await? + match &client.secret { + Some(ClientSecret::Plain(secret)) => { + let matches = match updated.secret.as_deref() { + Some(hash) if !hash.is_empty() => { + verify_secret_hash(hash, secret.as_bytes()).await? + } + _ => false, + }; + if !matches { + updated.secret = Some( + hash_secret(PasswordHashAlgorithm::Argon2id, secret.as_bytes().to_vec()) + .await?, + ); } - _ => false, - }; - if !matches { - updated.secret = Some( - hash_secret(PasswordHashAlgorithm::Argon2id, secret.as_bytes().to_vec()) - .await?, - ); } + Some(ClientSecret::Hash(hash)) => { + if updated.secret.as_deref() != Some(hash.as_str()) { + updated.secret = Some(hash.clone()); + } + } + None => {} } if updated == current { return Ok(()); @@ -318,9 +374,10 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res .await? } else { let secret = match &client.secret { - Some(secret) => Some( + Some(ClientSecret::Plain(secret)) => Some( hash_secret(PasswordHashAlgorithm::Argon2id, secret.as_bytes().to_vec()).await?, ), + Some(ClientSecret::Hash(hash)) => Some(hash.clone()), None => None, }; bp.registry @@ -365,7 +422,7 @@ mod tests { #[test] fn web_interface_gets_one_uri_per_prefix() { - let clients = first_party_clients("https://mail.example.org/", &[web_interface()], None, None); + let clients = first_party_clients("https://mail.example.org/", &[web_interface()], &[], &[], None); assert_eq!( clients, vec![FirstPartyClient { @@ -387,7 +444,7 @@ mod tests { let mut named = web_interface(); named.oauth_client_id = Some("custom".to_string()); named.url_prefix = Map::new(vec!["portal".into()]); - let clients = first_party_clients("https://h", &[disabled, named], None, None); + let clients = first_party_clients("https://h", &[disabled, named], &[], &[], None); assert_eq!(clients.len(), 1); assert_eq!(clients[0].client_id, "custom"); assert_eq!(clients[0].redirect_uris, vec!["https://h/portal/oauth/callback"]); @@ -398,8 +455,9 @@ mod tests { let clients = first_party_clients( "https://h", &[], - Some("https://admin.example.org/"), - Some(("https://webmail.example.org", "s3cret")), + &["https://admin.example.org/"], + &["https://webmail.example.org"], + Some(ClientSecret::Plain("s3cret".into())), ); assert_eq!(clients.len(), 2); assert_eq!(clients[0].client_id, ADMIN_CLIENT_ID); @@ -407,7 +465,7 @@ mod tests { assert_eq!(clients[0].secret, None); assert_eq!(clients[1].client_id, WEBMAIL_CLIENT_ID); assert_eq!(clients[1].redirect_uris, vec!["https://webmail.example.org/api/auth/callback"]); - assert_eq!(clients[1].secret.as_deref(), Some("s3cret")); + assert_eq!(clients[1].secret, Some(ClientSecret::Plain("s3cret".into()))); } #[test] @@ -423,7 +481,42 @@ mod tests { #[test] fn webmail_needs_a_secret() { - let clients = first_party_clients("https://h", &[], Some(" "), Some(("https://w", ""))); + let clients = first_party_clients("https://h", &[], &[" "], &["https://w"], Some(ClientSecret::Plain(String::new()))); + assert!(clients.is_empty()); + let clients = first_party_clients("https://h", &[], &[], &["https://w"], None); assert!(clients.is_empty()); } + + #[test] + fn both_addresses_of_a_front_end_are_registered() { + // The environment's and inbuxa:FrontEnds', once each + let clients = first_party_clients( + "https://h", + &[], + &["https://admin.a.example", "https://admin.b.example/", "https://admin.a.example/"], + &["https://webmail.b.example"], + Some(ClientSecret::Hash("$argon2id$x".into())), + ); + assert_eq!( + clients[0].redirect_uris, + vec![ + "https://admin.a.example/oauth/callback", + "https://admin.b.example/oauth/callback" + ] + ); + assert_eq!(clients[1].secret, Some(ClientSecret::Hash("$argon2id$x".into()))); + } + + #[test] + fn stored_front_ends_are_allowed_origins() { + let stored = FrontEnds { + admin_url: Some("https://Admin.Example.org/console".into()), + extra_origins: vec!["http://localhost:5173".into(), "not a url".into()], + ..Default::default() + }; + let origins = front_end_origins(&stored); + assert!(origins.contains(&"https://admin.example.org".to_string())); + assert!(origins.contains(&"http://localhost:5173".to_string())); + assert!(!origins.iter().any(|o| o.contains("not a url"))); + } } diff --git a/crates/features/src/security/front_ends.rs b/crates/features/src/security/front_ends.rs new file mode 100644 index 00000000..ed2b7e2b --- /dev/null +++ b/crates/features/src/security/front_ends.rs @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:FrontEnds`, where the server's front ends are (contract C-4): the +//! console's address, the webmail's and its client secret, and any further +//! origins allowed to call the server from a browser. +//! +//! It stands beside the `INBUXA_ADMIN_URL`, `INBUXA_WEBMAIL_URL` and +//! `INBUXA_WEBMAIL_CLIENT_SECRET` environment variables rather than replacing +//! them: a server configured through its environment keeps working, and what +//! is stored here is added to it. That is what lets a second machine add its +//! front ends over JMAP (the installer's `inbuxa join`) without touching the +//! server's environment. +//! +//! The webmail's secret is kept only as its Argon2 hash, the form the OAuth +//! client holds it in, so nothing here can be read back as the secret. +//! +//! Stored as JSON in the fork's subspace under `F` + `e`; unset reads as +//! empty, which changes nothing. + +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; +use store::{ + Deserialize, SUBSPACE_INBUXA, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass}, +}; +use trc::AddContext; + +#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrontEnds { + /// Where INBUXA Admin is served: registers `inbuxa-admin`'s redirect URI + /// and allows its origin (C-6, C-14). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admin_url: Option, + /// Where inbuxa-webmail is served: registers `ihasmail-inbuxa`'s redirect + /// URI and allows its origin. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webmail_url: Option, + /// The Argon2 hash of the webmail's client secret. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webmail_secret_hash: Option, + /// Further origins allowed to call the server from a browser. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extra_origins: Vec, + /// Seconds since the epoch, and who: the console shows them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub changed_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub changed_by: Option, +} + +fn key() -> ValueClass { + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key: b"Fe".to_vec(), + }) +} + +struct Json(FrontEnds); + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .caused_by(trc::location!()) + .reason(err) + }) + } +} + +/// What is stored, or the empty default. +pub async fn get(data: &Store) -> trc::Result { + Ok(data + .get_value::(ValueKey::from(key())) + .await + .caused_by(trc::location!())? + .map(|Json(front_ends)| front_ends) + .unwrap_or_default()) +} + +/// Stores it. +pub async fn set(data: &Store, front_ends: &FrontEnds) -> trc::Result<()> { + let bytes = serde_json::to_vec(front_ends).map_err(|err| { + trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .reason(err) + })?; + let mut batch = BatchBuilder::new(); + batch.set(key(), bytes); + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + +/// Why an address can't be a front end's, or `None` when it can: an +/// absolute `http` or `https` URL with a host, and nothing after the path a +/// redirect URI could be built on. +pub fn invalid_url(url: &str) -> Option<&'static str> { + let url = url.trim(); + let rest = if let Some(rest) = url.strip_prefix("https://") { + rest + } else if let Some(rest) = url.strip_prefix("http://") { + rest + } else { + return Some("must start with https:// or http://"); + }; + let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); + if host.is_empty() || host.starts_with(':') { + return Some("must name a host"); + } + if rest.contains('?') || rest.contains('#') { + return Some("must not have a query or a fragment"); + } + if rest.chars().any(char::is_whitespace) { + return Some("must not contain spaces"); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_is_its_own() { + let ValueClass::Any(k) = key() else { panic!() }; + assert_eq!(k.key, b"Fe"); + } + + #[test] + fn urls() { + assert_eq!(invalid_url("https://admin.example.org"), None); + assert_eq!(invalid_url("http://127.0.0.1:8082/"), None); + assert_eq!(invalid_url("https://example.org/console"), None); + assert!(invalid_url("admin.example.org").is_some()); + assert!(invalid_url("ftp://example.org").is_some()); + assert!(invalid_url("https://").is_some()); + assert!(invalid_url("https://:8080").is_some()); + assert!(invalid_url("https://example.org/?x=1").is_some()); + assert!(invalid_url("https://exa mple.org").is_some()); + } + + #[test] + fn the_hash_is_never_called_a_secret() { + let json = serde_json::to_string(&FrontEnds { + webmail_secret_hash: Some("$argon2id$...".into()), + ..Default::default() + }) + .unwrap(); + assert!(json.contains("webmailSecretHash")); + assert!(!json.contains("\"webmailClientSecret\"")); + } +} diff --git a/crates/features/src/security/mod.rs b/crates/features/src/security/mod.rs index 50ec3d23..9fe4f8a4 100644 --- a/crates/features/src/security/mod.rs +++ b/crates/features/src/security/mod.rs @@ -11,6 +11,7 @@ //! `legacy-protocols.md`. pub mod acceptance; +pub mod front_ends; pub mod legacy_use; pub mod log_files; pub mod listeners; diff --git a/crates/jmap-proto/src/object/inbuxa_front_ends.rs b/crates/jmap-proto/src/object/inbuxa_front_ends.rs new file mode 100644 index 00000000..b061af78 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_front_ends.rs @@ -0,0 +1,207 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:FrontEnds/get` and `/set` under `urn:inbuxa:jmap`: where the +//! server's front ends are (contract C-4). A singleton, id `singleton`. +//! +//! `webmailClientSecret` is write-only. The `environment*` properties, +//! `webmailClientSecretSet`, `changedAt` and `changedBy` are the server's to +//! say; a client that sets them is answered with `invalidProperties`. + +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct FrontEnds; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FrontEndsProperty { + Id, + /// Where INBUXA Admin is served, or null. + AdminUrl, + /// Where inbuxa-webmail is served, or null. + WebmailUrl, + /// Write-only: the webmail's client secret. Always null when read. + WebmailClientSecret, + /// Server-set: whether a webmail client secret is stored. + WebmailClientSecretSet, + /// Further origins allowed to call the server from a browser. + ExtraOrigins, + /// Server-set: `INBUXA_ADMIN_URL` on this node, or null. + EnvironmentAdminUrl, + /// Server-set: `INBUXA_WEBMAIL_URL` on this node, or null. + EnvironmentWebmailUrl, + /// Server-set: whether `INBUXA_WEBMAIL_CLIENT_SECRET` is set on this node. + EnvironmentWebmailClientSecret, + ChangedAt, + ChangedBy, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FrontEndsValue { + Id(Id), +} + +impl Property for FrontEndsProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + FrontEndsProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + FrontEndsProperty::Id => "id", + FrontEndsProperty::AdminUrl => "adminUrl", + FrontEndsProperty::WebmailUrl => "webmailUrl", + FrontEndsProperty::WebmailClientSecret => "webmailClientSecret", + FrontEndsProperty::WebmailClientSecretSet => "webmailClientSecretSet", + FrontEndsProperty::ExtraOrigins => "extraOrigins", + FrontEndsProperty::EnvironmentAdminUrl => "environmentAdminUrl", + FrontEndsProperty::EnvironmentWebmailUrl => "environmentWebmailUrl", + FrontEndsProperty::EnvironmentWebmailClientSecret => "environmentWebmailClientSecret", + FrontEndsProperty::ChangedAt => "changedAt", + FrontEndsProperty::ChangedBy => "changedBy", + } + .into() + } +} + +impl FrontEndsProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => FrontEndsProperty::Id, + b"adminUrl" => FrontEndsProperty::AdminUrl, + b"webmailUrl" => FrontEndsProperty::WebmailUrl, + b"webmailClientSecret" => FrontEndsProperty::WebmailClientSecret, + b"webmailClientSecretSet" => FrontEndsProperty::WebmailClientSecretSet, + b"extraOrigins" => FrontEndsProperty::ExtraOrigins, + b"environmentAdminUrl" => FrontEndsProperty::EnvironmentAdminUrl, + b"environmentWebmailUrl" => FrontEndsProperty::EnvironmentWebmailUrl, + b"environmentWebmailClientSecret" => FrontEndsProperty::EnvironmentWebmailClientSecret, + b"changedAt" => FrontEndsProperty::ChangedAt, + b"changedBy" => FrontEndsProperty::ChangedBy, + ) + } +} + +impl FrontEndsProperty { + /// Whether this property is the server's to say. A client that sets one + /// is answered with `invalidProperties`. + pub fn is_server_set(&self) -> bool { + matches!( + self, + FrontEndsProperty::WebmailClientSecretSet + | FrontEndsProperty::EnvironmentAdminUrl + | FrontEndsProperty::EnvironmentWebmailUrl + | FrontEndsProperty::EnvironmentWebmailClientSecret + | FrontEndsProperty::ChangedAt + | FrontEndsProperty::ChangedBy + ) + } +} + +impl FromStr for FrontEndsProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + FrontEndsProperty::parse(s).ok_or(()) + } +} + +impl Element for FrontEndsValue { + type Property = FrontEndsProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(FrontEndsProperty::Id) => { + Id::from_str(value).ok().map(FrontEndsValue::Id) + } + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + FrontEndsValue::Id(id) => id.to_string().into(), + } + } +} + +impl JmapObject for FrontEnds { + type Property = FrontEndsProperty; + + type Element = FrontEndsValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = FrontEndsProperty::Id; +} + +impl From for FrontEndsValue { + fn from(id: Id) -> Self { + FrontEndsValue::Id(id) + } +} + +impl JmapObjectId for FrontEndsValue { + fn as_id(&self) -> Option { + match self { + FrontEndsValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + FrontEndsValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = FrontEndsValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for FrontEndsProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index 79ed5e7e..d832819a 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -44,6 +44,7 @@ pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant pub mod inbuxa_sharing_policy; // inbuxa: MA-C, who may share mail +pub mod inbuxa_front_ends; // inbuxa: C-4, where the front ends are pub mod inbuxa_deleted_account; // inbuxa: undelete pub mod file_node; pub mod identity; diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index f2658116..cff58a46 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -127,6 +127,9 @@ impl Response<'_> { GetResponseMethod::SharingPolicy(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::FrontEnds(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::Principal(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index 1c3f56d8..6207bd41 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -72,6 +72,7 @@ impl Response<'_> { GetRequestMethod::SharingPolicy(request) => { request.resolve_references(self)? } + GetRequestMethod::FrontEnds(request) => request.resolve_references(self)?, GetRequestMethod::Principal(request) => request.resolve_references(self)?, GetRequestMethod::Quota(request) => request.resolve_references(self)?, GetRequestMethod::Blob(request) => request.resolve_references(self)?, @@ -184,6 +185,9 @@ impl Response<'_> { SetRequestMethod::SharingPolicy(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::FrontEnds(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::AddressBook(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 9020fccb..fdfae517 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -84,6 +84,7 @@ pub enum MethodObject { JournalVerification, TenantProtocolPolicy, SharingPolicy, + FrontEnds, } impl MethodObject { @@ -137,6 +138,7 @@ impl MethodObject { MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa, MethodObject::SharingPolicy => Capability::Inbuxa, + MethodObject::FrontEnds => Capability::Inbuxa, } } } @@ -373,6 +375,8 @@ impl MethodName { (MethodFunction::Set, MethodObject::SharingPolicy) => { "inbuxa:SharingPolicy/set" } + (MethodFunction::Get, MethodObject::FrontEnds) => "inbuxa:FrontEnds/get", + (MethodFunction::Set, MethodObject::FrontEnds) => "inbuxa:FrontEnds/set", (method, MethodObject::Registry(obj)) => { return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str())); } @@ -545,6 +549,8 @@ impl MethodName { "inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set), "inbuxa:SharingPolicy/get" => (MethodObject::SharingPolicy, MethodFunction::Get), "inbuxa:SharingPolicy/set" => (MethodObject::SharingPolicy, MethodFunction::Set), + "inbuxa:FrontEnds/get" => (MethodObject::FrontEnds, MethodFunction::Get), + "inbuxa:FrontEnds/set" => (MethodObject::FrontEnds, MethodFunction::Set), ).or_else(|| { let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; @@ -625,6 +631,7 @@ impl Display for MethodObject { MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", MethodObject::SharingPolicy => "inbuxa:SharingPolicy", + MethodObject::FrontEnds => "inbuxa:FrontEnds", MethodObject::Registry(obj) => { f.write_str("x:")?; return f.write_str(obj.as_str()); diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 5de1895c..9a366102 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -142,6 +142,7 @@ pub enum GetRequestMethod { SharingPolicy( Box>, ), + FrontEnds(Box>), } #[derive(Debug)] @@ -194,6 +195,7 @@ pub enum SetRequestMethod<'x> { SharingPolicy( Box>, ), + FrontEnds(Box>), } #[derive(Debug)] diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 1ce359b7..b93f3e2f 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -222,6 +222,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::FrontEnds) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::FrontEnds(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), @@ -433,6 +440,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Set, MethodObject::FrontEnds) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::FrontEnds(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index d0272b32..a7000fdf 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -129,6 +129,7 @@ pub enum GetResponseMethod { SharingPolicy( GetResponse, ), + FrontEnds(GetResponse), } #[derive(Debug, serde::Serialize)] @@ -182,6 +183,7 @@ pub enum SetResponseMethod { SharingPolicy( Box>, ), + FrontEnds(Box>), } #[derive(Debug, serde::Serialize)] @@ -398,6 +400,18 @@ impl<'x> From> } } +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::FrontEnds(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::FrontEnds(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::AiLimits(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 2ba40cbf..70531164 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -143,6 +143,9 @@ impl JmapAuthorization for AccessToken { // manages their tenant's, so the domain's permissions; the // server's own also needs sysSharingUpdate (see the method) GetRequestMethod::SharingPolicy(_) => Permission::SysDomainGet, + // inbuxa: C-4, where the front ends are: the HTTP settings' + // (the method also refuses anyone inside a tenant) + GetRequestMethod::FrontEnds(_) => Permission::SysHttpGet, GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet, GetRequestMethod::Quota(_) => Permission::JmapQuotaGet, GetRequestMethod::Blob(_) => Permission::JmapBlobGet, @@ -431,6 +434,15 @@ impl JmapAuthorization for AccessToken { Permission::SysDomainUpdate, Permission::SysDomainUpdate, ), + // inbuxa: C-4, where the front ends are: the HTTP + // settings' (the method also needs sysOAuthClientUpdate) + SetRequestMethod::FrontEnds(s) => validate_set( + s, + self, + Permission::SysHttpUpdate, + Permission::SysHttpUpdate, + Permission::SysHttpUpdate, + ), SetRequestMethod::VacationResponse(s) => validate_set( s, self, @@ -567,7 +579,8 @@ impl JmapAuthorization for AccessToken { | MethodObject::JournalVerification | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy - | MethodObject::SharingPolicy => Permission::JmapEmailChanges, + | MethodObject::SharingPolicy + | MethodObject::FrontEnds => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads MethodObject::Registry(object_type) => object_type.get_permission(), }, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 2cd48956..8f1df162 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -335,6 +335,9 @@ impl RequestHandler for Server { SetResponseMethod::SharingPolicy(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::FrontEnds(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::AddressBook(set_response) => { set_response.update_created_ids(&mut response); } @@ -631,6 +634,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:FrontEnds/get (C-4, where the front ends are) + GetRequestMethod::FrontEnds(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::front_ends::get(self, access_token, *req) + .await? + .into() + } GetRequestMethod::Principal(req) => { self.principal_get(*req, access_token).await?.into() } @@ -1300,6 +1310,23 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:FrontEnds/set (C-4, where the front ends are) + SetRequestMethod::FrontEnds(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + // inbuxa: AU-1.2, AU-3 + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + None, + *req, + |req| Box::pin(crate::inbuxa::front_ends::set(self, access_token, req)), + ) + .await? + .into() + } SetRequestMethod::AddressBook(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; access_token.assert_has_access(req.account_id, Collection::AddressBook)?; diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 4776b842..7e221fd0 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -445,6 +445,7 @@ impl IntermediateChangesResponse { | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy | MethodObject::SharingPolicy + | MethodObject::FrontEnds | MethodObject::Registry(_) => unreachable!(), }) } diff --git a/crates/jmap/src/inbuxa/front_ends.rs b/crates/jmap/src/inbuxa/front_ends.rs new file mode 100644 index 00000000..d92e2f67 --- /dev/null +++ b/crates/jmap/src/inbuxa/front_ends.rs @@ -0,0 +1,311 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:FrontEnds/get` and `/set`: where the server's front ends are +//! (contract C-4). A server setting, so a server administrator's only: the +//! HTTP settings' permissions, and `sysOAuthClientUpdate` to change it, since +//! setting it registers OAuth clients. Inside a tenant it doesn't exist. +//! +//! Setting it registers the front ends' OAuth clients at once (C-6) and +//! reloads the HTTP settings here and on every node, which is what puts the +//! new origins on the cross-origin allowlist (C-14). The environment +//! variables keep working beside it; `get` shows what they say, so whoever +//! reads it can tell which address came from where. +//! +//! It's stored in the registry's store, which is the one start-up and every +//! reload read it from. + +use common::{Server, auth::AccessToken, manager::first_party}; +use directory::core::secret::hash_secret; +use inbuxa_features::security::front_ends::{self, FrontEnds as Stored, invalid_url}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_front_ends::{FrontEnds, FrontEndsProperty as P, FrontEndsValue}, + request::IntoValid, +}; +use jmap_tools::{Key, Map, Value}; +use registry::schema::{ + enums::{PasswordHashAlgorithm, Permission}, + prelude::ObjectType, +}; +use store::registry::bootstrap::Bootstrap; +use trc::AddContext; +use types::id::Id; + +type PValue = Value<'static, P, FrontEndsValue>; + +const ALL: &[P] = &[ + P::Id, + P::AdminUrl, + P::WebmailUrl, + P::WebmailClientSecret, + P::WebmailClientSecretSet, + P::ExtraOrigins, + P::EnvironmentAdminUrl, + P::EnvironmentWebmailUrl, + P::EnvironmentWebmailClientSecret, + P::ChangedAt, + P::ChangedBy, +]; + +fn env(name: &str) -> Option { + types::branding::env_var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn opt_str(value: Option<&String>) -> PValue { + value + .map(|v| Value::Str(v.clone().into())) + .unwrap_or(Value::Null) +} + +fn to_value(stored: &Stored, properties: &[P]) -> PValue { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(FrontEndsValue::Id(Id::singleton())), + P::AdminUrl => opt_str(stored.admin_url.as_ref()), + P::WebmailUrl => opt_str(stored.webmail_url.as_ref()), + // Write-only: the stored form is a hash, and even that stays in + P::WebmailClientSecret => Value::Null, + P::WebmailClientSecretSet => Value::Bool(stored.webmail_secret_hash.is_some()), + P::ExtraOrigins => Value::Array( + stored + .extra_origins + .iter() + .map(|o| Value::Str(o.clone().into())) + .collect(), + ), + P::EnvironmentAdminUrl => opt_str(env("ADMIN_URL").as_ref()), + P::EnvironmentWebmailUrl => opt_str(env("WEBMAIL_URL").as_ref()), + P::EnvironmentWebmailClientSecret => { + Value::Bool(first_party::webmail_secret_from_environment()) + } + P::ChangedAt => stored + .changed_at + .map(|at| Value::Number(at.into())) + .unwrap_or(Value::Null), + P::ChangedBy => opt_str(stored.changed_by.as_ref()), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// `inbuxa:FrontEnds/get`. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let wanted = ids.unwrap_or_else(|| vec![Id::singleton()]); + let bp = Bootstrap::new(server.registry().clone()).await; + for id in wanted { + if id.is_singleton() && access_token.tenant_id().is_none() { + let stored = front_ends::get(&bp.data_store).await?; + response.list.push(to_value(&stored, &properties)); + } else { + response.push_not_found(id); + } + } + Ok(response) +} + +fn invalid(key: Key<'_, P>, why: impl Into) -> SetError

{ + SetError::invalid_properties() + .with_property(key.into_owned()) + .with_description(why.into()) +} + +/// A URL property: a front end's address, or null to clear it. +fn parse_url( + key: &Key<'_, P>, + value: &Value<'_, P, FrontEndsValue>, +) -> Result, SetError

> { + match value { + Value::Null => Ok(None), + Value::Str(s) => match invalid_url(s) { + None => Ok(Some(s.trim().trim_end_matches('/').to_string())), + Some(why) => Err(invalid(key.clone(), why)), + }, + _ => Err(invalid(key.clone(), "must be a URL or null")), + } +} + +/// `inbuxa:FrontEnds/set`: updates the singleton. `null` clears a property. +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, FrontEnds>, +) -> trc::Result> { + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + for (client_id, _) in request.unwrap_create() { + response.not_created.append( + client_id, + SetError::forbidden() + .with_description("inbuxa:FrontEnds is a singleton that always exists."), + ); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append( + id, + SetError::forbidden() + .with_description("inbuxa:FrontEnds is a singleton that always exists."), + ); + } + + let mut bp = Bootstrap::new(server.registry().clone()).await; + let mut changed = false; + for (id, value) in request.unwrap_update().into_valid() { + if !id.is_singleton() || access_token.tenant_id().is_some() { + response.not_updated.append(id, SetError::not_found()); + continue; + } + // Setting it registers OAuth clients, so it takes that permission too + if !access_token.has_permission(Permission::SysOAuthClientUpdate) { + response.not_updated.append( + id, + SetError::forbidden().with_description( + "Changing the front ends registers OAuth clients, which needs sysOAuthClientUpdate.", + ), + ); + continue; + } + + let previous = front_ends::get(&bp.data_store).await?; + let mut next = previous.clone(); + let mut error = None; + for (key, value) in value.into_expanded_object() { + let result: Result<(), SetError

> = match &key { + Key::Property(P::AdminUrl) => parse_url(&key, &value).map(|v| next.admin_url = v), + Key::Property(P::WebmailUrl) => { + parse_url(&key, &value).map(|v| next.webmail_url = v) + } + Key::Property(P::WebmailClientSecret) => match &value { + Value::Null => { + next.webmail_secret_hash = None; + Ok(()) + } + Value::Str(_) if first_party::webmail_secret_from_environment() => { + Err(SetError::forbidden().with_description( + "This server takes the webmail's client secret from \ + INBUXA_WEBMAIL_CLIENT_SECRET, which would replace this one at \ + every start. Remove it from the server's environment first.", + )) + } + Value::Str(s) if s.len() >= 32 => { + next.webmail_secret_hash = Some( + hash_secret(PasswordHashAlgorithm::Argon2id, s.as_bytes().to_vec()) + .await?, + ); + Ok(()) + } + Value::Str(_) => Err(invalid(key.clone(), "must be at least 32 characters")), + _ => Err(invalid(key.clone(), "must be a string or null")), + }, + Key::Property(P::ExtraOrigins) => match value { + Value::Null => { + next.extra_origins.clear(); + Ok(()) + } + Value::Array(items) => { + let mut origins = Vec::with_capacity(items.len()); + let mut bad = None; + for item in items { + match item { + Value::Str(s) => match first_party::origin_of(&s) { + Some(origin) if !origins.contains(&origin) => { + origins.push(origin) + } + Some(_) => {} + None => { + bad = Some(format!("{s:?} is not an http or https origin")) + } + }, + _ => bad = Some("must be a list of origins".to_string()), + } + } + match bad { + Some(why) => Err(invalid(key.clone(), why)), + None => { + next.extra_origins = origins; + Ok(()) + } + } + } + _ => Err(invalid(key.clone(), "must be a list of origins or null")), + }, + Key::Property(P::Id) => Err(invalid(key.clone(), "is immutable")), + Key::Property(property) if property.is_server_set() => { + Err(invalid(key.clone(), "is set by the server")) + } + _ => Err(invalid( + key.clone(), + "is not a property of inbuxa:FrontEnds", + )), + }; + if let Err(err) = result { + error = Some(err); + break; + } + } + if let Some(error) = error { + response.not_updated.append(id, error); + continue; + } + if next.webmail_url.is_some() + && next.webmail_secret_hash.is_none() + && !first_party::webmail_secret_from_environment() + { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Key::Property(P::WebmailClientSecret)) + .with_description("The webmail needs a client secret to be registered."), + ); + continue; + } + + let mut compare = previous.clone(); + compare.changed_at = next.changed_at; + compare.changed_by = next.changed_by.clone(); + if compare != next { + next.changed_at = Some(store::write::now()); + next.changed_by = Some(Id::from(access_token.account_id()).to_string()); + front_ends::set(&bp.data_store, &next) + .await + .caused_by(trc::location!())?; + changed = true; + } + response.updated.append(id, None); + } + + if changed { + // The OAuth clients now, so the front end can sign in at once (C-6) + first_party::ensure_first_party_clients(&mut bp) + .await + .caused_by(trc::location!())?; + // The allowlist is built with the HTTP settings, here and on every + // node (C-14). The value is stored whatever the reload says, as for + // any registry write; a refused reload is logged by the reload. + let _ = server.reload_after_write(ObjectType::Http).await; + } + Ok(response) +} diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index d028b967..bd975f4a 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -31,6 +31,7 @@ pub mod explanation; pub mod protocol_policy; pub mod tenant_protocol_policy; pub mod sharing_policy; +pub mod front_ends; pub mod deleted_account; pub mod fastmail; pub mod masked_email; diff --git a/docs/spec/contract.md b/docs/spec/contract.md index 994d36ac..e710e329 100644 --- a/docs/spec/contract.md +++ b/docs/spec/contract.md @@ -89,6 +89,23 @@ Each has an ID, and tests name the IDs they check. client registrations (C-6), the cross-origin allowlist (C-14) and the admin lane (C-18). The installer sets it, and so does INBUXA Admin's setup wizard. + **Built, 2026-10-11, as `inbuxa:FrontEnds`.** A fork JMAP object under + `urn:inbuxa:jmap` rather than a registry object (open question 1), kept in + the fork's subspace (`Fe`), like the other `inbuxa:` singletons; id + `singleton`. Properties: `adminUrl`, `webmailUrl`, `webmailClientSecret` + (write-only, at least 32 characters, stored only as its Argon2 hash; + `webmailClientSecretSet` says whether one is), `extraOrigins`, and, + server-set, `environmentAdminUrl`, `environmentWebmailUrl`, + `environmentWebmailClientSecret`, `changedAt`, `changedBy`. `/get` needs + `sysHttpGet`; `/set` needs `sysHttpUpdate` and `sysOAuthClientUpdate`; + neither exists inside a tenant. A set registers the clients at once (C-6) + and reloads the HTTP settings on every node (C-14); it's audited. The + environment variables below keep working beside it and both are honored; + where both give the webmail a secret the environment's wins, so setting + one is refused while `INBUXA_WEBMAIL_CLIENT_SECRET` is set. The installer's + `inbuxa join` sets it from a second machine. C-1 discovery and C-18's + admin lane don't read it yet. Tests: `system::front_ends::front_ends_tests`. + ### Sign-in - **C-5.** INBUXA requires client registration: `requireClientRegistration` @@ -126,8 +143,8 @@ Each has an ID, and tests name the IDs they check. **Built (interim), 2026-09-18.** C-5's defaults are in the server, and `crates/common/src/manager/first_party.rs` registers the clients on every - start. Until `x:FrontEnds` exists, three environment variables stand in for - it: `INBUXA_ADMIN_URL`, `INBUXA_WEBMAIL_URL` and + start, and again whenever `inbuxa:FrontEnds` is set (C-4). Beside it, + three environment variables give the same addresses: `INBUXA_ADMIN_URL`, `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` (the webmail client is registered only when both of its variables are set). A web interface the server serves itself (none on a new install since SPEC.md ยง5.3; possible on one upgraded from @@ -206,9 +223,10 @@ Each has an ID, and tests name the IDs they check. `Access-Control-Allow-Origin` with `Vary: Origin`, never `*`, and sends nothing for any other origin. - **Built (interim), 2026-09-18.** Until `x:FrontEnds` exists, the allowed - origins come from `INBUXA_ADMIN_URL`, `INBUXA_WEBMAIL_URL` and - `INBUXA_CORS_EXTRA_ORIGINS` (comma-separated). Each is reduced to its origin, + **Built (interim), 2026-09-18.** The allowed origins come from + `INBUXA_ADMIN_URL`, `INBUXA_WEBMAIL_URL` and `INBUXA_CORS_EXTRA_ORIGINS` + (comma-separated), and since 2026-10-11 also from `inbuxa:FrontEnds`'s + `adminUrl`, `webmailUrl` and `extraOrigins` (C-4). Each is reduced to its origin, lowercased and without a default port, and anything that isn't an `http` or `https` URL is skipped. Every response carries `Vary: Origin`. A matching origin gets `Access-Control-Allow-Origin` echoed with the allowed headers and @@ -396,9 +414,11 @@ client and reload settings. This is the state C-5 and C-6 make the default. ## Open questions -1. Whether `x:FrontEnds` is a new registry object (a schema addition in the - fork's namespace) or kept in the fork's own store. A registry object is - editable in INBUXA Admin for free, since the admin is schema-driven. +1. ~~Whether `x:FrontEnds` is a new registry object (a schema addition in the + fork's namespace) or kept in the fork's own store.~~ Settled 2026-10-11: + the fork's own store, as `inbuxa:FrontEnds` (C-4), like the other `inbuxa:` + singletons, which keeps upstream's generated schema untouched. INBUXA Admin + needs a page for it, as it has for the sharing policy. 2. The consent page's wording and whether it remembers a decision per client. 3. API keys and app passwords with explicit scopes (C-21): what upstream's `x:ApiKey` already supports, to observe before specifying. diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index 9b358d64..9428a47a 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -296,6 +296,17 @@ retention = "unbounded" [object."inbuxa:SharingPolicy".properties] changedBy = ["identifier"] +[object."inbuxa:FrontEnds"] +file = "inbuxa_front_ends.rs" +default = "none" +whose = ["administrator"] +where = ["data-store"] +scope = "server" +retention = "unbounded" +[object."inbuxa:FrontEnds".properties] +webmailClientSecret = ["credential"] +changedBy = ["identifier"] + [object."inbuxa:AiLimits"] file = "inbuxa_ai_limits.rs" default = "none" diff --git a/tests/src/system/front_ends.rs b/tests/src/system/front_ends.rs new file mode 100644 index 00000000..ef9e079e --- /dev/null +++ b/tests/src/system/front_ends.rs @@ -0,0 +1,348 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Where the front ends are (contract C-4): `inbuxa:FrontEnds` registers the +//! console's and the webmail's OAuth clients and puts their origins on the +//! cross-origin allowlist, at once and without touching the server's +//! environment. That is what the installer's `inbuxa join` relies on. + +use crate::utils::{account::Account, server::TestServerBuilder}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use registry::schema::structs::OAuthClient; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::time::Duration; + +const USING: &[&str] = &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"]; +const ADMIN: &str = "https://admin.joined.example"; +const WEBMAIL: &str = "https://webmail.joined.example"; +const SECRET: &str = "a-webmail-secret-of-more-than-thirty-two-characters"; +const VERIFIER: &str = "a-pkce-code-verifier-of-forty-three-or-more-characters"; + +impl Account { + async fn front_ends_call(&self, method: &str, arguments: Value) -> Value { + let response = self + .jmap_request(USING, json!([[method, arguments, "0"]])) + .await; + response + .0 + .pointer("/methodResponses/0/1") + .cloned() + .unwrap_or_else(|| panic!("{method}: {}", response.0)) + } + + async fn front_ends(&self) -> Value { + self.front_ends_call( + "inbuxa:FrontEnds/get", + json!({"accountId": self.id_string()}), + ) + .await["list"][0] + .clone() + } + + async fn set_front_ends(&self, update: Value) -> Value { + self.front_ends_call( + "inbuxa:FrontEnds/set", + json!({"accountId": self.id_string(), "update": {"singleton": update}}), + ) + .await + } + + /// The `Access-Control-Allow-Origin` a browser at `origin` gets on the + /// preflight it sends before an authorized JMAP request, if any. + async fn allowed_origin(&self, origin: &str) -> Option { + let response = http() + .request( + reqwest::Method::OPTIONS, + format!("{}/jmap/session", self.base_url()), + ) + .header("origin", origin) + .header("access-control-request-method", "GET") + .header("access-control-request-headers", "authorization") + .send() + .await + .unwrap(); + response + .headers() + .get("access-control-allow-origin") + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + } + + /// An authorization code for this account, for the webmail at WEBMAIL, + /// the way the server's own sign-in page gets one. + async fn webmail_code(&self) -> String { + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(VERIFIER.as_bytes())); + let response: Value = http() + .post(format!("{}/api/auth", self.base_url())) + .json(&json!({ + "type": "authCode", + "accountName": self.name(), + "accountSecret": self.secret(), + "clientId": "ihasmail-inbuxa", + "redirectUri": format!("{WEBMAIL}/api/auth/callback"), + "codeChallenge": challenge, + "codeChallengeMethod": "S256", + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + ["clientCode", "client_code", "code"] + .iter() + .find_map(|k| response.get(*k).and_then(Value::as_str)) + .unwrap_or_else(|| panic!("C-6: no code for the webmail: {response}")) + .to_string() + } + + /// Exchanges a code at the token endpoint as the webmail, with `secret`. + async fn exchange(&self, code: &str, secret: &str) -> Value { + http() + .post(format!("{}/auth/token", self.base_url())) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", &format!("{WEBMAIL}/api/auth/callback")), + ("client_id", "ihasmail-inbuxa"), + ("client_secret", secret), + ("code_verifier", VERIFIER), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap() + } + + async fn client(&self, client_id: &str) -> Option { + self.registry_get_all::() + .await + .into_iter() + .map(|(_, client)| client) + .find(|client| client.client_id == client_id) + } +} + +fn http() -> reqwest::Client { + reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(10)) + .build() + .unwrap() +} + +fn updated(response: &Value) -> bool { + response["updated"]["singleton"].is_null() && response["updated"].is_object() +} + +fn refusal(response: &Value) -> String { + response["notUpdated"]["singleton"]["type"] + .as_str() + .unwrap_or_default() + .to_string() +} + +/// Runs these tests alone: `cargo test -p tests front_ends_tests -- --ignored`. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn front_ends_tests() { + let mut test = TestServerBuilder::new("front_ends_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.org").await; + let ann = admin + .create_user_account("ann@example.org", "ann-secret-7710", "Ann", &[], vec![]) + .await; + println!("Running front ends tests..."); + + // Unset, it says so, and changes nothing + let current = admin.front_ends().await; + assert_eq!(current["id"], "singleton", "{current}"); + assert!(current["adminUrl"].is_null(), "{current}"); + assert_eq!(current["webmailClientSecretSet"], false, "{current}"); + assert_eq!( + current["environmentWebmailClientSecret"], false, + "{current}" + ); + assert_eq!( + admin.allowed_origin(ADMIN).await, + None, + "C-14: allowed before it was set" + ); + + // Someone who isn't a server administrator can't change it + let response = ann.set_front_ends(json!({"adminUrl": ADMIN})).await; + assert!( + !updated(&response), + "C-4: an ordinary account changed the front ends: {response}" + ); + + // What isn't a front end's address is refused, with the property named + for (property, value) in [ + ("adminUrl", json!("admin.joined.example")), + ("webmailUrl", json!("ftp://webmail.joined.example")), + ("webmailClientSecret", json!("short")), + ("extraOrigins", json!(["not an origin"])), + ("webmailClientSecretSet", json!(true)), + ("environmentAdminUrl", json!(ADMIN)), + ] { + let response = admin.set_front_ends(json!({property: value})).await; + assert_eq!( + refusal(&response), + "invalidProperties", + "{property}: {response}" + ); + } + // A webmail's address without its secret would register nothing + let response = admin.set_front_ends(json!({"webmailUrl": WEBMAIL})).await; + assert_eq!(refusal(&response), "invalidProperties", "{response}"); + // A singleton: never created or destroyed + let response = admin + .front_ends_call( + "inbuxa:FrontEnds/set", + json!({"accountId": admin.id_string(), "create": {"x": {}}, "destroy": ["singleton"]}), + ) + .await; + assert_eq!( + response["notCreated"]["x"]["type"], "forbidden", + "{response}" + ); + assert_eq!( + response["notDestroyed"]["singleton"]["type"], "forbidden", + "{response}" + ); + + // Set: the clients are registered and the origins allowed at once + let response = admin + .set_front_ends(json!({ + "adminUrl": format!("{ADMIN}/"), + "webmailUrl": WEBMAIL, + "webmailClientSecret": SECRET, + "extraOrigins": ["http://localhost:5173/some/path"], + })) + .await; + assert!(updated(&response), "C-4: {response}"); + + let current = admin.front_ends().await; + assert_eq!(current["adminUrl"], ADMIN, "trailing slash kept: {current}"); + assert_eq!(current["webmailUrl"], WEBMAIL, "{current}"); + assert!( + current["webmailClientSecret"].is_null(), + "the secret was read back: {current}" + ); + assert_eq!(current["webmailClientSecretSet"], true, "{current}"); + assert_eq!( + current["extraOrigins"], + json!(["http://localhost:5173"]), + "{current}" + ); + assert!(current["changedAt"].is_number(), "{current}"); + + let console = admin + .client("inbuxa-admin") + .await + .expect("C-6: no inbuxa-admin client"); + assert!( + console + .redirect_uris + .iter() + .any(|uri| uri == &format!("{ADMIN}/oauth/callback")), + "C-6: {:?}", + console.redirect_uris + ); + let webmail = admin + .client("ihasmail-inbuxa") + .await + .expect("C-6: no ihasmail-inbuxa client"); + assert!( + webmail + .redirect_uris + .iter() + .any(|uri| uri == &format!("{WEBMAIL}/api/auth/callback")), + "C-6: {:?}", + webmail.redirect_uris + ); + // The secret is read back redacted, as it should be, so it's proved the + // way the webmail uses it: a code for Ann, exchanged as the webmail. + let code = ann.webmail_code().await; + assert_eq!( + ann.exchange(&code, "not-the-secret").await["error"], + "invalid_client", + "C-6: a wrong webmail secret was taken" + ); + let granted = ann.exchange(&code, SECRET).await; + assert!( + granted["access_token"].is_string(), + "C-6: the webmail's secret was refused: {granted}" + ); + + assert_eq!( + admin.allowed_origin(ADMIN).await.as_deref(), + Some(ADMIN), + "C-14" + ); + assert_eq!( + admin.allowed_origin(WEBMAIL).await.as_deref(), + Some(WEBMAIL), + "C-14" + ); + assert_eq!( + admin + .allowed_origin("http://localhost:5173") + .await + .as_deref(), + Some("http://localhost:5173"), + "C-14: extra origin" + ); + assert_eq!( + admin.allowed_origin("https://evil.example").await, + None, + "C-14" + ); + + // Setting it again with the same values changes nothing, secret aside + let response = admin.set_front_ends(json!({"adminUrl": ADMIN})).await; + assert!(updated(&response), "{response}"); + + // Cleared, the console's origin is no longer allowed + let response = admin + .set_front_ends(json!({"adminUrl": null, "extraOrigins": null})) + .await; + assert!(updated(&response), "{response}"); + assert_eq!( + admin.allowed_origin(ADMIN).await, + None, + "C-14: still allowed after clearing" + ); + assert_eq!( + admin.allowed_origin(WEBMAIL).await.as_deref(), + Some(WEBMAIL), + "C-14" + ); + + // AU-1.2: every change is in the audit log + let query = admin + .front_ends_call( + "inbuxa:AuditEvent/query", + json!({"accountId": admin.id_string(), "filter": {"targetKind": "inbuxa:FrontEnds"}}), + ) + .await; + assert!( + query["ids"].as_array().is_some_and(|ids| ids.len() >= 2), + "AU-1.2: {query}" + ); + + if test.is_reset() { + test.temp_dir.delete(); + } +} diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index f8550a14..06013bcf 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -13,6 +13,7 @@ pub mod ai_calibration; pub mod ai_explain; pub mod account_lock; // inbuxa: account lock with delegation pub mod sharing_policy; // inbuxa: MA-C, who may share mail +pub mod front_ends; // inbuxa: C-4, where the front ends are pub mod legal_hold; // inbuxa: legal hold pub mod compliance; // inbuxa: the compliance roles pub mod mail_rules; // inbuxa: DLP and mail flow rules