Where the front ends are, set over JMAP: inbuxa:FrontEnds #195
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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<String>,
|
||||
pub secret: Option<String>,
|
||||
pub secret: Option<ClientSecret>,
|
||||
}
|
||||
|
||||
/// 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<ClientSecret>,
|
||||
) -> Vec<FirstPartyClient> {
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
let mut clients: Vec<FirstPartyClient> = 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<String> = 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<String> {
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
/// 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
types::branding::env_var(name)
|
||||
.ok()
|
||||
@@ -189,7 +231,9 @@ fn env(name: &str) -> Option<String> {
|
||||
.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::<SystemSettings>().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::<Vec<_>>();
|
||||
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<String> = env("ADMIN_URL").into_iter().chain(stored.admin_url.clone()).collect();
|
||||
let webmail_urls: Vec<String> = 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")));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// The Argon2 hash of the webmail's client secret.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webmail_secret_hash: Option<String>,
|
||||
/// Further origins allowed to call the server from a browser.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extra_origins: Vec<String>,
|
||||
/// Seconds since the epoch, and who: the console shows them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub changed_at: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub changed_by: Option<String>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<FrontEnds> {
|
||||
Ok(data
|
||||
.get_value::<Json>(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\""));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Self> {
|
||||
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<Self> {
|
||||
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<Self, Self::Err> {
|
||||
FrontEndsProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for FrontEndsValue {
|
||||
type Property = FrontEndsProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
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<Id> for FrontEndsValue {
|
||||
fn from(id: Id) -> Self {
|
||||
FrontEndsValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for FrontEndsValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
FrontEndsValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
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<Id> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)?
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -142,6 +142,7 @@ pub enum GetRequestMethod {
|
||||
SharingPolicy(
|
||||
Box<GetRequest<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||
),
|
||||
FrontEnds(Box<GetRequest<crate::object::inbuxa_front_ends::FrontEnds>>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -194,6 +195,7 @@ pub enum SetRequestMethod<'x> {
|
||||
SharingPolicy(
|
||||
Box<SetRequest<'x, crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||
),
|
||||
FrontEnds(Box<SetRequest<'x, crate::object::inbuxa_front_ends::FrontEnds>>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -129,6 +129,7 @@ pub enum GetResponseMethod {
|
||||
SharingPolicy(
|
||||
GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
|
||||
),
|
||||
FrontEnds(GetResponse<crate::object::inbuxa_front_ends::FrontEnds>),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -182,6 +183,7 @@ pub enum SetResponseMethod {
|
||||
SharingPolicy(
|
||||
Box<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||
),
|
||||
FrontEnds(Box<SetResponse<crate::object::inbuxa_front_ends::FrontEnds>>),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -398,6 +400,18 @@ impl<'x> From<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_front_ends::FrontEnds>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_front_ends::FrontEnds>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::FrontEnds(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_front_ends::FrontEnds>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_front_ends::FrontEnds>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::FrontEnds(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -445,6 +445,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::SharingPolicy
|
||||
| MethodObject::FrontEnds
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<FrontEnds>,
|
||||
) -> trc::Result<GetResponse<FrontEnds>> {
|
||||
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<String>) -> SetError<P> {
|
||||
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<Option<String>, SetError<P>> {
|
||||
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<SetResponse<FrontEnds>> {
|
||||
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<P>> = 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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+28
-8
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String> {
|
||||
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<OAuthClient> {
|
||||
self.registry_get_all::<OAuthClient>()
|
||||
.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("[email protected]").await;
|
||||
let ann = admin
|
||||
.create_user_account("[email protected]", "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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user