Files
inbuxa-server/crates/common/src/auth/mod.rs
T
jcoffey-dev 9976d52e29
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 1m8s
ci / build (pull_request) Successful in 4m30s
github/ci (branch) GitHub Actions
Shared mailboxes: a second kind of account lock
A shared mailbox (support@, legal@) belongs to no one person: nobody
signs in to it, and the people assigned open it beside their own mail
at an access level an administrator chose. An account lock already is
most of that: it keeps receiving mail, refuses every sign-in, and its
delegates reach it through real grants on every container (so IMAP,
DAV and JMAP honor them), never including Share. So a shared mailbox is
a lock of a second kind (multi-account spec, MA-S; John, 2026-10-05).

Lock gains kind: "lock" (the default, so stored locks read as before)
or "sharedMailbox", set on create and fixed after. A shared mailbox:

- needs no reason to make, change or end;
- holds up to 100 people, where a lock holds 10;
- runs its own Sieve replies and redirects, so an automatic
  acknowledgement goes out (a lock answers no one);
- records only what is sent as it (audit_send_as, which now covers it),
  not AL-9's access and per-change records, which would bury the log
  for a busy desk;
- sends only as itself (MA-S3): From and Reply-To must be its own
  addresses, so answers come back to the mailbox and not to whoever
  replied; anything else is forbiddenFrom.

The session marks it delegation: {locked: true, kind: "sharedMailbox"},
so a front end that knows no kind still treats it as a lock. The
console's layout gains Management › Directory › Shared Mailboxes
(CustomComponent/SharedMailboxes).

Tests: the account lock suite now goes on to a shared mailbox: made
without a reason with twelve people, sign-in refused, the session's
kind, its vacation reply delivered, an answer sent as it and recorded
as the agent with no per-change records, and a Reply-To naming the
agent refused; a lock unit test reads a stored lock without a kind.
account_lock_tests, jmap_tests, audit_log_tests and imap_tests pass
(RocksDB).
2026-10-05 15:27:52 -07:00

362 lines
9.6 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
expr::if_block::IfBlock,
network::limiter::ConcurrencyLimiter,
storage::{ObjectQuota, TenantQuota},
};
use directory::Credentials;
use quick_cache::Equivalent;
use registry::{
schema::enums::{Locale, Permission},
types::{EnumImpl, ipmask::IpAddrOrMask},
};
use std::{
hash::{Hash, Hasher},
net::IpAddr,
sync::Arc,
};
use tinyvec::TinyVec;
use trc::ipc::bitset::Bitset;
use types::collection::Collection;
use utils::{cache::CacheItemWeight, map::bitmap::Bitmap};
pub mod access_token;
pub mod authentication;
pub mod credential;
pub mod oauth;
pub mod permissions;
pub mod rate_limit;
pub const RECOVERY_ADMIN_ID: u32 = u32::MAX;
const PERMISSIONS_BITSET_SIZE: usize = Permission::COUNT.div_ceil(std::mem::size_of::<usize>());
pub type Permissions = Bitset<PERMISSIONS_BITSET_SIZE>;
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct EmailAddress {
pub local_part: Box<str>,
pub domain_id: u32,
}
#[derive(Debug, PartialEq, Eq)]
pub struct EmailAddressRef<'x> {
local_part: &'x str,
domain_id: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmailCache {
Account(u32),
MailingList(u32),
DisabledAccountAddress(u32),
DisabledListAddress(u32),
}
#[derive(Debug, Clone)]
pub struct DomainCache {
pub names: Box<[Box<str>]>,
pub id: u32,
pub id_directory: Option<u32>,
pub id_tenant: Option<u32>,
pub catch_all: Option<Box<str>>,
pub sub_addressing_custom: Option<Box<IfBlock>>,
pub flags: u8,
}
pub const DOMAIN_FLAG_RELAY: u8 = 1;
pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 1;
// inbuxa: SCIM-15, SCIM-58
pub const DOMAIN_FLAG_SCIM: u8 = 1 << 2;
#[derive(Debug, Clone, Default)]
pub struct AccountCache {
pub name: Box<str>,
pub id: u32,
pub addresses: Box<[EmailAddress]>,
pub id_tenant: Option<u32>,
pub id_member_of: TinyVec<[u32; 3]>,
pub quota_disk: u64,
pub quota_objects: Option<Box<ObjectQuota>>,
pub description: Option<Box<str>>,
pub encryption_key: Option<EncryptionKeys>,
pub locale: Locale,
pub flags: u64,
}
pub type EncryptionKeys = Box<[Box<[u8]>]>;
pub const ACCOUNT_IS_USER: u64 = 1;
pub const ACCOUNT_FLAG_ENCRYPT_TRAIN_SPAM_FILTER: u64 = 1 << 1;
pub const ACCOUNT_FLAG_ENCRYPT_METHOD_SMIME: u64 = 1 << 2;
pub const ACCOUNT_FLAG_ENCRYPT_METHOD_PGP: u64 = 1 << 3;
pub const ACCOUNT_FLAG_ENCRYPT_ALGO_AES256: u64 = 1 << 4;
pub const ACCOUNT_FLAG_ENCRYPT_ALGO_AES128: u64 = 1 << 5;
pub const ACCOUNT_FLAG_ENCRYPT_APPEND: u64 = 1 << 6;
pub const ACCOUNT_FLAG_ENCRYPT_ALGO_AES256_GCM: u64 = 1 << 7;
pub const ACCOUNT_FLAG_ENCRYPT_ALGO_CHACHA20_POLY1305: u64 = 1 << 8;
#[derive(Debug, Clone)]
pub struct RoleCache {
pub id_roles: TinyVec<[u32; 3]>,
pub permissions: PermissionsGroup,
}
#[derive(Debug, Clone)]
pub struct MailingListCache {
pub addresses: Box<[EmailAddress]>,
pub recipients: Arc<[Box<str>]>,
}
#[derive(Debug, Clone)]
pub struct TenantCache {
pub id_roles: TinyVec<[u32; 3]>,
pub quota_disk: u64,
pub quota_objects: Option<Box<TenantQuota>>,
pub permissions: Option<Box<PermissionsGroup>>,
}
#[derive(Debug, Clone, Default)]
pub struct PermissionsGroup {
pub enabled: Permissions,
pub disabled: Permissions,
pub merge: bool,
}
#[derive(Debug, Default, Clone)]
pub struct AccessToken {
scope_idx: usize,
inner: Arc<AccessTokenInner>,
// inbuxa: how this session signed in, for the audit log (AU-5)
origin: Option<Arc<inbuxa_features::audit::Via>>,
}
#[derive(Debug, Default, Clone)]
pub struct AccessTokenInner {
pub(crate) account_id: u32,
pub(crate) tenant_id: Option<u32>,
pub(crate) member_of: TinyVec<[u32; 3]>,
pub(crate) access_to: Box<[AccessTo]>,
pub(crate) scopes: Box<[AccessScope]>,
pub(crate) concurrent_http_requests: Option<ConcurrencyLimiter>,
pub(crate) concurrent_imap_requests: Option<ConcurrencyLimiter>,
pub(crate) concurrent_uploads: Option<ConcurrencyLimiter>,
pub(crate) revision_account: u64,
pub(crate) revision: u64,
pub(crate) credential_version: u64,
pub(crate) obj_size: u64,
// inbuxa: AL-2: the account is locked; it may not authenticate
pub(crate) locked: bool,
// inbuxa: MA-S: the lock is a shared mailbox
pub(crate) shared_mailbox: bool,
// inbuxa: AL-5: locked accounts handed to this one
pub(crate) delegations: Box<[Delegation]>,
}
/// inbuxa: a locked account this one may open, and how (AL-5, AL-6).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Delegation {
/// The locked account.
pub account_id: u32,
pub access: inbuxa_features::lock::Access,
pub send_as: bool,
/// Seconds since the epoch.
pub until: Option<u64>,
/// MA-S: a locked account, or a shared mailbox.
pub kind: inbuxa_features::lock::Kind,
}
#[derive(Debug, Default, Hash, Clone)]
pub struct AccessScope {
pub permissions: Permissions,
pub credential_id: u32,
pub expires_at: u64,
pub allowed_ips: Box<[IpAddrOrMask]>,
}
#[derive(Debug, Default, Hash, PartialEq, Eq, Clone)]
pub(crate) struct AccessTo {
pub account_id: u32,
pub collections: Bitmap<Collection>,
}
#[derive(Clone)]
pub struct AccountInfo {
pub account_id: u32,
pub account: Arc<AccountCache>,
pub addresses: Vec<String>,
}
#[derive(Clone, Copy)]
pub struct AccountTenantIds {
pub account_id: u32,
pub tenant_id: Option<u32>,
}
pub struct AuthRequest {
pub credentials: Credentials,
pub session_id: u64,
pub remote_ip: IpAddr,
}
impl CacheItemWeight for AccessTokenInner {
fn weight(&self) -> u64 {
self.obj_size
}
}
impl CacheItemWeight for EmailAddress {
fn weight(&self) -> u64 {
std::mem::size_of::<EmailAddress>() as u64 + self.local_part.len() as u64
}
}
impl CacheItemWeight for EmailCache {
fn weight(&self) -> u64 {
std::mem::size_of::<EmailCache>() as u64
}
}
impl CacheItemWeight for DomainCache {
fn weight(&self) -> u64 {
std::mem::size_of::<DomainCache>() as u64
+ self
.names
.iter()
.map(|s| s.len() as u64 + std::mem::size_of::<Box<str>>() as u64)
.sum::<u64>()
+ self.catch_all.as_ref().map_or(0, |s| s.len() as u64)
+ self
.sub_addressing_custom
.as_ref()
.map_or(0, |s| s.weight())
}
}
impl Equivalent<EmailAddress> for EmailAddressRef<'_> {
fn equivalent(&self, key: &EmailAddress) -> bool {
self.local_part == &*key.local_part && self.domain_id == key.domain_id
}
}
impl Hash for EmailAddress {
fn hash<H: Hasher>(&self, state: &mut H) {
self.local_part.as_ref().hash(state);
self.domain_id.hash(state);
}
}
impl Hash for EmailAddressRef<'_> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.local_part.hash(state);
self.domain_id.hash(state);
}
}
impl CacheItemWeight for AccountCache {
fn weight(&self) -> u64 {
std::mem::size_of::<AccountCache>() as u64
+ self.name.len() as u64
+ self
.addresses
.iter()
.map(|s| s.local_part.len() as u64 + std::mem::size_of::<EmailAddress>() as u64)
.sum::<u64>()
+ self.description.as_ref().map_or(0, |s| s.len() as u64)
+ self.encryption_key.as_ref().map_or(0, |keys| {
keys.iter()
.map(|k| k.len() as u64 + std::mem::size_of::<Box<[u8]>>() as u64)
.sum::<u64>()
})
}
}
impl CacheItemWeight for RoleCache {
fn weight(&self) -> u64 {
std::mem::size_of::<RoleCache>() as u64
}
}
impl CacheItemWeight for MailingListCache {
fn weight(&self) -> u64 {
std::mem::size_of::<MailingListCache>() as u64
+ self
.addresses
.iter()
.map(|s| s.local_part.len() as u64 + std::mem::size_of::<EmailAddress>() as u64)
.sum::<u64>()
+ self
.recipients
.iter()
.map(|s| s.len() as u64 + std::mem::size_of::<Box<str>>() as u64)
.sum::<u64>()
}
}
impl CacheItemWeight for TenantCache {
fn weight(&self) -> u64 {
std::mem::size_of::<TenantCache>() as u64
+ self.permissions.as_ref().map_or(0, |p| p.weight())
}
}
impl CacheItemWeight for PermissionsGroup {
fn weight(&self) -> u64 {
std::mem::size_of::<PermissionsGroup>() as u64
}
}
pub trait BuildAccessToken {
fn build(self) -> AccessToken;
}
impl BuildAccessToken for Arc<AccessTokenInner> {
fn build(self) -> AccessToken {
AccessToken {
scope_idx: 0,
origin: None,
inner: self,
}
}
}
impl EmailAddress {
pub fn new(local_part: impl Into<Box<str>>, domain_id: u32) -> Self {
Self {
local_part: local_part.into(),
domain_id,
}
}
}
impl<'x> EmailAddressRef<'x> {
pub fn new(local_part: &'x str, domain_id: u32) -> Self {
Self {
local_part,
domain_id,
}
}
}
impl AccountCache {
pub fn domain_id(&self) -> Option<u32> {
self.addresses.first().map(|address| address.domain_id)
}
}
impl DomainCache {
pub fn name(&self) -> &str {
self.names.first().map(|s| s.as_ref()).unwrap_or_default()
}
// inbuxa: SCIM-15, SCIM-58
pub fn allows_scim(&self) -> bool {
self.flags & DOMAIN_FLAG_SCIM != 0
}
}