feat(external user): add OXICLOUD_EXTERNAL_EMAIL_DOMAINS for allow list

This commit is contained in:
Edouard Vanbelle
2026-06-02 00:09:19 +02:00
parent c3fa1b3e93
commit df438b8269
+104 -3
View File
@@ -697,10 +697,23 @@ pub struct MagicLinkConfig {
/// - `POST /api/auth/magic-link/send` returns the uniform stub /// - `POST /api/auth/magic-link/send` returns the uniform stub
/// response without actually issuing a token. /// response without actually issuing a token.
/// ///
/// This is the coarser "turn it all off" switch; the future /// This is the coarser "turn it all off" switch; the fine-grained
/// `OXICLOUD_EXTERNAL_EMAIL_DOMAINS` allowlist is the fine-grained /// version is [`allowed_email_domains`] below.
/// version.
pub allow_external_users: bool, pub allow_external_users: bool,
/// Allowlist of email domains accepted when minting a new external
/// user. Empty = no restriction (any domain is allowed, subject to
/// [`allow_external_users`]). Entries are lowercased and trimmed
/// at load time; matching is case-insensitive exact-match on the
/// post-`@` part of the address.
///
/// Example: `["partner-a.com", "partner-b.io"]` — only addresses
/// `<anything>@partner-a.com` or `<anything>@partner-b.io` can be
/// invited; everything else is rejected with 403.
///
/// Wildcards / subdomain semantics are intentionally out of scope:
/// `partner.com` does NOT match `eng.partner.com`. List every
/// subdomain explicitly.
pub allowed_email_domains: Vec<String>,
} }
impl Default for MagicLinkConfig { impl Default for MagicLinkConfig {
@@ -708,10 +721,37 @@ impl Default for MagicLinkConfig {
Self { Self {
ttl_hours: 24, ttl_hours: 24,
allow_external_users: true, allow_external_users: true,
allowed_email_domains: Vec::new(),
} }
} }
} }
impl MagicLinkConfig {
/// Whether an email address is allowed under the current allowlist.
///
/// Returns `true` when the allowlist is empty (no restriction).
/// Otherwise the domain part of `email` (lowercased) must match one
/// of the allowlist entries exactly. Malformed addresses without an
/// `@` always return `false` — fail closed so a typo in the
/// upstream validator can't slip past this check.
///
/// Caller is expected to have already passed `email` through the
/// email regex / normaliser; this method does not re-validate. It
/// only performs the domain comparison.
pub fn is_email_allowed(&self, email: &str) -> bool {
if self.allowed_email_domains.is_empty() {
return true;
}
let Some((_, domain)) = email.rsplit_once('@') else {
return false;
};
let domain_lc = domain.to_ascii_lowercase();
self.allowed_email_domains
.iter()
.any(|d| d.as_str() == domain_lc.as_str())
}
}
/// Feature configuration (feature flags) /// Feature configuration (feature flags)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FeaturesConfig { pub struct FeaturesConfig {
@@ -1303,6 +1343,13 @@ impl AppConfig {
if let Ok(v) = env::var("OXICLOUD_ALLOW_EXTERNAL_USERS") { if let Ok(v) = env::var("OXICLOUD_ALLOW_EXTERNAL_USERS") {
config.magic_link.allow_external_users = v.parse::<bool>().unwrap_or(true); config.magic_link.allow_external_users = v.parse::<bool>().unwrap_or(true);
} }
if let Ok(v) = env::var("OXICLOUD_EXTERNAL_EMAIL_DOMAINS") {
config.magic_link.allowed_email_domains = v
.split(',')
.map(|d| d.trim().to_ascii_lowercase())
.filter(|d| !d.is_empty())
.collect();
}
config config
} }
@@ -1347,3 +1394,57 @@ impl AppConfig {
pub fn default_config() -> AppConfig { pub fn default_config() -> AppConfig {
AppConfig::default() AppConfig::default()
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_allowlist_accepts_any_email() {
let cfg = MagicLinkConfig::default();
assert!(cfg.allowed_email_domains.is_empty());
assert!(cfg.is_email_allowed("alice@example.com"));
assert!(cfg.is_email_allowed("bob@whatever.io"));
}
#[test]
fn allowlist_matches_case_insensitively() {
let cfg = MagicLinkConfig {
ttl_hours: 24,
allow_external_users: true,
allowed_email_domains: vec!["partner-a.com".to_string(), "partner-b.io".to_string()],
};
assert!(cfg.is_email_allowed("alice@partner-a.com"));
// Uppercase domain in the email — must still match.
assert!(cfg.is_email_allowed("alice@PARTNER-A.COM"));
assert!(cfg.is_email_allowed("eve@partner-b.io"));
// Unlisted domain — rejected.
assert!(!cfg.is_email_allowed("mallory@other.com"));
}
#[test]
fn allowlist_does_not_match_subdomains_implicitly() {
let cfg = MagicLinkConfig {
ttl_hours: 24,
allow_external_users: true,
allowed_email_domains: vec!["partner.com".to_string()],
};
assert!(cfg.is_email_allowed("alice@partner.com"));
// Subdomain must be listed explicitly — exact match only.
assert!(!cfg.is_email_allowed("alice@eng.partner.com"));
// Suffix match is not enough — different domain.
assert!(!cfg.is_email_allowed("alice@evilpartner.com"));
}
#[test]
fn malformed_email_fails_closed() {
let cfg = MagicLinkConfig {
ttl_hours: 24,
allow_external_users: true,
allowed_email_domains: vec!["partner.com".to_string()],
};
// No `@` — rejected even though allowlist is set.
assert!(!cfg.is_email_allowed("not-an-email"));
assert!(!cfg.is_email_allowed(""));
}
}