Phase 2 of the journaling spec. - A copy of each message is taken in MessageWrapper::queue, after DLP and transport rules, for every enabled journal that takes it (direction and scope: everyone, or accounts, groups, domains, tenants). If the copy can't be taken the message isn't queued (temporary failure). - The journal report: the envelope one field a line (sender, To, Cc, Bcc from the envelope, list members from their ORCPT, direction, held for review), then the queued message byte for byte as message/rfc822. - The built-in journal under J in the inbuxa subspace: one chain per node whose links name each entry by SHA-256, so entries can expire out of chain order; purge leaves a marker, and verify catches an entry changed or removed early and a report that doesn't match. - Retention per journal (30 to 3650 days); an entry keeps what it was written with. The daily maintenance purges what's due, keeping entries whose people a legal hold covers (deleted accounts a hold keeps too), and records the counts in the audit log. - inbuxa:Journal get/set, audited by the request layer. Permissions 680-683: administrators see and change journals; the Compliance Officer sees, searches and exports. Whoever changes journals may grant search and export without holding them, so officers can still be appointed. - Catalog entries (inbuxa:Journal, source "journal"); spec as-built notes. tests/src/system/journal.rs: validation, internal mail with a Bcc, outgoing into two journals, incoming over LMTP, the report and its original, tamper and early removal caught, hold-aware purge, retention changes leave entries alone, disabled and removed journals take nothing.
428 lines
16 KiB
Rust
428 lines
16 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::{
|
|
Server,
|
|
auth::{AccessToken, Permissions, PermissionsGroup},
|
|
};
|
|
use ahash::AHashSet;
|
|
use registry::{
|
|
schema::{
|
|
enums::Permission,
|
|
structs::{self, Account, PermissionsList, UserRoles},
|
|
},
|
|
types::EnumImpl,
|
|
};
|
|
use trc::AddContext;
|
|
use types::id::Id;
|
|
use utils::map::vec_map::VecMap;
|
|
|
|
impl Server {
|
|
pub async fn add_role_permissions(
|
|
&self,
|
|
mut base_permissions: PermissionsGroup,
|
|
roles: impl IntoIterator<Item = u32>,
|
|
) -> trc::Result<PermissionsGroup> {
|
|
let mut role_ids = roles.into_iter().collect::<Vec<u32>>();
|
|
let mut fetched_role_ids = AHashSet::new();
|
|
|
|
while let Some(role_id) = role_ids.pop() {
|
|
if fetched_role_ids.insert(role_id) {
|
|
let role = self.role(role_id).await.caused_by(trc::location!())?;
|
|
|
|
base_permissions.union(&role.permissions);
|
|
role_ids.extend(role.id_roles.iter().copied());
|
|
}
|
|
}
|
|
|
|
Ok(base_permissions)
|
|
}
|
|
|
|
pub async fn effective_permissions(
|
|
&self,
|
|
permissions: &structs::Permissions,
|
|
role_ids: &[Id],
|
|
tenant_id: Option<u32>,
|
|
) -> trc::Result<PermissionsGroup> {
|
|
// Calculate effective permissions
|
|
let (mut permissions, roles) = match permissions {
|
|
structs::Permissions::Inherit => (PermissionsGroup::default(), role_ids),
|
|
structs::Permissions::Merge(permissions) => {
|
|
(PermissionsGroup::from(permissions), role_ids)
|
|
}
|
|
structs::Permissions::Replace(permissions) => {
|
|
(PermissionsGroup::from(permissions), &[][..])
|
|
}
|
|
};
|
|
if !roles.is_empty() {
|
|
permissions = self
|
|
.add_role_permissions(permissions, roles.iter().map(|v| v.id() as u32))
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
}
|
|
|
|
// inbuxa: MT-13, MT-14, MT-15: cut down to what the tenant allows
|
|
if let Some(tenant_id) = tenant_id {
|
|
self.apply_tenant_ceiling(&mut permissions, tenant_id)
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
}
|
|
|
|
Ok(permissions)
|
|
}
|
|
|
|
/// inbuxa: MT-13. The tenant's roles give the base; its own permission
|
|
/// lists adjust it (`inbuxa_features::tenancy::ceiling`).
|
|
async fn apply_tenant_ceiling(
|
|
&self,
|
|
permissions: &mut PermissionsGroup,
|
|
tenant_id: u32,
|
|
) -> trc::Result<()> {
|
|
use inbuxa_features::tenancy::ceiling::{Policy, ceiling};
|
|
|
|
let tenant = self.tenant(tenant_id).await?;
|
|
let base = self
|
|
.add_role_permissions(PermissionsGroup::default(), tenant.id_roles.iter().copied())
|
|
.await?
|
|
.finalize();
|
|
let policy = match tenant.permissions.as_deref() {
|
|
None => Policy::Inherit,
|
|
Some(list) if list.merge => Policy::Merge {
|
|
enabled: &list.enabled,
|
|
disabled: &list.disabled,
|
|
},
|
|
Some(list) => Policy::Replace {
|
|
enabled: &list.enabled,
|
|
disabled: &list.disabled,
|
|
},
|
|
};
|
|
ceiling(base, policy).apply(&mut permissions.enabled, &mut permissions.disabled);
|
|
// inbuxa: MT-1, MT-15: impersonation would reach beyond the tenant
|
|
permissions.disabled.set(Permission::Impersonate as usize);
|
|
// inbuxa: LH-13: only server-level administrators see or place
|
|
// holds, and a hold may concern the tenant's own administrator
|
|
for permission in [
|
|
Permission::SysLegalHoldGet,
|
|
Permission::SysLegalHoldCreate,
|
|
Permission::SysLegalHoldUpdate,
|
|
Permission::SysLegalHoldExport,
|
|
] {
|
|
permissions.disabled.set(permission as usize);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn can_set_permissions(
|
|
&self,
|
|
access_token: &AccessToken,
|
|
account: &Account,
|
|
) -> trc::Result<Result<(), Vec<Permission>>> {
|
|
let (permissions, role_ids, tenant_id) = match account {
|
|
Account::User(account) => (
|
|
&account.permissions,
|
|
match &account.roles {
|
|
UserRoles::User => self.core.network.security.default_role_ids_user.as_slice(),
|
|
UserRoles::Admin => {
|
|
if access_token.tenant_id().is_none() {
|
|
self.core.network.security.default_role_ids_admin.as_slice()
|
|
} else {
|
|
self.core
|
|
.network
|
|
.security
|
|
.default_role_ids_tenant
|
|
.as_slice()
|
|
}
|
|
}
|
|
UserRoles::Custom(custom_roles) => custom_roles.role_ids.as_slice(),
|
|
},
|
|
account.member_tenant_id.map(|t| t.document_id()),
|
|
),
|
|
Account::Group(account) => (
|
|
&account.permissions,
|
|
account
|
|
.roles
|
|
.role_ids()
|
|
.unwrap_or(self.core.network.security.default_role_ids_group.as_slice()),
|
|
account.member_tenant_id.map(|t| t.document_id()),
|
|
),
|
|
};
|
|
|
|
self.effective_permissions(permissions, role_ids, tenant_id)
|
|
.await
|
|
.map(|permissions| access_token.can_grant_permissions(permissions.finalize()))
|
|
}
|
|
}
|
|
|
|
impl AccessToken {
|
|
pub fn can_grant_permissions(
|
|
&self,
|
|
mut requested_permissions: Permissions,
|
|
) -> Result<(), Vec<Permission>> {
|
|
requested_permissions.difference(self.permissions_bits());
|
|
// inbuxa: journaling, JR-18: whoever sets up journals may give
|
|
// others (or, through a role, themselves) the reading of them,
|
|
// which administrators don't hold by default; the role change is
|
|
// in the audit log
|
|
if self.has_permission(Permission::SysJournalUpdate) {
|
|
requested_permissions.clear(Permission::SysJournalSearch as usize);
|
|
requested_permissions.clear(Permission::SysJournalExport as usize);
|
|
}
|
|
if requested_permissions.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(requested_permissions.build_permissions_list())
|
|
}
|
|
}
|
|
}
|
|
|
|
pub trait PermissionsListBuilder {
|
|
fn build_permissions_list(&self) -> Vec<Permission>;
|
|
}
|
|
|
|
impl PermissionsListBuilder for Permissions {
|
|
fn build_permissions_list(&self) -> Vec<Permission> {
|
|
const USIZE_BITS: usize = std::mem::size_of::<usize>() * 8;
|
|
const USIZE_MASK: u32 = USIZE_BITS as u32 - 1;
|
|
let mut permissions = Vec::new();
|
|
|
|
for (block_num, bytes) in self.inner().iter().enumerate() {
|
|
let mut bytes = *bytes;
|
|
|
|
while bytes != 0 {
|
|
let item = USIZE_MASK - bytes.leading_zeros();
|
|
bytes ^= 1 << item;
|
|
if let Some(permission) =
|
|
Permission::from_id(((block_num * USIZE_BITS) + item as usize) as u16)
|
|
{
|
|
permissions.push(permission);
|
|
}
|
|
}
|
|
}
|
|
permissions
|
|
}
|
|
}
|
|
|
|
pub struct DefaultPermissions {
|
|
pub user: Vec<Permission>,
|
|
pub group: Vec<Permission>,
|
|
pub tenant: Vec<Permission>,
|
|
pub superuser: Vec<Permission>,
|
|
}
|
|
|
|
impl PermissionsGroup {
|
|
pub fn with_merge(mut self, merge: bool) -> Self {
|
|
self.merge = merge;
|
|
self
|
|
}
|
|
|
|
pub fn union(&mut self, other: &PermissionsGroup) {
|
|
self.enabled.union(&other.enabled);
|
|
self.disabled.union(&other.disabled);
|
|
}
|
|
|
|
pub fn restrict(&mut self, other: &PermissionsGroup) {
|
|
self.enabled.intersection(&other.enabled);
|
|
self.disabled.union(&other.disabled);
|
|
}
|
|
|
|
pub fn finalize(mut self) -> Permissions {
|
|
self.enabled.difference(&self.disabled);
|
|
self.enabled
|
|
}
|
|
|
|
pub fn finalize_as_ref(&self) -> Permissions {
|
|
let mut enabled = self.enabled.clone();
|
|
enabled.difference(&self.disabled);
|
|
enabled
|
|
}
|
|
|
|
pub fn user() -> Self {
|
|
let mut permissions = PermissionsGroup::default();
|
|
for permission in DefaultPermissions::default().user {
|
|
permissions.enabled.set(permission as usize);
|
|
}
|
|
|
|
permissions
|
|
}
|
|
}
|
|
|
|
impl Default for DefaultPermissions {
|
|
fn default() -> Self {
|
|
let mut default = Self {
|
|
user: Default::default(),
|
|
group: Default::default(),
|
|
tenant: Default::default(),
|
|
superuser: Default::default(),
|
|
};
|
|
|
|
for permission_id in 0..Permission::COUNT {
|
|
let permission = Permission::from_id(permission_id as u16).unwrap();
|
|
match permission {
|
|
Permission::Authenticate
|
|
| Permission::AuthenticateWithAlias
|
|
| Permission::InteractAi => {
|
|
default.user.push(permission);
|
|
default.superuser.push(permission);
|
|
default.tenant.push(permission);
|
|
}
|
|
Permission::Impersonate
|
|
// inbuxa: LH-13: holds are the server administrator's alone
|
|
| Permission::SysLegalHoldGet
|
|
| Permission::SysLegalHoldCreate
|
|
| Permission::SysLegalHoldUpdate
|
|
| Permission::SysLegalHoldExport
|
|
| Permission::UnlimitedRequests
|
|
| Permission::UnlimitedUploads
|
|
| Permission::LiveMetrics
|
|
| Permission::LiveTracing => {
|
|
default.superuser.push(permission);
|
|
}
|
|
Permission::FetchAnyBlob | Permission::LiveDeliveryTest => {
|
|
default.superuser.push(permission);
|
|
default.tenant.push(permission);
|
|
}
|
|
// inbuxa: MT-12: a tenant administrator reads its own tenant
|
|
Permission::SysTenantGet | Permission::SysTenantQuery => {
|
|
default.superuser.push(permission);
|
|
default.tenant.push(permission);
|
|
}
|
|
// inbuxa: AU-9: a tenant administrator reads and exports
|
|
// its tenant's audit log; retention stays the server's
|
|
Permission::SysAuditGet | Permission::SysAuditExport => {
|
|
default.superuser.push(permission);
|
|
default.tenant.push(permission);
|
|
}
|
|
// inbuxa: personal-data catalog: the data inventory, the
|
|
// server's or, inside a tenant, the tenant's slice
|
|
Permission::SysComplianceGet => {
|
|
default.superuser.push(permission);
|
|
default.tenant.push(permission);
|
|
}
|
|
// inbuxa: DLP and mail flow rules, and held mail, are the
|
|
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
|
|
// settled answer 3)
|
|
Permission::SysMailRuleGet
|
|
| Permission::SysMailRuleUpdate
|
|
| Permission::SysDlpPolicyGet
|
|
| Permission::SysDlpPolicyUpdate
|
|
| Permission::SysDlpReviewGet
|
|
| Permission::SysDlpReviewUpdate => {
|
|
default.superuser.push(permission);
|
|
}
|
|
// inbuxa: journals are the server's; administrators set them
|
|
// up but read what's journaled only if granted it
|
|
// (journaling spec, JR-18, settled answer 5)
|
|
Permission::SysJournalGet | Permission::SysJournalUpdate => {
|
|
default.superuser.push(permission);
|
|
}
|
|
Permission::SysJournalSearch | Permission::SysJournalExport => {}
|
|
// inbuxa: AL-12: tenant administrators lock and delegate
|
|
// within their tenant
|
|
Permission::SysAccountLockGet
|
|
| Permission::SysAccountLockCreate
|
|
| Permission::SysAccountLockUpdate
|
|
| Permission::SysAccountLockDestroy => {
|
|
default.superuser.push(permission);
|
|
default.tenant.push(permission);
|
|
}
|
|
permission => {
|
|
let name = permission.as_str();
|
|
if name.starts_with("jmap")
|
|
|| name.starts_with("imap")
|
|
|| name.starts_with("pop3")
|
|
|| name.starts_with("calendar")
|
|
|| name.starts_with("email")
|
|
|| name.starts_with("dav")
|
|
|| name.starts_with("sieve")
|
|
{
|
|
default.user.push(permission);
|
|
default.group.push(permission);
|
|
} else if name.starts_with("sysMaskedEmail")
|
|
|| name.starts_with("sysArchivedItem")
|
|
|| name.starts_with("sysAccountSettings")
|
|
|| name.starts_with("sysPublicKey")
|
|
|| (name.starts_with("sysSpamTrainingSample") && !name.contains("Create"))
|
|
{
|
|
default.user.push(permission);
|
|
default.group.push(permission);
|
|
default.superuser.push(permission);
|
|
} else if name.starts_with("sysAccountPassword")
|
|
|| name.starts_with("sysApiKey")
|
|
|| name.starts_with("sysAppPassword")
|
|
{
|
|
default.user.push(permission);
|
|
default.superuser.push(permission);
|
|
} else if name.starts_with("sysDomain")
|
|
|| name.starts_with("sysDkimSignature")
|
|
|| name.starts_with("sysAcmeProvider")
|
|
|| name.starts_with("sysAccount")
|
|
|| name.starts_with("sysRole")
|
|
|| name.starts_with("sysOAuthClient")
|
|
|| name.starts_with("sysMailingList")
|
|
|| name.starts_with("sysExternalReport")
|
|
|| name.starts_with("sysDnsServer")
|
|
|| name.starts_with("sysQueuedMessage")
|
|
{
|
|
default.tenant.push(permission);
|
|
default.superuser.push(permission);
|
|
} else {
|
|
default.superuser.push(permission);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
default
|
|
}
|
|
}
|
|
|
|
impl From<PermissionsList> for PermissionsGroup {
|
|
fn from(value: PermissionsList) -> Self {
|
|
Self::from(&value)
|
|
}
|
|
}
|
|
|
|
impl From<&PermissionsList> for PermissionsGroup {
|
|
fn from(value: &PermissionsList) -> Self {
|
|
PermissionsGroup {
|
|
enabled: Permissions::from_permission(value.enabled_permissions.as_slice()),
|
|
disabled: Permissions::from_permission(value.disabled_permissions.as_slice()),
|
|
merge: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&VecMap<Permission, bool>> for PermissionsGroup {
|
|
fn from(value: &VecMap<Permission, bool>) -> Self {
|
|
let mut permissions = PermissionsGroup::default();
|
|
for (permission, is_set) in value {
|
|
if *is_set {
|
|
permissions.enabled.set(*permission as usize);
|
|
} else {
|
|
permissions.disabled.set(*permission as usize);
|
|
}
|
|
}
|
|
permissions
|
|
}
|
|
}
|
|
|
|
pub trait BuildPermissions {
|
|
fn from_permission(list: &[Permission]) -> Permissions;
|
|
}
|
|
|
|
impl BuildPermissions for Permissions {
|
|
fn from_permission(list: &[Permission]) -> Permissions {
|
|
let mut permission = Permissions::default();
|
|
for p in list {
|
|
permission.set(*p as usize);
|
|
}
|
|
permission
|
|
}
|
|
}
|