diff --git a/Cargo.lock b/Cargo.lock index 2ac2bfa..726f254 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3960,10 +3960,12 @@ version = "0.16.22" dependencies = [ "ahash", "base64 0.23.1", + "flate2", "jmap_proto", "registry", "serde", "serde_json", + "sha2 0.11.0", "store", "tokio", "trc", diff --git a/crates/common/src/audit.rs b/crates/common/src/audit.rs new file mode 100644 index 0000000..2a19a79 --- /dev/null +++ b/crates/common/src/audit.rs @@ -0,0 +1,489 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa: the audit log's server side (audit-hold-lock spec, AU-1 to +//! AU-11). The records, the chain and queries live in +//! `inbuxa_features::audit`; this is what needs the running server: the +//! node's id, account names, and the sign-in and access hooks. + +use crate::{ + Server, + auth::{AccessToken, AuthRequest, permissions::DefaultPermissions}, +}; +use directory::Credentials; +use inbuxa_features::audit::{ + Action, Actor, AuditLog, EntryId, Outcome, Record, Target, Via, diff, log, scope, +}; +use registry::{ + jmap::IntoValue, + schema::{enums::Permission, prelude::ObjectType}, + types::EnumImpl, +}; +use std::{future::Future, pin::Pin, sync::Arc, sync::OnceLock}; +use store::{ + Store, + registry::hook::{RegistryChange, RegistryWriteHook}, + write::now, +}; +use types::id::Id; + +/// What kind of recorded access a dedupe key is for (AU-1.4, AU-1.6). +const KIND_ACCOUNT_ACCESS: u8 = 0; +const KIND_BLOB_ACCESS: u8 = 1; +const KIND_SIGN_IN: u8 = 2; +const KIND_SIGN_IN_FAILED: u8 = 3; + +/// The permissions that make an account an administrator for AU-1.4: every +/// `sys*` permission a plain user doesn't get by default, and impersonation. +fn admin_permissions() -> &'static [Permission] { + static ADMIN: OnceLock> = OnceLock::new(); + ADMIN.get_or_init(|| { + let user = DefaultPermissions::default().user; + (0..Permission::COUNT) + .filter_map(|id| Permission::from_id(id as u16)) + .filter(|permission| { + (permission.as_str().starts_with("sys") && !user.contains(permission)) + || matches!( + permission, + Permission::Impersonate | Permission::FetchAnyBlob + ) + }) + .collect() + }) +} + +/// Whether a session holds any administrator permission. +pub fn is_admin(token: &AccessToken) -> bool { + admin_permissions() + .iter() + .any(|permission| token.has_permission(*permission)) +} + +fn ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) +} + +/// A small, stable number for a sign-in's method and address, so repeated +/// sign-ins the same way are recorded once an hour (AU-1.4). +fn sign_in_key(via: Option<&Via>, ip: std::net::IpAddr) -> u32 { + use std::hash::{Hash, Hasher}; + let mut hasher = ahash::AHasher::default(); + via.hash(&mut hasher); + ip.hash(&mut hasher); + hasher.finish() as u32 +} + +impl Server { + fn audit(&self) -> &AuditLog { + &self.inner.data.audit + } + + /// This node's chain. + pub fn audit_node(&self) -> u64 { + self.core.network.node_id + } + + /// An account as an actor, named as it is now, which the record keeps + /// (AU-4). + pub async fn audit_actor(&self, token: &AccessToken) -> Actor { + let account_id = token.account_id(); + Actor::account( + account_id, + self.audit_account_name(account_id).await, + token.tenant_id(), + ) + } + + pub async fn audit_account_name(&self, account_id: u32) -> String { + self.account(account_id) + .await + .map(|account| account.name.to_string()) + .unwrap_or_else(|_| format!("account {}", Id::from(account_id))) + } + + /// Writes a record to this node's chain. An error means nothing was + /// written: a change must then be refused (AU-3). + pub async fn audit_append(&self, record: &Record) -> trc::Result { + match self + .audit() + .append(self.store(), self.audit_node(), record) + .await + { + Ok(id) => { + trc::event!( + Security(trc::SecurityEvent::AuditRecorded), + Id = id.to_string(), + Type = record.action.as_str(), + AccountName = record.actor.name.clone(), + Details = describe_target(&record.target), + Result = record.outcome.as_str(), + ); + Ok(id) + } + Err(err) => { + trc::event!( + Security(trc::SecurityEvent::AuditWriteFailed), + Type = record.action.as_str(), + AccountName = record.actor.name.clone(), + Details = describe_target(&record.target), + Reason = err.to_string(), + ); + Err(err) + } + } + } + + /// Writes the outcome of a record written as pending. + pub async fn audit_finish(&self, id: EntryId, outcome: Outcome) -> trc::Result<()> { + let result = outcome.as_str(); + match self + .audit() + .finish(self.store(), self.audit_node(), id, ms(), outcome) + .await + { + Ok(_) => { + trc::event!( + Security(trc::SecurityEvent::AuditRecorded), + Id = id.to_string(), + Result = result, + ); + Ok(()) + } + Err(err) => { + trc::event!( + Security(trc::SecurityEvent::AuditWriteFailed), + Id = id.to_string(), + Reason = err.to_string(), + ); + Err(err) + } + } + } + + /// Records something that isn't a change (a sign-in, an access), where + /// a failed write is reported but stops nothing. + pub async fn audit_note(&self, record: Record) -> bool { + self.audit_append(&record).await.is_ok() + } + + /// AU-1.4, AU-1.5: an administrator's sign-in, a master user's, or the + /// recovery administrator's, at most once an hour per account, method + /// and address. Using an OAuth or directory token isn't a sign-in: the + /// sign-in was on the server's own page, with a password. + pub async fn audit_sign_in(&self, req: &AuthRequest, token: &AccessToken) { + let via = token.origin(); + let (actor, target) = match via { + None | Some(Via::OAuth { .. }) | Some(Via::Directory) => return, + Some(Via::Master { account_id, name }) => { + let target_id = token.account_id(); + ( + Actor { + account_id: *account_id, + name: name.clone(), + tenant_id: None, + }, + Target { + kind: "account".into(), + id: Some(Id::from(target_id).to_string()), + name: Some(self.audit_account_name(target_id).await), + account_id: Some(target_id), + tenant_id: token.tenant_id(), + }, + ) + } + // The recovery admin is an account for the log's purposes, as + // its changes are: named, and signing in to itself + Some(Via::Recovery) => { + let actor = self.audit_actor(token).await; + let target = Target { + kind: "account".into(), + id: Some(Id::from(token.account_id()).to_string()), + name: Some(actor.name.clone()), + account_id: Some(token.account_id()), + tenant_id: None, + }; + (actor, target) + } + Some(_) if is_admin(token) => { + let actor = self.audit_actor(token).await; + let target = Target { + kind: "account".into(), + id: Some(Id::from(token.account_id()).to_string()), + name: Some(actor.name.clone()), + account_id: Some(token.account_id()), + tenant_id: token.tenant_id(), + }; + (actor, target) + } + Some(_) => return, + }; + let actor_key = actor.account_id.unwrap_or(u32::MAX); + let key = sign_in_key(via, req.remote_ip); + if !self + .audit() + .first_access_this_hour(actor_key, key, KIND_SIGN_IN, now()) + { + return; + } + let recorded = self + .audit_note(Record { + at: ms(), + actor, + via: via.cloned(), + remote_ip: Some(req.remote_ip), + action: Action::SignIn, + target, + changes: vec![], + details: None, + reason: None, + outcome: Outcome::success(), + }) + .await; + if !recorded { + self.audit().forget_access(actor_key, key, KIND_SIGN_IN); + } + } + + /// AU-1.4: a failed password sign-in to an administrator's account, at + /// most once an hour per account and address. Accounts that don't exist + /// or aren't administrators aren't recorded, so guessing doesn't fill + /// the log. + pub async fn audit_sign_in_failed(&self, req: &AuthRequest) { + let Credentials::Basic { username, .. } = &req.credentials else { + return; + }; + // `target%master` fails as the master + let name = username.rsplit('%').next().unwrap_or(username); + let Ok(Some(account_id)) = self.account_id_from_email(name, false).await else { + return; + }; + let Ok(token) = self.access_token(account_id).await else { + return; + }; + let token = AccessToken::new_maybe_invalid(token); + if !is_admin(&token) { + return; + } + let key = sign_in_key(None, req.remote_ip); + if !self + .audit() + .first_access_this_hour(account_id, key, KIND_SIGN_IN_FAILED, now()) + { + return; + } + let actor = self.audit_actor(&token).await; + let target = Target { + kind: "account".into(), + id: Some(Id::from(account_id).to_string()), + name: Some(actor.name.clone()), + account_id: Some(account_id), + tenant_id: token.tenant_id(), + }; + if !self + .audit_note(Record { + at: ms(), + actor, + via: None, + remote_ip: Some(req.remote_ip), + action: Action::SignInFailed, + target, + changes: vec![], + details: None, + reason: None, + outcome: Outcome::refused("authenticationFailed", None), + }) + .await + { + self.audit() + .forget_access(account_id, key, KIND_SIGN_IN_FAILED); + } + } + + /// AU-1.6: access to another account's data through `Impersonate` (or a + /// blob through `FetchAnyBlob`), once an hour per session's account and + /// target. Access through a share or group membership isn't this: the + /// owner granted it. + pub async fn audit_foreign_access(&self, token: &AccessToken, target_id: u32, blob: bool) { + if target_id == token.account_id() || token.is_member_directly(target_id) { + return; + } + let kind = if blob { + KIND_BLOB_ACCESS + } else { + KIND_ACCOUNT_ACCESS + }; + if !self + .audit() + .first_access_this_hour(token.account_id(), target_id, kind, now()) + { + return; + } + let actor = self.audit_actor(token).await; + let target_tenant = self + .account(target_id) + .await + .ok() + .and_then(|account| account.id_tenant); + if !self + .audit_note(Record { + at: ms(), + actor, + via: token.origin().cloned(), + remote_ip: None, + action: if blob { + Action::BlobAccess + } else { + Action::AccountAccess + }, + target: Target { + kind: "account".into(), + id: Some(Id::from(target_id).to_string()), + name: Some(self.audit_account_name(target_id).await), + account_id: Some(target_id), + tenant_id: target_tenant, + }, + changes: vec![], + details: None, + reason: None, + outcome: Outcome::success(), + }) + .await + { + self.audit() + .forget_access(token.account_id(), target_id, kind); + } + } + + /// AU-1.10: from here on, registry writes the server makes on its own + /// are recorded. Installed once boot has written its defaults. + pub fn install_audit_hook(&self) { + self.registry().set_write_hook(Arc::new(SystemWrites { + data: self.store().clone(), + log: AuditLog::new(), + node: self.audit_node(), + })); + } + + /// AU-7: removes entries past the retention period. + pub async fn audit_purge(&self) -> trc::Result { + let settings = log::settings(self.store()).await?; + let cutoff = ms().saturating_sub(settings.keep_for_secs.saturating_mul(1000)); + log::purge(self.store(), cutoff, |_| false).await + } +} + +fn describe_target(target: &Target) -> String { + match (&target.name, &target.id) { + (Some(name), _) => format!("{} {name}", target.kind), + (None, Some(id)) => format!("{} {id}", target.kind), + (None, None) => target.kind.clone(), + } +} + +/// AU-1.10: records a registry write made outside any request, as the +/// server's own, under the subsystem its task runs in. +struct SystemWrites { + data: Store, + log: AuditLog, + node: u64, +} + +/// Objects whose writes aren't the control plane: telemetry and mail data +/// the registry also stores. +fn is_quiet_object(object_type: ObjectType) -> bool { + matches!( + object_type, + ObjectType::SpamTrainingSample + | ObjectType::ArchivedItem + | ObjectType::Trace + | ObjectType::Metric + | ObjectType::Log + | ObjectType::ClusterNode + | ObjectType::Task + | ObjectType::QueuedMessage + | ObjectType::ArfExternalReport + | ObjectType::DmarcExternalReport + | ObjectType::TlsExternalReport + | ObjectType::DmarcInternalReport + | ObjectType::TlsInternalReport + ) +} + +impl RegistryWriteHook for SystemWrites { + fn written<'a>( + &'a self, + change: RegistryChange<'a>, + ) -> Pin + Send + 'a>> { + Box::pin(async move { + let subsystem = match scope::current() { + Some(scope::Scope::Request | scope::Scope::Quiet) => return, + Some(scope::Scope::System(subsystem)) => subsystem, + None => "server", + }; + if is_quiet_object(change.object_type) { + return; + } + let kind = format!("x:{}", change.object_type.as_str()); + let json = |object: ®istry::schema::prelude::Object| { + serde_json::to_value(object.clone().into_value()).unwrap_or_default() + }; + let before = change.before.map(json); + let after = change.after.map(json); + let described = after + .as_ref() + .or(before.as_ref()) + .map(diff::describe) + .unwrap_or_default(); + let action = match (&before, &after) { + (None, _) => Action::Create, + (Some(_), Some(_)) => Action::Update, + (Some(_), None) => Action::Destroy, + }; + let changes = match action { + Action::Destroy => vec![], + _ => diff::diff(&kind, before.as_ref(), after.as_ref()), + }; + let record = Record { + at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64), + actor: Actor::system(subsystem), + via: None, + remote_ip: None, + action, + target: Target { + kind, + id: Some(change.id.to_string()), + name: described.name, + account_id: described.account_id, + tenant_id: described.tenant_id, + }, + changes, + details: None, + reason: None, + outcome: Outcome::success(), + }; + match self.log.append(&self.data, self.node, &record).await { + Ok(id) => trc::event!( + Security(trc::SecurityEvent::AuditRecorded), + Id = id.to_string(), + Type = record.action.as_str(), + AccountName = record.actor.name.clone(), + Details = describe_target(&record.target), + ), + Err(err) => trc::event!( + Security(trc::SecurityEvent::AuditWriteFailed), + Type = record.action.as_str(), + AccountName = record.actor.name.clone(), + Details = describe_target(&record.target), + Reason = err.to_string(), + ), + } + }) + } +} diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 739e6a8..f6fcefe 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -376,6 +376,7 @@ impl AccessToken { pub fn new(inner: Arc, remote_ip: IpAddr) -> trc::Result { AccessToken { scope_idx: 0, + origin: None, inner, } .assert_is_valid(remote_ip) @@ -384,6 +385,7 @@ impl AccessToken { pub fn new_maybe_invalid(inner: Arc) -> Self { AccessToken { scope_idx: 0, + origin: None, inner, } } @@ -404,7 +406,11 @@ impl AccessToken { .ctx(trc::Key::Id, credential_id) .reason("Credential expired or removed.") }) - .map(|scope_idx| AccessToken { scope_idx, inner }) + .map(|scope_idx| AccessToken { + scope_idx, + inner, + origin: None, + }) .and_then(|token| token.assert_is_valid(remote_ip)) } @@ -418,6 +424,7 @@ impl AccessToken { } else { AccessToken { scope_idx: 0, + origin: None, inner, } .assert_is_valid(remote_ip) @@ -481,6 +488,15 @@ impl AccessToken { || self.has_permission(Permission::Impersonate) } + /// inbuxa: AU-1.6: whether the account is reachable without + /// impersonation: its own, a group's it belongs to, or one shared with + /// it. + pub fn is_member_directly(&self, account_id: u32) -> bool { + self.inner.account_id == account_id + || self.inner.member_of.contains(&account_id) + || self.inner.access_to.iter().any(|a| a.account_id == account_id) + } + pub fn is_account_id(&self, account_id: u32) -> bool { self.inner.account_id == account_id } @@ -579,6 +595,7 @@ impl AccessToken { access_token = AccessToken { scope_idx: access_token.scope_idx, + origin: access_token.origin.clone(), inner: Arc::new(inner), }; } @@ -758,9 +775,31 @@ impl AccessToken { } } + /// inbuxa: how this session signed in (AU-5). + pub fn origin(&self) -> Option<&inbuxa_features::audit::Via> { + self.origin.as_deref() + } + + /// inbuxa: records how this session signed in (AU-5). + pub fn with_origin(mut self, origin: inbuxa_features::audit::Via) -> Self { + self.origin = Some(Arc::new(origin)); + self + } + + pub fn origin_arc(&self) -> Option> { + self.origin.clone() + } + + /// inbuxa: restores how a cached session signed in (AU-5). + pub fn with_origin_arc(mut self, origin: Option>) -> Self { + self.origin = origin; + self + } + pub fn new_admin() -> AccessToken { AccessToken { scope_idx: 0, + origin: None, inner: Arc::new(AccessTokenInner::new_admin()), } } @@ -775,6 +814,7 @@ impl AccessToken { } AccessToken { scope_idx: 0, + origin: None, inner: Arc::new(AccessTokenInner { account_id, tenant_id: Default::default(), diff --git a/crates/common/src/auth/authentication.rs b/crates/common/src/auth/authentication.rs index fc7f354..045a2ec 100644 --- a/crates/common/src/auth/authentication.rs +++ b/crates/common/src/auth/authentication.rs @@ -26,6 +26,7 @@ use registry::schema::{ use serde::Deserialize; use std::{borrow::Cow, net::IpAddr, sync::Arc}; use store::write::now; +use inbuxa_features::audit::Via; use trc::AddContext; pub struct UsernameParts { @@ -45,8 +46,17 @@ impl Server { .await .and_then(|token| token.assert_has_permission(Permission::Authenticate)) { - Ok(token) => Ok(token), + Ok(token) => { + // inbuxa: AU-1.4, AU-1.5 + self.audit_sign_in(req, &token).await; + Ok(token) + } Err(err) => { + // inbuxa: AU-1.4 + if matches!(err.as_ref(), trc::EventType::Auth(trc::AuthEvent::Failed)) { + self.audit_sign_in_failed(req).await; + } + // Random delay to mitigate user enumeration attacks #[cfg(not(feature = "test_mode"))] { @@ -106,6 +116,13 @@ impl Server { self.access_token(account_id) .await .and_then(|token| AccessToken::new(token, req.remote_ip)) + // inbuxa: AU-1.5, AU-5 + .map(|token| { + token.with_origin(Via::Master { + account_id: None, + name: fallback_user.to_string(), + }) + }) } else { Err(trc::AuthEvent::Failed .into_err() @@ -119,7 +136,8 @@ impl Server { SpanId = req.session_id, ); - Ok(AccessToken::new_admin()) + // inbuxa: AU-1.5, AU-5 + Ok(AccessToken::new_admin().with_origin(Via::Recovery)) } } else { Err(trc::AuthEvent::Failed @@ -163,6 +181,12 @@ impl Server { req.session_id, ) .await + // inbuxa: AU-5 + .map(|token| { + token.with_origin(Via::AppPassword { + id: app_pass.credential_id, + }) + }) } else { Err(trc::AuthEvent::Failed .into_err() @@ -262,6 +286,7 @@ impl Server { // Validate master user access if username.is_master() { + let master_id = token.account_id(); // inbuxa: AU-5 token.assert_has_permissions(&[ Permission::Impersonate, Permission::Authenticate, @@ -282,6 +307,13 @@ impl Server { self.access_token(account_id) .await .map(AccessToken::new_maybe_invalid) + // inbuxa: AU-1.5, AU-5: the master stays known + .map(|impersonated| { + impersonated.with_origin(Via::Master { + account_id: Some(master_id), + name: master_address.to_string(), + }) + }) } else { Err(trc::AuthEvent::Failed .into_err() @@ -297,7 +329,12 @@ impl Server { SpanId = req.session_id, ); - Ok(token) + // inbuxa: AU-5 (a directory's token already says so) + Ok(if token.origin().is_none() { + token.with_origin(Via::Password) + } else { + token + }) } } Credentials::Bearer { username, token } => { @@ -311,7 +348,9 @@ impl Server { req.remote_ip, req.session_id, ) - .await; + .await + // inbuxa: AU-5 + .map(|token| token.with_origin(Via::ApiKey { id: key.credential_id })); } #[cfg(feature = "dev_mode")] @@ -368,7 +407,8 @@ impl Server { .ctx(trc::Key::AccountId, token.account_id()) .reason("Authenticated using an email alias but account does not have AuthenticateAlias permission")); } - return Ok(token); + // inbuxa: AU-5 + return Ok(token.with_origin(Via::Directory)); } Err(err) => { external_error = Some(err); @@ -384,7 +424,20 @@ impl Server { Ok(token_info) => self .access_token(token_info.account_id) .await - .and_then(|token| AccessToken::new(token, req.remote_ip)), + .and_then(|token| AccessToken::new(token, req.remote_ip)) + // inbuxa: AU-5 + .map(|token| { + token.with_origin(Via::OAuth { + client: token_info + .claims + .as_deref() + .filter(|claims| !claims.is_empty()) + .unwrap_or("unknown") + .chars() + .take(200) + .collect(), + }) + }), Err(err) => { if let Some(external_error) = external_error { Err(external_error) diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 52ecaea..3bd2710 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -132,6 +132,8 @@ pub struct PermissionsGroup { pub struct AccessToken { scope_idx: usize, inner: Arc, + // inbuxa: how this session signed in, for the audit log (AU-5) + origin: Option>, } #[derive(Debug, Default, Clone)] @@ -298,6 +300,7 @@ impl BuildAccessToken for Arc { fn build(self) -> AccessToken { AccessToken { scope_idx: 0, + origin: None, inner: self, } } diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index a982cb5..433ff0a 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -269,6 +269,12 @@ impl Default for DefaultPermissions { 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); + } permission => { let name = permission.as_str(); if name.starts_with("jmap") diff --git a/crates/common/src/cache/directory.rs b/crates/common/src/cache/directory.rs index 9b9d751..8e8b8e6 100644 --- a/crates/common/src/cache/directory.rs +++ b/crates/common/src/cache/directory.rs @@ -31,6 +31,19 @@ impl Server { pub async fn synchronize_account( &self, account: directory::Account, + ) -> trc::Result { + // inbuxa: AU-1.10: what a directory (LDAP, AD, SQL, OIDC) changed + // is recorded as its sync, not as the server acting on its own + inbuxa_features::audit::scope::system( + "directory-sync", + self.synchronize_account_unscoped(account), + ) + .await + } + + async fn synchronize_account_unscoped( + &self, + account: directory::Account, ) -> trc::Result { let (local, domain) = self.validate_address(&account.email).await?; @@ -267,6 +280,15 @@ impl Server { } pub async fn synchronize_group(&self, group: directory::Group) -> trc::Result { + // inbuxa: AU-1.10, as for accounts + inbuxa_features::audit::scope::system( + "directory-sync", + self.synchronize_group_unscoped(group), + ) + .await + } + + async fn synchronize_group_unscoped(&self, group: directory::Group) -> trc::Result { let (local, domain) = self.validate_address(&group.email).await?; match self diff --git a/crates/common/src/config/inner.rs b/crates/common/src/config/inner.rs index b1f32d8..213613b 100644 --- a/crates/common/src/config/inner.rs +++ b/crates/common/src/config/inner.rs @@ -99,6 +99,7 @@ impl Data { logos: Default::default(), smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"), build_errors: Default::default(), + audit: Default::default(), asn_geo_data: Default::default(), } } @@ -243,6 +244,7 @@ impl Default for Data { logos: Default::default(), smtp_connectors: TlsConnectors::try_new().unwrap(), build_errors: Default::default(), + audit: Default::default(), asn_geo_data: Default::default(), lookup_stores: Default::default(), } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 3f84f1d..54b0f53 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -67,6 +67,7 @@ use utils::{ pub mod auth; pub mod cache; +pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU) pub mod config; pub mod expr; pub mod i18n; @@ -174,6 +175,9 @@ pub struct Data { // inbuxa: the objects that failed to build when the running settings // were built, at boot or by the last applied reload (see reload_registry) pub build_errors: Mutex>, + + // inbuxa: the audit log's chain heads and recent-access marks (AU) + pub audit: inbuxa_features::audit::AuditLog, } #[derive(Clone)] @@ -282,6 +286,8 @@ pub struct HttpAuthCache { pub revision: u64, pub credential_id: Option, pub expires: Instant, + // inbuxa: how the cached credentials signed in (AU-5) + pub origin: Option>, } pub struct Ipc { diff --git a/crates/common/src/manager/boot.rs b/crates/common/src/manager/boot.rs index 877e163..40fdd16 100644 --- a/crates/common/src/manager/boot.rs +++ b/crates/common/src/manager/boot.rs @@ -243,6 +243,10 @@ impl BootManager { // inbuxa: a reload isn't refused over objects that failed here inner.build_server().record_build_errors(&bootstrap.errors); + // inbuxa: AU-1.10: the server's own registry writes are + // recorded from here on, after boot's defaults + inner.build_server().install_audit_hook(); + BootManager { inner, bootstrap, diff --git a/crates/common/src/manager/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index efc0ebd..0043d4b 100644 --- a/crates/common/src/manager/granted_permissions.rs +++ b/crates/common/src/manager/granted_permissions.rs @@ -29,11 +29,31 @@ use trc::AddContext; use types::id::Id; /// Granted to the default administrator roles: "Explain this" -/// (ai-explain spec, EX-4: superuser by default). -const ADMIN_GRANTS: &[Permission] = &[Permission::SysAiExplain]; +/// (ai-explain spec, EX-4: superuser by default), and the audit log +/// (audit-hold-lock spec, AU-9). +const ADMIN_GRANTS: &[Permission] = &[ + Permission::SysAiExplain, + Permission::SysAuditGet, + Permission::SysAuditExport, + Permission::SysAuditSettingsUpdate, +]; -fn granted_key(permission: Permission) -> ValueClass { +/// Granted to the default tenant administrator roles: reading and exporting +/// the tenant's audit log (AU-9). +const TENANT_GRANTS: &[Permission] = &[Permission::SysAuditGet, Permission::SysAuditExport]; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Audience { + Admin, + Tenant, +} + +fn granted_key(permission: Permission, audience: Audience) -> ValueClass { let mut key = b"Pg".to_vec(); + // Admin grants keep the key they were first recorded under + if audience == Audience::Tenant { + key.extend_from_slice(b"tenant:"); + } key.extend_from_slice(permission.as_str().as_bytes()); ValueClass::Any(AnyClass { subspace: SUBSPACE_INBUXA, @@ -42,11 +62,16 @@ fn granted_key(permission: Permission) -> ValueClass { } pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Result<()> { + grant(bp, Audience::Admin, ADMIN_GRANTS).await?; + grant(bp, Audience::Tenant, TENANT_GRANTS).await +} + +async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> trc::Result<()> { let mut pending = Vec::new(); - for permission in ADMIN_GRANTS { + for permission in grants { if bp .data_store - .get_value::(ValueKey::from(granted_key(*permission))) + .get_value::(ValueKey::from(granted_key(*permission, audience))) .await .caused_by(trc::location!())? .is_none() @@ -58,21 +83,33 @@ pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Resu return Ok(()); } // An administrator's default roles include the plain User role, which - // every user also holds; only roles that are administrators' alone get it + // every user also holds; only roles that are the audience's alone get it let admin_roles: Vec = bp .registry .object::(Id::singleton()) .await? .map(|auth| { - let shared = [ - auth.default_user_role_ids.as_slice(), - auth.default_group_role_ids.as_slice(), - auth.default_tenant_role_ids.as_slice(), - ] - .concat(); - auth.default_admin_role_ids - .as_slice() - .iter() + let (own, shared) = match audience { + Audience::Admin => ( + auth.default_admin_role_ids.as_slice(), + [ + auth.default_user_role_ids.as_slice(), + auth.default_group_role_ids.as_slice(), + auth.default_tenant_role_ids.as_slice(), + ] + .concat(), + ), + Audience::Tenant => ( + auth.default_tenant_role_ids.as_slice(), + [ + auth.default_user_role_ids.as_slice(), + auth.default_group_role_ids.as_slice(), + auth.default_admin_role_ids.as_slice(), + ] + .concat(), + ), + }; + own.iter() .filter(|id| !shared.contains(id)) .copied() .collect() @@ -114,7 +151,7 @@ pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Resu } let mut batch = BatchBuilder::new(); for permission in pending { - batch.set(granted_key(permission), b"granted".to_vec()); + batch.set(granted_key(permission, audience), b"granted".to_vec()); } bp.data_store .write(batch.build_all()) diff --git a/crates/common/src/network/security.rs b/crates/common/src/network/security.rs index 7ca0306..cdbadee 100644 --- a/crates/common/src/network/security.rs +++ b/crates/common/src/network/security.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -335,9 +337,10 @@ impl Server { .insert(IpWithTtl::new(ip, expires_at.unwrap_or(u64::MAX))); // Write blocked IP to config - let RegistryWriteResult::Success(id) = self - .registry() - .write(RegistryWrite::insert( + // inbuxa: AU-1.10: recorded as the server's automatic ban + let RegistryWriteResult::Success(id) = inbuxa_features::audit::scope::system( + "auto-ban", + self.registry().write(RegistryWrite::insert( &BlockedIp { address: IpAddrOrMask::from_ip(ip), created_at: UTCDateTime::from_timestamp(now as i64), @@ -345,8 +348,9 @@ impl Server { reason, } .into(), - )) - .await + )), + ) + .await .caused_by(trc::location!())? else { return Ok(()); diff --git a/crates/features/Cargo.toml b/crates/features/Cargo.toml index 268fefe..cda4b6e 100644 --- a/crates/features/Cargo.toml +++ b/crates/features/Cargo.toml @@ -17,6 +17,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" xxhash-rust = { version = "0.8.18", features = ["xxh3"] } base64 = "0.23" +sha2 = "0.11" +flate2 = "1.1" +tokio = { version = "1.53", features = ["sync", "rt"] } [dev-dependencies] tokio = { version = "1.53", features = ["macros", "rt"] } diff --git a/crates/features/src/ai/explain/schema.rs b/crates/features/src/ai/explain/schema.rs index 29e5478..df179d2 100644 --- a/crates/features/src/ai/explain/schema.rs +++ b/crates/features/src/ai/explain/schema.rs @@ -9,11 +9,27 @@ //! it holds a secret anywhere inside it. use serde_json::Value; -use std::collections::HashSet; +use std::{collections::HashSet, io::Read, sync::OnceLock}; /// The registry schema, as the console downloads it. pub struct Schema(Value); +/// The schema built into the server, read once. Also used by the audit log, +/// to know which properties hold secrets (AU-4). +pub fn embedded() -> Option<&'static Schema> { + static SCHEMA: OnceLock> = OnceLock::new(); + static SCHEMA_JSON: &[u8] = include_bytes!("../../../../../resources/schema/schema.json.gz"); + SCHEMA + .get_or_init(|| { + let mut json = Vec::new(); + flate2::read::GzDecoder::new(SCHEMA_JSON) + .read_to_end(&mut json) + .ok()?; + serde_json::from_slice(&json).ok().map(Schema::new) + }) + .as_ref() +} + /// What the schema says about one property of one object. #[derive(Debug, Clone, PartialEq)] pub struct PropertyInfo { diff --git a/crates/features/src/audit/diff.rs b/crates/features/src/audit/diff.rs new file mode 100644 index 0000000..f9bd1e9 --- /dev/null +++ b/crates/features/src/audit/diff.rs @@ -0,0 +1,215 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! What changed in an object, as audit changes (AU-4). Objects are compared +//! as their JMAP JSON, one top-level property at a time. A property that is +//! a secret, or holds one anywhere inside it, is recorded as changed and +//! never with its value: the registry schema says which those are, and a few +//! names are treated as secret whatever it says. + +use crate::{ai::explain::schema, audit::record::Change}; +use serde_json::{Map, Value}; +use std::str::FromStr; +use types::id::Id; + +/// Properties never recorded with a value, even if the schema lacks them. +const ALWAYS_SECRET: &[&str] = &[ + "secret", + "password", + "credentials", + "apiKey", + "token", + "privateKey", + "otpAuth", +]; + +/// Whether `property` of `object` (`x:AiModel`, `apiKey`) holds a secret. +pub fn is_secret(object: &str, property: &str) -> bool { + let lower = property.to_ascii_lowercase(); + ALWAYS_SECRET + .iter() + .any(|name| lower == name.to_ascii_lowercase()) + || lower.ends_with("secret") + || lower.ends_with("password") + || schema::embedded() + .and_then(|schema| schema.property(object, property)) + .is_some_and(|info| info.secret) +} + +/// The changes between two versions of an object; `None` for a side that +/// doesn't exist (a create or a destroy). +pub fn diff(object: &str, before: Option<&Value>, after: Option<&Value>) -> Vec { + let empty = Map::new(); + let before = before.and_then(Value::as_object).unwrap_or(&empty); + let after = after.and_then(Value::as_object).unwrap_or(&empty); + let mut fields = before.keys().chain(after.keys()).collect::>(); + fields.sort(); + fields.dedup(); + + let mut changes = Vec::new(); + for field in fields { + if field == "id" { + continue; + } + let old = before.get(field).filter(|v| !v.is_null()); + let new = after.get(field).filter(|v| !v.is_null()); + if old == new { + continue; + } + changes.push(if is_secret(object, field) { + Change::redacted(field.as_str()) + } else { + Change::new(field.as_str(), old.cloned(), new.cloned()) + }); + } + changes +} + +/// The changes a JMAP patch asks for, with what each place held before when +/// the old object is known. Patch keys are properties or JSON pointers +/// (`sections/0/enabled`); the property is the pointer's first part. +pub fn patch(object: &str, before: Option<&Value>, patch: &Map) -> Vec { + let mut changes = Vec::new(); + for (pointer, value) in patch { + let property = pointer.split('/').next().unwrap_or(pointer); + if property == "id" { + continue; + } + if is_secret(object, property) { + changes.push(Change::redacted(pointer.as_str())); + continue; + } + let old = before + .and_then(|before| before.pointer(&format!("/{pointer}"))) + .filter(|v| !v.is_null()) + .cloned(); + let new = Some(value.clone()).filter(|v| !v.is_null()); + if old == new { + continue; + } + changes.push(Change::new(pointer.as_str(), old, new)); + } + changes +} + +/// What an object is called, and whose it is, for an audit target. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Described { + pub name: Option, + pub account_id: Option, + pub tenant_id: Option, +} + +/// Reads a target's name and owners from its JSON. +pub fn describe(value: &Value) -> Described { + let name = [ + "name", + "email", + "address", + "hostname", + "domain", + "description", + ] + .iter() + .find_map(|key| value.get(key)?.as_str()) + .map(|name| name.chars().take(200).collect()); + let id = |key: &str| { + value + .get(key)? + .as_str() + .and_then(|id| Id::from_str(id).ok()) + .map(|id| id.document_id()) + }; + Described { + name, + account_id: id("accountId"), + tenant_id: id("memberTenantId"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn diffs_by_property() { + let before = json!({"id": "a", "name": "x", "enabled": true, "gone": 1}); + let after = json!({"id": "b", "name": "y", "enabled": true, "added": [1]}); + let changes = diff("x:Thing", Some(&before), Some(&after)); + assert_eq!( + changes, + vec![ + Change::new("added", None, Some(json!([1]))), + Change::new("gone", Some(json!(1)), None), + Change::new("name", Some(json!("x")), Some(json!("y"))), + ] + ); + // A create lists everything that is set + assert_eq!(diff("x:Thing", None, Some(&after)).len(), 3); + } + + #[test] + fn secrets_are_never_kept() { + let before = json!({"apiKey": "old-key", "userPassword": "a", "name": "m"}); + let after = json!({"apiKey": "new-key", "userPassword": "b", "name": "m"}); + let changes = diff("x:AiModel", Some(&before), Some(&after)); + assert_eq!( + changes, + vec![Change::redacted("apiKey"), Change::redacted("userPassword")] + ); + let text = serde_json::to_string(&changes).unwrap(); + assert!(!text.contains("new-key")); + assert!(!text.contains("old-key")); + // Unchanged secrets aren't mentioned at all + assert!(diff("x:AiModel", Some(&before), Some(&before)).is_empty()); + } + + #[test] + fn secrets_the_schema_knows() { + // x:AiModel's httpAuth holds a secret inside one of its variants + if schema::embedded().is_some() { + assert!(is_secret("x:AiModel", "httpAuth")); + assert!(!is_secret("x:AiModel", "name")); + } + } + + #[test] + fn patches_with_their_old_values() { + let before = json!({"name": "a", "list": [{"on": false}], "secret": "s"}); + let patch_value = json!({"name": "b", "list/0/on": true, "secret": "t", "new": 3}); + let changes = patch("x:Thing", Some(&before), patch_value.as_object().unwrap()); + assert!(changes.contains(&Change::new("name", Some(json!("a")), Some(json!("b"))))); + assert!(changes.contains(&Change::new( + "list/0/on", + Some(json!(false)), + Some(json!(true)) + ))); + assert!(changes.contains(&Change::redacted("secret"))); + assert!(changes.contains(&Change::new("new", None, Some(json!(3))))); + // Nothing to nothing isn't a change + let nulls = json!({"description": null}); + assert!(patch("x:Thing", None, nulls.as_object().unwrap()).is_empty()); + } + + #[test] + fn describes_targets() { + let d = describe(&json!({ + "name": "example.com", + "memberTenantId": Id::from(5u32).to_string(), + "accountId": Id::from(9u32).to_string(), + })); + assert_eq!( + d, + Described { + name: Some("example.com".into()), + account_id: Some(9), + tenant_id: Some(5) + } + ); + assert_eq!(describe(&json!({"n": 1})), Described::default()); + } +} diff --git a/crates/features/src/audit/log.rs b/crates/features/src/audit/log.rs new file mode 100644 index 0000000..0463505 --- /dev/null +++ b/crates/features/src/audit/log.rs @@ -0,0 +1,984 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The audit log's storage (AU-2, AU-3, AU-6, AU-7), in the fork's own +//! subspace (`store::SUBSPACE_INBUXA`). Every key starts with `L`, then one +//! byte for the kind: +//! +//! - `e` + node + seq: one entry of that node's chain, as JSON. An entry is +//! an event, or the outcome of an event written before its change was +//! tried. Each holds the SHA-256 of the entry before it on the same node. +//! - `t` + time + node + seq: the time index of events, for queries. +//! - `o` + node + seq: the seq of an event's outcome entry. +//! - `h` + node: the chain's head: that entry's hash, then its seq as the +//! last eight bytes, which each append asserts, so two writers can never +//! both add the same seq. +//! - `f` + node: where the chain starts after purging, and the hash the +//! first kept entry names. +//! - `s`: the settings (`keepFor`). +//! +//! Numbers are big-endian, so keys sort in time and chain order. Each node +//! writes only its own chain, so nodes never contend for a key; nothing about +//! a chain is kept in memory, so a node restarted or rebuilt carries on +//! from what is stored. + +use crate::audit::record::{Action, Outcome, Record}; +use ahash::AHashMap; +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; +use sha2::{Digest, Sha256}; +use std::{fmt, net::IpAddr, str::FromStr}; +use store::{ + Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue}, +}; +use tokio::sync::Mutex; +use trc::AddContext; + +const FEATURE: u8 = b'L'; +const KIND_ENTRY: u8 = b'e'; +const KIND_TIME: u8 = b't'; +const KIND_OUTCOME: u8 = b'o'; +const KIND_HEAD: u8 = b'h'; +const KIND_FLOOR: u8 = b'f'; +const KIND_SETTINGS: u8 = b's'; + +/// How long entries are kept unless set otherwise: two years (AU-7). +pub const DEFAULT_KEEP_FOR_SECS: u64 = 730 * 86_400; +/// The shortest period an administrator may set (AU-7). +pub const MIN_KEEP_FOR_SECS: u64 = 90 * 86_400; +/// Most results one query page returns. +pub const MAX_QUERY_LIMIT: usize = 500; +/// Keys cleared per purge batch. +const PURGE_BATCH: usize = 500; + +/// Where one entry sits: its node's chain and its place in it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct EntryId { + pub node: u64, + pub seq: u64, +} + +impl EntryId { + /// As one number, for JMAP ids: the node in the top 16 bits, the seq in + /// the rest. Node ids are 16 bits; a chain reaches 2^48 entries never. + pub fn to_u64(&self) -> u64 { + (self.node << 48) | (self.seq & ((1 << 48) - 1)) + } + + pub fn from_u64(id: u64) -> Self { + EntryId { + node: id >> 48, + seq: id & ((1 << 48) - 1), + } + } +} + +impl fmt::Display for EntryId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}-{}", self.node, self.seq) + } +} + +impl FromStr for EntryId { + type Err = (); + + fn from_str(s: &str) -> Result { + let (node, seq) = s.split_once('-').ok_or(())?; + Ok(EntryId { + node: node.parse().map_err(|_| ())?, + seq: seq.parse().map_err(|_| ())?, + }) + } +} + +/// What is kept for one chain entry. The hash of these exact bytes is what +/// the next entry names as `prev`. +#[derive(Debug, Clone, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +struct Stored { + seq: u64, + prev: String, + #[serde(flatten)] + entry: Entry, +} + +#[derive(Debug, Clone, SerdeSerialize, SerdeDeserialize)] +#[serde(tag = "entry", rename_all = "camelCase")] +enum Entry { + Event { record: Record }, + Outcome { of: u64, at: u64, outcome: Outcome }, +} + +impl Entry { + fn at(&self) -> u64 { + match self { + Entry::Event { record } => record.at, + Entry::Outcome { at, .. } => *at, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +struct Head { + seq: u64, + hash: String, +} + +impl Head { + fn to_bytes(&self) -> Vec { + let mut bytes = self.hash.as_bytes().to_vec(); + bytes.extend_from_slice(&self.seq.to_be_bytes()); + bytes + } +} + +impl Deserialize for Head { + fn deserialize(bytes: &[u8]) -> trc::Result { + let split = bytes.len().checked_sub(8).ok_or_else(|| { + trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid audit chain head") + })?; + Ok(Head { + seq: u64::from_be_bytes(bytes[split..].try_into().unwrap()), + hash: String::from_utf8_lossy(&bytes[..split]).into_owned(), + }) + } +} + +async fn head(data: &Store, node: u64) -> trc::Result> { + data.get_value::(key(KIND_HEAD, &[node])) + .await + .caused_by(trc::location!()) +} + +/// Attempts at an append that another writer beat to the same seq. +const APPEND_ATTEMPTS: usize = 5; + +#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)] +struct Floor { + seq: u64, + prev: String, +} + +/// The audit log's settings (`inbuxa:AuditSettings`). +#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct Settings { + pub keep_for_secs: u64, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + keep_for_secs: DEFAULT_KEEP_FOR_SECS, + } + } +} + +/// A value stored as JSON. +struct Json(T); + +impl Serialize for Json { + fn serialize(&self) -> trc::Result> { + serde_json::to_vec(&self.0).map_err(|err| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to serialize audit entry") + .reason(err) + }) + } +} + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid audit entry") + .reason(err) + }) + } +} + +/// Raw bytes, for entries whose hash is checked. +struct Raw(Vec); + +impl Deserialize for Raw { + fn deserialize(bytes: &[u8]) -> trc::Result { + Ok(Raw(bytes.to_vec())) + } +} + +struct U64(u64); + +impl Deserialize for U64 { + fn deserialize(bytes: &[u8]) -> trc::Result { + bytes + .try_into() + .map(|bytes| U64(u64::from_be_bytes(bytes))) + .map_err(|_| { + trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid audit outcome pointer") + }) + } +} + +fn class(kind: u8, parts: &[u64]) -> ValueClass { + let mut key = Vec::with_capacity(2 + parts.len() * 8); + key.push(FEATURE); + key.push(kind); + for part in parts { + key.extend_from_slice(&part.to_be_bytes()); + } + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +fn key(kind: u8, parts: &[u64]) -> ValueKey { + ValueKey::from(class(kind, parts)) +} + +/// Where an entry is kept, for tests and tools that check tampering is +/// caught. +pub fn entry_key(id: EntryId) -> ValueKey { + key(KIND_ENTRY, &[id.node, id.seq]) +} + +/// Where a node's chain head is kept, for the same. +pub fn head_key(node: u64) -> ValueKey { + key(KIND_HEAD, &[node]) +} + +/// The numbers after the kind byte, read from the key's tail: the iterator +/// may or may not hand back the subspace byte. +fn parse_key(key: &[u8], kind: u8, parts: usize) -> Option> { + let len = 2 + parts * 8; + let tail = key.get(key.len().checked_sub(len)?..)?; + (tail[0] == FEATURE && tail[1] == kind).then_some(())?; + Some( + tail[2..] + .chunks_exact(8) + .map(|chunk| u64::from_be_bytes(chunk.try_into().unwrap())) + .collect(), + ) +} + +fn hash(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +/// Lines up this process's appends, so they rarely race for a head; the +/// store's assert settles any that still do. +static APPENDING: Mutex<()> = Mutex::const_new(()); + +/// What a node keeps in memory: which accesses it has recorded lately +/// (AU-1.6). +#[derive(Default)] +pub struct AuditLog { + recent_access: std::sync::Mutex>, +} + +/// A query over events (AU-9), newest first. +#[derive(Debug, Clone, Default)] +pub struct Filter { + /// From this time on, in ms. + pub after: Option, + /// Before this time, in ms. + pub before: Option, + pub actor_id: Option, + pub action: Option, + pub target_kind: Option, + pub target_id: Option, + pub account_id: Option, + /// Records whose actor or target is in this tenant. + pub tenant_id: Option, + pub outcome: Option, + pub remote_ip: Option, + /// Words that must all appear in the actor's or target's name, the + /// target kind, or the details, ignoring case. + pub text: Option, +} + +impl Filter { + pub fn matches(&self, record: &Record) -> bool { + self.after.is_none_or(|after| record.at >= after) + && self.before.is_none_or(|before| record.at < before) + && self + .actor_id + .is_none_or(|actor| record.actor.account_id == Some(actor)) + && self.action.is_none_or(|action| record.action == action) + && self + .target_kind + .as_ref() + .is_none_or(|kind| record.target.kind.eq_ignore_ascii_case(kind)) + && self + .target_id + .as_ref() + .is_none_or(|target| record.target.id.as_ref() == Some(target)) + && self.account_id.is_none_or(|account| { + record.target.account_id == Some(account) + || record.actor.account_id == Some(account) + || (record.target.kind == "x:Account" + && record.target.id.as_deref() + == Some(types::id::Id::from(account).to_string().as_str())) + }) + && self + .tenant_id + .is_none_or(|tenant| in_tenant(record, tenant)) + && self + .outcome + .as_ref() + .is_none_or(|outcome| record.outcome.as_str() == outcome) + && self.remote_ip.is_none_or(|ip| record.remote_ip == Some(ip)) + && self.text.as_ref().is_none_or(|text| { + let haystack = format!( + "{} {} {} {} {}", + record.actor.name, + record.target.kind, + record.target.name.as_deref().unwrap_or_default(), + record.details.as_deref().unwrap_or_default(), + record.reason.as_deref().unwrap_or_default() + ) + .to_lowercase(); + text.to_lowercase() + .split_whitespace() + .all(|word| haystack.contains(word)) + }) + } +} + +/// Whether a tenant administrator may see a record: its actor or its +/// target is in the tenant (AU-9). +pub fn in_tenant(record: &Record, tenant_id: u32) -> bool { + record.actor.tenant_id == Some(tenant_id) || record.target.tenant_id == Some(tenant_id) +} + +/// One node's chain, as `verify` found it. +#[derive(Debug, Clone, PartialEq, SerdeSerialize)] +#[serde(rename_all = "camelCase")] +pub struct ChainReport { + pub node: u64, + pub entries: u64, + pub first_seq: u64, + pub last_seq: u64, + /// The first entry that doesn't follow from the one before it, or the + /// head that doesn't match the last entry. + #[serde(skip_serializing_if = "Option::is_none")] + pub broken_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Events written before their change whose outcome never followed. + pub unfinished: u64, +} + +impl AuditLog { + pub fn new() -> Self { + Self::default() + } + + /// Appends an event to this node's chain. An error means nothing was + /// written, and the caller must not go ahead with the change (AU-3). + pub async fn append(&self, data: &Store, node: u64, record: &Record) -> trc::Result { + self.append_entry( + data, + node, + Entry::Event { + record: record.clone(), + }, + ) + .await + } + + /// Appends the outcome of an event written as pending. + pub async fn finish( + &self, + data: &Store, + node: u64, + of: EntryId, + at: u64, + outcome: Outcome, + ) -> trc::Result { + self.append_entry( + data, + node, + Entry::Outcome { + of: of.seq, + at, + outcome, + }, + ) + .await + } + + async fn append_entry(&self, data: &Store, node: u64, entry: Entry) -> trc::Result { + let _appending = APPENDING.lock().await; + let at = entry.at(); + let event_of = match &entry { + Entry::Outcome { of, .. } => Some(*of), + Entry::Event { .. } => None, + }; + let mut stored = Stored { + seq: 0, + prev: String::new(), + entry, + }; + let mut attempt = 0; + loop { + attempt += 1; + let current = head(data, node).await?; + let (seq, prev) = current + .as_ref() + .map_or((1, String::new()), |head| (head.seq + 1, head.hash.clone())); + stored.seq = seq; + stored.prev = prev; + let bytes = Json(&stored).serialize()?; + let new_head = Head { + seq, + hash: hash(&bytes), + }; + + let mut batch = BatchBuilder::new(); + batch.assert_value( + class(KIND_HEAD, &[node]), + current.map_or(AssertValue::None, |head| AssertValue::U64(head.seq)), + ); + batch.set(class(KIND_ENTRY, &[node, seq]), bytes); + match event_of { + None => { + batch.set(class(KIND_TIME, &[at, node, seq]), vec![]); + } + Some(of) => { + batch.set(class(KIND_OUTCOME, &[node, of]), seq.to_be_bytes().to_vec()); + } + } + batch.set(class(KIND_HEAD, &[node]), new_head.to_bytes()); + match data.write(batch.build_all()).await { + Ok(_) => return Ok(EntryId { node, seq }), + Err(err) + if attempt < APPEND_ATTEMPTS + && matches!( + err.as_ref(), + trc::EventType::Store(trc::StoreEvent::AssertValueFailed) + ) => + { + continue; + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + } + } + + /// Whether an access of `target` by `actor` (kind 0: account, 1: blob) + /// is the first this hour on this node, and so should be recorded + /// (AU-1.6). Marks it recorded. + pub fn first_access_this_hour(&self, actor: u32, target: u32, kind: u8, now_secs: u64) -> bool { + let hour = now_secs / 3600; + let mut recent = self.recent_access.lock().unwrap_or_else(|e| e.into_inner()); + if recent.len() > 10_000 { + recent.retain(|_, seen| *seen == hour); + } + recent.insert((actor, target, kind), hour) != Some(hour) + } + + /// Forgets which accesses were recorded, so the next is recorded again + /// (after a write failed). + pub fn forget_access(&self, actor: u32, target: u32, kind: u8) { + self.recent_access + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&(actor, target, kind)); + } +} + +/// One event with its outcome, when that was written separately. +pub async fn get(data: &Store, id: EntryId) -> trc::Result> { + let Some(Json(stored)) = data + .get_value::>(key(KIND_ENTRY, &[id.node, id.seq])) + .await + .caused_by(trc::location!())? + else { + return Ok(None); + }; + let Entry::Event { mut record } = stored.entry else { + return Ok(None); + }; + if record.outcome == Outcome::Pending + && let Some(U64(outcome_seq)) = data + .get_value::(key(KIND_OUTCOME, &[id.node, id.seq])) + .await + .caused_by(trc::location!())? + && let Some(Json(Stored { + entry: Entry::Outcome { outcome, .. }, + .. + })) = data + .get_value::>(key(KIND_ENTRY, &[id.node, outcome_seq])) + .await + .caused_by(trc::location!())? + { + record.outcome = outcome; + } + Ok(Some(record)) +} + +/// One event with its outcome, and the hash of its entry and the hash that +/// entry follows: what an export carries so a recipient can match it +/// against a later verification (AU-11). +pub async fn get_with_hash( + data: &Store, + id: EntryId, +) -> trc::Result> { + let Some(Raw(bytes)) = data + .get_value::(key(KIND_ENTRY, &[id.node, id.seq])) + .await + .caused_by(trc::location!())? + else { + return Ok(None); + }; + let Json(stored) = Json::::deserialize(&bytes)?; + if !matches!(stored.entry, Entry::Event { .. }) { + return Ok(None); + } + let entry_hash = hash(&bytes); + Ok(get(data, id) + .await? + .map(|record| (record, entry_hash, stored.prev))) +} + +/// Every event matching `filter`, newest first, up to `max`: for exports. +pub async fn query_all(data: &Store, filter: &Filter, max: usize) -> trc::Result> { + query_inner(data, filter, 0, max, false) + .await + .map(|(ids, _)| ids) +} + +/// Events matching `filter`, newest first: the ids from `position`, at most +/// `limit` of them, and how many match in all when `count_all` is set. +pub async fn query( + data: &Store, + filter: &Filter, + position: usize, + limit: usize, + count_all: bool, +) -> trc::Result<(Vec, usize)> { + query_inner( + data, + filter, + position, + limit.min(MAX_QUERY_LIMIT), + count_all, + ) + .await +} + +async fn query_inner( + data: &Store, + filter: &Filter, + position: usize, + limit: usize, + count_all: bool, +) -> trc::Result<(Vec, usize)> { + let from = filter.after.unwrap_or(0); + let to = filter + .before + .map_or(u64::MAX, |before| before.saturating_sub(1)); + if from > to { + return Ok((Vec::new(), 0)); + } + + // Walk the time index newest first, collecting candidates + let mut candidates = Vec::new(); + data.iterate( + IterateParams::new( + key(KIND_TIME, &[from, 0, 0]), + key(KIND_TIME, &[to, u64::MAX, u64::MAX]), + ) + .descending() + .no_values(), + |key, _| { + if let Some(parts) = parse_key(key, KIND_TIME, 3) { + candidates.push(EntryId { + node: parts[1], + seq: parts[2], + }); + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + let mut ids = Vec::with_capacity(limit); + let mut matched = 0; + for id in candidates { + if !count_all && ids.len() >= limit { + break; + } + let Some(record) = get(data, id).await? else { + continue; + }; + if filter.matches(&record) { + if matched >= position && ids.len() < limit { + ids.push(id); + } + matched += 1; + } + } + Ok((ids, matched)) +} + +pub async fn settings(data: &Store) -> trc::Result { + Ok(data + .get_value::>(key(KIND_SETTINGS, &[])) + .await + .caused_by(trc::location!())? + .map(|Json(settings)| settings) + .unwrap_or_default()) +} + +pub async fn set_settings(data: &Store, settings: &Settings) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.set(class(KIND_SETTINGS, &[]), Json(settings).serialize()?); + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + +/// The nodes that have a chain. +async fn nodes(data: &Store) -> trc::Result> { + let mut nodes = Vec::new(); + data.iterate( + IterateParams::new(key(KIND_HEAD, &[0]), key(KIND_HEAD, &[u64::MAX])).no_values(), + |key, _| { + if let Some(parts) = parse_key(key, KIND_HEAD, 1) { + nodes.push(parts[0]); + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + Ok(nodes) +} + +async fn floor(data: &Store, node: u64) -> trc::Result { + Ok(data + .get_value::>(key(KIND_FLOOR, &[node])) + .await + .caused_by(trc::location!())? + .map(|Json(floor)| floor) + .unwrap_or(Floor { + seq: 1, + prev: String::new(), + })) +} + +/// Removes, from the start of every node's chain, the entries older than +/// `cutoff` (ms), stopping at the first one that is newer or that `keep` +/// holds on to (AU-7, LH-6). The chain stays verifiable: its new start and +/// the hash that start names are recorded. Returns how many were removed. +pub async fn purge( + data: &Store, + cutoff: u64, + keep: impl Fn(&Record) -> bool + Sync + Send, +) -> trc::Result { + let mut removed = 0; + for node in nodes(data).await? { + let start = floor(data, node).await?; + let mut doomed: Vec<(u64, Stored)> = Vec::new(); + let mut new_floor = None; + data.iterate( + IterateParams::new( + key(KIND_ENTRY, &[node, start.seq]), + key(KIND_ENTRY, &[node, u64::MAX]), + ) + .ascending(), + |key, value| { + let Some(parts) = parse_key(key, KIND_ENTRY, 2) else { + return Ok(true); + }; + let Json(stored) = Json::::deserialize(value)?; + let held = matches!(&stored.entry, Entry::Event { record } if keep(record)); + if stored.entry.at() >= cutoff || held || doomed.len() >= 100_000 { + new_floor = Some(Floor { + seq: parts[1], + prev: stored.prev, + }); + return Ok(false); + } + doomed.push((parts[1], stored)); + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if doomed.is_empty() { + continue; + } + // With nothing newer, the chain continues from its head + let new_floor = match new_floor { + Some(floor) => floor, + None => { + let head = head(data, node).await?.unwrap_or_default(); + Floor { + seq: head.seq + 1, + prev: head.hash, + } + } + }; + + // The floor moves first: a purge cut short leaves entries before it, + // which the next run clears, never a chain that looks broken + let mut batch = BatchBuilder::new(); + batch.set(class(KIND_FLOOR, &[node]), Json(&new_floor).serialize()?); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + for chunk in doomed.chunks(PURGE_BATCH / 3) { + let mut batch = BatchBuilder::new(); + for (seq, stored) in chunk { + batch.clear(class(KIND_ENTRY, &[node, *seq])); + match &stored.entry { + Entry::Event { record } => { + batch + .clear(class(KIND_TIME, &[record.at, node, *seq])) + .clear(class(KIND_OUTCOME, &[node, *seq])); + } + Entry::Outcome { .. } => {} + } + } + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + removed += chunk.len(); + } + } + Ok(removed) +} + +/// Rechecks every node's chain (AU-6): each entry must name the hash of the +/// one before it, seqs must run without gaps from the chain's start, and the +/// head must match the last entry. +pub async fn verify(data: &Store) -> trc::Result> { + let mut reports = Vec::new(); + for node in nodes(data).await? { + let start = floor(data, node).await?; + let head = head(data, node).await?.unwrap_or_default(); + let mut report = ChainReport { + node, + entries: 0, + first_seq: start.seq, + last_seq: start.seq.saturating_sub(1), + broken_at: None, + reason: None, + unfinished: 0, + }; + let mut expected_seq = start.seq; + let mut expected_prev = start.prev.clone(); + let mut pending: ahash::AHashSet = Default::default(); + + data.iterate( + IterateParams::new( + key(KIND_ENTRY, &[node, start.seq]), + key(KIND_ENTRY, &[node, u64::MAX]), + ) + .ascending(), + |key, value| { + let Some(parts) = parse_key(key, KIND_ENTRY, 2) else { + return Ok(true); + }; + let seq = parts[1]; + let broken = |report: &mut ChainReport, reason: String| { + report.broken_at = Some(EntryId { node, seq }.to_string()); + report.reason = Some(reason); + }; + let Raw(bytes) = Raw::deserialize(value)?; + let Ok(Json(stored)) = Json::::deserialize(&bytes) else { + broken(&mut report, "The entry can't be read.".into()); + return Ok(false); + }; + if seq != expected_seq || stored.seq != seq { + broken( + &mut report, + format!("Entry {expected_seq} is missing; the next one found is {seq}."), + ); + return Ok(false); + } + if stored.prev != expected_prev { + broken( + &mut report, + "The entry doesn't follow from the one before it: one of them was changed." + .into(), + ); + return Ok(false); + } + match &stored.entry { + Entry::Event { record } if record.outcome == Outcome::Pending => { + pending.insert(seq); + } + Entry::Outcome { of, .. } => { + pending.remove(of); + } + Entry::Event { .. } => {} + } + expected_prev = hash(&bytes); + expected_seq = seq + 1; + report.entries += 1; + report.last_seq = seq; + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if report.broken_at.is_none() { + if head.seq != report.last_seq || (report.entries > 0 && head.hash != expected_prev) { + report.broken_at = Some( + EntryId { + node, + seq: report.last_seq, + } + .to_string(), + ); + report.reason = Some( + "The chain's recorded end doesn't match its last entry: entries were \ + removed or changed at the end." + .into(), + ); + } + } + report.unfinished = pending.len() as u64; + reports.push(report); + } + Ok(reports) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_read_back() { + let ValueClass::Any(any) = class(KIND_TIME, &[5, 3, 9]) else { + panic!() + }; + assert_eq!(parse_key(&any.key, KIND_TIME, 3), Some(vec![5, 3, 9])); + let mut with_subspace = vec![SUBSPACE_INBUXA]; + with_subspace.extend_from_slice(&any.key); + assert_eq!(parse_key(&with_subspace, KIND_TIME, 3), Some(vec![5, 3, 9])); + assert_eq!(parse_key(&any.key, KIND_ENTRY, 3), None); + } + + #[test] + fn ids_read_back() { + let id = EntryId { node: 2, seq: 1042 }; + assert_eq!(id.to_string(), "2-1042"); + assert_eq!("2-1042".parse::(), Ok(id)); + assert!("2".parse::().is_err()); + assert!("a-1".parse::().is_err()); + assert_eq!(EntryId::from_u64(id.to_u64()), id); + let big = EntryId { + node: 65535, + seq: (1 << 48) - 1, + }; + assert_eq!(EntryId::from_u64(big.to_u64()), big); + } + + #[test] + fn filters() { + use crate::audit::record::{Actor, Target}; + let record = Record { + at: 1000, + actor: Actor::account(7, "Admin@Example.com", Some(4)), + via: None, + remote_ip: None, + action: Action::Update, + target: Target { + kind: "x:Domain".into(), + id: Some("d".into()), + name: Some("example.org".into()), + tenant_id: Some(9), + ..Default::default() + }, + changes: vec![], + details: None, + reason: None, + outcome: Outcome::success(), + }; + let yes = |filter: Filter| assert!(filter.matches(&record), "{filter:?}"); + let no = |filter: Filter| assert!(!filter.matches(&record), "{filter:?}"); + yes(Filter::default()); + yes(Filter { + after: Some(1000), + before: Some(1001), + ..Default::default() + }); + no(Filter { + before: Some(1000), + ..Default::default() + }); + yes(Filter { + tenant_id: Some(4), + ..Default::default() + }); + yes(Filter { + tenant_id: Some(9), + ..Default::default() + }); + no(Filter { + tenant_id: Some(5), + ..Default::default() + }); + yes(Filter { + text: Some("admin EXAMPLE.ORG".into()), + ..Default::default() + }); + no(Filter { + text: Some("admin other".into()), + ..Default::default() + }); + yes(Filter { + outcome: Some("success".into()), + action: Some(Action::Update), + target_kind: Some("x:domain".into()), + ..Default::default() + }); + no(Filter { + actor_id: Some(8), + ..Default::default() + }); + } + + #[test] + fn heads_read_back() { + let head = Head { + seq: 77, + hash: hash(b"x"), + }; + let bytes = head.to_bytes(); + assert!(AssertValue::U64(77).matches(&bytes)); + assert!(!AssertValue::U64(76).matches(&bytes)); + assert_eq!(Head::deserialize(&bytes).unwrap(), head); + assert!(Head::deserialize(b"short").is_err()); + } + + #[test] + fn hashes_are_sha256_hex() { + assert_eq!( + hash(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } +} diff --git a/crates/features/src/audit/mod.rs b/crates/features/src/audit/mod.rs new file mode 100644 index 0000000..884b21c --- /dev/null +++ b/crates/features/src/audit/mod.rs @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The audit log (audit-hold-lock spec, AU-1 to AU-11): a permanent record +//! of what administrators and the server itself did to the control plane, +//! kept in the fork's own subspace as one hash chain per node. +//! +//! - `record`: what one entry says. +//! - `log`: appending to the chain, reading, querying, purging, verifying. +//! - `scope`: who is acting, carried with the task, so a registry write the +//! server makes on its own is told apart from one a request made. +//! - `diff`: what changed in a registry object, with secrets redacted. + +pub mod diff; +pub mod log; +pub mod record; +pub mod scope; + +pub use log::{AuditLog, EntryId}; +pub use record::{Action, Actor, Change, Outcome, Record, Target, Via}; diff --git a/crates/features/src/audit/record.rs b/crates/features/src/audit/record.rs new file mode 100644 index 0000000..3951bd5 --- /dev/null +++ b/crates/features/src/audit/record.rs @@ -0,0 +1,349 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! What an audit entry holds (AU-4). Stored as JSON, so entries written by +//! one version of the fork read back in the next. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::net::IpAddr; + +/// Longest value kept for one side of a change; longer ones are cut, with +/// their original length noted. +pub const MAX_VALUE_LEN: usize = 2048; + +/// One thing that happened. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Record { + /// Milliseconds since the epoch. + pub at: u64, + pub actor: Actor, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub via: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ip: Option, + pub action: Action, + pub target: Target, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub changes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, + /// Why, as the actor gave it: required for holds, locks and exports, + /// optional for everything else. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub outcome: Outcome, +} + +/// Who acted: an account, named as it was then, or the server itself. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Actor { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, + /// The account's name, or `system:`. + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, +} + +impl Actor { + pub fn account(account_id: u32, name: impl Into, tenant_id: Option) -> Self { + Actor { + account_id: Some(account_id), + name: name.into(), + tenant_id, + } + } + + pub fn system(subsystem: &str) -> Self { + Actor { + account_id: None, + name: format!("system:{subsystem}"), + tenant_id: None, + } + } + + pub fn is_system(&self) -> bool { + self.account_id.is_none() + } +} + +/// How the actor signed in (AU-5). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Via { + Password, + AppPassword { + id: u32, + }, + ApiKey { + id: u32, + }, + #[serde(rename = "oauth")] + OAuth { + client: String, + }, + /// A token from an external directory (OIDC). + Directory, + /// Signed in as someone else with a master user's password. + #[serde(rename_all = "camelCase")] + Master { + #[serde(default, skip_serializing_if = "Option::is_none")] + account_id: Option, + name: String, + }, + /// The recovery administrator from the server's own configuration. + Recovery, +} + +/// What kind of thing happened. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Action { + Create, + Update, + Destroy, + SignIn, + SignInFailed, + /// JMAP access to another account through `Impersonate`. + AccountAccess, + /// A blob of another account read through `FetchAnyBlob`. + BlobAccess, + Export, + Verify, +} + +impl Action { + pub fn as_str(&self) -> &'static str { + match self { + Action::Create => "create", + Action::Update => "update", + Action::Destroy => "destroy", + Action::SignIn => "signIn", + Action::SignInFailed => "signInFailed", + Action::AccountAccess => "accountAccess", + Action::BlobAccess => "blobAccess", + Action::Export => "export", + Action::Verify => "verify", + } + } + + pub fn parse(value: &str) -> Option { + Some(match value { + "create" => Action::Create, + "update" => Action::Update, + "destroy" => Action::Destroy, + "signIn" => Action::SignIn, + "signInFailed" => Action::SignInFailed, + "accountAccess" => Action::AccountAccess, + "blobAccess" => Action::BlobAccess, + "export" => Action::Export, + "verify" => Action::Verify, + _ => return None, + }) + } +} + +/// What it happened to. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Target { + /// An object type (`x:Domain`, `inbuxa:ProtocolPolicy`), or `account` + /// for sign-ins and access. + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// The account the object belongs to, when it belongs to one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, +} + +/// One property's change. A secret is never stored: `redacted` says it +/// changed, and both sides are left out (AU-4). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Change { + pub field: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub redacted: bool, +} + +impl Change { + pub fn new(field: impl Into, before: Option, after: Option) -> Self { + Change { + field: field.into(), + before: before.map(shorten), + after: after.map(shorten), + redacted: false, + } + } + + pub fn redacted(field: impl Into) -> Self { + Change { + field: field.into(), + before: None, + after: None, + redacted: true, + } + } +} + +/// How it ended. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "status", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum Outcome { + Success { + /// The id a create was given. + #[serde(default, skip_serializing_if = "Option::is_none")] + created_id: Option, + }, + Refused { + /// The JMAP error type (`forbidden`, `invalidProperties`, …). + error: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + }, + /// Written before the change was tried; its outcome follows in a later + /// entry, or never if the server stopped in between (AU-3). + Pending, +} + +impl Outcome { + pub fn success() -> Self { + Outcome::Success { created_id: None } + } + + pub fn refused(error: impl Into, description: Option) -> Self { + Outcome::Refused { + error: error.into(), + description: description.map(|d| shorten_str(d, 500)), + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Outcome::Success { .. } => "success", + Outcome::Refused { .. } => "refused", + Outcome::Pending => "pending", + } + } +} + +/// Cuts a long value, keeping it valid JSON. +pub fn shorten(value: Value) -> Value { + match value { + Value::String(s) if s.len() > MAX_VALUE_LEN => Value::String(shorten_str(s, MAX_VALUE_LEN)), + Value::String(_) | Value::Null | Value::Bool(_) | Value::Number(_) => value, + other => { + let text = other.to_string(); + if text.len() > MAX_VALUE_LEN { + Value::String(shorten_str(text, MAX_VALUE_LEN)) + } else { + other + } + } + } +} + +fn shorten_str(s: String, max: usize) -> String { + if s.len() <= max { + return s; + } + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}… ({} bytes in all)", &s[..end], s.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_back_as_written() { + let record = Record { + at: 1_800_000_000_000, + actor: Actor::account(3, "admin@example.com", None), + via: Some(Via::OAuth { + client: "inbuxa-admin".into(), + }), + remote_ip: Some("192.0.2.1".parse().unwrap()), + action: Action::Update, + target: Target { + kind: "x:Domain".into(), + id: Some("b".into()), + name: Some("example.com".into()), + ..Default::default() + }, + changes: vec![ + Change::new("isEnabled", Some(true.into()), Some(false.into())), + Change::redacted("secret"), + ], + details: None, + reason: Some("Ticket 42".into()), + outcome: Outcome::Pending, + }; + let json = serde_json::to_string(&record).unwrap(); + assert!(json.contains("\"kind\":\"oauth\"")); + let created = serde_json::to_string(&Outcome::Success { + created_id: Some("c".into()), + }) + .unwrap(); + assert_eq!(created, r#"{"status":"success","createdId":"c"}"#); + assert!(json.contains("\"redacted\":true")); + assert!(!json.contains("\"details\"")); + assert_eq!(serde_json::from_str::(&json).unwrap(), record); + } + + #[test] + fn long_values_are_cut() { + let long = "é".repeat(MAX_VALUE_LEN); + let Value::String(cut) = shorten(Value::String(long.clone())) else { + panic!() + }; + assert!(cut.len() < long.len()); + assert!(cut.ends_with(&format!("({} bytes in all)", long.len()))); + let array = Value::Array((0..2000).map(Value::from).collect()); + assert!(shorten(array).is_string()); + assert_eq!(shorten(Value::from(5)), Value::from(5)); + } + + #[test] + fn actions_round_trip() { + for action in [ + Action::Create, + Action::Update, + Action::Destroy, + Action::SignIn, + Action::SignInFailed, + Action::AccountAccess, + Action::BlobAccess, + Action::Export, + Action::Verify, + ] { + assert_eq!(Action::parse(action.as_str()), Some(action)); + assert_eq!( + serde_json::to_value(action).unwrap(), + Value::String(action.as_str().into()) + ); + } + } +} diff --git a/crates/features/src/audit/scope.rs b/crates/features/src/audit/scope.rs new file mode 100644 index 0000000..b52d3e8 --- /dev/null +++ b/crates/features/src/audit/scope.rs @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Who a registry write is for, carried with the task that makes it. +//! +//! A JMAP request records its own changes, with the actor and what was +//! asked (AU-1.1), so the registry's write hook stays quiet inside one. A +//! write outside any request is the server acting on its own (AU-1.10) and +//! is recorded by the hook, under the subsystem named here or as +//! `system:server` when none is. + +use std::future::Future; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + /// A request that records its own changes. + Request, + /// The server acting on its own, in the named subsystem. + System(&'static str), + /// Writes counted, not recorded one by one: a bulk update records one + /// summary itself (spam rules from an update, for one). + Quiet, +} + +tokio::task_local! { + static SCOPE: Scope; +} + +/// Runs `f` as a request that records its own changes. +pub async fn request(f: F) -> F::Output { + SCOPE.scope(Scope::Request, f).await +} + +/// Runs `f` as the server's own `subsystem`. +pub async fn system(subsystem: &'static str, f: F) -> F::Output { + SCOPE.scope(Scope::System(subsystem), f).await +} + +/// Runs `f` without recording its registry writes one by one. +pub async fn quiet(f: F) -> F::Output { + SCOPE.scope(Scope::Quiet, f).await +} + +/// The scope the current task runs in, if any. +pub fn current() -> Option { + SCOPE.try_with(|scope| *scope).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn nested_scopes() { + assert_eq!(current(), None); + system("acme", async { + assert_eq!(current(), Some(Scope::System("acme"))); + request(async { + assert_eq!(current(), Some(Scope::Request)); + }) + .await; + assert_eq!(current(), Some(Scope::System("acme"))); + }) + .await; + assert_eq!(current(), None); + } +} diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs index a4ee4a9..2d3ac72 100644 --- a/crates/features/src/lib.rs +++ b/crates/features/src/lib.rs @@ -19,6 +19,7 @@ //! `common::Server`. pub mod ai; +pub mod audit; pub mod branding; pub mod masked_email; pub mod security; diff --git a/crates/http/src/auth/authenticate.rs b/crates/http/src/auth/authenticate.rs index 5d9f271..e15d397 100644 --- a/crates/http/src/auth/authenticate.rs +++ b/crates/http/src/auth/authenticate.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use common::auth::AccessToken; @@ -36,7 +38,9 @@ impl Authenticator for Server { self.access_token(http_cache.account_id).await?, http_cache.credential_id, session.remote_ip, - )?; + )? + // inbuxa: AU-5 + .with_origin_arc(http_cache.origin.clone()); if access_token.revision() == http_cache.revision { // Enforce authenticated rate limit @@ -99,6 +103,7 @@ impl Authenticator for Server { credential_id: access_token.credential_id(), expires: Instant::now() + Duration::from_secs(self.core.oauth.oauth_expiry_token), + origin: access_token.origin_arc(), }, ); diff --git a/crates/http/src/auth/oauth/registration.rs b/crates/http/src/auth/oauth/registration.rs index c1c56f7..c14e3a7 100644 --- a/crates/http/src/auth/oauth/registration.rs +++ b/crates/http/src/auth/oauth/registration.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::ErrorType; @@ -164,9 +166,10 @@ impl ClientRegistrationHandler for Server { .await .caused_by(trc::location!())?; - let result = self - .registry() - .write(RegistryWrite::insert( + // inbuxa: AU-1.10: a client registering itself + let result = inbuxa_features::audit::scope::system( + "oauth-registration", + self.registry().write(RegistryWrite::insert( &OAuthClient { client_id: client_id.clone(), description: request.client_name.clone(), @@ -179,9 +182,10 @@ impl ClientRegistrationHandler for Server { ..Default::default() } .into(), - )) - .await - .caused_by(trc::location!())?; + )), + ) + .await + .caused_by(trc::location!())?; if !matches!(result, RegistryWriteResult::Success(_)) { return Err(trc::StoreEvent::UnexpectedError diff --git a/crates/http/src/auth/oauth/token.rs b/crates/http/src/auth/oauth/token.rs index 5b49980..08e9038 100644 --- a/crates/http/src/auth/oauth/token.rs +++ b/crates/http/src/auth/oauth/token.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ @@ -327,7 +329,8 @@ impl TokenHandler for Server { account_id, account_name, self.core.oauth.oauth_expiry_token, - None, + // inbuxa: AU-5: the token names the client it was issued to + Some(client_id), credential_version.into(), ) .await?, diff --git a/crates/jmap-proto/src/object/inbuxa_audit.rs b/crates/jmap-proto/src/object/inbuxa_audit.rs new file mode 100644 index 0000000..3df6e7f --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_audit.rs @@ -0,0 +1,353 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The audit log's JMAP objects under `urn:inbuxa:jmap` +//! (`inbuxa-drafts/specs/audit-hold-lock.md`, AU-9 to AU-11): +//! +//! - `inbuxa:AuditEvent/get` and `/query`: the records, read-only. +//! - `inbuxa:AuditSettings/get` and `/set`: how long records are kept. +//! - `inbuxa:AuditExport/set`: create one to get a file of the records a +//! filter matches. +//! - `inbuxa:AuditVerification/set`: create one to recheck every chain. +//! +//! They share one set of properties. Nested values (an event's actor, its +//! target and changes, an export's filter) are plain JSON objects. + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + request::deserialize::DeserializeArguments, +}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct AuditEvent; + +#[derive(Debug, Clone, Default)] +pub struct AuditSettings; + +#[derive(Debug, Clone, Default)] +pub struct AuditExport; + +#[derive(Debug, Clone, Default)] +pub struct AuditVerification; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AuditProperty { + Id, + // AuditEvent + At, + Node, + Actor, + Via, + RemoteIp, + Action, + Target, + Changes, + Details, + Reason, + Outcome, + // AuditSettings + KeepForDays, + // AuditExport + Format, + Filter, + BlobId, + Count, + Size, + Sha256, + // AuditVerification + Verified, + Chains, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AuditValue { + Id(Id), +} + +impl Property for AuditProperty { + fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option { + // Only the objects' own properties: keys inside a filter, an actor + // or a target stay plain keys + match parent { + None => AuditProperty::parse(value), + Some(_) => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + AuditProperty::Id => "id", + AuditProperty::At => "at", + AuditProperty::Node => "node", + AuditProperty::Actor => "actor", + AuditProperty::Via => "via", + AuditProperty::RemoteIp => "remoteIp", + AuditProperty::Action => "action", + AuditProperty::Target => "target", + AuditProperty::Changes => "changes", + AuditProperty::Details => "details", + AuditProperty::Reason => "reason", + AuditProperty::Outcome => "outcome", + AuditProperty::KeepForDays => "keepForDays", + AuditProperty::Format => "format", + AuditProperty::Filter => "filter", + AuditProperty::BlobId => "blobId", + AuditProperty::Count => "count", + AuditProperty::Size => "size", + AuditProperty::Sha256 => "sha256", + AuditProperty::Verified => "verified", + AuditProperty::Chains => "chains", + } + .into() + } +} + +impl AuditProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => AuditProperty::Id, + b"at" => AuditProperty::At, + b"node" => AuditProperty::Node, + b"actor" => AuditProperty::Actor, + b"via" => AuditProperty::Via, + b"remoteIp" => AuditProperty::RemoteIp, + b"action" => AuditProperty::Action, + b"target" => AuditProperty::Target, + b"changes" => AuditProperty::Changes, + b"details" => AuditProperty::Details, + b"reason" => AuditProperty::Reason, + b"outcome" => AuditProperty::Outcome, + b"keepForDays" => AuditProperty::KeepForDays, + b"format" => AuditProperty::Format, + b"filter" => AuditProperty::Filter, + b"blobId" => AuditProperty::BlobId, + b"count" => AuditProperty::Count, + b"size" => AuditProperty::Size, + b"sha256" => AuditProperty::Sha256, + b"verified" => AuditProperty::Verified, + b"chains" => AuditProperty::Chains, + ) + } +} + +impl FromStr for AuditProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + AuditProperty::parse(s).ok_or(()) + } +} + +impl Element for AuditValue { + type Property = AuditProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(AuditProperty::Id) => Id::from_str(value).ok().map(AuditValue::Id), + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + AuditValue::Id(id) => id.to_string().into(), + } + } +} + +/// One condition of an `inbuxa:AuditEvent/query` filter. Several in one +/// filter object must all hold. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuditFilter { + /// From this time on (UTC date). + After(String), + /// Before this time (UTC date). + Before(String), + ActorId(Id), + Action(String), + TargetKind(String), + TargetId(String), + AccountId(Id), + TenantId(Id), + Outcome(String), + RemoteIp(String), + Text(String), + _T(String), +} + +impl Default for AuditFilter { + fn default() -> Self { + AuditFilter::_T(String::new()) + } +} + +impl<'de> DeserializeArguments<'de> for AuditFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"after" => { + *self = AuditFilter::After(map.next_value()?); + }, + b"before" => { + *self = AuditFilter::Before(map.next_value()?); + }, + b"actorId" => { + *self = AuditFilter::ActorId(map.next_value()?); + }, + b"action" => { + *self = AuditFilter::Action(map.next_value()?); + }, + b"targetKind" => { + *self = AuditFilter::TargetKind(map.next_value()?); + }, + b"targetId" => { + *self = AuditFilter::TargetId(map.next_value()?); + }, + b"accountId" => { + *self = AuditFilter::AccountId(map.next_value()?); + }, + b"tenantId" => { + *self = AuditFilter::TenantId(map.next_value()?); + }, + b"outcome" => { + *self = AuditFilter::Outcome(map.next_value()?); + }, + b"remoteIp" => { + *self = AuditFilter::RemoteIp(map.next_value()?); + }, + b"text" => { + *self = AuditFilter::Text(map.next_value()?); + }, + _ => { + *self = AuditFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +/// Events sort newest first, by `at`; nothing else. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuditComparator { + At, + _T(String), +} + +impl Default for AuditComparator { + fn default() -> Self { + AuditComparator::_T(String::new()) + } +} + +impl<'de> DeserializeArguments<'de> for AuditComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + *self = if value == "at" { + AuditComparator::At + } else { + AuditComparator::_T(value.into_owned()) + }; + } else { + let _ = map.next_value::()?; + } + Ok(()) + } +} + +macro_rules! audit_object { + ($object:ty, $filter:ty, $comparator:ty) => { + impl JmapObject for $object { + type Property = AuditProperty; + + type Element = AuditValue; + + type Id = Id; + + type Filter = $filter; + + type Comparator = $comparator; + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = AuditProperty::Id; + } + }; +} + +audit_object!(AuditEvent, AuditFilter, AuditComparator); +audit_object!(AuditSettings, (), ()); +audit_object!(AuditExport, (), ()); +audit_object!(AuditVerification, (), ()); + +impl From for AuditValue { + fn from(id: Id) -> Self { + AuditValue::Id(id) + } +} + +impl JmapObjectId for AuditValue { + fn as_id(&self) -> Option { + match self { + AuditValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + AuditValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = AuditValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for AuditProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index 75c2c40..a7e26bc 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -22,6 +22,7 @@ pub mod email; pub mod email_submission; pub mod fastmail_masked_email; // inbuxa: masked email pub mod inbuxa_ai_limits; // inbuxa: AI spam classification +pub mod inbuxa_audit; // inbuxa: the audit log pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 7a62d74..736c8fa 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -61,6 +61,12 @@ impl Response<'_> { GetResponseMethod::AiLimits(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::AuditEvent(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::AuditSettings(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::ProtocolPolicy(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index eafd6bc..6026692 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -46,6 +46,8 @@ impl Response<'_> { GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?, GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?, GetRequestMethod::AiLimits(request) => request.resolve_references(self)?, + GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?, + GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?, GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?, GetRequestMethod::TenantProtocolPolicy(request) => { request.resolve_references(self)? @@ -96,6 +98,15 @@ impl Response<'_> { SetRequestMethod::Explanation(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::AuditSettings(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::AuditExport(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::AuditVerification(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::ProtocolPolicy(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index ebe2ec5..f47c1bf 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -51,6 +51,11 @@ pub enum MethodObject { AiLimits, // inbuxa: "Explain this" with the local model Explanation, + // inbuxa: the audit log + AuditEvent, + AuditSettings, + AuditExport, + AuditVerification, ProtocolPolicy, TenantProtocolPolicy, } @@ -80,6 +85,10 @@ impl MethodObject { MethodObject::DeletedAccount => Capability::Inbuxa, MethodObject::AiLimits => Capability::Inbuxa, MethodObject::Explanation => Capability::Inbuxa, + MethodObject::AuditEvent + | MethodObject::AuditSettings + | MethodObject::AuditExport + | MethodObject::AuditVerification => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa, } @@ -260,6 +269,14 @@ impl MethodName { (MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get", (MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set", (MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set", + (MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get", + (MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query", + (MethodFunction::Get, MethodObject::AuditSettings) => "inbuxa:AuditSettings/get", + (MethodFunction::Set, MethodObject::AuditSettings) => "inbuxa:AuditSettings/set", + (MethodFunction::Set, MethodObject::AuditExport) => "inbuxa:AuditExport/set", + (MethodFunction::Set, MethodObject::AuditVerification) => { + "inbuxa:AuditVerification/set" + } (MethodFunction::Get, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/get", (MethodFunction::Set, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/set", (MethodFunction::Get, MethodObject::TenantProtocolPolicy) => { @@ -394,6 +411,12 @@ impl MethodName { "inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get), "inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set), "inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set), + "inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get), + "inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query), + "inbuxa:AuditSettings/get" => (MethodObject::AuditSettings, MethodFunction::Get), + "inbuxa:AuditSettings/set" => (MethodObject::AuditSettings, MethodFunction::Set), + "inbuxa:AuditExport/set" => (MethodObject::AuditExport, MethodFunction::Set), + "inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set), "inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get), "inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set), "inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get), @@ -452,6 +475,10 @@ impl Display for MethodObject { MethodObject::DeletedAccount => "inbuxa:DeletedAccount", MethodObject::AiLimits => "inbuxa:AiLimits", MethodObject::Explanation => "inbuxa:Explanation", + MethodObject::AuditEvent => "inbuxa:AuditEvent", + MethodObject::AuditSettings => "inbuxa:AuditSettings", + MethodObject::AuditExport => "inbuxa:AuditExport", + MethodObject::AuditVerification => "inbuxa:AuditVerification", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", MethodObject::Registry(obj) => { diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 00078f5..6325cbf 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -116,6 +116,8 @@ pub enum GetRequestMethod { MaskedEmail(Box>), DeletedAccount(Box>), AiLimits(Box>), + AuditEvent(Box>), + AuditSettings(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( Box>, @@ -144,6 +146,9 @@ pub enum SetRequestMethod<'x> { DeletedAccount(Box>), AiLimits(Box>), Explanation(Box>), + AuditSettings(Box>), + AuditExport(Box>), + AuditVerification(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( Box>, @@ -175,6 +180,7 @@ pub enum QueryRequestMethod { CalendarEventNotification(Box>), ShareNotification(Box>), Registry(Box>), + AuditEvent(Box>), } #[derive(Debug)] diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index cffb11b..b7e06fe 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -551,6 +551,49 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + // inbuxa: the audit log + (MethodFunction::Get, MethodObject::AuditEvent) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AuditEvent(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::AuditEvent) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::AuditEvent(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::AuditSettings) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AuditSettings(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::AuditSettings) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::AuditSettings(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::AuditExport) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::AuditExport(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::AuditVerification) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::AuditVerification(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Query, MethodObject::Registry(_)) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Registry(value)), Err(err) => RequestMethod::invalid(err), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 2e49110..014979c 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -103,6 +103,8 @@ pub enum GetResponseMethod { MaskedEmail(GetResponse), DeletedAccount(GetResponse), AiLimits(GetResponse), + AuditEvent(GetResponse), + AuditSettings(GetResponse), ProtocolPolicy(GetResponse), TenantProtocolPolicy( GetResponse, @@ -131,6 +133,9 @@ pub enum SetResponseMethod { MaskedEmail(Box>), DeletedAccount(Box>), AiLimits(Box>), + AuditSettings(Box>), + AuditExport(Box>), + AuditVerification(Box>), Explanation(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( @@ -714,3 +719,34 @@ impl From> for ResponseMethod<'_> { ))) } } + +// inbuxa: the audit log +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::AuditEvent(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::AuditSettings(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::AuditSettings(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::AuditExport(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::AuditVerification(Box::new(value))) + } +} diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index f65140e..93b63d0 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -77,6 +77,10 @@ impl JmapAuthorization for AccessToken { GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet, // inbuxa: AI call limits, with the classifier's permissions GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet, + // inbuxa: the audit log (AU-9) + GetRequestMethod::AuditEvent(_) | GetRequestMethod::AuditSettings(_) => { + Permission::SysAuditGet + } // inbuxa: legacy protocols off. It takes listeners away and // puts them back, so it takes the listener's permissions GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, @@ -180,6 +184,28 @@ impl JmapAuthorization for AccessToken { Permission::SysSpamLlmUpdate, Permission::SysSpamLlmUpdate, ), + // inbuxa: the audit log (AU-7, AU-9, AU-11) + SetRequestMethod::AuditSettings(s) => validate_set( + s, + self, + Permission::SysAuditSettingsUpdate, + Permission::SysAuditSettingsUpdate, + Permission::SysAuditSettingsUpdate, + ), + SetRequestMethod::AuditExport(s) => validate_set( + s, + self, + Permission::SysAuditExport, + Permission::SysAuditExport, + Permission::SysAuditExport, + ), + SetRequestMethod::AuditVerification(s) => validate_set( + s, + self, + Permission::SysAuditGet, + Permission::SysAuditGet, + Permission::SysAuditGet, + ), // inbuxa: "Explain this" (EX-4) SetRequestMethod::Explanation(s) => validate_set( s, @@ -315,6 +341,10 @@ impl JmapAuthorization for AccessToken { | MethodObject::DeletedAccount | MethodObject::AiLimits | MethodObject::Explanation + | MethodObject::AuditEvent + | MethodObject::AuditSettings + | MethodObject::AuditExport + | MethodObject::AuditVerification | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads @@ -371,6 +401,8 @@ impl JmapAuthorization for AccessToken { Permission::JmapCalendarEventNotificationQuery } QueryRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationQuery, + // inbuxa: the audit log (AU-9) + QueryRequestMethod::AuditEvent(_) => Permission::SysAuditGet, QueryRequestMethod::Registry(_) => { let MethodObject::Registry(object_type) = object else { unreachable!() diff --git a/crates/jmap/src/api/mod.rs b/crates/jmap/src/api/mod.rs index 2e61190..07204e0 100644 --- a/crates/jmap/src/api/mod.rs +++ b/crates/jmap/src/api/mod.rs @@ -188,10 +188,13 @@ impl ToRequestError for trc::Error { trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => { RequestError::forbidden() } - // inbuxa: legacy-protocols LP-8 is an event, never an error + // inbuxa: legacy-protocols LP-8 is an event, never an error; + // a failed audit write refuses the change (AU-3) trc::SecurityEvent::IpBlockExpired | trc::SecurityEvent::IpAllowExpired - | trc::SecurityEvent::LegacyProtocolsChanged => { + | trc::SecurityEvent::LegacyProtocolsChanged + | trc::SecurityEvent::AuditRecorded + | trc::SecurityEvent::AuditWriteFailed => { RequestError::internal_server_error() } }, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 4fedc23..4915b2c 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -161,13 +161,16 @@ impl RequestHandler for Server { }, _ => None, }; - let method_call = self.handle_method_call( - call.method, - call.name, - access_token, - &mut next_call, - session, - ); + // inbuxa: AU-1.6: which accounts it reached by impersonation + let method_call = crate::inbuxa::audit::collect_access(Box::pin( + self.handle_method_call( + call.method, + call.name, + access_token, + &mut next_call, + session, + ), + )); let result = if eligible { store::backend::scaleout::replica::replica_read( access_token.all_ids().map(|account_id| { @@ -184,6 +187,10 @@ impl RequestHandler for Server { } else { method_call.await }; + let (result, reached) = result; + for account_id in reached { + self.audit_foreign_access(access_token, account_id, false).await; + } match result { Ok(mut method_response) => { @@ -221,6 +228,15 @@ impl RequestHandler for Server { SetResponseMethod::AiLimits(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::AuditSettings(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::AuditExport(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::AuditVerification(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::Explanation(set_response) => { set_response.update_created_ids(&mut response); } @@ -385,6 +401,19 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: the audit log (AU-9) + GetRequestMethod::AuditEvent(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::audit_log::event_get(self, access_token, *req) + .await? + .into() + } + GetRequestMethod::AuditSettings(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::audit_log::settings_get(self, *req) + .await? + .into() + } // inbuxa: inbuxa:ProtocolPolicy/get (legacy protocols off) GetRequestMethod::ProtocolPolicy(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -560,6 +589,13 @@ impl RequestHandler for Server { self.share_notification_query(*req).await?.into() } + // inbuxa: the audit log (AU-9) + QueryRequestMethod::AuditEvent(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::audit_log::event_query(self, access_token, *req) + .await? + .into() + } QueryRequestMethod::Registry(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; assert_registry_account(self, method_name.obj, access_token, req.account_id) @@ -622,21 +658,75 @@ impl RequestHandler for Server { // inbuxa: Fastmail's MaskedEmail/set SetRequestMethod::MaskedEmail(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; - crate::inbuxa::fastmail::set(self, access_token, *req) - .await? - .into() + // inbuxa: AU-1.2, AU-3 + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + *req, + |req| Box::pin(crate::inbuxa::fastmail::set(self, access_token, req)), + ) + .await? + .into() } // inbuxa: inbuxa:DeletedAccount/set (UD-17) SetRequestMethod::DeletedAccount(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; - crate::inbuxa::deleted_account::set(self, access_token, *req) - .await? - .into() + // inbuxa: AU-1.2, AU-3 + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + *req, + |req| Box::pin(crate::inbuxa::deleted_account::set(self, access_token, req)), + ) + .await? + .into() } // inbuxa: inbuxa:AiLimits/set SetRequestMethod::AiLimits(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; - crate::inbuxa::ai_limits::set(self, access_token, *req) + // inbuxa: AU-1.2, AU-3 + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + *req, + |req| Box::pin(crate::inbuxa::ai_limits::set(self, access_token, req)), + ) + .await? + .into() + } + // inbuxa: the audit log (AU-7, AU-11, AU-6) + SetRequestMethod::AuditSettings(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + *req, + |req| Box::pin(crate::inbuxa::audit_log::settings_set(self, access_token, req)), + ) + .await? + .into() + } + SetRequestMethod::AuditExport(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::audit_log::export_set(self, access_token, session, *req) + .await? + .into() + } + SetRequestMethod::AuditVerification(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::audit_log::verification_set(self, access_token, session, *req) .await? .into() } @@ -650,16 +740,34 @@ impl RequestHandler for Server { // inbuxa: inbuxa:ProtocolPolicy/set (legacy protocols off) SetRequestMethod::ProtocolPolicy(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; - crate::inbuxa::protocol_policy::set(self, access_token, *req) - .await? - .into() + // inbuxa: AU-1.2, AU-3 + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + *req, + |req| Box::pin(crate::inbuxa::protocol_policy::set(self, access_token, req)), + ) + .await? + .into() } // inbuxa: inbuxa:TenantProtocolPolicy/set (legacy protocols off, per tenant) SetRequestMethod::TenantProtocolPolicy(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; - crate::inbuxa::tenant_protocol_policy::set(self, access_token, *req) - .await? - .into() + // inbuxa: AU-1.2, AU-3 + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + *req, + |req| Box::pin(crate::inbuxa::tenant_protocol_policy::set(self, access_token, req)), + ) + .await? + .into() } SetRequestMethod::AddressBook(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -724,12 +832,17 @@ impl RequestHandler for Server { assert_registry_account(self, method_name.obj, access_token, req.account_id) .await?; - Box::pin(self.registry_set( - method_name.obj.unwrap_registry(), - *req, + // inbuxa: AU-1.1, AU-3: recorded before and after + let object_type = method_name.obj.unwrap_registry(); + crate::inbuxa::audit::recorded( + self, access_token, session, - )) + &method_name.obj.to_string(), + Some(object_type), + *req, + |req| Box::pin(self.registry_set(object_type, req, access_token, session)), + ) .await? .into() } @@ -906,6 +1019,8 @@ pub(crate) fn resolve_account_id( access_token: &AccessToken, ) -> trc::Result<()> { if account_id.id() < INVALID_ACCOUNT_ID { + // inbuxa: AU-1.6 + crate::inbuxa::audit::note_access(account_id.document_id(), access_token); Ok(()) } else if matches!( obj, diff --git a/crates/jmap/src/blob/download.rs b/crates/jmap/src/blob/download.rs index 741aa9a..ae8e822 100644 --- a/crates/jmap/src/blob/download.rs +++ b/crates/jmap/src/blob/download.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use common::{Server, auth::AccessToken}; @@ -115,6 +117,9 @@ impl BlobDownload for Server { document_id, } => { if access_token.is_member(*account_id) { + // inbuxa: AU-1.6: another account's blob + self.audit_foreign_access(access_token, *account_id, true) + .await; true } else { match Collection::from(*collection) { diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index c8fd7aa..5b3389e 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -419,6 +419,10 @@ impl IntermediateChangesResponse { | MethodObject::DeletedAccount | MethodObject::AiLimits | MethodObject::Explanation + | MethodObject::AuditEvent + | MethodObject::AuditSettings + | MethodObject::AuditExport + | MethodObject::AuditVerification | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy | MethodObject::Registry(_) => unreachable!(), diff --git a/crates/jmap/src/inbuxa/audit.rs b/crates/jmap/src/inbuxa/audit.rs new file mode 100644 index 0000000..de0be04 --- /dev/null +++ b/crates/jmap/src/inbuxa/audit.rs @@ -0,0 +1,412 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The audit log's request layer (audit-hold-lock spec, AU-1.1 to AU-1.3, +//! AU-3). Before a set method changes anything, one pending record per +//! requested create, update and destroy is written, with what was asked +//! and, for registry objects, what each changed place held before. If that +//! write fails, nothing is changed. After the method, each record's outcome +//! follows. The method runs in a request scope, so the registry's write hook +//! doesn't record the same writes again. + +use common::{Server, auth::AccessToken}; +use http_proto::HttpSessionData; +use inbuxa_features::audit::{Action, EntryId, Outcome, Record, Target, diff, scope}; +use jmap_proto::{ + error::set::SetError, + method::set::{SetRequest, SetResponse}, + object::JmapObject, + request::{MaybeInvalid, reference::MaybeResultReference}, +}; +use registry::schema::enums::Permission; +use registry::{ + schema::prelude::{OBJ_FILTER_ACCOUNT, OBJ_SINGLETON, ObjectType}, + types::id::ObjectId, +}; +use serde_json::Value; +use std::{cell::RefCell, future::Future}; +use types::id::Id; + +tokio::task_local! { + /// Accounts a method call reached through impersonation (AU-1.6). + static REACHED: RefCell>; +} + +/// Runs one method call, collecting the accounts it reached through +/// `Impersonate` rather than as the caller's own, a group's or a share. +pub async fn collect_access(f: F) -> (F::Output, Vec) { + REACHED + .scope(RefCell::new(Vec::new()), async { + let output = f.await; + let reached = REACHED.with(|reached| std::mem::take(&mut *reached.borrow_mut())); + (output, reached) + }) + .await +} + +/// Notes an account a method call is about to reach (AU-1.6). +pub fn note_access(account_id: u32, access_token: &AccessToken) { + if !access_token.is_member_directly(account_id) + && access_token.has_permission(Permission::Impersonate) + { + let _ = REACHED.try_with(|reached| { + let mut reached = reached.borrow_mut(); + if !reached.contains(&account_id) { + reached.push(account_id); + } + }); + } +} + +enum Item { + Create(String), + Update(MaybeInvalid), + Destroy(MaybeInvalid), +} + +/// The pending records written for one set method. +pub struct Pending { + items: Vec<(Item, EntryId)>, +} + +fn ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) +} + +/// Whether a set on this object isn't recorded: content a user manages for +/// themselves, which isn't the control plane. +pub fn is_exempt(object: &str, account_id: Id, access_token: &AccessToken) -> bool { + let own = account_id.document_id() == access_token.account_id(); + match object { + // Spam training is mail handling, and can come with every message + "x:SpamTrainingSample" => true, + // A user's own masks and archive are their own business; an + // administrator reaching someone else's is recorded + "x:MaskedEmail" | "MaskedEmail" | "x:ArchivedItem" => own, + _ => false, + } +} + +/// `before`, boxed in a frame of its own (see `recorded`). +fn before_boxed<'a, T: JmapObject>( + server: &'a Server, + access_token: &'a AccessToken, + session: &'a HttpSessionData, + object: &'a str, + registry: Option, + request: &'a SetRequest<'_, T>, +) -> std::pin::Pin> + Send + 'a>> { + Box::pin(before( + server, + access_token, + session, + object, + registry, + request, + )) +} + +/// Runs a set method with its requested changes recorded first and its +/// outcomes after (AU-3). `method` returns its future already boxed, so +/// this frame and the scope around it hold a pointer, not the method's +/// state. +pub async fn recorded<'x, T, F, Fut>( + server: &Server, + access_token: &AccessToken, + session: &HttpSessionData, + object: &str, + registry: Option, + request: SetRequest<'x, T>, + method: F, +) -> trc::Result> +where + T: JmapObject, + F: FnOnce(SetRequest<'x, T>) -> std::pin::Pin>, + Fut: Future>> + ?Sized, +{ + if is_exempt(object, request.account_id, access_token) { + return method(request).await; + } + // Every inner future is boxed where it's made, never held in this + // frame: a debug build's stack can't take a copy of registry_set's + // state on top of the request's own + let pending = before_boxed(server, access_token, session, object, registry, &request).await?; + let result = scope::request(method(request)).await; + after(server, pending, &result).await; + result +} + +async fn before( + server: &Server, + access_token: &AccessToken, + session: &HttpSessionData, + object: &str, + registry: Option, + request: &SetRequest<'_, T>, +) -> trc::Result { + let actor = server.audit_actor(access_token).await; + let via = access_token.origin().cloned(); + // The request's account is the target's only for objects that belong + // to an account; a domain created by an administrator isn't theirs + let account_id = registry + .is_none_or(|object_type| object_type.flags() & OBJ_FILTER_ACCOUNT != 0) + .then(|| request.account_id.document_id()); + let mut records = Vec::new(); + + for (client_id, value) in request.create.iter().flat_map(|c| c.iter()) { + let after = serde_json::to_value(value).unwrap_or_default(); + let described = diff::describe(&after); + let changes = after + .as_object() + .map(|patch| diff::patch(object, None, patch)) + .unwrap_or_default(); + records.push(( + Item::Create(client_id.clone()), + Action::Create, + Target { + kind: object.to_string(), + id: None, + name: described.name, + account_id: described.account_id.or(account_id), + tenant_id: described.tenant_id.or(access_token.tenant_id()), + }, + changes, + )); + } + + for (id, value) in request.update.iter().flat_map(|u| u.iter()) { + let before = match registry { + Some(_) => stored(server, registry, id).await, + None => fork_current(server, object, id).await, + }; + let patch = serde_json::to_value(value).unwrap_or_default(); + let described = before.as_ref().map(diff::describe).unwrap_or_default(); + let changes = patch + .as_object() + .map(|patch| diff::patch(object, before.as_ref(), patch)) + .unwrap_or_default(); + records.push(( + Item::Update(id.clone()), + Action::Update, + Target { + kind: object.to_string(), + id: Some(id_text(id)), + name: described.name, + account_id: described.account_id.or(account_id), + tenant_id: described.tenant_id.or(access_token.tenant_id()), + }, + changes, + )); + } + + if let Some(MaybeResultReference::Value(destroy)) = &request.destroy { + for id in destroy { + let before = stored(server, registry, id).await; + let described = before.as_ref().map(diff::describe).unwrap_or_default(); + records.push(( + Item::Destroy(id.clone()), + Action::Destroy, + Target { + kind: object.to_string(), + id: Some(id_text(id)), + name: described.name, + account_id: described.account_id.or(account_id), + tenant_id: described.tenant_id.or(access_token.tenant_id()), + }, + vec![], + )); + } + } + + let mut pending = Pending { + items: Vec::with_capacity(records.len()), + }; + for (item, action, target, changes) in records { + let record = Record { + at: ms(), + actor: actor.clone(), + via: via.clone(), + remote_ip: Some(session.remote_ip), + action, + target, + changes, + details: None, + reason: None, + outcome: Outcome::Pending, + }; + match server.audit_append(&record).await { + Ok(entry) => pending.items.push((item, entry)), + Err(err) => { + // Nothing is changed: the records already written say so + for (_, entry) in pending.items { + let _ = server + .audit_finish( + entry, + Outcome::refused( + "serverFail", + Some("The audit log couldn't be written.".into()), + ), + ) + .await; + } + return Err( + err.details("The audit log couldn't be written, so nothing was changed.") + ); + } + } + } + Ok(pending) +} + +async fn after( + server: &Server, + pending: Pending, + result: &trc::Result>, +) { + for (item, entry) in pending.items { + let outcome = match result { + Err(err) => Outcome::refused( + "serverFail", + err.value_as_str(trc::Key::Details).map(str::to_string), + ), + Ok(response) => outcome(response, &item), + }; + // The change is done: a failure here is reported, and the record + // stays pending, which verify counts (AU-6) + let _ = server.audit_finish(entry, outcome).await; + } +} + +fn outcome(response: &SetResponse, item: &Item) -> Outcome { + let refused = |err: &SetError| { + Outcome::refused( + err.error_type().as_str(), + err.description().map(str::to_string), + ) + }; + match item { + Item::Create(client_id) => { + if let Some(created) = response.created.get(client_id) { + Outcome::Success { + created_id: serde_json::to_value(created) + .ok() + .and_then(|v| v.get("id").and_then(Value::as_str).map(str::to_string)), + } + } else if let Some(err) = response.not_created.get(client_id) { + refused(err) + } else { + Outcome::refused("notProcessed", None) + } + } + Item::Update(id) => { + if let MaybeInvalid::Value(id) = id + && response.updated.contains_key(id) + { + Outcome::success() + } else if let Some(err) = response.not_updated.get(id) { + refused(err) + } else { + Outcome::refused("notProcessed", None) + } + } + Item::Destroy(id) => { + if let MaybeInvalid::Value(id) = id + && response.destroyed.contains(id) + { + Outcome::success() + } else if let Some(err) = response.not_destroyed.get(id) { + refused(err) + } else { + Outcome::refused("notProcessed", None) + } + } + } +} + +fn id_text(id: &MaybeInvalid) -> String { + match id { + MaybeInvalid::Value(id) => id.to_string(), + MaybeInvalid::Invalid(text) => text.chars().take(100).collect(), + } +} + +/// The fork's own settings as they are now, as JSON, so their changes are +/// recorded with what they replaced. Their stored names are the JMAP +/// property names. +async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid) -> Option { + use inbuxa_features::{ai::limits, audit::log, security}; + let data = server.store(); + match object { + "inbuxa:AuditSettings" => log::settings(data) + .await + .ok() + .map(|settings| serde_json::json!({"keepForDays": settings.keep_for_secs / 86_400})), + "inbuxa:AiLimits" => limits::get(data) + .await + .ok() + .and_then(|limits| serde_json::to_value(limits).ok()), + "inbuxa:ProtocolPolicy" => security::protocol_policy::get(data) + .await + .ok() + .and_then(|policy| serde_json::to_value(policy).ok()), + "inbuxa:TenantProtocolPolicy" => match id { + MaybeInvalid::Value(id) => { + security::tenant_protocol_policy::get(data, id.document_id()) + .await + .ok() + .and_then(|policy| serde_json::to_value(policy).ok()) + } + MaybeInvalid::Invalid(_) => None, + }, + _ => None, + } +} + +/// A registry object as it is now, as JSON: what an update or destroy +/// starts from. A singleton never saved holds its defaults. +async fn stored( + server: &Server, + registry: Option, + id: &MaybeInvalid, +) -> Option { + let (Some(object_type), MaybeInvalid::Value(id)) = (registry, id) else { + return None; + }; + let object = match server + .registry() + .get(ObjectId::new(object_type, *id)) + .await + .ok()? + { + Some(object) => object, + None if id.is_singleton() && object_type.flags() & OBJ_SINGLETON != 0 => { + registry::schema::prelude::Object::from(object_type) + } + None => return None, + }; + serde_json::to_value(registry::jmap::IntoValue::into_value(object)).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_content_is_exempt() { + let token = AccessToken::from_permissions(5, []); + let own = Id::from(5u32); + let other = Id::from(6u32); + assert!(is_exempt("x:SpamTrainingSample", other, &token)); + assert!(is_exempt("x:MaskedEmail", own, &token)); + assert!(!is_exempt("x:MaskedEmail", other, &token)); + assert!(is_exempt("x:ArchivedItem", own, &token)); + assert!(!is_exempt("x:ArchivedItem", other, &token)); + assert!(!is_exempt("x:Domain", own, &token)); + assert!(!is_exempt("x:AppPassword", own, &token)); + } +} diff --git a/crates/jmap/src/inbuxa/audit_log.rs b/crates/jmap/src/inbuxa/audit_log.rs new file mode 100644 index 0000000..a3e1352 --- /dev/null +++ b/crates/jmap/src/inbuxa/audit_log.rs @@ -0,0 +1,864 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The audit log over JMAP (audit-hold-lock spec, AU-6, AU-7, AU-9 to +//! AU-11): reading records, the retention setting, exports and +//! verification. Tenant administrators see only records whose actor or +//! target is in their tenant; retention and verification are the server's. + +use common::{Server, auth::AccessToken}; +use http_proto::HttpSessionData; +use inbuxa_features::audit::{ + Action, EntryId, Outcome, Record, Target, + log::{self, ChainReport, Filter, MIN_KEEP_FOR_SECS, Settings}, +}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + query::{Filter as QueryFilter, QueryRequest, QueryResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_audit::{ + AuditEvent, AuditExport, AuditFilter, AuditProperty as P, AuditSettings, AuditValue, + AuditVerification, + }, + request::IntoValid, + types::{date::UTCDate, state::State}, +}; +use jmap_tools::{Key, Map, Value}; +use sha2::{Digest, Sha256}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +type AValue = Value<'static, P, AuditValue>; + +/// Most records one export holds. +const MAX_EXPORT: usize = 100_000; + +const EVENT_PROPERTIES: &[P] = &[ + P::Id, + P::At, + P::Node, + P::Actor, + P::Via, + P::RemoteIp, + P::Action, + P::Target, + P::Changes, + P::Details, + P::Reason, + P::Outcome, +]; + +fn ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) +} + +/// A record's time, to the millisecond, in RFC 3339. +fn iso(at_ms: u64) -> String { + let date = UTCDate::from_timestamp((at_ms / 1000) as i64).to_string(); + // `2026-09-27T10:00:00Z` becomes `2026-09-27T10:00:00.123Z` + match date.strip_suffix('Z') { + Some(date) => format!("{date}.{:03}Z", at_ms % 1000), + None => date, + } +} + +fn json_to_value(json: serde_json::Value) -> AValue { + match json { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(b) => Value::Bool(b), + serde_json::Value::Number(n) => { + if let Some(n) = n.as_u64() { + Value::Number(n.into()) + } else if let Some(n) = n.as_i64() { + Value::Number(n.into()) + } else { + Value::Number(n.as_f64().unwrap_or_default().into()) + } + } + serde_json::Value::String(s) => Value::Str(Cow::Owned(s)), + serde_json::Value::Array(items) => { + Value::Array(items.into_iter().map(json_to_value).collect()) + } + serde_json::Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + for (key, value) in map { + out.insert_unchecked(Key::Owned(key), json_to_value(value)); + } + Value::Object(out) + } + } +} + +fn to_json(value: &T) -> serde_json::Value { + serde_json::to_value(value).unwrap_or_default() +} + +/// Account and tenant ids as JMAP ids, not the numbers they're stored as. +fn with_jmap_ids(mut value: serde_json::Value) -> serde_json::Value { + if let Some(map) = value.as_object_mut() { + for key in ["accountId", "tenantId"] { + if let Some(id) = map.get(key).and_then(serde_json::Value::as_u64) { + map.insert(key.into(), Id::from(id as u32).to_string().into()); + } + } + } + value +} + +/// One record as a JMAP object. +fn event_value(id: EntryId, record: &Record, properties: &[P]) -> AValue { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(AuditValue::Id(Id::new(id.to_u64()))), + P::At => Value::Str(iso(record.at).into()), + P::Node => Value::Number(id.node.into()), + P::Actor => json_to_value(with_jmap_ids(to_json(&record.actor))), + P::Via => record.via.as_ref().map_or(Value::Null, |via| { + json_to_value(with_jmap_ids(to_json(via))) + }), + P::RemoteIp => record + .remote_ip + .map_or(Value::Null, |ip| Value::Str(ip.to_string().into())), + P::Action => Value::Str(record.action.as_str().into()), + P::Target => json_to_value(with_jmap_ids(to_json(&record.target))), + P::Changes => json_to_value(to_json(&record.changes)), + P::Details => record + .details + .as_ref() + .map_or(Value::Null, |d| Value::Str(d.clone().into())), + P::Reason => record + .reason + .as_ref() + .map_or(Value::Null, |r| Value::Str(r.clone().into())), + P::Outcome => json_to_value(to_json(&record.outcome)), + _ => continue, + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// The tenant a caller's view is limited to (AU-9). +fn view_tenant(access_token: &AccessToken) -> Option { + access_token.tenant_id() +} + +fn server_level(access_token: &AccessToken) -> trc::Result<()> { + if access_token.tenant_id().is_some() { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("This is for server administrators.")) + } else { + Ok(()) + } +} + +/// `inbuxa:AuditEvent/get`. +pub async fn event_get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + let properties = request.unwrap_properties(EVENT_PROPERTIES); + let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let Some(ids) = ids else { + return Err(trc::JmapEvent::RequestTooLarge + .into_err() + .details("Name the records to get; use inbuxa:AuditEvent/query to find them.")); + }; + let tenant = view_tenant(access_token); + for id in ids { + let entry = EntryId::from_u64(id.id()); + match log::get(server.store(), entry).await? { + Some(record) if tenant.is_none_or(|tenant| log::in_tenant(&record, tenant)) => { + response.list.push(event_value(entry, &record, &properties)); + } + _ => response.push_not_found(id), + } + } + Ok(response) +} + +fn date_ms(value: &str) -> Result { + UTCDate::from_str(value) + .map(|date| date.timestamp().max(0) as u64 * 1000) + .map_err(|_| format!("{value} isn't a UTC date.")) +} + +/// The conditions of a query filter, all of which must hold. `Or` and +/// `Not` aren't supported. +fn build_filter(conditions: Vec>) -> trc::Result { + let unsupported = |why: String| trc::JmapEvent::UnsupportedFilter.into_err().details(why); + let mut filter = Filter::default(); + for condition in conditions { + match condition { + QueryFilter::Property(condition) => match condition { + AuditFilter::After(date) => { + filter.after = Some(date_ms(&date).map_err(unsupported)?) + } + AuditFilter::Before(date) => { + filter.before = Some(date_ms(&date).map_err(unsupported)?) + } + AuditFilter::ActorId(id) => filter.actor_id = Some(id.document_id()), + AuditFilter::Action(action) => { + filter.action = + Some(Action::parse(&action).ok_or_else(|| { + unsupported(format!("{action} isn't an audit action.")) + })?) + } + AuditFilter::TargetKind(kind) => filter.target_kind = Some(kind), + AuditFilter::TargetId(id) => filter.target_id = Some(id), + AuditFilter::AccountId(id) => filter.account_id = Some(id.document_id()), + AuditFilter::TenantId(id) => filter.tenant_id = Some(id.document_id()), + AuditFilter::Outcome(outcome) => filter.outcome = Some(outcome), + AuditFilter::RemoteIp(ip) => { + filter.remote_ip = Some( + ip.parse() + .map_err(|_| unsupported(format!("{ip} isn't an IP address.")))?, + ) + } + AuditFilter::Text(text) => filter.text = Some(text), + AuditFilter::_T(other) => { + return Err(unsupported(format!("Unknown filter property {other}."))); + } + }, + QueryFilter::And | QueryFilter::Close => {} + QueryFilter::Or | QueryFilter::Not => { + return Err(unsupported( + "Audit queries take conditions that must all hold; OR and NOT aren't supported." + .into(), + )); + } + } + } + Ok(filter) +} + +/// Applies the caller's reach: a tenant administrator sees its tenant only. +fn scoped(mut filter: Filter, access_token: &AccessToken) -> Option { + if let Some(tenant) = view_tenant(access_token) { + match filter.tenant_id { + Some(asked) if asked != tenant => return None, + _ => filter.tenant_id = Some(tenant), + } + } + Some(filter) +} + +/// `inbuxa:AuditEvent/query`: newest first. +pub async fn event_query( + server: &Server, + access_token: &AccessToken, + request: QueryRequest, +) -> trc::Result { + let filter = build_filter(request.filter)?; + let position = request.position.unwrap_or(0); + if position < 0 || request.anchor.is_some() { + return Err(trc::JmapEvent::UnsupportedFilter + .into_err() + .details("Audit queries page by a position from the start.")); + } + let limit = request + .limit + .unwrap_or(log::MAX_QUERY_LIMIT) + .min(log::MAX_QUERY_LIMIT); + let count_all = request.calculate_total.unwrap_or(false); + let (ids, total) = match scoped(filter, access_token) { + Some(filter) => { + log::query(server.store(), &filter, position as usize, limit, count_all).await? + } + None => (Vec::new(), 0), + }; + Ok(QueryResponse { + account_id: request.account_id, + query_state: State::Initial, + can_calculate_changes: false, + position, + ids: ids.into_iter().map(|id| Id::new(id.to_u64())).collect(), + total: count_all.then_some(total), + limit: Some(limit), + }) +} + +fn settings_value(settings: &Settings, properties: &[P]) -> Value<'static, P, AuditValue> { + let mut out = Map::with_capacity(2); + for property in properties { + let value = match property { + P::Id => Value::Element(AuditValue::Id(Id::singleton())), + P::KeepForDays => Value::Number((settings.keep_for_secs / 86_400).into()), + _ => continue, + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// `inbuxa:AuditSettings/get`: a singleton. +pub async fn settings_get( + server: &Server, + mut request: GetRequest, +) -> trc::Result> { + let properties = request.unwrap_properties(&[P::Id, P::KeepForDays]); + let (ids, not_found) = request.unwrap_ids(1)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let settings = log::settings(server.store()).await?; + match ids { + None => response.list.push(settings_value(&settings, &properties)), + Some(ids) => { + for id in ids { + if id.is_singleton() { + response.list.push(settings_value(&settings, &properties)); + } else { + response.push_not_found(id); + } + } + } + } + Ok(response) +} + +/// `inbuxa:AuditSettings/set`: update `keepForDays` on the singleton +/// (AU-7). The request layer records the change. +pub async fn settings_set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, AuditSettings>, +) -> trc::Result> { + server_level(access_token)?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + for (client_id, _) in request.unwrap_create() { + response + .not_created + .append(client_id, SetError::singleton()); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append(id, SetError::singleton()); + } + for (id, value) in request.unwrap_update().into_valid() { + if !id.is_singleton() { + response.not_updated.append(id, SetError::not_found()); + continue; + } + let mut settings = log::settings(server.store()).await?; + let mut error = None; + for (key, value) in value.into_expanded_object() { + match (&key, value) { + (Key::Property(P::KeepForDays), Value::Number(days)) => { + let secs = days.cast_to_u64().saturating_mul(86_400); + if secs < MIN_KEEP_FOR_SECS { + error = Some( + SetError::invalid_properties() + .with_property(P::KeepForDays) + .with_description(format!( + "Records are kept for at least {} days.", + MIN_KEEP_FOR_SECS / 86_400 + )), + ); + break; + } + settings.keep_for_secs = secs; + } + (Key::Property(P::KeepForDays), Value::Null) => { + settings = Settings::default(); + } + (Key::Property(P::Id), _) => {} + _ => { + error = Some(SetError::invalid_properties().with_property(key.into_owned())); + break; + } + } + } + match error { + Some(error) => response.not_updated.append(id, error), + None => { + log::set_settings(server.store(), &settings).await?; + response.updated.append(id, None); + } + } + } + Ok(response) +} + +/// Reads an export's `filter` object, the same conditions a query takes. +fn export_filter(value: Option) -> Result { + let Some(value) = value else { + return Ok(Filter::default()); + }; + let json: serde_json::Value = value.into(); + let Some(map) = json.as_object() else { + return Err("The filter must be an object.".into()); + }; + let mut filter = Filter::default(); + for (key, value) in map { + let text = || { + value + .as_str() + .map(str::to_string) + .ok_or_else(|| format!("{key} must be a string.")) + }; + let id = || { + Id::from_str(&text()?) + .map(|id| id.document_id()) + .map_err(|_| format!("{key} must be an id.")) + }; + match key.as_str() { + "after" => filter.after = Some(date_ms(&text()?)?), + "before" => filter.before = Some(date_ms(&text()?)?), + "actorId" => filter.actor_id = Some(id()?), + "action" => { + filter.action = + Some(Action::parse(&text()?).ok_or_else(|| "Unknown action.".to_string())?) + } + "targetKind" => filter.target_kind = Some(text()?), + "targetId" => filter.target_id = Some(text()?), + "accountId" => filter.account_id = Some(id()?), + "tenantId" => filter.tenant_id = Some(id()?), + "outcome" => filter.outcome = Some(text()?), + "remoteIp" => { + filter.remote_ip = Some( + text()? + .parse() + .map_err(|_| "remoteIp must be an address.")?, + ) + } + "text" => filter.text = Some(text()?), + other => return Err(format!("Unknown filter property {other}.")), + } + } + Ok(filter) +} + +#[derive(Clone, Copy, PartialEq)] +enum Format { + Csv, + JsonLines, +} + +fn csv_field(value: &str) -> String { + if value.contains([',', '"', '\n', '\r']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } +} + +/// The export file's text: one line per record, then a manifest line +/// (AU-11). Each line carries the entry's hash and the hash it follows. +fn render( + format: Format, + entries: &[(EntryId, Record, String, String)], + filter_json: &serde_json::Value, +) -> (Vec, String) { + let mut out = String::new(); + if format == Format::Csv { + out.push_str( + "id,at,node,actor,actorId,actorTenantId,via,remoteIp,action,targetKind,targetId,\ + targetName,targetAccountId,targetTenantId,outcome,error,changes,details,reason,\ + hash,prev\r\n", + ); + } + for (id, record, hash, prev) in entries { + match format { + Format::Csv => { + let (outcome, error) = match &record.outcome { + Outcome::Refused { error, .. } => ("refused", error.as_str()), + other => (other.as_str(), ""), + }; + let opt = |v: Option| v.map(|v| Id::from(v).to_string()).unwrap_or_default(); + let fields = [ + Id::new(id.to_u64()).to_string(), + iso(record.at), + id.node.to_string(), + record.actor.name.clone(), + opt(record.actor.account_id), + opt(record.actor.tenant_id), + record + .via + .as_ref() + .map(|via| to_json(via).to_string()) + .unwrap_or_default(), + record + .remote_ip + .map(|ip| ip.to_string()) + .unwrap_or_default(), + record.action.as_str().to_string(), + record.target.kind.clone(), + record.target.id.clone().unwrap_or_default(), + record.target.name.clone().unwrap_or_default(), + opt(record.target.account_id), + opt(record.target.tenant_id), + outcome.to_string(), + error.to_string(), + if record.changes.is_empty() { + String::new() + } else { + to_json(&record.changes).to_string() + }, + record.details.clone().unwrap_or_default(), + record.reason.clone().unwrap_or_default(), + hash.clone(), + prev.clone(), + ]; + out.push_str( + &fields + .iter() + .map(|field| csv_field(field)) + .collect::>() + .join(","), + ); + out.push_str("\r\n"); + } + Format::JsonLines => { + let mut line = to_json(record); + if let Some(map) = line.as_object_mut() { + for key in ["actor", "target", "via"] { + if let Some(value) = map.remove(key) { + map.insert(key.into(), with_jmap_ids(value)); + } + } + map.insert("id".into(), Id::new(id.to_u64()).to_string().into()); + map.insert("node".into(), id.node.into()); + map.insert("at".into(), iso(record.at).into()); + map.insert("hash".into(), hash.clone().into()); + map.insert("prev".into(), prev.clone().into()); + } + out.push_str(&line.to_string()); + out.push('\n'); + } + } + } + let body_hash = hex(&Sha256::digest(out.as_bytes())); + let manifest = serde_json::json!({ + "manifest": { + "exportedAt": iso(ms()), + "filter": filter_json, + "count": entries.len(), + "first": entries.last().map(|(id, ..)| Id::new(id.to_u64()).to_string()), + "last": entries.first().map(|(id, ..)| Id::new(id.to_u64()).to_string()), + "recordsSha256": body_hash, + } + }); + match format { + Format::Csv => { + out.push_str("# "); + out.push_str(&manifest.to_string()); + out.push_str("\r\n"); + } + Format::JsonLines => { + out.push_str(&manifest.to_string()); + out.push('\n'); + } + } + let file_hash = hex(&Sha256::digest(out.as_bytes())); + (out.into_bytes(), file_hash) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// `inbuxa:AuditExport/set`: create `{format, filter}`; the created object +/// names the file's blob, its size, the number of records and its SHA-256 +/// (AU-11). The export is recorded before the file is built, and refused +/// if it can't be (AU-1.9, AU-3). +pub async fn export_set( + server: &Server, + access_token: &AccessToken, + session: &HttpSessionData, + mut request: SetRequest<'_, AuditExport>, +) -> trc::Result> { + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + for (id, _) in request.unwrap_update().into_valid() { + response.not_updated.append( + id, + SetError::forbidden().with_description("Exports can't be changed."), + ); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append( + id, + SetError::forbidden().with_description("Exports aren't kept to destroy."), + ); + } + + for (client_id, value) in request.unwrap_create() { + let mut format = Format::Csv; + let mut filter_value = None; + let mut reason = None; + let mut invalid = None; + for (key, value) in value.into_expanded_object() { + match (&key, value) { + (Key::Property(P::Format), Value::Str(f)) if f == "csv" => format = Format::Csv, + (Key::Property(P::Format), Value::Str(f)) if f == "jsonl" => { + format = Format::JsonLines + } + (Key::Property(P::Filter), value) => filter_value = Some(value.into_owned()), + (Key::Property(P::Reason), Value::Str(r)) => { + reason = Some(r.chars().take(500).collect::()) + } + (Key::Property(P::Reason), Value::Null) => {} + _ => { + invalid = Some(SetError::invalid_properties().with_property(key.into_owned())); + break; + } + } + } + if let Some(error) = invalid { + response.not_created.append(client_id, error); + continue; + } + let filter_json: serde_json::Value = filter_value + .clone() + .map(Into::into) + .unwrap_or(serde_json::Value::Object(Default::default())); + let filter = match export_filter(filter_value) { + Ok(filter) => filter, + Err(why) => { + response.not_created.append( + client_id, + SetError::invalid_properties() + .with_property(P::Filter) + .with_description(why), + ); + continue; + } + }; + + // Recorded first: no export leaves without its record + let record = Record { + at: ms(), + actor: server.audit_actor(access_token).await, + via: access_token.origin().cloned(), + remote_ip: Some(session.remote_ip), + action: Action::Export, + target: Target { + kind: "inbuxa:AuditEvent".into(), + tenant_id: access_token.tenant_id(), + ..Default::default() + }, + changes: vec![], + details: Some(format!( + "{} export, filter {filter_json}", + if format == Format::Csv { + "CSV" + } else { + "JSON Lines" + } + )), + reason, + outcome: Outcome::Pending, + }; + let entry = server.audit_append(&record).await.map_err(|err| { + err.details("The audit log couldn't be written, so nothing was exported.") + })?; + + let result = build_export(server, access_token, format, filter, &filter_json).await; + let outcome = match &result { + Ok(_) => Outcome::success(), + Err(_) => Outcome::refused("serverFail", None), + }; + let _ = server.audit_finish(entry, outcome).await; + let (blob_id, size, count, sha256) = result?; + + let mut created = Map::with_capacity(5); + created.insert_unchecked( + Key::Property(P::Id), + Value::Element(AuditValue::Id(Id::new(entry.to_u64()))), + ); + created.insert_unchecked(Key::Property(P::BlobId), Value::Str(blob_id.into())); + created.insert_unchecked(Key::Property(P::Size), Value::Number((size as u64).into())); + created.insert_unchecked( + Key::Property(P::Count), + Value::Number((count as u64).into()), + ); + created.insert_unchecked(Key::Property(P::Sha256), Value::Str(sha256.into())); + response.created.insert(client_id, Value::Object(created)); + } + Ok(response) +} + +async fn build_export( + server: &Server, + access_token: &AccessToken, + format: Format, + filter: Filter, + filter_json: &serde_json::Value, +) -> trc::Result<(String, usize, usize, String)> { + let mut entries = Vec::new(); + if let Some(filter) = scoped(filter, access_token) { + for id in log::query_all(server.store(), &filter, MAX_EXPORT).await? { + if let Some((record, hash, prev)) = log::get_with_hash(server.store(), id).await? { + entries.push((id, record, hash, prev)); + } + } + } + let (bytes, sha256) = render(format, &entries, filter_json); + let blob = server + .put_jmap_blob(access_token.account_id(), &bytes) + .await?; + Ok((blob.to_string(), bytes.len(), entries.len(), sha256)) +} + +/// `inbuxa:AuditVerification/set`: create `{}` to recheck every node's +/// chain (AU-6). Server administrators only. +pub async fn verification_set( + server: &Server, + access_token: &AccessToken, + session: &HttpSessionData, + mut request: SetRequest<'_, AuditVerification>, +) -> trc::Result> { + server_level(access_token)?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + for (id, _) in request.unwrap_update().into_valid() { + response.not_updated.append(id, SetError::forbidden()); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append(id, SetError::forbidden()); + } + for (client_id, _) in request.unwrap_create() { + let chains = log::verify(server.store()).await?; + let verified = chains.iter().all(|chain| chain.broken_at.is_none()); + let record = Record { + at: ms(), + actor: server.audit_actor(access_token).await, + via: access_token.origin().cloned(), + remote_ip: Some(session.remote_ip), + action: Action::Verify, + target: Target { + kind: "inbuxa:AuditEvent".into(), + ..Default::default() + }, + changes: vec![], + details: Some(summary(&chains)), + reason: None, + outcome: if verified { + Outcome::success() + } else { + Outcome::refused("chainBroken", None) + }, + }; + let entry = server.audit_append(&record).await.ok(); + + let mut created = Map::with_capacity(3); + created.insert_unchecked( + Key::Property(P::Id), + Value::Element(AuditValue::Id(Id::new( + entry.map_or(0, |entry| entry.to_u64()), + ))), + ); + created.insert_unchecked(Key::Property(P::Verified), Value::Bool(verified)); + created.insert_unchecked(Key::Property(P::Chains), json_to_value(to_json(&chains))); + response.created.insert(client_id, Value::Object(created)); + } + Ok(response) +} + +fn summary(chains: &[ChainReport]) -> String { + chains + .iter() + .map(|chain| match (&chain.broken_at, &chain.reason) { + (Some(at), Some(reason)) => format!("node {}: broken at {at}: {reason}", chain.node), + _ => format!( + "node {}: {} entries verified ({} to {})", + chain.node, chain.entries, chain.first_seq, chain.last_seq + ), + }) + .collect::>() + .join("; ") +} + +#[cfg(test)] +mod tests { + use super::*; + use inbuxa_features::audit::{Actor, Change}; + + #[test] + fn times_keep_milliseconds() { + assert_eq!(iso(1_790_000_000_123), "2026-09-21T14:13:20.123Z"); + assert_eq!(iso(1_790_000_000_000), "2026-09-21T14:13:20.000Z"); + } + + #[test] + fn csv_quotes_what_needs_it() { + assert_eq!(csv_field("plain"), "plain"); + assert_eq!(csv_field("a,b"), "\"a,b\""); + assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\""); + } + + #[test] + fn exports_end_with_a_manifest() { + let record = Record { + at: 1_790_000_000_000, + actor: Actor::account(3, "admin@example.com", None), + via: None, + remote_ip: None, + action: Action::Update, + target: Target { + kind: "x:Domain".into(), + name: Some("example.com".into()), + ..Default::default() + }, + changes: vec![Change::new( + "isEnabled", + Some(true.into()), + Some(false.into()), + )], + details: None, + reason: None, + outcome: Outcome::success(), + }; + let entries = vec![(EntryId { node: 1, seq: 9 }, record, "h".into(), "p".into())]; + let filter = serde_json::json!({}); + for format in [Format::Csv, Format::JsonLines] { + let (bytes, sha) = render(format, &entries, &filter); + let text = String::from_utf8(bytes.clone()).unwrap(); + let last = text.trim_end().lines().last().unwrap(); + assert!(last.contains("\"manifest\""), "{last}"); + assert!(last.contains("\"count\":1")); + assert_eq!(sha, hex(&Sha256::digest(&bytes))); + assert!(text.contains("example.com")); + } + } + + #[test] + fn filters_parse() { + let filter = build_filter(vec![ + QueryFilter::Property(AuditFilter::Action("signIn".into())), + QueryFilter::Property(AuditFilter::After("2026-09-01T00:00:00Z".into())), + ]) + .unwrap(); + assert_eq!(filter.action, Some(Action::SignIn)); + assert!(filter.after.is_some()); + assert!(build_filter(vec![QueryFilter::Or]).is_err()); + assert!( + build_filter(vec![QueryFilter::Property(AuditFilter::Action("x".into()))]).is_err() + ); + } + + #[test] + fn tenant_view_is_forced() { + let token = AccessToken::from_permissions(5, []); + let filter = scoped(Filter::default(), &token).unwrap(); + assert_eq!(filter.tenant_id, None); + } +} diff --git a/crates/jmap/src/inbuxa/explanation.rs b/crates/jmap/src/inbuxa/explanation.rs index de99873..b5e54b9 100644 --- a/crates/jmap/src/inbuxa/explanation.rs +++ b/crates/jmap/src/inbuxa/explanation.rs @@ -89,15 +89,7 @@ const NOT_SETTINGS: &[ObjectType] = &[ /// The registry schema the console downloads, read once. fn schema() -> Option<&'static Schema> { - static SCHEMA: OnceLock> = OnceLock::new(); - static SCHEMA_JSON: &[u8] = include_bytes!("../../../../resources/schema/schema.json.gz"); - SCHEMA - .get_or_init(|| { - let mut json = Vec::new(); - GzDecoder::new(SCHEMA_JSON).read_to_end(&mut json).ok()?; - serde_json::from_slice(&json).ok().map(Schema::new) - }) - .as_ref() + inbuxa_features::ai::explain::schema::embedded() } fn server_fail(why: &'static str) -> SetError

{ diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index e115737..1f62fec 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -8,6 +8,8 @@ //! `crates/features`; this module only speaks JMAP for them. pub mod access; +pub mod audit; +pub mod audit_log; pub mod ai_limits; pub mod explanation; pub mod protocol_policy; diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index 80556f1..2ba8a41 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1730,6 +1730,10 @@ pub enum Permission { ScimAccess = 660, // inbuxa: "Explain this" (ai-explain spec) SysAiExplain = 661, + // inbuxa: the audit log (audit-hold-lock spec, AU-9) + SysAuditGet = 662, + SysAuditExport = 663, + SysAuditSettingsUpdate = 664, SysAccountGet = 219, SysAccountCreate = 220, SysAccountUpdate = 221, diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index a75ef19..acb5d87 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7073,6 +7073,9 @@ impl EnumImpl for Permission { b"liveDeliveryTest" => Permission::LiveDeliveryTest, b"scimAccess" => Permission::ScimAccess, b"sysAiExplain" => Permission::SysAiExplain, + b"sysAuditGet" => Permission::SysAuditGet, + b"sysAuditExport" => Permission::SysAuditExport, + b"sysAuditSettingsUpdate" => Permission::SysAuditSettingsUpdate, b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7751,6 +7754,9 @@ impl EnumImpl for Permission { Permission::LiveDeliveryTest => "liveDeliveryTest", Permission::ScimAccess => "scimAccess", Permission::SysAiExplain => "sysAiExplain", + Permission::SysAuditGet => "sysAuditGet", + Permission::SysAuditExport => "sysAuditExport", + Permission::SysAuditSettingsUpdate => "sysAuditSettingsUpdate", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8422,6 +8428,9 @@ impl EnumImpl for Permission { 218 => Some(Permission::LiveDeliveryTest), 660 => Some(Permission::ScimAccess), 661 => Some(Permission::SysAiExplain), + 662 => Some(Permission::SysAuditGet), + 663 => Some(Permission::SysAuditExport), + 664 => Some(Permission::SysAuditSettingsUpdate), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 221 => Some(Permission::SysAccountUpdate), @@ -8866,7 +8875,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 662; + const COUNT: usize = 665; } impl serde::Serialize for Permission { diff --git a/crates/services/src/task_manager/maintenance.rs b/crates/services/src/task_manager/maintenance.rs index 9b85004..89eb916 100644 --- a/crates/services/src/task_manager/maintenance.rs +++ b/crates/services/src/task_manager/maintenance.rs @@ -263,6 +263,12 @@ async fn store_maintenance( } } + // inbuxa: AU-7: audit records past their retention go; a + // failure leaves them for the next run + if let Err(err) = server.audit_purge().await { + trc::error!(err.details("Failed to purge audit records")); + } + trc::event!( Store(StoreEvent::DataStorePurged), Elapsed = started.elapsed() diff --git a/crates/services/src/task_manager/manager.rs b/crates/services/src/task_manager/manager.rs index 18703f4..86686f0 100644 --- a/crates/services/src/task_manager/manager.rs +++ b/crates/services/src/task_manager/manager.rs @@ -514,6 +514,16 @@ async fn run_task( server: &Server, task: &Task, server_instance: Arc, +) -> TaskResult { + // inbuxa: AU-1.10: registry writes a task makes are the server's own + inbuxa_features::audit::scope::system(task.name(), run_task_unscoped(server, task, server_instance)) + .await +} + +async fn run_task_unscoped( + server: &Server, + task: &Task, + server_instance: Arc, ) -> TaskResult { match task { Task::CalendarAlarmEmail(task) => { diff --git a/crates/services/src/task_manager/spam_classifier.rs b/crates/services/src/task_manager/spam_classifier.rs index 9744cb1..dd5f41e 100644 --- a/crates/services/src/task_manager/spam_classifier.rs +++ b/crates/services/src/task_manager/spam_classifier.rs @@ -77,7 +77,8 @@ async fn spam_filter_maintenance( } } TaskSpamFilterMaintenanceType::UpdateRules => { - return update_spam_rules(server).await; + // inbuxa: AU-1.10: one summary record, not one per rule + return inbuxa_features::audit::scope::quiet(update_spam_rules(server)).await; } } @@ -276,6 +277,37 @@ async fn update_spam_rules(server: &Server) -> trc::Result { .await; } + // inbuxa: AU-1.10: what the update added, as one audit record + let added = stats + .iter() + .filter(|(_, result)| result.success > 0) + .map(|(object_type, result)| format!("{} {}", result.success, object_type.as_str())) + .collect::>(); + if !added.is_empty() { + let mut added = added; + added.sort(); + server + .audit_note(inbuxa_features::audit::Record { + at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64), + actor: inbuxa_features::audit::Actor::system("SpamFilterMaintenance"), + via: None, + remote_ip: None, + action: inbuxa_features::audit::Action::Update, + target: inbuxa_features::audit::Target { + kind: "x:SpamRule".into(), + name: Some("Spam filter rules".into()), + ..Default::default() + }, + changes: vec![], + details: Some(format!("Rules update added {}", added.join(", "))), + reason: None, + outcome: inbuxa_features::audit::Outcome::success(), + }) + .await; + } + trc::event!( Spam(SpamEvent::RulesUpdated), Details = stats diff --git a/crates/store/src/build/registry.rs b/crates/store/src/build/registry.rs index cac3370..5751a74 100644 --- a/crates/store/src/build/registry.rs +++ b/crates/store/src/build/registry.rs @@ -172,6 +172,7 @@ impl RegistryStore { env_hostname: hostname, env_public_url: None, id_generator: utils::snowflake::SnowflakeIdGenerator::new(), + write_hook: Default::default(), }, true, ) diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 05ddd3f..a332cfc 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -212,6 +212,8 @@ pub struct RegistryStoreInner { pub(crate) env_hostname: String, pub(crate) env_public_url: Option, pub(crate) id_generator: SnowflakeIdGenerator, + // inbuxa: AU-1.10, shared by every clone of this registry + pub(crate) write_hook: registry::hook::RegistryHookSlot, } #[cfg(feature = "sqlite")] diff --git a/crates/store/src/registry/hook.rs b/crates/store/src/registry/hook.rs new file mode 100644 index 0000000..9138811 --- /dev/null +++ b/crates/store/src/registry/hook.rs @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa: told of every registry write that succeeded, with the object as +//! it was and as it is, so the audit log records what the server changed on +//! its own (audit-hold-lock spec, AU-1.10). The store knows nothing of the +//! audit log; the server installs the hook once it has one. + +use registry::schema::prelude::{Object, ObjectType}; +use std::{future::Future, pin::Pin, sync::Arc}; +use types::id::Id; + +/// One registry write that succeeded. +pub struct RegistryChange<'a> { + pub object_type: ObjectType, + pub id: Id, + /// Absent for an insert. + pub before: Option<&'a Object>, + /// Absent for a delete. + pub after: Option<&'a Object>, +} + +pub trait RegistryWriteHook: Send + Sync { + fn written<'a>( + &'a self, + change: RegistryChange<'a>, + ) -> Pin + Send + 'a>>; +} + +pub type RegistryHookSlot = Arc>>; diff --git a/crates/store/src/registry/local.rs b/crates/store/src/registry/local.rs index 780dd27..6f6e065 100644 --- a/crates/store/src/registry/local.rs +++ b/crates/store/src/registry/local.rs @@ -67,6 +67,7 @@ impl RegistryStoreInner { }) }), env_hostname, + write_hook: Default::default(), } } diff --git a/crates/store/src/registry/mod.rs b/crates/store/src/registry/mod.rs index 3550f6f..aae7610 100644 --- a/crates/store/src/registry/mod.rs +++ b/crates/store/src/registry/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ pub mod bootstrap; @@ -10,6 +12,10 @@ pub mod local; pub mod query; pub mod write; +// inbuxa: the audit log's view of registry writes (audit-hold-lock spec, +// AU-1.10) +pub mod hook; + use crate::{ Deserialize, SerializeInfallible, U16_LEN, U32_LEN, U64_LEN, write::key::{DeserializeBigEndian, KeySerializer}, diff --git a/crates/store/src/registry/write.rs b/crates/store/src/registry/write.rs index 61f2c63..99c5d1f 100644 --- a/crates/store/src/registry/write.rs +++ b/crates/store/src/registry/write.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -73,7 +75,42 @@ pub enum RegistryWrite<'x> { } impl RegistryStore { + /// inbuxa: installs the audit log's hook (AU-1.10). Only the first one + /// installed is kept. + pub fn set_write_hook(&self, hook: std::sync::Arc) { + let _ = self.0.write_hook.set(hook); + } + pub async fn write(&self, write: RegistryWrite<'_>) -> trc::Result { + // inbuxa: AU-1.10: the hook hears of every write that succeeded + let Some(hook) = self.0.write_hook.get() else { + return self.write_unhooked(write).await; + }; + let (object_type, id, before, after) = match &write { + RegistryWrite::Insert { object, id } => (object.object_type(), *id, None, Some(*object)), + RegistryWrite::Update { + object, + id, + old_object, + } => (object.object_type(), Some(*id), Some(*old_object), Some(*object)), + RegistryWrite::Delete { + object_id, object, .. + } => (object_id.object(), Some(object_id.id()), *object, None), + }; + let result = self.write_unhooked(write).await?; + if let RegistryWriteResult::Success(written) = &result { + hook.written(super::hook::RegistryChange { + object_type, + id: id.unwrap_or(*written), + before, + after, + }) + .await; + } + Ok(result) + } + + async fn write_unhooked(&self, write: RegistryWrite<'_>) -> trc::Result { let mut set_index = IndexBuilder::default(); let mut clear_index = IndexBuilder::default(); diff --git a/crates/trc/src/event/enums.rs b/crates/trc/src/event/enums.rs index dd5ee4b..2a7132a 100644 --- a/crates/trc/src/event/enums.rs +++ b/crates/trc/src/event/enums.rs @@ -11,8 +11,9 @@ // inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is // auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is // security.legacy-protocols-changed (LP-8); 644 to 646 are the cluster -// coordinator's connection events -pub const TOTAL_EVENT_COUNT: usize = 647; +// coordinator's connection events; 647 and 648 are the audit log's +// (audit-hold-lock spec, AU-3, AU-8) +pub const TOTAL_EVENT_COUNT: usize = 649; pub const TOTAL_METRIC_COUNT: usize = 369; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -663,6 +664,9 @@ pub enum SecurityEvent { Unauthorized = 552, // inbuxa: legacy-protocols LP-8 LegacyProtocolsChanged = 643, + // inbuxa: the audit log (AU-3, AU-8) + AuditRecorded = 647, + AuditWriteFailed = 648, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/crates/trc/src/event/enums_impl.rs b/crates/trc/src/event/enums_impl.rs index d4ebe88..3e7c1a5 100644 --- a/crates/trc/src/event/enums_impl.rs +++ b/crates/trc/src/event/enums_impl.rs @@ -452,6 +452,9 @@ impl EventType { b"security.unauthorized" => EventType::Security(SecurityEvent::Unauthorized), // inbuxa: legacy-protocols LP-8 b"security.legacy-protocols-changed" => EventType::Security(SecurityEvent::LegacyProtocolsChanged), + // inbuxa: the audit log (AU-3, AU-8) + b"security.audit-recorded" => EventType::Security(SecurityEvent::AuditRecorded), + b"security.audit-write-failed" => EventType::Security(SecurityEvent::AuditWriteFailed), b"server.startup" => EventType::Server(ServerEvent::Startup), b"server.shutdown" => EventType::Server(ServerEvent::Shutdown), b"server.startup-error" => EventType::Server(ServerEvent::StartupError), @@ -1229,6 +1232,9 @@ impl EventType { EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { "security.legacy-protocols-changed" } + // inbuxa: the audit log (AU-3, AU-8) + EventType::Security(SecurityEvent::AuditRecorded) => "security.audit-recorded", + EventType::Security(SecurityEvent::AuditWriteFailed) => "security.audit-write-failed", EventType::Server(ServerEvent::Startup) => "server.startup", EventType::Server(ServerEvent::Shutdown) => "server.shutdown", EventType::Server(ServerEvent::StartupError) => "server.startup-error", @@ -1907,6 +1913,9 @@ impl EventType { EventType::Security(SecurityEvent::Unauthorized) => 552, // inbuxa: legacy-protocols LP-8 EventType::Security(SecurityEvent::LegacyProtocolsChanged) => 643, + // inbuxa: the audit log (AU-3, AU-8) + EventType::Security(SecurityEvent::AuditRecorded) => 647, + EventType::Security(SecurityEvent::AuditWriteFailed) => 648, EventType::Server(ServerEvent::Startup) => 393, EventType::Server(ServerEvent::Shutdown) => 392, EventType::Server(ServerEvent::StartupError) => 394, @@ -2601,6 +2610,9 @@ impl EventType { 552 => Some(EventType::Security(SecurityEvent::Unauthorized)), // inbuxa: legacy-protocols LP-8 643 => Some(EventType::Security(SecurityEvent::LegacyProtocolsChanged)), + // inbuxa: the audit log (AU-3, AU-8) + 647 => Some(EventType::Security(SecurityEvent::AuditRecorded)), + 648 => Some(EventType::Security(SecurityEvent::AuditWriteFailed)), 393 => Some(EventType::Server(ServerEvent::Startup)), 392 => Some(EventType::Server(ServerEvent::Shutdown)), 394 => Some(EventType::Server(ServerEvent::StartupError)), @@ -3022,6 +3034,9 @@ impl EventType { EventType::Security(SecurityEvent::Unauthorized) => Level::Info, // inbuxa: legacy-protocols LP-8 EventType::Security(SecurityEvent::LegacyProtocolsChanged) => Level::Info, + // inbuxa: the audit log (AU-3, AU-8) + EventType::Security(SecurityEvent::AuditRecorded) => Level::Info, + EventType::Security(SecurityEvent::AuditWriteFailed) => Level::Error, EventType::Server(ServerEvent::Startup) => Level::Info, EventType::Server(ServerEvent::Shutdown) => Level::Info, EventType::Server(ServerEvent::Licensing) => Level::Info, @@ -3757,6 +3772,9 @@ impl EventType { EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { "Legacy mail protocols switch changed" } + // inbuxa: the audit log (AU-3, AU-8) + EventType::Security(SecurityEvent::AuditRecorded) => "Audit record written", + EventType::Security(SecurityEvent::AuditWriteFailed) => "Audit record not written", EventType::Server(ServerEvent::Startup) => "Starting inbuxa Server", EventType::Server(ServerEvent::Shutdown) => "Shutting down inbuxa Server", EventType::Server(ServerEvent::StartupError) => "Server startup error", @@ -4154,6 +4172,13 @@ impl EventType { EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { "Legacy mail protocols switch changed" } + // inbuxa: the audit log (AU-3, AU-8) + EventType::Security(SecurityEvent::AuditRecorded) => { + "An administrator's action or a sign-in was written to the audit log" + } + EventType::Security(SecurityEvent::AuditWriteFailed) => { + "The audit log couldn't be written, so the change was refused" + } EventType::Smtp(SmtpEvent::ConnectionStart) => "SMTP error", EventType::Smtp(SmtpEvent::ConnectionEnd) => "SMTP error", EventType::Smtp(SmtpEvent::Error) => "SMTP error", @@ -4721,6 +4746,9 @@ impl EventType { EventType::Security(SecurityEvent::Unauthorized), // inbuxa: legacy-protocols LP-8 EventType::Security(SecurityEvent::LegacyProtocolsChanged), + // inbuxa: the audit log (AU-3, AU-8) + EventType::Security(SecurityEvent::AuditRecorded), + EventType::Security(SecurityEvent::AuditWriteFailed), EventType::Server(ServerEvent::Startup), EventType::Server(ServerEvent::Shutdown), EventType::Server(ServerEvent::StartupError), diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 2036daf..c7264e8 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 77da559..4ee30b7 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -krR-kLAFyDZPDN7u7qgMjHqDWn_BepUPrzpaSfZZEOM \ No newline at end of file +0CZ88XU8AvHiGwlrTmlc5Lt-4dgmms3pJphCNzK5FKI \ No newline at end of file diff --git a/tests/src/system/audit.rs b/tests/src/system/audit.rs new file mode 100644 index 0000000..bb776ea --- /dev/null +++ b/tests/src/system/audit.rs @@ -0,0 +1,563 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Audit log acceptance tests, from `inbuxa-drafts/specs/audit-hold-lock.md` +//! (AU-1 to AU-11, and tests 1 to 5 of its list). Each check names the +//! requirement or test number. + +use crate::utils::{ + account::Account, + server::{TestServer, TestServerBuilder}, +}; +use inbuxa_features::audit::{EntryId, log}; +use registry::{ + schema::{ + prelude::{ObjectType, Property}, + structs::{ + BlockedIp, CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, + UserRoles, + }, + }, + types::ipmask::IpAddrOrMask, +}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::str::FromStr; +use store::{Deserialize, registry::write::RegistryWrite, write::BatchBuilder}; +use types::id::Id; + +const USING: &[&str] = &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"]; + +struct Bytes(Vec); + +impl Deserialize for Bytes { + fn deserialize(bytes: &[u8]) -> trc::Result { + Ok(Bytes(bytes.to_vec())) + } +} + +impl Account { + /// Records matching `filter`, newest first. + async fn audit(&self, filter: Value) -> Vec { + let response = self + .jmap_request( + USING, + json!([ + ["inbuxa:AuditEvent/query", { + "accountId": self.id_string(), "filter": filter, "limit": 100 + }, "q"], + ["inbuxa:AuditEvent/get", { + "accountId": self.id_string(), + "#ids": {"resultOf": "q", "name": "inbuxa:AuditEvent/query", "path": "/ids"} + }, "g"] + ]), + ) + .await; + response + .0 + .pointer("/methodResponses/1/1/list") + .and_then(Value::as_array) + .cloned() + .unwrap_or_else(|| panic!("audit query failed: {}", response.0)) + } + + /// One method's response: its name and arguments. + async fn audit_call(&self, method: &str, arguments: Value) -> (String, Value) { + let response = self + .jmap_request(USING, json!([[method, arguments, "0"]])) + .await; + let call = response + .0 + .pointer("/methodResponses/0") + .cloned() + .unwrap_or_else(|| panic!("{method}: {}", response.0)); + ( + call[0].as_str().unwrap_or_default().to_string(), + call[1].clone(), + ) + } + + async fn audit_verify(&self) -> Value { + let (_, response) = self + .audit_call( + "inbuxa:AuditVerification/set", + json!({"accountId": self.id_string(), "create": {"v": {}}}), + ) + .await; + response["created"]["v"].clone() + } + + async fn create_audit_domain(&self, name: &str, tenant: Option) -> Id { + self.registry_create_object(Domain { + name: name.to_string(), + is_enabled: true, + member_tenant_id: tenant, + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await + } +} + +fn changed(record: &Value, field: &str) -> Option<(Value, Value)> { + record["changes"] + .as_array()? + .iter() + .find(|change| change["field"] == field) + .map(|change| (change["before"].clone(), change["after"].clone())) +} + +pub async fn test(test: &mut TestServer) { + println!("Running audit log tests..."); + let admin = test.account("admin@example.org"); + let admin_id = admin.id_string().to_string(); + + // AU-1.4, AU-5: the administrator's sign-in, by password + admin.jmap_session_object().await; + let signins = admin + .audit(json!({"action": "signIn", "actorId": admin_id})) + .await; + assert!(!signins.is_empty(), "AU-1.4: no sign-in recorded"); + assert_eq!( + signins[0]["via"]["kind"], "password", + "AU-5: {}", + signins[0] + ); + assert!(signins[0]["remoteIp"].is_string(), "AU-4"); + + // Test 1: a change, with its field's before and after + let domain = admin.create_audit_domain("audit.example.org", None).await; + let created = admin + .audit(json!({"action": "create", "targetKind": "x:Domain"})) + .await; + let record = created + .iter() + .find(|r| r["outcome"]["createdId"] == domain.to_string()) + .unwrap_or_else(|| panic!("test 1: no create record in {created:?}")); + assert_eq!(record["outcome"]["status"], "success", "test 1"); + assert_eq!(record["target"]["name"], "audit.example.org", "test 1"); + assert_eq!(record["actor"]["name"], "admin@example.org", "test 1"); + + admin + .registry_update_object( + ObjectType::Domain, + domain, + json!({Property::IsEnabled: false}), + ) + .await; + let updated = admin + .audit(json!({"action": "update", "targetId": domain.to_string()})) + .await; + assert_eq!(updated.len(), 1, "test 1: {updated:?}"); + assert_eq!( + changed(&updated[0], "isEnabled"), + Some((json!(true), json!(false))), + "test 1: {}", + updated[0] + ); + assert_eq!(updated[0]["target"]["name"], "audit.example.org", "test 1"); + let update_id = updated[0]["id"].as_str().unwrap().to_string(); + + // Test 1: a secret is recorded as changed, never with its value + let secret = "a-very-secret-password-0192"; + let user = admin + .create_user_account("audituser@example.org", secret, "Audit user", &[], vec![]) + .await; + let accounts = admin + .audit(json!({"action": "create", "targetKind": "x:Account"})) + .await; + assert!(!accounts.is_empty(), "test 1: account create"); + for record in &accounts { + assert!( + !record.to_string().contains(secret), + "test 1: secret kept: {record}" + ); + } + + // AU-1.10: a registry write outside any request is the server's own + let blocked = IpAddrOrMask::from_ip("192.0.2.99".parse().unwrap()); + test.server + .registry() + .write(RegistryWrite::insert( + &BlockedIp { + address: blocked, + ..Default::default() + } + .into(), + )) + .await + .unwrap(); + let system = admin.audit(json!({"targetKind": "x:BlockedIp"})).await; + assert_eq!(system.len(), 1, "AU-1.10: {system:?}"); + assert_eq!(system[0]["actor"]["name"], "system:server", "AU-1.10"); + assert!(system[0]["actor"]["accountId"].is_null(), "AU-1.10"); + + // Test 3, AU-1.6: impersonated access, once an hour + for _ in 0..2 { + let response = admin + .jmap_request( + &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + json!([["Mailbox/get", {"accountId": user.id_string(), "ids": null}, "0"]]), + ) + .await; + assert_eq!( + response.0.pointer("/methodResponses/0/0"), + Some(&json!("Mailbox/get")), + "test 3: {}", + response.0 + ); + } + let access = admin + .audit(json!({"action": "accountAccess", "accountId": user.id_string()})) + .await; + assert_eq!(access.len(), 1, "test 3: {access:?}"); + assert_eq!( + access[0]["target"]["name"], "audituser@example.org", + "test 3" + ); + + // AU-9: a plain user can't read the audit log + let plain = Account::new( + "audituser@example.org", + "a-very-secret-password-0192", + &[], + "", + user.id(), + ); + let (name, response) = plain + .audit_call( + "inbuxa:AuditEvent/query", + json!({"accountId": user.id_string(), "filter": {}}), + ) + .await; + assert_eq!(name, "error", "AU-9: a user read the audit log: {response}"); + assert_eq!(response["type"], "forbidden", "AU-9"); + + // AU-1.4: a failed password sign-in to an administrator's account + let wrong = Account::new( + "admin@example.org", + "not-the-password", + &[], + "Admin", + admin.id(), + ); + let failed = wrong.jmap_session_object().await; + assert!( + failed.0.pointer("/accounts").is_none(), + "wrong password worked" + ); + let failures = admin + .audit(json!({"action": "signInFailed", "actorId": admin_id})) + .await; + assert_eq!(failures.len(), 1, "AU-1.4: {failures:?}"); + assert_eq!(failures[0]["outcome"]["status"], "refused", "AU-1.4"); + // A user that isn't an administrator isn't recorded + let wrong_user = Account::new( + "audituser@example.org", + "not-the-password", + &[], + "", + user.id(), + ); + wrong_user.jmap_session_object().await; + assert!( + admin + .audit(json!({"action": "signInFailed", "actorId": user.id_string()})) + .await + .is_empty(), + "AU-1.4: a plain user's failure recorded" + ); + + // Test 5, AU-9: a tenant administrator sees its tenant only + let tenant = admin + .registry_create_object(Tenant { + name: "Audit tenant".to_string(), + ..Default::default() + }) + .await; + let tenant_domain = admin + .create_audit_domain("tenant-audit.example.org", Some(tenant)) + .await; + let t_admin = admin + .create_user_account( + "tadmin@tenant-audit.example.org", + "tenant-admin-secret-3391", + "Tenant admin", + &[], + vec![], + ) + .await; + admin + .registry_update_object( + ObjectType::Account, + t_admin.id(), + json!({Property::Roles: UserRoles::Admin}), + ) + .await; + let seen = t_admin.audit(json!({})).await; + assert!(!seen.is_empty(), "test 5: nothing seen"); + let tenant_str = tenant.to_string(); + for record in &seen { + assert!( + record["actor"]["tenantId"] == tenant_str.as_str() + || record["target"]["tenantId"] == tenant_str.as_str(), + "test 5: outside the tenant: {record}" + ); + } + // The server administrator's change to the tenant's domain is included + assert!( + seen.iter() + .any(|r| r["outcome"]["createdId"] == tenant_domain.to_string()), + "test 5: the server admin's create is missing" + ); + assert!( + t_admin + .audit(json!({"targetId": domain.to_string()})) + .await + .is_empty(), + "test 5: another domain's records seen" + ); + let (name, _) = t_admin + .audit_call( + "inbuxa:AuditSettings/set", + json!({"accountId": t_admin.id_string(), + "update": {"singleton": {"keepForDays": 400}}}), + ) + .await; + assert_eq!(name, "error", "AU-9: a tenant admin changed retention"); + let (name, _) = t_admin + .audit_call( + "inbuxa:AuditVerification/set", + json!({"accountId": t_admin.id_string(), "create": {"v": {}}}), + ) + .await; + assert_eq!(name, "error", "AU-9: a tenant admin verified"); + + // AU-7: retention + let (_, response) = admin + .audit_call( + "inbuxa:AuditSettings/set", + json!({"accountId": admin_id, "update": {"singleton": {"keepForDays": 30}}}), + ) + .await; + assert_eq!( + response["notUpdated"]["singleton"]["type"], "invalidProperties", + "AU-7: {response}" + ); + let (_, response) = admin + .audit_call( + "inbuxa:AuditSettings/set", + json!({"accountId": admin_id, "update": {"singleton": {"keepForDays": 365}}}), + ) + .await; + assert!( + response["updated"]["singleton"].is_null(), + "AU-7: {response}" + ); + let (_, response) = admin + .audit_call( + "inbuxa:AuditSettings/get", + json!({"accountId": admin_id, "ids": null}), + ) + .await; + assert_eq!(response["list"][0]["keepForDays"], 365, "AU-7"); + let settings_changes = admin + .audit(json!({"targetKind": "inbuxa:AuditSettings"})) + .await; + assert_eq!( + changed(&settings_changes[0], "keepForDays"), + Some((json!(730), json!(365))), + "AU-7: the retention change is recorded with what it replaced" + ); + + // AU-11: an export, recorded, with its hash + let (_, response) = admin + .audit_call( + "inbuxa:AuditExport/set", + json!({"accountId": admin_id, "create": {"x": { + "format": "jsonl", + "filter": {"targetId": domain.to_string()}, + "reason": "Test export" + }}}), + ) + .await; + let export = response["created"]["x"].clone(); + assert!(export["blobId"].is_string(), "AU-11: {response}"); + assert!(export["count"].as_u64().unwrap() >= 2, "AU-11: {export}"); + let file = admin + .http_get_raw( + &format!( + "{}/jmap/download/{}/{}/audit.jsonl", + admin.base_url(), + admin_id, + export["blobId"].as_str().unwrap() + ), + None, + ) + .await; + assert_eq!(file.status, 200, "AU-11: download"); + let sha: String = Sha256::digest(&file.body) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + assert_eq!(export["sha256"], sha.as_str(), "AU-11: file hash"); + let text = String::from_utf8(file.body).unwrap(); + let manifest: Value = serde_json::from_str(text.trim_end().lines().last().unwrap()).unwrap(); + assert_eq!(manifest["manifest"]["count"], export["count"], "AU-11"); + assert!(text.contains("\"hash\""), "AU-11: lines carry hashes"); + let exports = admin.audit(json!({"action": "export"})).await; + assert_eq!(exports.len(), 1, "AU-1.9: {exports:?}"); + assert_eq!(exports[0]["reason"], "Test export", "AU-1.9"); + assert_eq!(exports[0]["outcome"]["status"], "success", "AU-1.9"); + + // Test 4, AU-6: verification passes, then catches an edited entry + let report = admin.audit_verify().await; + assert_eq!(report["verified"], true, "test 4: {report}"); + + let store = test.server.store(); + let tampered = EntryId::from_u64(Id::from_str(&update_id).unwrap().id()); + let key = log::entry_key(tampered); + let original = store + .get_value::(key.clone()) + .await + .unwrap() + .unwrap() + .0; + let edited = String::from_utf8(original.clone()).unwrap().replacen( + "\"after\":false", + "\"after\":true", + 1, + ); + assert_ne!( + edited.as_bytes(), + original.as_slice(), + "test 4: nothing to edit" + ); + let write = |bytes: Vec| { + let key = key.clone(); + async move { + let mut batch = BatchBuilder::new(); + batch.set(key.class, bytes); + store.write(batch.build_all()).await.unwrap(); + } + }; + write(edited.into_bytes()).await; + let report = admin.audit_verify().await; + assert_eq!(report["verified"], false, "test 4: {report}"); + let broken = report["chains"] + .as_array() + .unwrap() + .iter() + .find_map(|chain| chain["brokenAt"].as_str()) + .unwrap_or_else(|| panic!("test 4: no break named: {report}")) + .to_string(); + assert_eq!( + broken, + EntryId { + node: tampered.node, + seq: tampered.seq + 1 + } + .to_string(), + "test 4: the break is found at the entry after the edited one" + ); + write(original).await; + assert_eq!( + admin.audit_verify().await["verified"], + true, + "test 4: restored" + ); + + // Test 2, AU-3: no change without its record + let head = log::head_key(tampered.node); + let head_bytes = store + .get_value::(head.clone()) + .await + .unwrap() + .unwrap() + .0; + let mut batch = BatchBuilder::new(); + batch.set(head.class.clone(), b"bad".to_vec()); + store.write(batch.build_all()).await.unwrap(); + let (name, response) = admin + .audit_call( + "x:Domain/set", + json!({"accountId": admin_id, "create": {"d": { + "name": "refused.example.org", + "isEnabled": true, + "certificateManagement": {"@type": "Manual"}, + "dnsManagement": {"@type": "Manual"}, + "dkimManagement": {"@type": "Manual"} + }}}), + ) + .await; + assert_eq!(name, "error", "test 2: the change went ahead: {response}"); + let mut batch = BatchBuilder::new(); + batch.set(head.class, head_bytes); + store.write(batch.build_all()).await.unwrap(); + assert!( + admin + .registry_query_ids( + ObjectType::Domain, + [(Property::Name, "refused.example.org")], + Vec::<&str>::new(), + ) + .await + .is_empty(), + "test 2: the domain exists" + ); + + // AU-7: purging leaves a chain that verifies and carries on + let removed = log::purge(store, u64::MAX, |_| false).await.unwrap(); + assert!(removed > 0, "AU-7: nothing purged"); + assert_eq!( + admin.audit_verify().await["verified"], + true, + "AU-7: after purge" + ); + admin + .registry_update_object( + ObjectType::Domain, + domain, + json!({Property::IsEnabled: true}), + ) + .await; + assert_eq!( + admin + .audit(json!({"targetId": domain.to_string()})) + .await + .len(), + 1, + "AU-7: only the change after the purge" + ); + assert_eq!( + admin.audit_verify().await["verified"], + true, + "AU-7: continued" + ); + + // Clean up what later suites could trip over + admin.registry_destroy_all(ObjectType::BlockedIp).await; +} + +/// Runs these tests alone: `cargo test -p tests audit_log_tests -- --ignored`. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn audit_log_tests() { + let mut test = TestServerBuilder::new("audit_log_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.org").await; + test.insert_account(admin); + self::test(&mut test).await; + if test.is_reset() { + test.temp_dir.delete(); + } +} diff --git a/tests/src/system/authorization.rs b/tests/src/system/authorization.rs index 0eafc3a..49222af 100644 --- a/tests/src/system/authorization.rs +++ b/tests/src/system/authorization.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::{jmap::JmapUtils, server::TestServer}; @@ -183,13 +185,14 @@ pub async fn test(test: &mut TestServer) { permission ); + // inbuxa: the fork's own permissions (sysAuditGet) guard fork + // methods, not registry objects; the audit suite checks those if let Some(name) = permission .as_str() .strip_prefix("sys") .and_then(|perm| perm.strip_suffix("Get")) + && let Some(object_type) = ObjectType::parse(name) { - let object_type = ObjectType::parse(name).unwrap(); - assert_eq!( user.registry_get_many(object_type, Vec::<&str>::new()) .await diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index f16a892..d1d6ea6 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -11,6 +11,7 @@ pub mod authentication; pub mod ai; pub mod ai_calibration; pub mod ai_explain; +pub mod audit; // inbuxa: the audit log pub mod authorization; pub mod auto_reload; // inbuxa: registry writes apply at once pub mod branding; diff --git a/tests/src/utils/server.rs b/tests/src/utils/server.rs index 1e4dded..b5662e0 100644 --- a/tests/src/utils/server.rs +++ b/tests/src/utils/server.rs @@ -398,6 +398,9 @@ impl TestServerBuilder { .parse_tcp_acceptors(&mut self.bootstrap, inner.clone()) .await; + // inbuxa: AU-1.10, as boot does + inner.build_server().install_audit_hook(); + // inbuxa: a compat run opens a copy of a real server's store, which // carries that server's listeners: 25, 443, 993 and the rest. Nothing // here runs as root, so every one of them fails to bind and the run