From 501ac6550cf9b9ece2d769de1b7ab51c0b087f6b Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 11 Oct 2026 23:21:05 -0700 Subject: [PATCH] Where the front ends are, set over JMAP: inbuxa:FrontEnds Contract C-4's singleton, which the INBUXA_ADMIN_URL, INBUXA_WEBMAIL_URL and INBUXA_WEBMAIL_CLIENT_SECRET environment variables have stood in for. It records the console's address, the webmail's and its client secret, and further allowed origins. Setting it registers the front ends' OAuth clients at once (C-6) and reloads the HTTP settings here and on every node, which puts the origins on the cross-origin allowlist (C-14): a front end on another machine can be added without touching the server's environment or restarting it. The installer's `inbuxa join` does exactly that. - A fork JMAP object like the other inbuxa: singletons, kept in the fork's subspace under Fe (open question 1, settled): upstream's generated schema is untouched. - webmailClientSecret is write-only, at least 32 characters, and stored only as its Argon2 hash; webmailClientSecretSet says whether one is. - /get needs sysHttpGet, /set sysHttpUpdate and sysOAuthClientUpdate; neither exists inside a tenant. Every change is audited. - The environment variables keep working beside it, both are honored, and where both give the webmail a secret the environment's wins, so setting one is refused while INBUXA_WEBMAIL_CLIENT_SECRET is set. /get shows what the environment says, so a refusal can be explained. Tests: system::front_ends::front_ends_tests (refusals, the clients and their redirect URIs, the secret proved by exchanging Ann's code as the webmail with a wrong and the right secret, the preflight answered for the console, the webmail and an extra origin and not for anyone else, clearing, the audit log); unit tests for the store, the URL checks and merging both sources. jmap_tests, branding, sharing_policy, audit_log, settings_reload, reload and system_tests pass (RocksDB). The installer's join e2e case passes against a build of this branch. --- crates/common/src/config/network.rs | 5 +- crates/common/src/manager/first_party.rs | 195 +++++++--- crates/features/src/security/front_ends.rs | 157 ++++++++ crates/features/src/security/mod.rs | 1 + .../src/object/inbuxa_front_ends.rs | 207 +++++++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 3 + crates/jmap-proto/src/references/resolve.rs | 4 + crates/jmap-proto/src/request/method.rs | 7 + crates/jmap-proto/src/request/mod.rs | 2 + crates/jmap-proto/src/request/parser.rs | 14 + crates/jmap-proto/src/response/mod.rs | 14 + crates/jmap/src/api/auth.rs | 15 +- crates/jmap/src/api/request.rs | 27 ++ crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/front_ends.rs | 311 ++++++++++++++++ crates/jmap/src/inbuxa/mod.rs | 1 + docs/spec/contract.md | 36 +- resources/privacy/catalog.toml | 11 + tests/src/system/front_ends.rs | 348 ++++++++++++++++++ tests/src/system/mod.rs | 1 + 21 files changed, 1300 insertions(+), 61 deletions(-) create mode 100644 crates/features/src/security/front_ends.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_front_ends.rs create mode 100644 crates/jmap/src/inbuxa/front_ends.rs create mode 100644 tests/src/system/front_ends.rs 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 -- 2.54.0