Audit log: a permanent, tamper-evident record of admin actions #64

Merged
jcoffey-dev merged 1 commits from feature/audit-log into main 2026-09-27 21:45:51 +00:00
59 changed files with 5075 additions and 79 deletions
Showing only changes of commit 86d7ebd982 - Show all commits
Generated
+2
View File
@@ -3960,10 +3960,12 @@ version = "0.16.22"
dependencies = [ dependencies = [
"ahash", "ahash",
"base64 0.23.1", "base64 0.23.1",
"flate2",
"jmap_proto", "jmap_proto",
"registry", "registry",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0",
"store", "store",
"tokio", "tokio",
"trc", "trc",
+489
View File
@@ -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<Vec<Permission>> = 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<EntryId> {
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<usize> {
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<Box<dyn Future<Output = ()> + 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: &registry::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(),
),
}
})
}
}
+41 -1
View File
@@ -376,6 +376,7 @@ impl AccessToken {
pub fn new(inner: Arc<AccessTokenInner>, remote_ip: IpAddr) -> trc::Result<Self> { pub fn new(inner: Arc<AccessTokenInner>, remote_ip: IpAddr) -> trc::Result<Self> {
AccessToken { AccessToken {
scope_idx: 0, scope_idx: 0,
origin: None,
inner, inner,
} }
.assert_is_valid(remote_ip) .assert_is_valid(remote_ip)
@@ -384,6 +385,7 @@ impl AccessToken {
pub fn new_maybe_invalid(inner: Arc<AccessTokenInner>) -> Self { pub fn new_maybe_invalid(inner: Arc<AccessTokenInner>) -> Self {
AccessToken { AccessToken {
scope_idx: 0, scope_idx: 0,
origin: None,
inner, inner,
} }
} }
@@ -404,7 +406,11 @@ impl AccessToken {
.ctx(trc::Key::Id, credential_id) .ctx(trc::Key::Id, credential_id)
.reason("Credential expired or removed.") .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)) .and_then(|token| token.assert_is_valid(remote_ip))
} }
@@ -418,6 +424,7 @@ impl AccessToken {
} else { } else {
AccessToken { AccessToken {
scope_idx: 0, scope_idx: 0,
origin: None,
inner, inner,
} }
.assert_is_valid(remote_ip) .assert_is_valid(remote_ip)
@@ -481,6 +488,15 @@ impl AccessToken {
|| self.has_permission(Permission::Impersonate) || 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 { pub fn is_account_id(&self, account_id: u32) -> bool {
self.inner.account_id == account_id self.inner.account_id == account_id
} }
@@ -579,6 +595,7 @@ impl AccessToken {
access_token = AccessToken { access_token = AccessToken {
scope_idx: access_token.scope_idx, scope_idx: access_token.scope_idx,
origin: access_token.origin.clone(),
inner: Arc::new(inner), 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<Arc<inbuxa_features::audit::Via>> {
self.origin.clone()
}
/// inbuxa: restores how a cached session signed in (AU-5).
pub fn with_origin_arc(mut self, origin: Option<Arc<inbuxa_features::audit::Via>>) -> Self {
self.origin = origin;
self
}
pub fn new_admin() -> AccessToken { pub fn new_admin() -> AccessToken {
AccessToken { AccessToken {
scope_idx: 0, scope_idx: 0,
origin: None,
inner: Arc::new(AccessTokenInner::new_admin()), inner: Arc::new(AccessTokenInner::new_admin()),
} }
} }
@@ -775,6 +814,7 @@ impl AccessToken {
} }
AccessToken { AccessToken {
scope_idx: 0, scope_idx: 0,
origin: None,
inner: Arc::new(AccessTokenInner { inner: Arc::new(AccessTokenInner {
account_id, account_id,
tenant_id: Default::default(), tenant_id: Default::default(),
+59 -6
View File
@@ -26,6 +26,7 @@ use registry::schema::{
use serde::Deserialize; use serde::Deserialize;
use std::{borrow::Cow, net::IpAddr, sync::Arc}; use std::{borrow::Cow, net::IpAddr, sync::Arc};
use store::write::now; use store::write::now;
use inbuxa_features::audit::Via;
use trc::AddContext; use trc::AddContext;
pub struct UsernameParts { pub struct UsernameParts {
@@ -45,8 +46,17 @@ impl Server {
.await .await
.and_then(|token| token.assert_has_permission(Permission::Authenticate)) .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) => { 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 // Random delay to mitigate user enumeration attacks
#[cfg(not(feature = "test_mode"))] #[cfg(not(feature = "test_mode"))]
{ {
@@ -106,6 +116,13 @@ impl Server {
self.access_token(account_id) self.access_token(account_id)
.await .await
.and_then(|token| AccessToken::new(token, req.remote_ip)) .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 { } else {
Err(trc::AuthEvent::Failed Err(trc::AuthEvent::Failed
.into_err() .into_err()
@@ -119,7 +136,8 @@ impl Server {
SpanId = req.session_id, SpanId = req.session_id,
); );
Ok(AccessToken::new_admin()) // inbuxa: AU-1.5, AU-5
Ok(AccessToken::new_admin().with_origin(Via::Recovery))
} }
} else { } else {
Err(trc::AuthEvent::Failed Err(trc::AuthEvent::Failed
@@ -163,6 +181,12 @@ impl Server {
req.session_id, req.session_id,
) )
.await .await
// inbuxa: AU-5
.map(|token| {
token.with_origin(Via::AppPassword {
id: app_pass.credential_id,
})
})
} else { } else {
Err(trc::AuthEvent::Failed Err(trc::AuthEvent::Failed
.into_err() .into_err()
@@ -262,6 +286,7 @@ impl Server {
// Validate master user access // Validate master user access
if username.is_master() { if username.is_master() {
let master_id = token.account_id(); // inbuxa: AU-5
token.assert_has_permissions(&[ token.assert_has_permissions(&[
Permission::Impersonate, Permission::Impersonate,
Permission::Authenticate, Permission::Authenticate,
@@ -282,6 +307,13 @@ impl Server {
self.access_token(account_id) self.access_token(account_id)
.await .await
.map(AccessToken::new_maybe_invalid) .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 { } else {
Err(trc::AuthEvent::Failed Err(trc::AuthEvent::Failed
.into_err() .into_err()
@@ -297,7 +329,12 @@ impl Server {
SpanId = req.session_id, 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 } => { Credentials::Bearer { username, token } => {
@@ -311,7 +348,9 @@ impl Server {
req.remote_ip, req.remote_ip,
req.session_id, req.session_id,
) )
.await; .await
// inbuxa: AU-5
.map(|token| token.with_origin(Via::ApiKey { id: key.credential_id }));
} }
#[cfg(feature = "dev_mode")] #[cfg(feature = "dev_mode")]
@@ -368,7 +407,8 @@ impl Server {
.ctx(trc::Key::AccountId, token.account_id()) .ctx(trc::Key::AccountId, token.account_id())
.reason("Authenticated using an email alias but account does not have AuthenticateAlias permission")); .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) => { Err(err) => {
external_error = Some(err); external_error = Some(err);
@@ -384,7 +424,20 @@ impl Server {
Ok(token_info) => self Ok(token_info) => self
.access_token(token_info.account_id) .access_token(token_info.account_id)
.await .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) => { Err(err) => {
if let Some(external_error) = external_error { if let Some(external_error) = external_error {
Err(external_error) Err(external_error)
+3
View File
@@ -132,6 +132,8 @@ pub struct PermissionsGroup {
pub struct AccessToken { pub struct AccessToken {
scope_idx: usize, scope_idx: usize,
inner: Arc<AccessTokenInner>, inner: Arc<AccessTokenInner>,
// inbuxa: how this session signed in, for the audit log (AU-5)
origin: Option<Arc<inbuxa_features::audit::Via>>,
} }
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
@@ -298,6 +300,7 @@ impl BuildAccessToken for Arc<AccessTokenInner> {
fn build(self) -> AccessToken { fn build(self) -> AccessToken {
AccessToken { AccessToken {
scope_idx: 0, scope_idx: 0,
origin: None,
inner: self, inner: self,
} }
} }
+6
View File
@@ -269,6 +269,12 @@ impl Default for DefaultPermissions {
default.superuser.push(permission); default.superuser.push(permission);
default.tenant.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 => { permission => {
let name = permission.as_str(); let name = permission.as_str();
if name.starts_with("jmap") if name.starts_with("jmap")
+22
View File
@@ -31,6 +31,19 @@ impl Server {
pub async fn synchronize_account( pub async fn synchronize_account(
&self, &self,
account: directory::Account, account: directory::Account,
) -> trc::Result<AccountWithId> {
// 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<AccountWithId> { ) -> trc::Result<AccountWithId> {
let (local, domain) = self.validate_address(&account.email).await?; 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<u32> { pub async fn synchronize_group(&self, group: directory::Group) -> trc::Result<u32> {
// 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<u32> {
let (local, domain) = self.validate_address(&group.email).await?; let (local, domain) = self.validate_address(&group.email).await?;
match self match self
+2
View File
@@ -99,6 +99,7 @@ impl Data {
logos: Default::default(), logos: Default::default(),
smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"), smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"),
build_errors: Default::default(), build_errors: Default::default(),
audit: Default::default(),
asn_geo_data: Default::default(), asn_geo_data: Default::default(),
} }
} }
@@ -243,6 +244,7 @@ impl Default for Data {
logos: Default::default(), logos: Default::default(),
smtp_connectors: TlsConnectors::try_new().unwrap(), smtp_connectors: TlsConnectors::try_new().unwrap(),
build_errors: Default::default(), build_errors: Default::default(),
audit: Default::default(),
asn_geo_data: Default::default(), asn_geo_data: Default::default(),
lookup_stores: Default::default(), lookup_stores: Default::default(),
} }
+6
View File
@@ -67,6 +67,7 @@ use utils::{
pub mod auth; pub mod auth;
pub mod cache; pub mod cache;
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
pub mod config; pub mod config;
pub mod expr; pub mod expr;
pub mod i18n; pub mod i18n;
@@ -174,6 +175,9 @@ pub struct Data {
// inbuxa: the objects that failed to build when the running settings // inbuxa: the objects that failed to build when the running settings
// were built, at boot or by the last applied reload (see reload_registry) // were built, at boot or by the last applied reload (see reload_registry)
pub build_errors: Mutex<AHashSet<registry::types::id::ObjectId>>, pub build_errors: Mutex<AHashSet<registry::types::id::ObjectId>>,
// inbuxa: the audit log's chain heads and recent-access marks (AU)
pub audit: inbuxa_features::audit::AuditLog,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -282,6 +286,8 @@ pub struct HttpAuthCache {
pub revision: u64, pub revision: u64,
pub credential_id: Option<u32>, pub credential_id: Option<u32>,
pub expires: Instant, pub expires: Instant,
// inbuxa: how the cached credentials signed in (AU-5)
pub origin: Option<Arc<inbuxa_features::audit::Via>>,
} }
pub struct Ipc { pub struct Ipc {
+4
View File
@@ -243,6 +243,10 @@ impl BootManager {
// inbuxa: a reload isn't refused over objects that failed here // inbuxa: a reload isn't refused over objects that failed here
inner.build_server().record_build_errors(&bootstrap.errors); 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 { BootManager {
inner, inner,
bootstrap, bootstrap,
@@ -29,11 +29,31 @@ use trc::AddContext;
use types::id::Id; use types::id::Id;
/// Granted to the default administrator roles: "Explain this" /// Granted to the default administrator roles: "Explain this"
/// (ai-explain spec, EX-4: superuser by default). /// (ai-explain spec, EX-4: superuser by default), and the audit log
const ADMIN_GRANTS: &[Permission] = &[Permission::SysAiExplain]; /// (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(); 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()); key.extend_from_slice(permission.as_str().as_bytes());
ValueClass::Any(AnyClass { ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA, 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<()> { 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(); let mut pending = Vec::new();
for permission in ADMIN_GRANTS { for permission in grants {
if bp if bp
.data_store .data_store
.get_value::<String>(ValueKey::from(granted_key(*permission))) .get_value::<String>(ValueKey::from(granted_key(*permission, audience)))
.await .await
.caused_by(trc::location!())? .caused_by(trc::location!())?
.is_none() .is_none()
@@ -58,21 +83,33 @@ pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Resu
return Ok(()); return Ok(());
} }
// An administrator's default roles include the plain User role, which // 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<Id> = bp let admin_roles: Vec<Id> = bp
.registry .registry
.object::<Authentication>(Id::singleton()) .object::<Authentication>(Id::singleton())
.await? .await?
.map(|auth| { .map(|auth| {
let shared = [ let (own, shared) = match audience {
Audience::Admin => (
auth.default_admin_role_ids.as_slice(),
[
auth.default_user_role_ids.as_slice(), auth.default_user_role_ids.as_slice(),
auth.default_group_role_ids.as_slice(), auth.default_group_role_ids.as_slice(),
auth.default_tenant_role_ids.as_slice(), auth.default_tenant_role_ids.as_slice(),
] ]
.concat(); .concat(),
auth.default_admin_role_ids ),
.as_slice() Audience::Tenant => (
.iter() 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)) .filter(|id| !shared.contains(id))
.copied() .copied()
.collect() .collect()
@@ -114,7 +151,7 @@ pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Resu
} }
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
for permission in pending { 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 bp.data_store
.write(batch.build_all()) .write(batch.build_all())
+8 -4
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -335,9 +337,10 @@ impl Server {
.insert(IpWithTtl::new(ip, expires_at.unwrap_or(u64::MAX))); .insert(IpWithTtl::new(ip, expires_at.unwrap_or(u64::MAX)));
// Write blocked IP to config // Write blocked IP to config
let RegistryWriteResult::Success(id) = self // inbuxa: AU-1.10: recorded as the server's automatic ban
.registry() let RegistryWriteResult::Success(id) = inbuxa_features::audit::scope::system(
.write(RegistryWrite::insert( "auto-ban",
self.registry().write(RegistryWrite::insert(
&BlockedIp { &BlockedIp {
address: IpAddrOrMask::from_ip(ip), address: IpAddrOrMask::from_ip(ip),
created_at: UTCDateTime::from_timestamp(now as i64), created_at: UTCDateTime::from_timestamp(now as i64),
@@ -345,7 +348,8 @@ impl Server {
reason, reason,
} }
.into(), .into(),
)) )),
)
.await .await
.caused_by(trc::location!())? .caused_by(trc::location!())?
else { else {
+3
View File
@@ -17,6 +17,9 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
xxhash-rust = { version = "0.8.18", features = ["xxh3"] } xxhash-rust = { version = "0.8.18", features = ["xxh3"] }
base64 = "0.23" base64 = "0.23"
sha2 = "0.11"
flate2 = "1.1"
tokio = { version = "1.53", features = ["sync", "rt"] }
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.53", features = ["macros", "rt"] } tokio = { version = "1.53", features = ["macros", "rt"] }
+17 -1
View File
@@ -9,11 +9,27 @@
//! it holds a secret anywhere inside it. //! it holds a secret anywhere inside it.
use serde_json::Value; use serde_json::Value;
use std::collections::HashSet; use std::{collections::HashSet, io::Read, sync::OnceLock};
/// The registry schema, as the console downloads it. /// The registry schema, as the console downloads it.
pub struct Schema(Value); 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<Option<Schema>> = 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. /// What the schema says about one property of one object.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct PropertyInfo { pub struct PropertyInfo {
+215
View File
@@ -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<Change> {
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::<Vec<_>>();
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<String, Value>) -> Vec<Change> {
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<String>,
pub account_id: Option<u32>,
pub tenant_id: Option<u32>,
}
/// 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());
}
}
+984
View File
@@ -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<Self, Self::Err> {
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<u8> {
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<Self> {
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<Option<Head>> {
data.get_value::<Head>(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>(T);
impl<T: SerdeSerialize> Serialize for Json<T> {
fn serialize(&self) -> trc::Result<Vec<u8>> {
serde_json::to_vec(&self.0).map_err(|err| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to serialize audit entry")
.reason(err)
})
}
}
impl<T: serde::de::DeserializeOwned + Sync + Send> Deserialize for Json<T> {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
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<u8>);
impl Deserialize for Raw {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
Ok(Raw(bytes.to_vec()))
}
}
struct U64(u64);
impl Deserialize for U64 {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
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<ValueClass> {
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<ValueClass> {
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<ValueClass> {
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<Vec<u64>> {
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<AHashMap<(u32, u32, u8), u64>>,
}
/// A query over events (AU-9), newest first.
#[derive(Debug, Clone, Default)]
pub struct Filter {
/// From this time on, in ms.
pub after: Option<u64>,
/// Before this time, in ms.
pub before: Option<u64>,
pub actor_id: Option<u32>,
pub action: Option<Action>,
pub target_kind: Option<String>,
pub target_id: Option<String>,
pub account_id: Option<u32>,
/// Records whose actor or target is in this tenant.
pub tenant_id: Option<u32>,
pub outcome: Option<String>,
pub remote_ip: Option<IpAddr>,
/// Words that must all appear in the actor's or target's name, the
/// target kind, or the details, ignoring case.
pub text: Option<String>,
}
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// 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<EntryId> {
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<EntryId> {
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<EntryId> {
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<Option<Record>> {
let Some(Json(stored)) = data
.get_value::<Json<Stored>>(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::<U64>(key(KIND_OUTCOME, &[id.node, id.seq]))
.await
.caused_by(trc::location!())?
&& let Some(Json(Stored {
entry: Entry::Outcome { outcome, .. },
..
})) = data
.get_value::<Json<Stored>>(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<Option<(Record, String, String)>> {
let Some(Raw(bytes)) = data
.get_value::<Raw>(key(KIND_ENTRY, &[id.node, id.seq]))
.await
.caused_by(trc::location!())?
else {
return Ok(None);
};
let Json(stored) = Json::<Stored>::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<Vec<EntryId>> {
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<EntryId>, 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<EntryId>, 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<Settings> {
Ok(data
.get_value::<Json<Settings>>(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<Vec<u64>> {
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<Floor> {
Ok(data
.get_value::<Json<Floor>>(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<usize> {
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::<Stored>::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<Vec<ChainReport>> {
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<u64> = 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::<Stored>::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::<EntryId>(), Ok(id));
assert!("2".parse::<EntryId>().is_err());
assert!("a-1".parse::<EntryId>().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, "[email protected]", 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"
);
}
}
+23
View File
@@ -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};
+349
View File
@@ -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<Via>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_ip: Option<IpAddr>,
pub action: Action,
pub target: Target,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changes: Vec<Change>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
/// 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<String>,
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<u32>,
/// The account's name, or `system:<subsystem>`.
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<u32>,
}
impl Actor {
pub fn account(account_id: u32, name: impl Into<String>, tenant_id: Option<u32>) -> 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<u32>,
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<Self> {
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// The account the object belongs to, when it belongs to one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account_id: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<u32>,
}
/// 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<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<Value>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub redacted: bool,
}
impl Change {
pub fn new(field: impl Into<String>, before: Option<Value>, after: Option<Value>) -> Self {
Change {
field: field.into(),
before: before.map(shorten),
after: after.map(shorten),
redacted: false,
}
}
pub fn redacted(field: impl Into<String>) -> 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<String>,
},
Refused {
/// The JMAP error type (`forbidden`, `invalidProperties`, …).
error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
/// 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<String>, description: Option<String>) -> 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, "[email protected]", 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::<Record>(&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())
);
}
}
}
+70
View File
@@ -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: Future>(f: F) -> F::Output {
SCOPE.scope(Scope::Request, f).await
}
/// Runs `f` as the server's own `subsystem`.
pub async fn system<F: Future>(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: Future>(f: F) -> F::Output {
SCOPE.scope(Scope::Quiet, f).await
}
/// The scope the current task runs in, if any.
pub fn current() -> Option<Scope> {
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);
}
}
+1
View File
@@ -19,6 +19,7 @@
//! `common::Server`. //! `common::Server`.
pub mod ai; pub mod ai;
pub mod audit;
pub mod branding; pub mod branding;
pub mod masked_email; pub mod masked_email;
pub mod security; pub mod security;
+6 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use common::auth::AccessToken; use common::auth::AccessToken;
@@ -36,7 +38,9 @@ impl Authenticator for Server {
self.access_token(http_cache.account_id).await?, self.access_token(http_cache.account_id).await?,
http_cache.credential_id, http_cache.credential_id,
session.remote_ip, session.remote_ip,
)?; )?
// inbuxa: AU-5
.with_origin_arc(http_cache.origin.clone());
if access_token.revision() == http_cache.revision { if access_token.revision() == http_cache.revision {
// Enforce authenticated rate limit // Enforce authenticated rate limit
@@ -99,6 +103,7 @@ impl Authenticator for Server {
credential_id: access_token.credential_id(), credential_id: access_token.credential_id(),
expires: Instant::now() expires: Instant::now()
+ Duration::from_secs(self.core.oauth.oauth_expiry_token), + Duration::from_secs(self.core.oauth.oauth_expiry_token),
origin: access_token.origin_arc(),
}, },
); );
+8 -4
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::ErrorType; use super::ErrorType;
@@ -164,9 +166,10 @@ impl ClientRegistrationHandler for Server {
.await .await
.caused_by(trc::location!())?; .caused_by(trc::location!())?;
let result = self // inbuxa: AU-1.10: a client registering itself
.registry() let result = inbuxa_features::audit::scope::system(
.write(RegistryWrite::insert( "oauth-registration",
self.registry().write(RegistryWrite::insert(
&OAuthClient { &OAuthClient {
client_id: client_id.clone(), client_id: client_id.clone(),
description: request.client_name.clone(), description: request.client_name.clone(),
@@ -179,7 +182,8 @@ impl ClientRegistrationHandler for Server {
..Default::default() ..Default::default()
} }
.into(), .into(),
)) )),
)
.await .await
.caused_by(trc::location!())?; .caused_by(trc::location!())?;
+4 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::{ use super::{
@@ -327,7 +329,8 @@ impl TokenHandler for Server {
account_id, account_id,
account_name, account_name,
self.core.oauth.oauth_expiry_token, 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(), credential_version.into(),
) )
.await?, .await?,
@@ -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<Self> {
// 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<Self> {
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<Self, Self::Err> {
AuditProperty::parse(s).ok_or(())
}
}
impl Element for AuditValue {
type Property = AuditProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
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<A>(&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::<serde::de::IgnoredAny>()?;
}
);
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<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
if key == "property" {
let value = map.next_value::<Cow<str>>()?;
*self = if value == "at" {
AuditComparator::At
} else {
AuditComparator::_T(value.into_owned())
};
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
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<Id> for AuditValue {
fn from(id: Id) -> Self {
AuditValue::Id(id)
}
}
impl JmapObjectId for AuditValue {
fn as_id(&self) -> Option<Id> {
match self {
AuditValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
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<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
+1
View File
@@ -22,6 +22,7 @@ pub mod email;
pub mod email_submission; pub mod email_submission;
pub mod fastmail_masked_email; // inbuxa: masked email pub mod fastmail_masked_email; // inbuxa: masked email
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification 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_explanation; // inbuxa: "Explain this" with the local model
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
+6
View File
@@ -61,6 +61,12 @@ impl Response<'_> {
GetResponseMethod::AiLimits(response) => { GetResponseMethod::AiLimits(response) => {
response.eval_jptr(path, &mut results) 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) => { GetResponseMethod::ProtocolPolicy(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
@@ -46,6 +46,8 @@ impl Response<'_> {
GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?, GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?,
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?, GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
GetRequestMethod::AiLimits(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::ProtocolPolicy(request) => request.resolve_references(self)?,
GetRequestMethod::TenantProtocolPolicy(request) => { GetRequestMethod::TenantProtocolPolicy(request) => {
request.resolve_references(self)? request.resolve_references(self)?
@@ -96,6 +98,15 @@ impl Response<'_> {
SetRequestMethod::Explanation(request) => { SetRequestMethod::Explanation(request) => {
request.resolve_references(self, 1, false)? 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) => { SetRequestMethod::ProtocolPolicy(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
+27
View File
@@ -51,6 +51,11 @@ pub enum MethodObject {
AiLimits, AiLimits,
// inbuxa: "Explain this" with the local model // inbuxa: "Explain this" with the local model
Explanation, Explanation,
// inbuxa: the audit log
AuditEvent,
AuditSettings,
AuditExport,
AuditVerification,
ProtocolPolicy, ProtocolPolicy,
TenantProtocolPolicy, TenantProtocolPolicy,
} }
@@ -80,6 +85,10 @@ impl MethodObject {
MethodObject::DeletedAccount => Capability::Inbuxa, MethodObject::DeletedAccount => Capability::Inbuxa,
MethodObject::AiLimits => Capability::Inbuxa, MethodObject::AiLimits => Capability::Inbuxa,
MethodObject::Explanation => Capability::Inbuxa, MethodObject::Explanation => Capability::Inbuxa,
MethodObject::AuditEvent
| MethodObject::AuditSettings
| MethodObject::AuditExport
| MethodObject::AuditVerification => Capability::Inbuxa,
MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa,
MethodObject::TenantProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
} }
@@ -260,6 +269,14 @@ impl MethodName {
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get", (MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set", (MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
(MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/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::Get, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/get",
(MethodFunction::Set, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/set", (MethodFunction::Set, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/set",
(MethodFunction::Get, MethodObject::TenantProtocolPolicy) => { (MethodFunction::Get, MethodObject::TenantProtocolPolicy) => {
@@ -394,6 +411,12 @@ impl MethodName {
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get), "inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set), "inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
"inbuxa:Explanation/set" => (MethodObject::Explanation, 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/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set), "inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get), "inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
@@ -452,6 +475,10 @@ impl Display for MethodObject {
MethodObject::DeletedAccount => "inbuxa:DeletedAccount", MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
MethodObject::AiLimits => "inbuxa:AiLimits", MethodObject::AiLimits => "inbuxa:AiLimits",
MethodObject::Explanation => "inbuxa:Explanation", 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::ProtocolPolicy => "inbuxa:ProtocolPolicy",
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
MethodObject::Registry(obj) => { MethodObject::Registry(obj) => {
+6
View File
@@ -116,6 +116,8 @@ pub enum GetRequestMethod {
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>), MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>), DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>), AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>), ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy( TenantProtocolPolicy(
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>, Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
@@ -144,6 +146,9 @@ pub enum SetRequestMethod<'x> {
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>), DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>), AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
Explanation(Box<SetRequest<'x, crate::object::inbuxa_explanation::Explanation>>), Explanation(Box<SetRequest<'x, crate::object::inbuxa_explanation::Explanation>>),
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>), ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy( TenantProtocolPolicy(
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>, Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
@@ -175,6 +180,7 @@ pub enum QueryRequestMethod {
CalendarEventNotification(Box<QueryRequest<CalendarEventNotification>>), CalendarEventNotification(Box<QueryRequest<CalendarEventNotification>>),
ShareNotification(Box<QueryRequest<ShareNotification>>), ShareNotification(Box<QueryRequest<ShareNotification>>),
Registry(Box<QueryRequest<Registry>>), Registry(Box<QueryRequest<Registry>>),
AuditEvent(Box<QueryRequest<crate::object::inbuxa_audit::AuditEvent>>),
} }
#[derive(Debug)] #[derive(Debug)]
+43
View File
@@ -551,6 +551,49 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self)); 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() { (MethodFunction::Query, MethodObject::Registry(_)) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Registry(value)), Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Registry(value)),
Err(err) => RequestMethod::invalid(err), Err(err) => RequestMethod::invalid(err),
+36
View File
@@ -103,6 +103,8 @@ pub enum GetResponseMethod {
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>), MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>), DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>), AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>), ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
TenantProtocolPolicy( TenantProtocolPolicy(
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>, GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
@@ -131,6 +133,9 @@ pub enum SetResponseMethod {
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>), MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>), DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>), AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>),
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>), Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>), ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy( TenantProtocolPolicy(
@@ -714,3 +719,34 @@ impl From<SetResponse<CalendarEventNotification>> for ResponseMethod<'_> {
))) )))
} }
} }
// inbuxa: the audit log
impl<'x> From<GetResponse<crate::object::inbuxa_audit::AuditEvent>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_audit::AuditEvent>) -> Self {
ResponseMethod::Get(GetResponseMethod::AuditEvent(value))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_audit::AuditSettings>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_audit::AuditSettings>) -> Self {
ResponseMethod::Get(GetResponseMethod::AuditSettings(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_audit::AuditSettings>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_audit::AuditSettings>) -> Self {
ResponseMethod::Set(SetResponseMethod::AuditSettings(Box::new(value)))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_audit::AuditExport>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_audit::AuditExport>) -> Self {
ResponseMethod::Set(SetResponseMethod::AuditExport(Box::new(value)))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_audit::AuditVerification>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_audit::AuditVerification>) -> Self {
ResponseMethod::Set(SetResponseMethod::AuditVerification(Box::new(value)))
}
}
+32
View File
@@ -77,6 +77,10 @@ impl JmapAuthorization for AccessToken {
GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet, GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet,
// inbuxa: AI call limits, with the classifier's permissions // inbuxa: AI call limits, with the classifier's permissions
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet, 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 // inbuxa: legacy protocols off. It takes listeners away and
// puts them back, so it takes the listener's permissions // puts them back, so it takes the listener's permissions
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
@@ -180,6 +184,28 @@ impl JmapAuthorization for AccessToken {
Permission::SysSpamLlmUpdate, Permission::SysSpamLlmUpdate,
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) // inbuxa: "Explain this" (EX-4)
SetRequestMethod::Explanation(s) => validate_set( SetRequestMethod::Explanation(s) => validate_set(
s, s,
@@ -315,6 +341,10 @@ impl JmapAuthorization for AccessToken {
| MethodObject::DeletedAccount | MethodObject::DeletedAccount
| MethodObject::AiLimits | MethodObject::AiLimits
| MethodObject::Explanation | MethodObject::Explanation
| MethodObject::AuditEvent
| MethodObject::AuditSettings
| MethodObject::AuditExport
| MethodObject::AuditVerification
| MethodObject::ProtocolPolicy | MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
// inbuxa: x:MaskedEmail/changes reads what /get reads // inbuxa: x:MaskedEmail/changes reads what /get reads
@@ -371,6 +401,8 @@ impl JmapAuthorization for AccessToken {
Permission::JmapCalendarEventNotificationQuery Permission::JmapCalendarEventNotificationQuery
} }
QueryRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationQuery, QueryRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationQuery,
// inbuxa: the audit log (AU-9)
QueryRequestMethod::AuditEvent(_) => Permission::SysAuditGet,
QueryRequestMethod::Registry(_) => { QueryRequestMethod::Registry(_) => {
let MethodObject::Registry(object_type) = object else { let MethodObject::Registry(object_type) = object else {
unreachable!() unreachable!()
+5 -2
View File
@@ -188,10 +188,13 @@ impl ToRequestError for trc::Error {
trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => { trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => {
RequestError::forbidden() 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::IpBlockExpired
| trc::SecurityEvent::IpAllowExpired | trc::SecurityEvent::IpAllowExpired
| trc::SecurityEvent::LegacyProtocolsChanged => { | trc::SecurityEvent::LegacyProtocolsChanged
| trc::SecurityEvent::AuditRecorded
| trc::SecurityEvent::AuditWriteFailed => {
RequestError::internal_server_error() RequestError::internal_server_error()
} }
}, },
+126 -11
View File
@@ -161,13 +161,16 @@ impl RequestHandler for Server {
}, },
_ => None, _ => None,
}; };
let method_call = self.handle_method_call( // 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.method,
call.name, call.name,
access_token, access_token,
&mut next_call, &mut next_call,
session, session,
); ),
));
let result = if eligible { let result = if eligible {
store::backend::scaleout::replica::replica_read( store::backend::scaleout::replica::replica_read(
access_token.all_ids().map(|account_id| { access_token.all_ids().map(|account_id| {
@@ -184,6 +187,10 @@ impl RequestHandler for Server {
} else { } else {
method_call.await method_call.await
}; };
let (result, reached) = result;
for account_id in reached {
self.audit_foreign_access(access_token, account_id, false).await;
}
match result match result
{ {
Ok(mut method_response) => { Ok(mut method_response) => {
@@ -221,6 +228,15 @@ impl RequestHandler for Server {
SetResponseMethod::AiLimits(set_response) => { SetResponseMethod::AiLimits(set_response) => {
set_response.update_created_ids(&mut 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) => { SetResponseMethod::Explanation(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
@@ -385,6 +401,19 @@ impl RequestHandler for Server {
.await? .await?
.into() .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) // inbuxa: inbuxa:ProtocolPolicy/get (legacy protocols off)
GetRequestMethod::ProtocolPolicy(mut req) => { GetRequestMethod::ProtocolPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; 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() 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) => { QueryRequestMethod::Registry(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
assert_registry_account(self, method_name.obj, access_token, req.account_id) 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 // inbuxa: Fastmail's MaskedEmail/set
SetRequestMethod::MaskedEmail(mut req) => { SetRequestMethod::MaskedEmail(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::fastmail::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::fastmail::set(self, access_token, req)),
)
.await? .await?
.into() .into()
} }
// inbuxa: inbuxa:DeletedAccount/set (UD-17) // inbuxa: inbuxa:DeletedAccount/set (UD-17)
SetRequestMethod::DeletedAccount(mut req) => { SetRequestMethod::DeletedAccount(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::deleted_account::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::deleted_account::set(self, access_token, req)),
)
.await? .await?
.into() .into()
} }
// inbuxa: inbuxa:AiLimits/set // inbuxa: inbuxa:AiLimits/set
SetRequestMethod::AiLimits(mut req) => { SetRequestMethod::AiLimits(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; 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? .await?
.into() .into()
} }
@@ -650,14 +740,32 @@ impl RequestHandler for Server {
// inbuxa: inbuxa:ProtocolPolicy/set (legacy protocols off) // inbuxa: inbuxa:ProtocolPolicy/set (legacy protocols off)
SetRequestMethod::ProtocolPolicy(mut req) => { SetRequestMethod::ProtocolPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::protocol_policy::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::protocol_policy::set(self, access_token, req)),
)
.await? .await?
.into() .into()
} }
// inbuxa: inbuxa:TenantProtocolPolicy/set (legacy protocols off, per tenant) // inbuxa: inbuxa:TenantProtocolPolicy/set (legacy protocols off, per tenant)
SetRequestMethod::TenantProtocolPolicy(mut req) => { SetRequestMethod::TenantProtocolPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::tenant_protocol_policy::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::tenant_protocol_policy::set(self, access_token, req)),
)
.await? .await?
.into() .into()
} }
@@ -724,12 +832,17 @@ impl RequestHandler for Server {
assert_registry_account(self, method_name.obj, access_token, req.account_id) assert_registry_account(self, method_name.obj, access_token, req.account_id)
.await?; .await?;
Box::pin(self.registry_set( // inbuxa: AU-1.1, AU-3: recorded before and after
method_name.obj.unwrap_registry(), let object_type = method_name.obj.unwrap_registry();
*req, crate::inbuxa::audit::recorded(
self,
access_token, access_token,
session, session,
)) &method_name.obj.to_string(),
Some(object_type),
*req,
|req| Box::pin(self.registry_set(object_type, req, access_token, session)),
)
.await? .await?
.into() .into()
} }
@@ -906,6 +1019,8 @@ pub(crate) fn resolve_account_id(
access_token: &AccessToken, access_token: &AccessToken,
) -> trc::Result<()> { ) -> trc::Result<()> {
if account_id.id() < INVALID_ACCOUNT_ID { if account_id.id() < INVALID_ACCOUNT_ID {
// inbuxa: AU-1.6
crate::inbuxa::audit::note_access(account_id.document_id(), access_token);
Ok(()) Ok(())
} else if matches!( } else if matches!(
obj, obj,
+5
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use common::{Server, auth::AccessToken}; use common::{Server, auth::AccessToken};
@@ -115,6 +117,9 @@ impl BlobDownload for Server {
document_id, document_id,
} => { } => {
if access_token.is_member(*account_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 true
} else { } else {
match Collection::from(*collection) { match Collection::from(*collection) {
+4
View File
@@ -419,6 +419,10 @@ impl IntermediateChangesResponse {
| MethodObject::DeletedAccount | MethodObject::DeletedAccount
| MethodObject::AiLimits | MethodObject::AiLimits
| MethodObject::Explanation | MethodObject::Explanation
| MethodObject::AuditEvent
| MethodObject::AuditSettings
| MethodObject::AuditExport
| MethodObject::AuditVerification
| MethodObject::ProtocolPolicy | MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy | MethodObject::TenantProtocolPolicy
| MethodObject::Registry(_) => unreachable!(), | MethodObject::Registry(_) => unreachable!(),
+412
View File
@@ -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<Vec<u32>>;
}
/// 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: Future>(f: F) -> (F::Output, Vec<u32>) {
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<Id>),
Destroy(MaybeInvalid<Id>),
}
/// 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<ObjectType>,
request: &'a SetRequest<'_, T>,
) -> std::pin::Pin<Box<dyn Future<Output = trc::Result<Pending>> + 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<ObjectType>,
request: SetRequest<'x, T>,
method: F,
) -> trc::Result<SetResponse<T>>
where
T: JmapObject,
F: FnOnce(SetRequest<'x, T>) -> std::pin::Pin<Box<Fut>>,
Fut: Future<Output = trc::Result<SetResponse<T>>> + ?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<T: JmapObject>(
server: &Server,
access_token: &AccessToken,
session: &HttpSessionData,
object: &str,
registry: Option<ObjectType>,
request: &SetRequest<'_, T>,
) -> trc::Result<Pending> {
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<T: JmapObject>(
server: &Server,
pending: Pending,
result: &trc::Result<SetResponse<T>>,
) {
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<T: JmapObject>(response: &SetResponse<T>, item: &Item) -> Outcome {
let refused = |err: &SetError<T::Property>| {
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<Id>) -> 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<Id>) -> Option<Value> {
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<ObjectType>,
id: &MaybeInvalid<Id>,
) -> Option<Value> {
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));
}
}
+864
View File
@@ -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<T: serde::Serialize>(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<u32> {
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<AuditEvent>,
) -> trc::Result<GetResponse<AuditEvent>> {
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<u64, String> {
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<QueryFilter<AuditFilter>>) -> trc::Result<Filter> {
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<Filter> {
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<AuditEvent>,
) -> trc::Result<QueryResponse> {
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<AuditSettings>,
) -> trc::Result<GetResponse<AuditSettings>> {
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<SetResponse<AuditSettings>> {
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<AValue>) -> Result<Filter, String> {
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<u8>, 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<u32>| 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::<Vec<_>>()
.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<SetResponse<AuditExport>> {
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::<String>())
}
(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<SetResponse<AuditVerification>> {
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::<Vec<_>>()
.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, "[email protected]", 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);
}
}
+1 -9
View File
@@ -89,15 +89,7 @@ const NOT_SETTINGS: &[ObjectType] = &[
/// The registry schema the console downloads, read once. /// The registry schema the console downloads, read once.
fn schema() -> Option<&'static Schema> { fn schema() -> Option<&'static Schema> {
static SCHEMA: OnceLock<Option<Schema>> = OnceLock::new(); inbuxa_features::ai::explain::schema::embedded()
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()
} }
fn server_fail(why: &'static str) -> SetError<P> { fn server_fail(why: &'static str) -> SetError<P> {
+2
View File
@@ -8,6 +8,8 @@
//! `crates/features`; this module only speaks JMAP for them. //! `crates/features`; this module only speaks JMAP for them.
pub mod access; pub mod access;
pub mod audit;
pub mod audit_log;
pub mod ai_limits; pub mod ai_limits;
pub mod explanation; pub mod explanation;
pub mod protocol_policy; pub mod protocol_policy;
+4
View File
@@ -1730,6 +1730,10 @@ pub enum Permission {
ScimAccess = 660, ScimAccess = 660,
// inbuxa: "Explain this" (ai-explain spec) // inbuxa: "Explain this" (ai-explain spec)
SysAiExplain = 661, SysAiExplain = 661,
// inbuxa: the audit log (audit-hold-lock spec, AU-9)
SysAuditGet = 662,
SysAuditExport = 663,
SysAuditSettingsUpdate = 664,
SysAccountGet = 219, SysAccountGet = 219,
SysAccountCreate = 220, SysAccountCreate = 220,
SysAccountUpdate = 221, SysAccountUpdate = 221,
+10 -1
View File
@@ -7073,6 +7073,9 @@ impl EnumImpl for Permission {
b"liveDeliveryTest" => Permission::LiveDeliveryTest, b"liveDeliveryTest" => Permission::LiveDeliveryTest,
b"scimAccess" => Permission::ScimAccess, b"scimAccess" => Permission::ScimAccess,
b"sysAiExplain" => Permission::SysAiExplain, b"sysAiExplain" => Permission::SysAiExplain,
b"sysAuditGet" => Permission::SysAuditGet,
b"sysAuditExport" => Permission::SysAuditExport,
b"sysAuditSettingsUpdate" => Permission::SysAuditSettingsUpdate,
b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountGet" => Permission::SysAccountGet,
b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountCreate" => Permission::SysAccountCreate,
b"sysAccountUpdate" => Permission::SysAccountUpdate, b"sysAccountUpdate" => Permission::SysAccountUpdate,
@@ -7751,6 +7754,9 @@ impl EnumImpl for Permission {
Permission::LiveDeliveryTest => "liveDeliveryTest", Permission::LiveDeliveryTest => "liveDeliveryTest",
Permission::ScimAccess => "scimAccess", Permission::ScimAccess => "scimAccess",
Permission::SysAiExplain => "sysAiExplain", Permission::SysAiExplain => "sysAiExplain",
Permission::SysAuditGet => "sysAuditGet",
Permission::SysAuditExport => "sysAuditExport",
Permission::SysAuditSettingsUpdate => "sysAuditSettingsUpdate",
Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountGet => "sysAccountGet",
Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountCreate => "sysAccountCreate",
Permission::SysAccountUpdate => "sysAccountUpdate", Permission::SysAccountUpdate => "sysAccountUpdate",
@@ -8422,6 +8428,9 @@ impl EnumImpl for Permission {
218 => Some(Permission::LiveDeliveryTest), 218 => Some(Permission::LiveDeliveryTest),
660 => Some(Permission::ScimAccess), 660 => Some(Permission::ScimAccess),
661 => Some(Permission::SysAiExplain), 661 => Some(Permission::SysAiExplain),
662 => Some(Permission::SysAuditGet),
663 => Some(Permission::SysAuditExport),
664 => Some(Permission::SysAuditSettingsUpdate),
219 => Some(Permission::SysAccountGet), 219 => Some(Permission::SysAccountGet),
220 => Some(Permission::SysAccountCreate), 220 => Some(Permission::SysAccountCreate),
221 => Some(Permission::SysAccountUpdate), 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 { impl serde::Serialize for Permission {
@@ -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!( trc::event!(
Store(StoreEvent::DataStorePurged), Store(StoreEvent::DataStorePurged),
Elapsed = started.elapsed() Elapsed = started.elapsed()
@@ -514,6 +514,16 @@ async fn run_task(
server: &Server, server: &Server,
task: &Task, task: &Task,
server_instance: Arc<ServerInstance>, server_instance: Arc<ServerInstance>,
) -> 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<ServerInstance>,
) -> TaskResult { ) -> TaskResult {
match task { match task {
Task::CalendarAlarmEmail(task) => { Task::CalendarAlarmEmail(task) => {
@@ -77,7 +77,8 @@ async fn spam_filter_maintenance(
} }
} }
TaskSpamFilterMaintenanceType::UpdateRules => { 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<TaskResult> {
.await; .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::<Vec<_>>();
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!( trc::event!(
Spam(SpamEvent::RulesUpdated), Spam(SpamEvent::RulesUpdated),
Details = stats Details = stats
+1
View File
@@ -172,6 +172,7 @@ impl RegistryStore {
env_hostname: hostname, env_hostname: hostname,
env_public_url: None, env_public_url: None,
id_generator: utils::snowflake::SnowflakeIdGenerator::new(), id_generator: utils::snowflake::SnowflakeIdGenerator::new(),
write_hook: Default::default(),
}, },
true, true,
) )
+2
View File
@@ -212,6 +212,8 @@ pub struct RegistryStoreInner {
pub(crate) env_hostname: String, pub(crate) env_hostname: String,
pub(crate) env_public_url: Option<String>, pub(crate) env_public_url: Option<String>,
pub(crate) id_generator: SnowflakeIdGenerator, 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")] #[cfg(feature = "sqlite")]
+33
View File
@@ -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<Box<dyn Future<Output = ()> + Send + 'a>>;
}
pub type RegistryHookSlot = Arc<std::sync::OnceLock<Arc<dyn RegistryWriteHook>>>;
+1
View File
@@ -67,6 +67,7 @@ impl RegistryStoreInner {
}) })
}), }),
env_hostname, env_hostname,
write_hook: Default::default(),
} }
} }
+6
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
pub mod bootstrap; pub mod bootstrap;
@@ -10,6 +12,10 @@ pub mod local;
pub mod query; pub mod query;
pub mod write; pub mod write;
// inbuxa: the audit log's view of registry writes (audit-hold-lock spec,
// AU-1.10)
pub mod hook;
use crate::{ use crate::{
Deserialize, SerializeInfallible, U16_LEN, U32_LEN, U64_LEN, Deserialize, SerializeInfallible, U16_LEN, U32_LEN, U64_LEN,
write::key::{DeserializeBigEndian, KeySerializer}, write::key::{DeserializeBigEndian, KeySerializer},
+37
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -73,7 +75,42 @@ pub enum RegistryWrite<'x> {
} }
impl RegistryStore { 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<dyn super::hook::RegistryWriteHook>) {
let _ = self.0.write_hook.set(hook);
}
pub async fn write(&self, write: RegistryWrite<'_>) -> trc::Result<RegistryWriteResult> { pub async fn write(&self, write: RegistryWrite<'_>) -> trc::Result<RegistryWriteResult> {
// 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<RegistryWriteResult> {
let mut set_index = IndexBuilder::default(); let mut set_index = IndexBuilder::default();
let mut clear_index = IndexBuilder::default(); let mut clear_index = IndexBuilder::default();
+6 -2
View File
@@ -11,8 +11,9 @@
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is // inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is
// auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is // auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is
// security.legacy-protocols-changed (LP-8); 644 to 646 are the cluster // security.legacy-protocols-changed (LP-8); 644 to 646 are the cluster
// coordinator's connection events // coordinator's connection events; 647 and 648 are the audit log's
pub const TOTAL_EVENT_COUNT: usize = 647; // (audit-hold-lock spec, AU-3, AU-8)
pub const TOTAL_EVENT_COUNT: usize = 649;
pub const TOTAL_METRIC_COUNT: usize = 369; pub const TOTAL_METRIC_COUNT: usize = 369;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -663,6 +664,9 @@ pub enum SecurityEvent {
Unauthorized = 552, Unauthorized = 552,
// inbuxa: legacy-protocols LP-8 // inbuxa: legacy-protocols LP-8
LegacyProtocolsChanged = 643, LegacyProtocolsChanged = 643,
// inbuxa: the audit log (AU-3, AU-8)
AuditRecorded = 647,
AuditWriteFailed = 648,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+28
View File
@@ -452,6 +452,9 @@ impl EventType {
b"security.unauthorized" => EventType::Security(SecurityEvent::Unauthorized), b"security.unauthorized" => EventType::Security(SecurityEvent::Unauthorized),
// inbuxa: legacy-protocols LP-8 // inbuxa: legacy-protocols LP-8
b"security.legacy-protocols-changed" => EventType::Security(SecurityEvent::LegacyProtocolsChanged), 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.startup" => EventType::Server(ServerEvent::Startup),
b"server.shutdown" => EventType::Server(ServerEvent::Shutdown), b"server.shutdown" => EventType::Server(ServerEvent::Shutdown),
b"server.startup-error" => EventType::Server(ServerEvent::StartupError), b"server.startup-error" => EventType::Server(ServerEvent::StartupError),
@@ -1229,6 +1232,9 @@ impl EventType {
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
"security.legacy-protocols-changed" "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::Startup) => "server.startup",
EventType::Server(ServerEvent::Shutdown) => "server.shutdown", EventType::Server(ServerEvent::Shutdown) => "server.shutdown",
EventType::Server(ServerEvent::StartupError) => "server.startup-error", EventType::Server(ServerEvent::StartupError) => "server.startup-error",
@@ -1907,6 +1913,9 @@ impl EventType {
EventType::Security(SecurityEvent::Unauthorized) => 552, EventType::Security(SecurityEvent::Unauthorized) => 552,
// inbuxa: legacy-protocols LP-8 // inbuxa: legacy-protocols LP-8
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => 643, 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::Startup) => 393,
EventType::Server(ServerEvent::Shutdown) => 392, EventType::Server(ServerEvent::Shutdown) => 392,
EventType::Server(ServerEvent::StartupError) => 394, EventType::Server(ServerEvent::StartupError) => 394,
@@ -2601,6 +2610,9 @@ impl EventType {
552 => Some(EventType::Security(SecurityEvent::Unauthorized)), 552 => Some(EventType::Security(SecurityEvent::Unauthorized)),
// inbuxa: legacy-protocols LP-8 // inbuxa: legacy-protocols LP-8
643 => Some(EventType::Security(SecurityEvent::LegacyProtocolsChanged)), 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)), 393 => Some(EventType::Server(ServerEvent::Startup)),
392 => Some(EventType::Server(ServerEvent::Shutdown)), 392 => Some(EventType::Server(ServerEvent::Shutdown)),
394 => Some(EventType::Server(ServerEvent::StartupError)), 394 => Some(EventType::Server(ServerEvent::StartupError)),
@@ -3022,6 +3034,9 @@ impl EventType {
EventType::Security(SecurityEvent::Unauthorized) => Level::Info, EventType::Security(SecurityEvent::Unauthorized) => Level::Info,
// inbuxa: legacy-protocols LP-8 // inbuxa: legacy-protocols LP-8
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => Level::Info, 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::Startup) => Level::Info,
EventType::Server(ServerEvent::Shutdown) => Level::Info, EventType::Server(ServerEvent::Shutdown) => Level::Info,
EventType::Server(ServerEvent::Licensing) => Level::Info, EventType::Server(ServerEvent::Licensing) => Level::Info,
@@ -3757,6 +3772,9 @@ impl EventType {
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
"Legacy mail protocols switch changed" "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::Startup) => "Starting inbuxa Server",
EventType::Server(ServerEvent::Shutdown) => "Shutting down inbuxa Server", EventType::Server(ServerEvent::Shutdown) => "Shutting down inbuxa Server",
EventType::Server(ServerEvent::StartupError) => "Server startup error", EventType::Server(ServerEvent::StartupError) => "Server startup error",
@@ -4154,6 +4172,13 @@ impl EventType {
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
"Legacy mail protocols switch changed" "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::ConnectionStart) => "SMTP error",
EventType::Smtp(SmtpEvent::ConnectionEnd) => "SMTP error", EventType::Smtp(SmtpEvent::ConnectionEnd) => "SMTP error",
EventType::Smtp(SmtpEvent::Error) => "SMTP error", EventType::Smtp(SmtpEvent::Error) => "SMTP error",
@@ -4721,6 +4746,9 @@ impl EventType {
EventType::Security(SecurityEvent::Unauthorized), EventType::Security(SecurityEvent::Unauthorized),
// inbuxa: legacy-protocols LP-8 // inbuxa: legacy-protocols LP-8
EventType::Security(SecurityEvent::LegacyProtocolsChanged), 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::Startup),
EventType::Server(ServerEvent::Shutdown), EventType::Server(ServerEvent::Shutdown),
EventType::Server(ServerEvent::StartupError), EventType::Server(ServerEvent::StartupError),
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
krR-kLAFyDZPDN7u7qgMjHqDWn_BepUPrzpaSfZZEOM 0CZ88XU8AvHiGwlrTmlc5Lt-4dgmms3pJphCNzK5FKI
+563
View File
@@ -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<u8>);
impl Deserialize for Bytes {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
Ok(Bytes(bytes.to_vec()))
}
}
impl Account {
/// Records matching `filter`, newest first.
async fn audit(&self, filter: Value) -> Vec<Value> {
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>) -> 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("[email protected]");
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"], "[email protected]", "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("[email protected]", 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"], "[email protected]",
"test 3"
);
// AU-9: a plain user can't read the audit log
let plain = Account::new(
"[email protected]",
"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(
"[email protected]",
"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(
"[email protected]",
"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(
"[email protected]",
"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::<Bytes>(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<u8>| {
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::<Bytes>(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("[email protected]").await;
test.insert_account(admin);
self::test(&mut test).await;
if test.is_reset() {
test.temp_dir.delete();
}
}
+5 -2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * 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}; use crate::utils::{jmap::JmapUtils, server::TestServer};
@@ -183,13 +185,14 @@ pub async fn test(test: &mut TestServer) {
permission permission
); );
// inbuxa: the fork's own permissions (sysAuditGet) guard fork
// methods, not registry objects; the audit suite checks those
if let Some(name) = permission if let Some(name) = permission
.as_str() .as_str()
.strip_prefix("sys") .strip_prefix("sys")
.and_then(|perm| perm.strip_suffix("Get")) .and_then(|perm| perm.strip_suffix("Get"))
&& let Some(object_type) = ObjectType::parse(name)
{ {
let object_type = ObjectType::parse(name).unwrap();
assert_eq!( assert_eq!(
user.registry_get_many(object_type, Vec::<&str>::new()) user.registry_get_many(object_type, Vec::<&str>::new())
.await .await
+1
View File
@@ -11,6 +11,7 @@ pub mod authentication;
pub mod ai; pub mod ai;
pub mod ai_calibration; pub mod ai_calibration;
pub mod ai_explain; pub mod ai_explain;
pub mod audit; // inbuxa: the audit log
pub mod authorization; pub mod authorization;
pub mod auto_reload; // inbuxa: registry writes apply at once pub mod auto_reload; // inbuxa: registry writes apply at once
pub mod branding; pub mod branding;
+3
View File
@@ -398,6 +398,9 @@ impl TestServerBuilder {
.parse_tcp_acceptors(&mut self.bootstrap, inner.clone()) .parse_tcp_acceptors(&mut self.bootstrap, inner.clone())
.await; .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 // 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 // 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 // here runs as root, so every one of them fails to bind and the run