From 86d7ebd982993221706d58a5ebb956dc5487a1f3 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 13:40:12 -0700 Subject: [PATCH] Audit log: a permanent, tamper-evident record of admin actions What administrators and the server itself do to the control plane is now recorded, from inbuxa-drafts/specs/audit-hold-lock.md (AU-1 to AU-12): settings, accounts, domains, roles and every other registry change, with each field's before and after (secrets only as "changed"); the fork's own settings objects; administrator sign-ins (and failed ones to administrator accounts), master-user and recovery-admin sign-ins, once an hour per account, method and address; access to another account's data through impersonation or FetchAnyBlob, once an hour; exports and tamper checks; and registry writes the server makes on its own, named by subsystem (system:AcmeRenewal, system:auto-ban, system:directory-sync, ...), with a spam rules update as one summary record. No change without its record (AU-3): before a set method changes anything, a pending record per requested create, update and destroy is written; if that fails, the method is refused with serverFail. Its outcome follows as a later entry. A change interrupted by a crash stays "unfinished". Records live in the fork's subspace under L, as one SHA-256 hash chain per node. The chain's head is stored, never cached, and every append asserts it, so two writers can't take the same place. Nothing can edit or delete a record; the daily purge removes the oldest past the retention (default 730 days, minimum 90) and records where the chain now starts, so verification still passes. security.audit-recorded (647) copies each record to webhooks, OpenTelemetry and the log; security.audit-write-failed (648) reports a failed write. New JMAP objects under urn:inbuxa:jmap: inbuxa:AuditEvent/get and /query (filters: time, actor, action, target, account, tenant, outcome, address, text), inbuxa:AuditSettings, inbuxa:AuditExport (CSV or JSON Lines built on the server, each line with its chain hash, ending in a manifest; the created object names the blob and its SHA-256) and inbuxa:AuditVerification. New permissions sysAuditGet, sysAuditExport and sysAuditSettingsUpdate: the Administrator role gets all three, the Tenant Administrator role gets read and export, once, on existing installs too. A tenant administrator sees records whose actor or target is in its tenant, including a server administrator's changes there. Sign-in method on the session: access tokens now remember how they signed in (password, app password, API key, OAuth client, directory, master user, recovery admin), including across the HTTP credential cache. New OAuth access tokens carry their client id in the sealed claims; older ones show as client "unknown" until they expire. The schema gains the permissions, the two events and a Management > Compliance > Audit Log link. Stack: the request layer boxes every inner future where it's made. Without that, a debug build overflowed the default 2 MB worker stack on a registry set; measured with the same request, the branch and main now overflow at the same stack size (between 1856 and 1920 KiB, debug), so the layer adds nothing measurable. Tests: unit tests in inbuxa-features and jmap; system::audit::audit_log_tests (run with --ignored) passes on RocksDB, SQLite, PostgreSQL, PostgreSQL with a read replica, MySQL, MySQL with a replica and FoundationDB. The system, JMAP and SCIM suites pass. authorization.rs skipped fork permissions that guard no registry object; the audit suite checks a plain user is refused instead. --- Cargo.lock | 2 + crates/common/src/audit.rs | 489 +++++++++ crates/common/src/auth/access_token.rs | 42 +- crates/common/src/auth/authentication.rs | 65 +- crates/common/src/auth/mod.rs | 3 + crates/common/src/auth/permissions.rs | 6 + crates/common/src/cache/directory.rs | 22 + crates/common/src/config/inner.rs | 2 + crates/common/src/lib.rs | 6 + crates/common/src/manager/boot.rs | 4 + .../common/src/manager/granted_permissions.rs | 69 +- crates/common/src/network/security.rs | 14 +- crates/features/Cargo.toml | 3 + crates/features/src/ai/explain/schema.rs | 18 +- crates/features/src/audit/diff.rs | 215 ++++ crates/features/src/audit/log.rs | 984 ++++++++++++++++++ crates/features/src/audit/mod.rs | 23 + crates/features/src/audit/record.rs | 349 +++++++ crates/features/src/audit/scope.rs | 70 ++ crates/features/src/lib.rs | 1 + crates/http/src/auth/authenticate.rs | 7 +- crates/http/src/auth/oauth/registration.rs | 16 +- crates/http/src/auth/oauth/token.rs | 5 +- crates/jmap-proto/src/object/inbuxa_audit.rs | 353 +++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 6 + crates/jmap-proto/src/references/resolve.rs | 11 + crates/jmap-proto/src/request/method.rs | 27 + crates/jmap-proto/src/request/mod.rs | 6 + crates/jmap-proto/src/request/parser.rs | 43 + crates/jmap-proto/src/response/mod.rs | 36 + crates/jmap/src/api/auth.rs | 32 + crates/jmap/src/api/mod.rs | 7 +- crates/jmap/src/api/request.rs | 163 ++- crates/jmap/src/blob/download.rs | 5 + crates/jmap/src/changes/get.rs | 4 + crates/jmap/src/inbuxa/audit.rs | 412 ++++++++ crates/jmap/src/inbuxa/audit_log.rs | 864 +++++++++++++++ crates/jmap/src/inbuxa/explanation.rs | 10 +- crates/jmap/src/inbuxa/mod.rs | 2 + crates/registry/src/schema/enums.rs | 4 + crates/registry/src/schema/enums_impl.rs | 11 +- .../services/src/task_manager/maintenance.rs | 6 + crates/services/src/task_manager/manager.rs | 10 + .../src/task_manager/spam_classifier.rs | 34 +- crates/store/src/build/registry.rs | 1 + crates/store/src/lib.rs | 2 + crates/store/src/registry/hook.rs | 33 + crates/store/src/registry/local.rs | 1 + crates/store/src/registry/mod.rs | 6 + crates/store/src/registry/write.rs | 37 + crates/trc/src/event/enums.rs | 8 +- crates/trc/src/event/enums_impl.rs | 28 + resources/schema/schema.json.gz | Bin 150787 -> 151134 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/system/audit.rs | 563 ++++++++++ tests/src/system/authorization.rs | 7 +- tests/src/system/mod.rs | 1 + tests/src/utils/server.rs | 3 + 59 files changed, 5075 insertions(+), 79 deletions(-) create mode 100644 crates/common/src/audit.rs create mode 100644 crates/features/src/audit/diff.rs create mode 100644 crates/features/src/audit/log.rs create mode 100644 crates/features/src/audit/mod.rs create mode 100644 crates/features/src/audit/record.rs create mode 100644 crates/features/src/audit/scope.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_audit.rs create mode 100644 crates/jmap/src/inbuxa/audit.rs create mode 100644 crates/jmap/src/inbuxa/audit_log.rs create mode 100644 crates/store/src/registry/hook.rs create mode 100644 tests/src/system/audit.rs 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 2036daf7bdf3a1f1eba5c523a8ab617b7f183764..c7264e87fb81e44b591c4b3fa191906ac1b92130 100644 GIT binary patch delta 55926 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 delta 55559 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 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 -- 2.54.0