From df438b8269966ad2024b08351552f7a6b515446f Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 2 Jun 2026 00:09:19 +0200 Subject: [PATCH] feat(external user): add OXICLOUD_EXTERNAL_EMAIL_DOMAINS for allow list --- src/common/config.rs | 107 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 3 deletions(-) diff --git a/src/common/config.rs b/src/common/config.rs index 54e0ddf6..738acbbf 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -697,10 +697,23 @@ pub struct MagicLinkConfig { /// - `POST /api/auth/magic-link/send` returns the uniform stub /// response without actually issuing a token. /// - /// This is the coarser "turn it all off" switch; the future - /// `OXICLOUD_EXTERNAL_EMAIL_DOMAINS` allowlist is the fine-grained - /// version. + /// This is the coarser "turn it all off" switch; the fine-grained + /// version is [`allowed_email_domains`] below. 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 + /// `@partner-a.com` or `@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, } impl Default for MagicLinkConfig { @@ -708,10 +721,37 @@ impl Default for MagicLinkConfig { Self { ttl_hours: 24, 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) #[derive(Debug, Clone)] pub struct FeaturesConfig { @@ -1303,6 +1343,13 @@ impl AppConfig { if let Ok(v) = env::var("OXICLOUD_ALLOW_EXTERNAL_USERS") { config.magic_link.allow_external_users = v.parse::().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 } @@ -1347,3 +1394,57 @@ impl AppConfig { pub fn default_config() -> AppConfig { 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("")); + } +}