feat(external user): add OXICLOUD_EXTERNAL_EMAIL_DOMAINS for allow list
This commit is contained in:
+104
-3
@@ -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(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user