Audit log: a permanent, tamper-evident record of admin actions
ci / fork-checks (pull_request) Successful in 52s
ci / build (pull_request) Successful in 1h4m15s

What administrators and the server itself do to the control plane is now
recorded, from inbuxa-drafts/specs/audit-hold-lock.md (AU-1 to AU-12):
settings, accounts, domains, roles and every other registry change, with
each field's before and after (secrets only as "changed"); the fork's own
settings objects; administrator sign-ins (and failed ones to administrator
accounts), master-user and recovery-admin sign-ins, once an hour per
account, method and address; access to another account's data through
impersonation or FetchAnyBlob, once an hour; exports and tamper checks;
and registry writes the server makes on its own, named by subsystem
(system:AcmeRenewal, system:auto-ban, system:directory-sync, ...), with a
spam rules update as one summary record.

No change without its record (AU-3): before a set method changes anything,
a pending record per requested create, update and destroy is written; if
that fails, the method is refused with serverFail. Its outcome follows as
a later entry. A change interrupted by a crash stays "unfinished".

Records live in the fork's subspace under L, as one SHA-256 hash chain per
node. The chain's head is stored, never cached, and every append asserts
it, so two writers can't take the same place. Nothing can edit or delete
a record; the daily purge removes the oldest past the retention (default
730 days, minimum 90) and records where the chain now starts, so
verification still passes. security.audit-recorded (647) copies each
record to webhooks, OpenTelemetry and the log; security.audit-write-failed
(648) reports a failed write.

New JMAP objects under urn:inbuxa:jmap: inbuxa:AuditEvent/get and /query
(filters: time, actor, action, target, account, tenant, outcome, address,
text), inbuxa:AuditSettings, inbuxa:AuditExport (CSV or JSON Lines built
on the server, each line with its chain hash, ending in a manifest; the
created object names the blob and its SHA-256) and
inbuxa:AuditVerification. New permissions sysAuditGet, sysAuditExport and
sysAuditSettingsUpdate: the Administrator role gets all three, the Tenant
Administrator role gets read and export, once, on existing installs too.
A tenant administrator sees records whose actor or target is in its
tenant, including a server administrator's changes there.

Sign-in method on the session: access tokens now remember how they signed
in (password, app password, API key, OAuth client, directory, master user,
recovery admin), including across the HTTP credential cache. New OAuth
access tokens carry their client id in the sealed claims; older ones show
as client "unknown" until they expire.

The schema gains the permissions, the two events and a Management >
Compliance > Audit Log link.

Stack: the request layer boxes every inner future where it's made. Without
that, a debug build overflowed the default 2 MB worker stack on a registry
set; measured with the same request, the branch and main now overflow at
the same stack size (between 1856 and 1920 KiB, debug), so the layer adds
nothing measurable.

Tests: unit tests in inbuxa-features and jmap; system::audit::audit_log_tests
(run with --ignored) passes on RocksDB, SQLite, PostgreSQL, PostgreSQL with a
read replica, MySQL, MySQL with a replica and FoundationDB. The system, JMAP
and SCIM suites pass. authorization.rs skipped fork permissions that guard
no registry object; the audit suite checks a plain user is refused instead.
This commit is contained in:
2026-09-27 13:40:12 -07:00
parent d3ebfb79f9
commit 86d7ebd982
59 changed files with 5075 additions and 79 deletions
+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> {
AccessToken {
scope_idx: 0,
origin: None,
inner,
}
.assert_is_valid(remote_ip)
@@ -384,6 +385,7 @@ impl AccessToken {
pub fn new_maybe_invalid(inner: Arc<AccessTokenInner>) -> Self {
AccessToken {
scope_idx: 0,
origin: None,
inner,
}
}
@@ -404,7 +406,11 @@ impl AccessToken {
.ctx(trc::Key::Id, credential_id)
.reason("Credential expired or removed.")
})
.map(|scope_idx| AccessToken { scope_idx, inner })
.map(|scope_idx| AccessToken {
scope_idx,
inner,
origin: None,
})
.and_then(|token| token.assert_is_valid(remote_ip))
}
@@ -418,6 +424,7 @@ impl AccessToken {
} else {
AccessToken {
scope_idx: 0,
origin: None,
inner,
}
.assert_is_valid(remote_ip)
@@ -481,6 +488,15 @@ impl AccessToken {
|| self.has_permission(Permission::Impersonate)
}
/// inbuxa: AU-1.6: whether the account is reachable without
/// impersonation: its own, a group's it belongs to, or one shared with
/// it.
pub fn is_member_directly(&self, account_id: u32) -> bool {
self.inner.account_id == account_id
|| self.inner.member_of.contains(&account_id)
|| self.inner.access_to.iter().any(|a| a.account_id == account_id)
}
pub fn is_account_id(&self, account_id: u32) -> bool {
self.inner.account_id == account_id
}
@@ -579,6 +595,7 @@ impl AccessToken {
access_token = AccessToken {
scope_idx: access_token.scope_idx,
origin: access_token.origin.clone(),
inner: Arc::new(inner),
};
}
@@ -758,9 +775,31 @@ impl AccessToken {
}
}
/// inbuxa: how this session signed in (AU-5).
pub fn origin(&self) -> Option<&inbuxa_features::audit::Via> {
self.origin.as_deref()
}
/// inbuxa: records how this session signed in (AU-5).
pub fn with_origin(mut self, origin: inbuxa_features::audit::Via) -> Self {
self.origin = Some(Arc::new(origin));
self
}
pub fn origin_arc(&self) -> Option<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 {
AccessToken {
scope_idx: 0,
origin: None,
inner: Arc::new(AccessTokenInner::new_admin()),
}
}
@@ -775,6 +814,7 @@ impl AccessToken {
}
AccessToken {
scope_idx: 0,
origin: None,
inner: Arc::new(AccessTokenInner {
account_id,
tenant_id: Default::default(),
+59 -6
View File
@@ -26,6 +26,7 @@ use registry::schema::{
use serde::Deserialize;
use std::{borrow::Cow, net::IpAddr, sync::Arc};
use store::write::now;
use inbuxa_features::audit::Via;
use trc::AddContext;
pub struct UsernameParts {
@@ -45,8 +46,17 @@ impl Server {
.await
.and_then(|token| token.assert_has_permission(Permission::Authenticate))
{
Ok(token) => Ok(token),
Ok(token) => {
// inbuxa: AU-1.4, AU-1.5
self.audit_sign_in(req, &token).await;
Ok(token)
}
Err(err) => {
// inbuxa: AU-1.4
if matches!(err.as_ref(), trc::EventType::Auth(trc::AuthEvent::Failed)) {
self.audit_sign_in_failed(req).await;
}
// Random delay to mitigate user enumeration attacks
#[cfg(not(feature = "test_mode"))]
{
@@ -106,6 +116,13 @@ impl Server {
self.access_token(account_id)
.await
.and_then(|token| AccessToken::new(token, req.remote_ip))
// inbuxa: AU-1.5, AU-5
.map(|token| {
token.with_origin(Via::Master {
account_id: None,
name: fallback_user.to_string(),
})
})
} else {
Err(trc::AuthEvent::Failed
.into_err()
@@ -119,7 +136,8 @@ impl Server {
SpanId = req.session_id,
);
Ok(AccessToken::new_admin())
// inbuxa: AU-1.5, AU-5
Ok(AccessToken::new_admin().with_origin(Via::Recovery))
}
} else {
Err(trc::AuthEvent::Failed
@@ -163,6 +181,12 @@ impl Server {
req.session_id,
)
.await
// inbuxa: AU-5
.map(|token| {
token.with_origin(Via::AppPassword {
id: app_pass.credential_id,
})
})
} else {
Err(trc::AuthEvent::Failed
.into_err()
@@ -262,6 +286,7 @@ impl Server {
// Validate master user access
if username.is_master() {
let master_id = token.account_id(); // inbuxa: AU-5
token.assert_has_permissions(&[
Permission::Impersonate,
Permission::Authenticate,
@@ -282,6 +307,13 @@ impl Server {
self.access_token(account_id)
.await
.map(AccessToken::new_maybe_invalid)
// inbuxa: AU-1.5, AU-5: the master stays known
.map(|impersonated| {
impersonated.with_origin(Via::Master {
account_id: Some(master_id),
name: master_address.to_string(),
})
})
} else {
Err(trc::AuthEvent::Failed
.into_err()
@@ -297,7 +329,12 @@ impl Server {
SpanId = req.session_id,
);
Ok(token)
// inbuxa: AU-5 (a directory's token already says so)
Ok(if token.origin().is_none() {
token.with_origin(Via::Password)
} else {
token
})
}
}
Credentials::Bearer { username, token } => {
@@ -311,7 +348,9 @@ impl Server {
req.remote_ip,
req.session_id,
)
.await;
.await
// inbuxa: AU-5
.map(|token| token.with_origin(Via::ApiKey { id: key.credential_id }));
}
#[cfg(feature = "dev_mode")]
@@ -368,7 +407,8 @@ impl Server {
.ctx(trc::Key::AccountId, token.account_id())
.reason("Authenticated using an email alias but account does not have AuthenticateAlias permission"));
}
return Ok(token);
// inbuxa: AU-5
return Ok(token.with_origin(Via::Directory));
}
Err(err) => {
external_error = Some(err);
@@ -384,7 +424,20 @@ impl Server {
Ok(token_info) => self
.access_token(token_info.account_id)
.await
.and_then(|token| AccessToken::new(token, req.remote_ip)),
.and_then(|token| AccessToken::new(token, req.remote_ip))
// inbuxa: AU-5
.map(|token| {
token.with_origin(Via::OAuth {
client: token_info
.claims
.as_deref()
.filter(|claims| !claims.is_empty())
.unwrap_or("unknown")
.chars()
.take(200)
.collect(),
})
}),
Err(err) => {
if let Some(external_error) = external_error {
Err(external_error)
+3
View File
@@ -132,6 +132,8 @@ pub struct PermissionsGroup {
pub struct AccessToken {
scope_idx: usize,
inner: Arc<AccessTokenInner>,
// inbuxa: how this session signed in, for the audit log (AU-5)
origin: Option<Arc<inbuxa_features::audit::Via>>,
}
#[derive(Debug, Default, Clone)]
@@ -298,6 +300,7 @@ impl BuildAccessToken for Arc<AccessTokenInner> {
fn build(self) -> AccessToken {
AccessToken {
scope_idx: 0,
origin: None,
inner: self,
}
}
+6
View File
@@ -269,6 +269,12 @@ impl Default for DefaultPermissions {
default.superuser.push(permission);
default.tenant.push(permission);
}
// inbuxa: AU-9: a tenant administrator reads and exports
// its tenant's audit log; retention stays the server's
Permission::SysAuditGet | Permission::SysAuditExport => {
default.superuser.push(permission);
default.tenant.push(permission);
}
permission => {
let name = permission.as_str();
if name.starts_with("jmap")
+22
View File
@@ -31,6 +31,19 @@ impl Server {
pub async fn synchronize_account(
&self,
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> {
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> {
// 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?;
match self
+2
View File
@@ -99,6 +99,7 @@ impl Data {
logos: Default::default(),
smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"),
build_errors: Default::default(),
audit: Default::default(),
asn_geo_data: Default::default(),
}
}
@@ -243,6 +244,7 @@ impl Default for Data {
logos: Default::default(),
smtp_connectors: TlsConnectors::try_new().unwrap(),
build_errors: Default::default(),
audit: Default::default(),
asn_geo_data: Default::default(),
lookup_stores: Default::default(),
}
+6
View File
@@ -67,6 +67,7 @@ use utils::{
pub mod auth;
pub mod cache;
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
pub mod config;
pub mod expr;
pub mod i18n;
@@ -174,6 +175,9 @@ pub struct Data {
// inbuxa: the objects that failed to build when the running settings
// were built, at boot or by the last applied reload (see reload_registry)
pub build_errors: Mutex<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)]
@@ -282,6 +286,8 @@ pub struct HttpAuthCache {
pub revision: u64,
pub credential_id: Option<u32>,
pub expires: Instant,
// inbuxa: how the cached credentials signed in (AU-5)
pub origin: Option<Arc<inbuxa_features::audit::Via>>,
}
pub struct Ipc {
+4
View File
@@ -243,6 +243,10 @@ impl BootManager {
// inbuxa: a reload isn't refused over objects that failed here
inner.build_server().record_build_errors(&bootstrap.errors);
// inbuxa: AU-1.10: the server's own registry writes are
// recorded from here on, after boot's defaults
inner.build_server().install_audit_hook();
BootManager {
inner,
bootstrap,
@@ -29,11 +29,31 @@ use trc::AddContext;
use types::id::Id;
/// Granted to the default administrator roles: "Explain this"
/// (ai-explain spec, EX-4: superuser by default).
const ADMIN_GRANTS: &[Permission] = &[Permission::SysAiExplain];
/// (ai-explain spec, EX-4: superuser by default), and the audit log
/// (audit-hold-lock spec, AU-9).
const ADMIN_GRANTS: &[Permission] = &[
Permission::SysAiExplain,
Permission::SysAuditGet,
Permission::SysAuditExport,
Permission::SysAuditSettingsUpdate,
];
fn granted_key(permission: Permission) -> ValueClass {
/// Granted to the default tenant administrator roles: reading and exporting
/// the tenant's audit log (AU-9).
const TENANT_GRANTS: &[Permission] = &[Permission::SysAuditGet, Permission::SysAuditExport];
#[derive(Clone, Copy, PartialEq, Eq)]
enum Audience {
Admin,
Tenant,
}
fn granted_key(permission: Permission, audience: Audience) -> ValueClass {
let mut key = b"Pg".to_vec();
// Admin grants keep the key they were first recorded under
if audience == Audience::Tenant {
key.extend_from_slice(b"tenant:");
}
key.extend_from_slice(permission.as_str().as_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
@@ -42,11 +62,16 @@ fn granted_key(permission: Permission) -> ValueClass {
}
pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Result<()> {
grant(bp, Audience::Admin, ADMIN_GRANTS).await?;
grant(bp, Audience::Tenant, TENANT_GRANTS).await
}
async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> trc::Result<()> {
let mut pending = Vec::new();
for permission in ADMIN_GRANTS {
for permission in grants {
if bp
.data_store
.get_value::<String>(ValueKey::from(granted_key(*permission)))
.get_value::<String>(ValueKey::from(granted_key(*permission, audience)))
.await
.caused_by(trc::location!())?
.is_none()
@@ -58,21 +83,33 @@ pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Resu
return Ok(());
}
// An administrator's default roles include the plain User role, which
// every user also holds; only roles that are administrators' alone get it
// every user also holds; only roles that are the audience's alone get it
let admin_roles: Vec<Id> = bp
.registry
.object::<Authentication>(Id::singleton())
.await?
.map(|auth| {
let shared = [
auth.default_user_role_ids.as_slice(),
auth.default_group_role_ids.as_slice(),
auth.default_tenant_role_ids.as_slice(),
]
.concat();
auth.default_admin_role_ids
.as_slice()
.iter()
let (own, shared) = match audience {
Audience::Admin => (
auth.default_admin_role_ids.as_slice(),
[
auth.default_user_role_ids.as_slice(),
auth.default_group_role_ids.as_slice(),
auth.default_tenant_role_ids.as_slice(),
]
.concat(),
),
Audience::Tenant => (
auth.default_tenant_role_ids.as_slice(),
[
auth.default_user_role_ids.as_slice(),
auth.default_group_role_ids.as_slice(),
auth.default_admin_role_ids.as_slice(),
]
.concat(),
),
};
own.iter()
.filter(|id| !shared.contains(id))
.copied()
.collect()
@@ -114,7 +151,7 @@ pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Resu
}
let mut batch = BatchBuilder::new();
for permission in pending {
batch.set(granted_key(permission), b"granted".to_vec());
batch.set(granted_key(permission, audience), b"granted".to_vec());
}
bp.data_store
.write(batch.build_all())
+9 -5
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
@@ -335,9 +337,10 @@ impl Server {
.insert(IpWithTtl::new(ip, expires_at.unwrap_or(u64::MAX)));
// Write blocked IP to config
let RegistryWriteResult::Success(id) = self
.registry()
.write(RegistryWrite::insert(
// inbuxa: AU-1.10: recorded as the server's automatic ban
let RegistryWriteResult::Success(id) = inbuxa_features::audit::scope::system(
"auto-ban",
self.registry().write(RegistryWrite::insert(
&BlockedIp {
address: IpAddrOrMask::from_ip(ip),
created_at: UTCDateTime::from_timestamp(now as i64),
@@ -345,8 +348,9 @@ impl Server {
reason,
}
.into(),
))
.await
)),
)
.await
.caused_by(trc::location!())?
else {
return Ok(());