Compare commits

..
Author SHA1 Message Date
jcoffey-dev 7720a57ac9 Merge pull request 'Release 2026.9.27.1' (#67) from release-2026.9.27.1 into main
publish / publish-amd64 (push) Successful in 28m13s
ci / build (push) Successful in 34m51s
publish / release (push) Successful in 2s
publish / publish-arm64 (push) Successful in 42m7s
publish / binaries (push) Successful in 39s
publish / announce (push) Successful in 22s
publish / version (push) Successful in 28s
ci / fork-checks (push) Successful in 14s
2026-09-27 23:43:31 +00:00
jcoffey-dev 30ea43d019 Release 2026.9.27.1
ci / fork-checks (pull_request) Successful in 13s
ci / build (pull_request) Successful in 7m33s
The audit log (#64): every administrator change, admin sign-in and look
into someone else's data, recorded before it happens, chained per node
and checkable for tampering, exportable with a manifest, kept 2 years.

Locked accounts (#65, #66): an account that keeps receiving mail but
can't sign in and sends nothing on its own, handed to delegates at read,
organize or full, ending at a date when one is set.

Prepared Explain answers relabeled for this release; no setting changed
since 2026.9.27, so all 706 carry over.
2026-09-27 16:35:42 -07:00
jcoffey-dev dd3eec3936 Merge pull request 'End a locked account's delegation at its date' (#66) from fix/delegation-until into main
ci / fork-checks (push) Successful in 1m1s
ci / build (push) Canceled after 12m1s
2026-09-27 23:31:30 +00:00
jcoffey-dev a36236efff End a locked account's delegation at its date
ci / fork-checks (pull_request) Successful in 44s
ci / build (pull_request) Successful in 4m59s
A delegation with an end date dropped out of the delegate's token then,
but its folder grants stayed until the daily sweep, so the delegate kept
the account as an ordinary share for up to a day. Each node now sleeps
until the soonest end date, woken early by any lock write and at least
hourly, and re-applies that lock under a cluster-wide claim.

The sweep also had a second-run bug: a delegation past its date gave the
delegate back its earlier share, then dropped the note, so the next sweep
removed that share entirely. The note is now kept while the delegate is
still listed.
2026-09-27 16:26:04 -07:00
jcoffey-dev 224597cab2 Merge pull request 'Locked accounts: keep receiving mail, no sign-in, hand to delegates' (#65) from feature/account-lock into main
ci / fork-checks (push) Successful in 14s
ci / build (push) Canceled after 35m53s
2026-09-27 22:55:36 +00:00
jcoffey-dev 447229f871 Lock accounts: keep receiving mail, no sign-in, hand to delegates
ci / fork-checks (pull_request) Successful in 1m4s
ci / build (pull_request) Successful in 8m47s
A locked account can't sign in (it fails as a wrong password does), its
sessions end on every node, refresh tokens stop working, and its Sieve
scripts forward and reply to nothing. Mail keeps arriving.

Delegates get real ACL grants on the account's mailboxes, calendars,
address books and files at read, organize or full, with the rights they
replaced restored on unlock. Folders made later are granted after the
create and in a daily sweep. Organize delegates can't destroy; send-as
needs organize or full. The JMAP session marks delegated accounts in
urn:inbuxa:jmap.

New inbuxa:AccountLock object with get/set, permissions 665-668, and a
Compliance > Locked Accounts entry in the schema. Lock, unlock and
delegate changes need a reason and are audited; delegate access and
writes are audited too (audit-hold-lock spec AL-1 to AL-12).
2026-09-27 14:46:06 -07:00
jcoffey-dev ebf2fe11d9 Merge pull request 'Audit log: a permanent, tamper-evident record of admin actions' (#64) from feature/audit-log into main
ci / fork-checks (push) Successful in 1m22s
ci / build (push) Successful in 21m54s
2026-09-27 21:45:50 +00:00
jcoffey-dev 86d7ebd982 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.
2026-09-27 13:40:12 -07:00
jcoffey-dev d3ebfb79f9 Merge pull request 'Release 2026.9.27' (#63) from release-2026.9.27 into main
ci / fork-checks (push) Successful in 28s
publish / version (push) Successful in 29s
publish / publish-amd64 (push) Successful in 24m11s
publish / release (push) Successful in 1s
ci / build (push) Successful in 35m44s
publish / publish-arm64 (push) Successful in 35m26s
publish / binaries (push) Successful in 33s
publish / announce (push) Successful in 22s
2026-09-27 05:18:54 +00:00
jcoffey-dev 833e6871f7 Release 2026.9.27
ci / fork-checks (pull_request) Successful in 45s
ci / build (pull_request) Successful in 4m51s
inbuxa's own mark (#62): the kitten over a server with a bay for each
piece of the suite, on the built-in sign-in and RSVP pages, the web
logo and the email logo.

Prepared Explain answers relabeled for this release; no setting changed
since 2026.9.26.1, so all 706 carry over.
2026-09-26 22:13:54 -07:00
jcoffey-dev 056bbb179d Merge pull request 'Brand: inbuxa own kitten replaces ihasmail cat' (#62) from brand/new-mark into main
ci / fork-checks (push) Successful in 53s
ci / build (push) Canceled after 6m7s
2026-09-27 05:12:44 +00:00
jcoffey-dev d7182f4511 Brand: inbuxa's own kitten replaces ihasmail's cat
ci / fork-checks (pull_request) Successful in 43s
ci / build (pull_request) Successful in 3m18s
inbuxa's mark was ihasmail's cat-and-envelope reused unchanged. The new
one keeps the family's face, paws and colors, over a server with a bay
for each piece of the suite: the letter (webmail), a prompt (console),
status lights (server).

- The built-in sign-in and calendar RSVP pages, and the web logo, drew
  the old cat as an embedded PNG. They now draw the mark as vector in
  the same slot, keeping class="symbol"; each page is about 31 KB
  lighter. The .min copies are updated the same way and the .min.gz
  regenerated with gzip -9 -n, as minify_html.sh does.
- resources/branding: email-logo.png (the compact lockup at 380x80 on
  white, as before) with its .b64 regenerated byte-for-byte in the old
  76-column form, and favicon-64.png.
- img/brand: the logo bundle, now pure vector, with its README.
2026-09-26 22:05:17 -07:00
jcoffey-dev 055752f3a3 Merge pull request 'Announce releases on the community forum' (#61) from announce-releases into main
ci / fork-checks (push) Successful in 1m19s
ci / build (push) Successful in 1h28m25s
2026-09-27 02:40:21 +00:00
jcoffey-dev 07557ba8e2 Announce releases on the community forum
ci / fork-checks (pull_request) Successful in 45s
ci / build (pull_request) Successful in 6m11s
announce.yml runs coffey-labs/actions discourse-release on every published
release, posting it to this project's Announcements category on
community.coffeylabs.org. The release workflow also announces
from its own job, since a release made with the job token fires no
'on: release' workflow in Gitea.
2026-09-26 19:28:04 -07:00
108 changed files with 8155 additions and 333 deletions
+17
View File
@@ -0,0 +1,17 @@
# Announce each published release on the community forum, in this project's
# Announcements category (coffey-labs/actions discourse-release; the repo ->
# category map is its release-map.json). Safe to re-run: one topic per tag.
name: announce
on:
release:
types: [published]
jobs:
announce:
runs-on: light
steps:
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
with:
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
+13
View File
@@ -285,3 +285,16 @@ jobs:
PY
- if: always()
run: docker logout "$REGISTRY" || true
# The release above is made with the job's own token, and Gitea starts no
# workflow for events the Actions bot causes -- announce.yml's
# 'on: release' never fires for it -- so announce it from here.
announce:
needs: [release, binaries]
runs-on: light
steps:
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
with:
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
tag: ${{ github.ref_name }}
Generated
+2
View File
@@ -3960,10 +3960,12 @@ version = "0.16.22"
dependencies = [
"ahash",
"base64 0.23.1",
"flate2",
"jmap_proto",
"registry",
"serde",
"serde_json",
"sha2 0.11.0",
"store",
"tokio",
"trc",
+564
View File
@@ -0,0 +1,564 @@
/*
* 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;
const KIND_DELEGATE_ACCESS: u8 = 4;
/// 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(),
}));
}
/// AL-9: a delegate reaching a locked account: its access once an hour,
/// and every change it makes there, one record per method call.
pub async fn audit_delegate(
&self,
token: &AccessToken,
locked_id: u32,
access: &str,
write: Option<&str>,
error: Option<&trc::Error>,
) {
let first = self.audit().first_access_this_hour(
token.account_id(),
locked_id,
KIND_DELEGATE_ACCESS,
now(),
);
if !first && write.is_none() {
return;
}
let actor = self.audit_actor(token).await;
let target = Target {
kind: "account".into(),
id: Some(Id::from(locked_id).to_string()),
name: Some(self.audit_account_name(locked_id).await),
account_id: Some(locked_id),
tenant_id: self
.account(locked_id)
.await
.ok()
.and_then(|account| account.id_tenant),
};
let mut records = Vec::new();
if first {
records.push(Record {
at: ms(),
actor: actor.clone(),
via: token.origin().cloned(),
remote_ip: None,
action: Action::AccountAccess,
target: target.clone(),
changes: vec![],
details: Some(format!("As a delegate ({access})")),
reason: None,
outcome: Outcome::success(),
});
}
if let Some(method) = write {
records.push(Record {
at: ms(),
actor,
via: token.origin().cloned(),
remote_ip: None,
action: Action::Update,
target,
changes: vec![],
details: Some(format!("{method} as a delegate ({access})")),
reason: None,
outcome: match error {
None => Outcome::success(),
Some(err) => Outcome::refused(
"error",
err.value_as_str(trc::Key::Details).map(str::to_string),
),
},
});
}
for record in records {
if !self.audit_note(record).await && first {
self.audit()
.forget_access(token.account_id(), locked_id, KIND_DELEGATE_ACCESS);
}
}
}
/// 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(),
),
}
})
}
}
+110 -2
View File
@@ -43,6 +43,27 @@ impl Server {
revision: u64,
revision_account: u64,
) -> trc::Result<AccessTokenInner> {
// inbuxa: AL-2, AL-5: whether this account is locked, and which
// locked accounts are handed to it. The token is their cache: every
// change to a lock invalidates the tokens it touches.
let locked = inbuxa_features::lock::get(self.store(), account_id)
.await
.caused_by(trc::location!())?
.is_some();
let now_secs = now();
let delegations: Box<[super::Delegation]> =
inbuxa_features::lock::delegated_to(self.store(), account_id)
.await
.caused_by(trc::location!())?
.into_iter()
.filter(|(_, delegate)| delegate.is_current(now_secs))
.map(|(locked_id, delegate)| super::Delegation {
account_id: locked_id,
access: delegate.access,
send_as: delegate.send_as,
until: delegate.until,
})
.collect();
match account {
Account::User(account) => {
let tenant_id = account.member_tenant_id.map(|t| t.id() as u32);
@@ -202,6 +223,8 @@ impl Server {
.upload_max_concurrent
.map(ConcurrencyLimiter::new),
obj_size: 0,
locked,
delegations: delegations.clone(),
revision,
revision_account,
credential_version,
@@ -211,7 +234,15 @@ impl Server {
access_to: access_to.into_boxed_slice(),
scopes: []
.into_iter()
.chain(credential_scopes)
.chain(credential_scopes.into_iter().map(|mut scope| {
// inbuxa: AL-2: no credential of a locked
// account authenticates; receiving mail isn't
// signing in, so EmailReceive stays
if locked {
scope.permissions.clear(Permission::Authenticate as usize);
}
scope
}))
.collect::<Box<[AccessScope]>>(),
}
.update_size())
@@ -245,6 +276,8 @@ impl Server {
.upload_max_concurrent
.map(ConcurrencyLimiter::new),
obj_size: 0,
locked,
delegations: delegations.clone(),
revision,
revision_account,
credential_version: 0,
@@ -376,6 +409,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 +418,7 @@ impl AccessToken {
pub fn new_maybe_invalid(inner: Arc<AccessTokenInner>) -> Self {
AccessToken {
scope_idx: 0,
origin: None,
inner,
}
}
@@ -404,7 +439,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 +457,7 @@ impl AccessToken {
} else {
AccessToken {
scope_idx: 0,
origin: None,
inner,
}
.assert_is_valid(remote_ip)
@@ -481,6 +521,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
}
@@ -575,10 +624,13 @@ impl AccessToken {
revision: old_inner.revision,
credential_version: old_inner.credential_version,
obj_size: old_inner.obj_size,
locked: old_inner.locked,
delegations: old_inner.delegations.clone(),
};
access_token = AccessToken {
scope_idx: access_token.scope_idx,
origin: access_token.origin.clone(),
inner: Arc::new(inner),
};
}
@@ -758,9 +810,55 @@ impl AccessToken {
}
}
/// inbuxa: AL-2: the account is locked.
pub fn is_locked(&self) -> bool {
self.inner.locked
}
/// inbuxa: AL-5: this account's delegation into a locked account, if it
/// has one that hasn't ended.
pub fn delegation(&self, account_id: u32) -> Option<&super::Delegation> {
let now = now();
self.inner
.delegations
.iter()
.find(|d| d.account_id == account_id && d.until.is_none_or(|until| until > now))
}
/// inbuxa: AL-5: every current delegation this account holds.
pub fn delegations(&self) -> impl Iterator<Item = &super::Delegation> {
let now = now();
self.inner
.delegations
.iter()
.filter(move |d| d.until.is_none_or(|until| until > now))
}
/// 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 +873,7 @@ impl AccessToken {
}
AccessToken {
scope_idx: 0,
origin: None,
inner: Arc::new(AccessTokenInner {
account_id,
tenant_id: Default::default(),
@@ -788,6 +887,8 @@ impl AccessToken {
revision_account: Default::default(),
credential_version: Default::default(),
obj_size: Default::default(),
locked: false,
delegations: Default::default(),
}),
}
}
@@ -798,6 +899,11 @@ impl AccessToken {
}
impl AccessTokenInner {
/// inbuxa: AL-2: the account is locked.
pub fn is_locked(&self) -> bool {
self.locked
}
/// inbuxa: SCIM-27: the account's own effective permission, from its
/// roles, its own settings and its tenant, before a credential narrows it
pub fn account_has_permission(&self, permission: Permission) -> bool {
@@ -841,6 +947,8 @@ impl AccessTokenInner {
revision_account: Default::default(),
credential_version: Default::default(),
obj_size: Default::default(),
locked: false,
delegations: Default::default(),
}
}
+72 -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 {
@@ -43,10 +44,32 @@ impl Server {
pub async fn authenticate(&self, req: &AuthRequest) -> trc::Result<AccessToken> {
match Box::pin(self.route_auth_request(req))
.await
// inbuxa: AL-2: a locked account fails as a wrong password does,
// so the right password learns nothing; master and recovery
// sign-ins as it fail the same way
.and_then(|token| {
if token.is_locked() {
Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountId, token.account_id())
.reason("Account is locked"))
} else {
Ok(token)
}
})
.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 +129,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 +149,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 +194,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 +299,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 +320,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 +342,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 +361,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 +420,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 +437,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)
+18
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)]
@@ -148,6 +150,21 @@ pub struct AccessTokenInner {
pub(crate) revision: u64,
pub(crate) credential_version: u64,
pub(crate) obj_size: u64,
// inbuxa: AL-2: the account is locked; it may not authenticate
pub(crate) locked: bool,
// inbuxa: AL-5: locked accounts handed to this one
pub(crate) delegations: Box<[Delegation]>,
}
/// inbuxa: a locked account this one may open, and how (AL-5, AL-6).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Delegation {
/// The locked account.
pub account_id: u32,
pub access: inbuxa_features::lock::Access,
pub send_as: bool,
/// Seconds since the epoch.
pub until: Option<u64>,
}
#[derive(Debug, Default, Hash, Clone)]
@@ -298,6 +315,7 @@ impl BuildAccessToken for Arc<AccessTokenInner> {
fn build(self) -> AccessToken {
AccessToken {
scope_idx: 0,
origin: None,
inner: self,
}
}
+15
View File
@@ -269,6 +269,21 @@ 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);
}
// inbuxa: AL-12: tenant administrators lock and delegate
// within their tenant
Permission::SysAccountLockGet
| Permission::SysAccountLockCreate
| Permission::SysAccountLockUpdate
| Permission::SysAccountLockDestroy => {
default.superuser.push(permission);
default.tenant.push(permission);
}
permission => {
let name = permission.as_str();
if name.starts_with("jmap")
+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(),
}
+2
View File
@@ -86,6 +86,8 @@ pub enum BroadcastEvent {
CacheInvalidateNegative,
MtaQueueStatus { is_running: bool },
QueueRefresh,
// inbuxa: AL-3: end an account's open sessions on every node
EndSessions(u32),
}
#[derive(Debug, Clone, Copy)]
+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,43 @@ 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,
Permission::SysAccountLockGet,
Permission::SysAccountLockCreate,
Permission::SysAccountLockUpdate,
Permission::SysAccountLockDestroy,
];
fn granted_key(permission: Permission) -> ValueClass {
/// Granted to the default tenant administrator roles: reading and exporting
/// the tenant's audit log (AU-9), and locking and delegating its accounts
/// (AL-12).
const TENANT_GRANTS: &[Permission] = &[
Permission::SysAuditGet,
Permission::SysAuditExport,
Permission::SysAccountLockGet,
Permission::SysAccountLockCreate,
Permission::SysAccountLockUpdate,
Permission::SysAccountLockDestroy,
];
#[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 +74,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 +95,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 = [
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();
auth.default_admin_role_ids
.as_slice()
.iter()
.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 +163,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())
+8 -4
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,7 +348,8 @@ impl Server {
reason,
}
.into(),
))
)),
)
.await
.caused_by(trc::location!())?
else {
+10
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 super::proppatch::FilePropPatchRequestHandler;
@@ -131,6 +133,14 @@ impl FileMkColRequestHandler for Server {
let etag = batch.etag();
self.commit_batch(batch).await.caused_by(trc::location!())?;
// inbuxa: AL-7: a folder a delegate makes in a locked account gets
// the lock's grants
if account_id != access_token.account_id()
&& let Err(err) = groupware::inbuxa_lock::reconcile_dav(self, account_id).await
{
trc::error!(err.details("Failed to grant a lock's delegates on a new folder"));
}
if let Some(prop_stat) = return_prop_stat {
Ok(HttpResponse::new(StatusCode::CREATED)
.with_xml_body(
+10
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::{
@@ -299,6 +301,14 @@ impl FileUpdateRequestHandler for Server {
let etag = batch.etag();
self.commit_batch(batch).await.caused_by(trc::location!())?;
// inbuxa: AL-7: a top-level file a delegate adds to a locked
// account gets the lock's grants
if account_id != access_token.account_id()
&& let Err(err) = groupware::inbuxa_lock::reconcile_dav(self, account_id).await
{
trc::error!(err.details("Failed to grant a lock's delegates on a new file"));
}
Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag))
}
}
+128
View File
@@ -0,0 +1,128 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: a locked account's grants, whole (audit-hold-lock spec, AL-7,
//! AL-10): its mailboxes here, and its calendars, address books and files
//! through `groupware::inbuxa_lock`.
//!
//! A delegate's access is real ACL grants on the locked account's
//! containers, the sharing IMAP, DAV and JMAP already honor, so a delegate
//! sees the account as a shared one everywhere. The lock notes what each
//! delegate had on a container before, so ending a delegation or the lock
//! puts it back. Idempotent: run again, it grants on containers made since
//! and changes nothing else.
use crate::{cache::MessageCacheFetch, mailbox::Mailbox};
use common::{Server, storage::index::ObjectIndexBuilder};
use groupware::inbuxa_lock::{apply_dav_grants, invalidate, same_replaced};
use inbuxa_features::lock::{self, Lock, Replaced};
use store::{
ValueKey,
write::{AlignedBytes, Archive, BatchBuilder, now},
};
use trc::AddContext;
use types::{collection::Collection, special_use::SpecialUse};
/// Grants a lock's delegates their rights on every container of the locked
/// account, and takes away those of delegations that ended. Returns what the
/// lock now has to remember.
pub async fn apply_grants(
server: &Server,
account_id: u32,
old: Option<&Lock>,
new: Option<&Lock>,
) -> trc::Result<Vec<Replaced>> {
let now = now();
let mut replaced = Vec::new();
let mut batch = BatchBuilder::new();
let cache = server
.get_cached_messages(account_id)
.await
.caused_by(trc::location!())?;
for mailbox in cache.mailboxes.items.iter() {
// Mail in Trash and Junk is destroyed in time: an organizing
// delegate may look, not move mail in
let is_trash = matches!(mailbox.role, SpecialUse::Trash | SpecialUse::Junk);
let current = mailbox.acls.to_vec();
let Some(acls) = lock::merge_grants(
&current,
Collection::Mailbox,
mailbox.document_id,
is_trash,
old,
new,
now,
&mut replaced,
) else {
continue;
};
let Some(archive) = server
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::Mailbox,
mailbox.document_id,
))
.await
.caused_by(trc::location!())?
else {
continue;
};
let current = archive
.into_deserialized::<Mailbox>()
.caused_by(trc::location!())?;
let mut changed = current.inner.clone();
changed.acls = acls;
batch
.with_account_id(account_id)
.with_collection(Collection::Mailbox)
.with_document(mailbox.document_id)
.custom(
ObjectIndexBuilder::new()
.with_changes(changed)
.with_current(current),
)
.caused_by(trc::location!())?;
}
apply_dav_grants(server, account_id, old, new, now, &mut replaced, &mut batch).await?;
if !batch.is_empty() {
server
.commit_batch(batch)
.await
.caused_by(trc::location!())?;
}
Ok(replaced)
}
/// Re-applies the lock on `account_id`, if any, so containers made since get
/// its grants: after a delegate creates something there, and daily.
pub async fn reconcile(server: &Server, account_id: u32) -> trc::Result<()> {
let data = server.store();
let Some(current) = lock::get(data, account_id).await? else {
return Ok(());
};
let replaced = apply_grants(server, account_id, Some(&current), Some(&current)).await?;
if !same_replaced(&replaced, &current.replaced) {
let updated = Lock {
replaced,
..current.clone()
};
lock::set(data, &updated, Some(&current)).await?;
}
invalidate(server, account_id, Some(&current), Some(&current)).await
}
/// Re-applies every lock: the daily sweep, for containers made by the server
/// itself (a Sieve `fileinto :create`) rather than by a delegate.
pub async fn reconcile_all(server: &Server) -> trc::Result<()> {
for current in lock::all(server.store()).await? {
reconcile(server, current.account_id).await?;
}
Ok(())
}
+1
View File
@@ -14,6 +14,7 @@
pub mod cache;
pub mod identity;
pub mod inbuxa_lock; // inbuxa: account lock grants
pub mod mailbox;
pub mod message;
pub mod push;
+23
View File
@@ -287,6 +287,18 @@ impl SieveScriptIngest for Server {
do_discard = true;
input = true.into();
}
// inbuxa: AL-4: a locked account answers no sender, so a
// rejection is kept instead; sieve has already cleared
// the implicit keep, so it is filed here
Event::Reject { .. } if access_token.is_locked() => {
if let Some(message) = messages.get_mut(0)
&& !message.file_into.contains(&INBOX_ID)
{
message.file_into.push(INBOX_ID);
}
do_deliver = true;
input = true.into();
}
Event::Reject { reason, .. } => {
reject_reason = reason.into();
do_discard = true;
@@ -388,6 +400,17 @@ impl SieveScriptIngest for Server {
}
input = true.into();
}
// inbuxa: AL-4: a locked account sends nothing on its
// own: no redirect, vacation reply or notification. An
// unsent redirect leaves the message to be kept.
Event::SendMessage { .. } if access_token.is_locked() => {
trc::event!(
Sieve(SieveEvent::ActionReject),
Details = "Account is locked: nothing is sent",
SpanId = session_id
);
input = true.into();
}
Event::SendMessage {
recipient,
message_id,
+3
View File
@@ -17,6 +17,9 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
xxhash-rust = { version = "0.8.18", features = ["xxh3"] }
base64 = "0.23"
sha2 = "0.11"
flate2 = "1.1"
tokio = { version = "1.53", features = ["sync", "rt"] }
[dev-dependencies]
tokio = { version = "1.53", features = ["macros", "rt"] }
+17 -1
View File
@@ -9,11 +9,27 @@
//! it holds a secret anywhere inside it.
use serde_json::Value;
use std::collections::HashSet;
use std::{collections::HashSet, io::Read, sync::OnceLock};
/// The registry schema, as the console downloads it.
pub struct Schema(Value);
/// The schema built into the server, read once. Also used by the audit log,
/// to know which properties hold secrets (AU-4).
pub fn embedded() -> Option<&'static Schema> {
static SCHEMA: OnceLock<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.
#[derive(Debug, Clone, PartialEq)]
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);
}
}
+2
View File
@@ -19,7 +19,9 @@
//! `common::Server`.
pub mod ai;
pub mod audit;
pub mod branding;
pub mod lock;
pub mod masked_email;
pub mod security;
pub mod tenancy;
+653
View File
@@ -0,0 +1,653 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Account lock with delegation (audit-hold-lock spec, AL-1 to AL-12).
//!
//! A locked account keeps receiving mail but can't sign in, by any means,
//! and sends nothing on its own. Delegates open it as a separate account,
//! through real ACL grants on its containers (the sharing every protocol
//! already honors), at a level the administrator chose.
//!
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
//! with `K`, then one byte for the kind:
//!
//! - `l` + account: the lock, as JSON.
//! - `d` + delegate + account: an index, so a delegate's access token can
//! find the accounts delegated to it with one scan.
//!
//! Numbers are big-endian. Nothing is cached in memory: the access token is
//! the cache, built from these keys and invalidated on every change.
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
/// Rung when a lock is written, so this node's expiry timer re-reads the
/// `until` dates (AL-5): a delegation ends at its time, not at a sweep.
pub static UNTIL_CHANGED: tokio::sync::Notify = tokio::sync::Notify::const_new();
/// The soonest `until` still ahead of `now`, across every lock.
pub fn next_until(locks: &[Lock], now: u64) -> Option<u64> {
locks
.iter()
.flat_map(|lock| &lock.delegates)
.filter_map(|delegate| delegate.until)
.filter(|until| *until > now)
.min()
}
/// Locks with a delegation that ended in `(after, now]`.
pub fn ended_between(locks: &[Lock], after: u64, now: u64) -> impl Iterator<Item = u32> + '_ {
locks
.iter()
.filter(move |lock| {
lock.delegates
.iter()
.any(|d| d.until.is_some_and(|until| until > after && until <= now))
})
.map(|lock| lock.account_id)
}
use types::{
acl::{Acl, AclGrant},
collection::Collection,
};
use utils::map::bitmap::Bitmap;
const FEATURE: u8 = b'K';
const KIND_LOCK: u8 = b'l';
const KIND_DELEGATE: u8 = b'd';
/// Most delegates one lock may have (AL-5).
pub const MAX_DELEGATES: usize = 10;
/// What a delegate may do in the locked account (AL-6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum Access {
/// See and download everything; change nothing, not even `$seen`.
Read,
/// Read, set keywords, move mail and create and rename folders; never
/// destroy.
Organize,
/// Everything the owner could do. Deletions are still kept under a hold.
Full,
}
impl Access {
pub fn as_str(&self) -> &'static str {
match self {
Access::Read => "read",
Access::Organize => "organize",
Access::Full => "full",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"read" => Some(Access::Read),
"organize" => Some(Access::Organize),
"full" => Some(Access::Full),
_ => None,
}
}
/// Whether a delegate at this level may destroy anything.
pub fn may_destroy(&self) -> bool {
matches!(self, Access::Full)
}
/// The rights granted on one container. `is_trash` marks a mailbox with
/// the Trash or Junk role: an organizing delegate may read it, but not
/// move mail into it, since mail there is destroyed in time.
pub fn grants(&self, collection: Collection, is_trash: bool) -> Bitmap<Acl> {
let read = [Acl::Read, Acl::ReadItems];
let rights: &[Acl] = match (self, collection) {
(Access::Read, _) => &read,
(Access::Organize, Collection::Mailbox) if is_trash => &read,
(Access::Organize, Collection::Mailbox) => &[
Acl::Read,
Acl::ReadItems,
Acl::Modify,
Acl::AddItems,
Acl::ModifyItems,
Acl::RemoveItems,
Acl::CreateChild,
],
// Calendars, address books and files have no "move": organizing
// there is adding and changing, never removing
(Access::Organize, _) => &[
Acl::Read,
Acl::ReadItems,
Acl::AddItems,
Acl::ModifyItems,
Acl::CreateChild,
],
(Access::Full, _) => &[
Acl::Read,
Acl::Modify,
Acl::Delete,
Acl::ReadItems,
Acl::AddItems,
Acl::ModifyItems,
Acl::RemoveItems,
Acl::CreateChild,
Acl::Submit,
Acl::ModifyItemsOwn,
Acl::ModifyPrivateProperties,
Acl::ModifyRSVP,
Acl::SchedulingReadFreeBusy,
Acl::SchedulingInvite,
Acl::SchedulingReply,
],
};
Bitmap::from_iter(rights.iter().copied())
}
}
/// One person the locked account is handed to (AL-5).
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Delegate {
pub account_id: u32,
pub access: Access,
/// May send from the locked account's identities (AL-8). Needs
/// `organize` or `full`: a message is made in its Drafts first.
#[serde(default)]
pub send_as: bool,
/// Seconds since the epoch; the delegation ends then on its own.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<u64>,
}
impl Delegate {
pub fn is_current(&self, now: u64) -> bool {
self.until.is_none_or(|until| until > now)
}
}
/// A delegate's rights a lock replaced on one container, put back when the
/// lock or that delegation ends (AL-10). A container with no entry had no
/// grant for that delegate before.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Replaced {
pub collection: u8,
pub document_id: u32,
pub delegate: u32,
/// The rights as a bitmap's raw value.
pub rights: u64,
}
/// An account's lock (AL-1).
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Lock {
pub account_id: u32,
pub reason: String,
/// Seconds since the epoch.
pub locked_at: u64,
pub locked_by: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locked_by_id: Option<u32>,
#[serde(default)]
pub delegates: Vec<Delegate>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub replaced: Vec<Replaced>,
}
impl Lock {
pub fn delegate(&self, account_id: u32) -> Option<&Delegate> {
self.delegates.iter().find(|d| d.account_id == account_id)
}
/// The grants a new container of this account gets: one per current
/// delegate (AL-7, containers made later).
pub fn grants_for_new(
&self,
collection: Collection,
is_trash: bool,
now: u64,
) -> Vec<(u32, Bitmap<Acl>)> {
self.delegates
.iter()
.filter(|d| d.is_current(now))
.map(|d| (d.account_id, d.access.grants(collection, is_trash)))
.collect()
}
}
/// One container's ACL as a lock change leaves it (AL-7, AL-10).
///
/// Delegates in `new` get their level's rights. The first time a delegate
/// is given a container, whatever it had there before is noted in
/// `replaced`; entries `old` already noted are carried over. Delegates only
/// in `old` get back what they had before, or nothing. Returns the new ACL
/// when it differs from `current`.
pub fn merge_grants(
current: &[AclGrant],
collection: Collection,
document_id: u32,
is_trash: bool,
old: Option<&Lock>,
new: Option<&Lock>,
now: u64,
replaced: &mut Vec<Replaced>,
) -> Option<Vec<AclGrant>> {
let mut acls = current.to_vec();
let collection_id = collection as u8;
let noted = |lock: &Lock, delegate: u32| {
lock.replaced
.iter()
.find(|r| {
r.collection == collection_id && r.document_id == document_id && r.delegate == delegate
})
.cloned()
};
let is_current = |lock: Option<&Lock>, delegate: u32| {
lock.and_then(|lock| lock.delegate(delegate))
.is_some_and(|d| d.is_current(now))
};
let set = |acls: &mut Vec<AclGrant>, account_id: u32, grants: Bitmap<Acl>| {
acls.retain(|a| a.account_id != account_id);
if !grants.is_empty() {
acls.push(AclGrant { account_id, grants });
}
};
// Delegations that ended get back what they had
if let Some(old) = old {
for delegate in &old.delegates {
if is_current(new, delegate.account_id) {
continue;
}
let note = noted(old, delegate.account_id);
let before = note
.as_ref()
.map(|r| Bitmap::from(r.rights))
.unwrap_or_default();
set(&mut acls, delegate.account_id, before);
// Still listed but past its `until`: keep the note, so running
// this again puts back the same share instead of removing it
if let Some(note) = note
&& new.is_some_and(|new| new.delegate(delegate.account_id).is_some())
{
replaced.push(note);
}
}
}
// Current delegations get their level
if let Some(new) = new {
for delegate in new.delegates.iter().filter(|d| d.is_current(now)) {
let had = old.and_then(|old| {
is_current(Some(old), delegate.account_id)
.then(|| noted(old, delegate.account_id))
.flatten()
});
match had {
Some(entry) => replaced.push(entry),
None if !is_current(old, delegate.account_id) => {
if let Some(existing) = current.iter().find(|a| a.account_id == delegate.account_id) {
replaced.push(Replaced {
collection: collection_id,
document_id,
delegate: delegate.account_id,
rights: existing.grants.into(),
});
}
}
None => {}
}
set(
&mut acls,
delegate.account_id,
delegate.access.grants(collection, is_trash),
);
}
}
let sorted = |acls: &[AclGrant]| {
let mut v = acls.iter().map(|a| (a.account_id, u64::from(a.grants))).collect::<Vec<_>>();
v.sort();
v
};
(sorted(&acls) != sorted(current)).then_some(acls)
}
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 account lock")
.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 account lock")
.reason(err)
})
}
}
fn class(kind: u8, parts: &[u32]) -> ValueClass {
let mut key = Vec::with_capacity(2 + parts.len() * 4);
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: &[u32]) -> ValueKey<ValueClass> {
ValueKey::from(class(kind, parts))
}
/// The numbers after the kind byte, 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<u32>> {
let len = 2 + parts * 4;
let tail = key.get(key.len().checked_sub(len)?..)?;
(tail[0] == FEATURE && tail[1] == kind).then_some(())?;
Some(
tail[2..]
.chunks_exact(4)
.map(|chunk| u32::from_be_bytes(chunk.try_into().unwrap()))
.collect(),
)
}
/// An account's lock, if it is locked.
pub async fn get(data: &Store, account_id: u32) -> trc::Result<Option<Lock>> {
Ok(data
.get_value::<Json<Lock>>(key(KIND_LOCK, &[account_id]))
.await
.caused_by(trc::location!())?
.map(|Json(lock)| lock))
}
/// Every lock, for the console's list.
pub async fn all(data: &Store) -> trc::Result<Vec<Lock>> {
let mut locks = Vec::new();
data.iterate(
IterateParams::new(key(KIND_LOCK, &[0]), key(KIND_LOCK, &[u32::MAX])),
|_, value| {
if let Ok(Json(lock)) = Json::<Lock>::deserialize(value) {
locks.push(lock);
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
Ok(locks)
}
/// The accounts delegated to `delegate`, with its delegation in each.
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate)>> {
let mut locked = Vec::new();
data.iterate(
IterateParams::new(
key(KIND_DELEGATE, &[delegate, 0]),
key(KIND_DELEGATE, &[delegate, u32::MAX]),
)
.no_values(),
|key, _| {
if let Some(parts) = parse_key(key, KIND_DELEGATE, 2) {
locked.push(parts[1]);
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
let mut delegations = Vec::with_capacity(locked.len());
for account_id in locked {
if let Some(lock) = get(data, account_id).await?
&& let Some(delegation) = lock.delegate(delegate)
{
delegations.push((account_id, delegation.clone()));
}
}
Ok(delegations)
}
/// Writes a lock, keeping the delegate index in step with `previous`.
pub async fn set(data: &Store, lock: &Lock, previous: Option<&Lock>) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
if let Some(previous) = previous {
for delegate in &previous.delegates {
if lock.delegate(delegate.account_id).is_none() {
batch.clear(class(KIND_DELEGATE, &[delegate.account_id, lock.account_id]));
}
}
}
for delegate in &lock.delegates {
batch.set(
class(KIND_DELEGATE, &[delegate.account_id, lock.account_id]),
vec![],
);
}
batch.set(class(KIND_LOCK, &[lock.account_id]), Json(lock).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
UNTIL_CHANGED.notify_one();
Ok(())
}
/// Removes a lock and its delegate index.
pub async fn remove(data: &Store, lock: &Lock) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
for delegate in &lock.delegates {
batch.clear(class(KIND_DELEGATE, &[delegate.account_id, lock.account_id]));
}
batch.clear(class(KIND_LOCK, &[lock.account_id]));
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_read_back() {
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
panic!()
};
assert_eq!(parse_key(&any.key, KIND_DELEGATE, 2), Some(vec![7, 9]));
let mut with_subspace = vec![SUBSPACE_INBUXA];
with_subspace.extend_from_slice(&any.key);
assert_eq!(parse_key(&with_subspace, KIND_DELEGATE, 2), Some(vec![7, 9]));
assert_eq!(parse_key(&any.key, KIND_LOCK, 2), None);
}
#[test]
fn levels_grant_what_they_say() {
let read = Access::Read.grants(Collection::Mailbox, false);
assert!(read.contains(Acl::ReadItems));
assert!(!read.contains(Acl::ModifyItems), "read can't set $seen");
assert!(!read.contains(Acl::RemoveItems));
let organize = Access::Organize.grants(Collection::Mailbox, false);
assert!(organize.contains(Acl::RemoveItems), "moving needs it");
assert!(!organize.contains(Acl::Delete));
assert!(!organize.contains(Acl::Submit));
let trash = Access::Organize.grants(Collection::Mailbox, true);
assert!(!trash.contains(Acl::AddItems), "nothing moved into Trash");
let calendar = Access::Organize.grants(Collection::Calendar, false);
assert!(!calendar.contains(Acl::RemoveItems));
let full = Access::Full.grants(Collection::Mailbox, false);
assert!(full.contains(Acl::Delete) && full.contains(Acl::RemoveItems));
assert!(!full.contains(Acl::Share), "a delegate can't pass it on");
assert!(Access::Full.may_destroy() && !Access::Organize.may_destroy());
}
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
Lock {
account_id: 1,
reason: "r".into(),
locked_at: 0,
locked_by: "admin".into(),
locked_by_id: None,
delegates,
replaced,
}
}
fn delegate(account_id: u32, access: Access) -> Delegate {
Delegate {
account_id,
access,
send_as: false,
until: None,
}
}
#[test]
fn grants_are_added_and_restored() {
let read = Access::Read.grants(Collection::Mailbox, false);
let full = Access::Full.grants(Collection::Mailbox, false);
// Delegate 2 already had a share here; delegate 3 had nothing
let earlier: Bitmap<Acl> = Bitmap::from_iter([Acl::Read]);
let current = vec![AclGrant {
account_id: 2,
grants: earlier,
}];
let lock = lock_with(
vec![delegate(2, Access::Full), delegate(3, Access::Read)],
vec![],
);
let mut replaced = Vec::new();
let acls = merge_grants(&current, Collection::Mailbox, 5, false, None, Some(&lock), 0, &mut replaced)
.unwrap();
assert!(acls.contains(&AclGrant { account_id: 2, grants: full }));
assert!(acls.contains(&AclGrant { account_id: 3, grants: read }));
assert_eq!(replaced.len(), 1, "only 2 had rights to put back");
assert_eq!(replaced[0].rights, u64::from(earlier));
// Running it again changes nothing and keeps the note
let locked = Lock { replaced: replaced.clone(), ..lock.clone() };
let mut again = Vec::new();
assert!(merge_grants(&acls, Collection::Mailbox, 5, false, Some(&locked), Some(&locked), 0, &mut again).is_none());
assert_eq!(again, replaced);
// Unlocking puts 2's share back and removes 3
let mut none = Vec::new();
let back = merge_grants(&acls, Collection::Mailbox, 5, false, Some(&locked), None, 0, &mut none).unwrap();
assert_eq!(back, vec![AclGrant { account_id: 2, grants: earlier }]);
// Ending one delegation keeps the other
let fewer = lock_with(vec![delegate(3, Access::Read)], vec![]);
let mut kept = Vec::new();
let after = merge_grants(&acls, Collection::Mailbox, 5, false, Some(&locked), Some(&fewer), 0, &mut kept).unwrap();
assert!(after.contains(&AclGrant { account_id: 2, grants: earlier }));
assert!(after.contains(&AclGrant { account_id: 3, grants: read }));
}
#[test]
fn an_expired_delegation_gives_back_its_share_every_time() {
let earlier: Bitmap<Acl> = Bitmap::from_iter([Acl::Read]);
let note = Replaced {
collection: Collection::Mailbox as u8,
document_id: 5,
delegate: 2,
rights: u64::from(earlier),
};
let mut ending = delegate(2, Access::Full);
ending.until = Some(200);
let lock = lock_with(vec![ending], vec![note.clone()]);
let during = vec![AclGrant {
account_id: 2,
grants: Access::Full.grants(Collection::Mailbox, false),
}];
// At its `until`, the share it had before comes back, and the note stays
let mut replaced = Vec::new();
let after = merge_grants(&during, Collection::Mailbox, 5, false, Some(&lock), Some(&lock), 300, &mut replaced)
.unwrap();
assert_eq!(after, vec![AclGrant { account_id: 2, grants: earlier }]);
assert_eq!(replaced, vec![note.clone()]);
// The next sweep changes nothing, rather than removing that share
let swept = Lock { replaced: replaced.clone(), ..lock };
let mut again = Vec::new();
assert!(
merge_grants(&after, Collection::Mailbox, 5, false, Some(&swept), Some(&swept), 400, &mut again).is_none()
);
assert_eq!(again, vec![note]);
}
#[test]
fn the_timer_finds_the_next_end() {
let ends_at = |account_id, until| {
let mut d = delegate(account_id, Access::Read);
d.until = until;
d
};
let a = Lock { account_id: 10, ..lock_with(vec![ends_at(2, Some(500)), ends_at(3, None)], vec![]) };
let b = Lock { account_id: 11, ..lock_with(vec![ends_at(4, Some(300))], vec![]) };
let locks = vec![a, b];
assert_eq!(next_until(&locks, 100), Some(300));
assert_eq!(next_until(&locks, 300), Some(500));
assert_eq!(next_until(&locks, 500), None);
assert_eq!(ended_between(&locks, 100, 300).collect::<Vec<_>>(), vec![11]);
assert_eq!(ended_between(&locks, 300, 600).collect::<Vec<_>>(), vec![10]);
assert!(ended_between(&locks, 600, 900).next().is_none());
}
#[test]
fn expired_delegations_grant_nothing() {
let lock = Lock {
account_id: 1,
reason: "Left the company".into(),
locked_at: 100,
locked_by: "admin".into(),
locked_by_id: None,
delegates: vec![
Delegate {
account_id: 2,
access: Access::Read,
send_as: false,
until: Some(200),
},
Delegate {
account_id: 3,
access: Access::Full,
send_as: true,
until: None,
},
],
replaced: vec![],
};
let grants = lock.grants_for_new(Collection::Mailbox, false, 300);
assert_eq!(grants.len(), 1);
assert_eq!(grants[0].0, 3);
let json = serde_json::to_string(&lock).unwrap();
assert_eq!(serde_json::from_str::<Lock>(&json).unwrap(), lock);
assert!(json.contains("\"access\":\"full\""));
}
}
+221
View File
@@ -0,0 +1,221 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: a locked account's grants on its calendars, address books, file
//! folders and top-level files (audit-hold-lock spec, AL-7, AL-10). The
//! mailbox half, and the whole, are in `email::inbuxa_lock`; this half is
//! here so DAV, which sees only these, can grant on what it creates.
use crate::{cache::GroupwareCache, calendar::Calendar, contact::AddressBook, file::FileNode};
use common::{
DavResourceMetadata, Server,
auth::AccountTenantIds,
cache::invalidate::CacheInvalidationBuilder,
ipc::CacheInvalidation,
};
use inbuxa_features::lock::{self, Lock, Replaced};
use store::{
ValueKey,
write::{AlignedBytes, Archive, BatchBuilder, now},
};
use trc::AddContext;
use types::collection::{Collection, SyncCollection};
/// The collections this half covers.
pub const DAV_COLLECTIONS: [Collection; 3] = [
Collection::Calendar,
Collection::AddressBook,
Collection::FileNode,
];
/// Who a lock's grant changes are recorded as having been made by: the
/// locked account itself, as the server acting for it.
pub async fn changed_by(server: &Server, account_id: u32) -> AccountTenantIds {
AccountTenantIds {
account_id,
tenant_id: server.account(account_id).await.ok().and_then(|a| a.id_tenant),
}
}
/// Grants on calendars, address books, file folders and top-level files,
/// into `batch`, with what they replaced into `replaced`.
#[allow(clippy::too_many_arguments)]
pub async fn apply_dav_grants(
server: &Server,
account_id: u32,
old: Option<&Lock>,
new: Option<&Lock>,
now: u64,
replaced: &mut Vec<Replaced>,
batch: &mut BatchBuilder,
) -> trc::Result<()> {
let changed_by = changed_by(server, account_id).await;
for (sync, collection) in [
(SyncCollection::Calendar, Collection::Calendar),
(SyncCollection::AddressBook, Collection::AddressBook),
(SyncCollection::FileNode, Collection::FileNode),
] {
let resources = server
.fetch_dav_resources(account_id, account_id, sync)
.await
.caused_by(trc::location!())?;
for resource in &resources.resources {
// A folder covers what's in it; a file outside any folder
// needs its own grant
let top_level_file = matches!(
&resource.data,
DavResourceMetadata::File {
parent_id: None,
..
}
);
if !resource.is_container() && !top_level_file {
continue;
}
let Some(current) = resource.acls() else {
continue;
};
let Some(acls) = lock::merge_grants(
current,
collection,
resource.document_id,
false,
old,
new,
now,
replaced,
) else {
continue;
};
let Some(archive) = server
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
collection,
resource.document_id,
))
.await
.caused_by(trc::location!())?
else {
continue;
};
match collection {
Collection::Calendar => {
let current = archive
.to_unarchived::<Calendar>()
.caused_by(trc::location!())?;
let mut changed = current
.deserialize::<Calendar>()
.caused_by(trc::location!())?;
changed.acls = acls;
changed
.update(changed_by, current, account_id, resource.document_id, batch)
.caused_by(trc::location!())?;
}
Collection::AddressBook => {
let current = archive
.to_unarchived::<AddressBook>()
.caused_by(trc::location!())?;
let mut changed = current
.deserialize::<AddressBook>()
.caused_by(trc::location!())?;
changed.acls = acls;
changed
.update(changed_by, current, account_id, resource.document_id, batch)
.caused_by(trc::location!())?;
}
_ => {
let current = archive
.to_unarchived::<FileNode>()
.caused_by(trc::location!())?;
let mut changed = current
.deserialize::<FileNode>()
.caused_by(trc::location!())?;
changed.acls = acls;
changed
.update(
changed_by,
current,
account_id,
resource.document_id,
false,
batch,
)
.caused_by(trc::location!())?;
}
}
}
}
Ok(())
}
/// Every token a lock change touches is rebuilt on its next use, on every
/// node: the locked account's and each delegate's, before and after.
pub async fn invalidate(
server: &Server,
account_id: u32,
old: Option<&Lock>,
new: Option<&Lock>,
) -> trc::Result<()> {
let mut builder = CacheInvalidationBuilder::default();
builder.invalidate(CacheInvalidation::AccessToken(account_id));
for delegate in old.into_iter().chain(new).flat_map(|l| &l.delegates) {
builder.invalidate(CacheInvalidation::AccessToken(delegate.account_id));
}
server.invalidate_caches(builder).await
}
/// Whether two lists of replaced rights say the same, in any order.
pub fn same_replaced(a: &[Replaced], b: &[Replaced]) -> bool {
let key = |r: &Replaced| (r.collection, r.document_id, r.delegate, r.rights);
let mut a = a.iter().map(key).collect::<Vec<_>>();
let mut b = b.iter().map(key).collect::<Vec<_>>();
a.sort();
b.sort();
a == b
}
/// Grants the lock on `account_id`, if any, on calendars, address books and
/// files made since. For DAV, after a delegate creates one there.
pub async fn reconcile_dav(server: &Server, account_id: u32) -> trc::Result<()> {
let data = server.store();
let Some(current) = lock::get(data, account_id).await? else {
return Ok(());
};
// Mailbox entries aren't this half's to change
let mut replaced = current
.replaced
.iter()
.filter(|r| !DAV_COLLECTIONS.iter().any(|c| *c as u8 == r.collection))
.cloned()
.collect::<Vec<_>>();
let mut batch = BatchBuilder::new();
apply_dav_grants(
server,
account_id,
Some(&current),
Some(&current),
now(),
&mut replaced,
&mut batch,
)
.await?;
if batch.is_empty() {
return Ok(());
}
server
.commit_batch(batch)
.await
.caused_by(trc::location!())?;
if !same_replaced(&replaced, &current.replaced) {
let updated = Lock {
replaced,
..current.clone()
};
lock::set(data, &updated, Some(&current)).await?;
}
invalidate(server, account_id, Some(&current), Some(&current)).await
}
+1
View File
@@ -23,6 +23,7 @@ pub mod calendar;
pub mod contact;
pub mod file;
pub mod inbuxa; // inbuxa: undelete notes
pub mod inbuxa_lock; // inbuxa: account lock grants
pub mod scheduling;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+6 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use common::auth::AccessToken;
@@ -36,7 +38,9 @@ impl Authenticator for Server {
self.access_token(http_cache.account_id).await?,
http_cache.credential_id,
session.remote_ip,
)?;
)?
// inbuxa: AU-5
.with_origin_arc(http_cache.origin.clone());
if access_token.revision() == http_cache.revision {
// Enforce authenticated rate limit
@@ -99,6 +103,7 @@ impl Authenticator for Server {
credential_id: access_token.credential_id(),
expires: Instant::now()
+ Duration::from_secs(self.core.oauth.oauth_expiry_token),
origin: access_token.origin_arc(),
},
);
+8 -4
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use super::ErrorType;
@@ -164,9 +166,10 @@ impl ClientRegistrationHandler for Server {
.await
.caused_by(trc::location!())?;
let result = self
.registry()
.write(RegistryWrite::insert(
// inbuxa: AU-1.10: a client registering itself
let result = inbuxa_features::audit::scope::system(
"oauth-registration",
self.registry().write(RegistryWrite::insert(
&OAuthClient {
client_id: client_id.clone(),
description: request.client_name.clone(),
@@ -179,7 +182,8 @@ impl ClientRegistrationHandler for Server {
..Default::default()
}
.into(),
))
)),
)
.await
.caused_by(trc::location!())?;
+17 -3
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use super::{
@@ -237,10 +239,20 @@ impl TokenHandler for Server {
.validate_access_token(GrantType::RefreshToken.into(), refresh_token)
.await
{
// inbuxa: AL-2: a locked account gets no new tokens
Ok(token_info)
if self
.access_token(token_info.account_id)
.await
.is_ok_and(|token| token.is_locked()) =>
{
TokenResponse::error(ErrorType::InvalidGrant)
}
Ok(token_info) => self
.issue_token(
token_info.account_id,
"",
// inbuxa: AU-5: the client travels in the refresh token
token_info.claims.as_deref().unwrap_or_default(),
issuer,
None,
None,
@@ -327,7 +339,8 @@ impl TokenHandler for Server {
account_id,
account_name,
self.core.oauth.oauth_expiry_token,
None,
// inbuxa: AU-5: the token names the client it was issued to
Some(client_id),
credential_version.into(),
)
.await?,
@@ -339,7 +352,8 @@ impl TokenHandler for Server {
account_id,
account_name,
self.core.oauth.oauth_expiry_refresh_token,
None,
// inbuxa: AU-5: so a refreshed access token still names it
Some(client_id),
credential_version.into(),
)
.await?
+9
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use ahash::AHashMap;
@@ -198,6 +200,13 @@ impl<T: SessionStream> SessionData<T> {
.access_token(self.account_id)
.await
.and_then(|inner| {
// inbuxa: AL-3: a session opened before its account was
// locked is refused from its next command
if inner.is_locked() {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Account is locked"));
}
AccessToken::renew(inner, self.access_token.credential_id(), self.remote_addr)
})
.caused_by(trc::location!())
+10
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
@@ -141,6 +143,14 @@ impl<T: SessionStream> SessionData<T> {
.await
.imap_ctx(&arguments.tag, trc::location!())?;
// inbuxa: AL-7: a folder a delegate makes in a locked account gets
// the lock's grants, so the delegate can see it
if params.account_id != self.account_id
&& let Err(err) = email::inbuxa_lock::reconcile(&self.server, params.account_id).await
{
trc::error!(err.details("Failed to grant a lock's delegates on a new folder"));
}
trc::event!(
Imap(trc::ImapEvent::CreateMailbox),
SpanId = self.session_id,
+19 -1
View File
@@ -45,7 +45,15 @@ impl<T: SessionStream> Session<T> {
let (data, mailbox) = self.state.select_data();
// Validate ACL
if !data
// inbuxa: AL-6: a delegate below full may move mail, never delete it
let may_destroy = data
.refresh_access_token()
.await
.imap_ctx(&request.tag, trc::location!())?
.delegation(mailbox.id.account_id)
.is_none_or(|delegation| delegation.access.may_destroy());
if !may_destroy
|| !data
.check_mailbox_acl(
mailbox.id.account_id,
mailbox.id.mailbox_id,
@@ -143,6 +151,16 @@ impl<T: SessionStream> SessionData<T> {
) -> trc::Result<Option<u32>> {
// Obtain message ids
let account_id = mailbox.id.account_id;
// inbuxa: AL-6: nothing is deleted for a delegate below full (CLOSE
// expunges quietly, so it deletes nothing, quietly)
if self
.refresh_access_token()
.await?
.delegation(account_id)
.is_some_and(|delegation| !delegation.access.may_destroy())
{
return Ok(None);
}
let mut deleted_ids = RoaringBitmap::from_iter(
self.server
.get_cached_messages(account_id)
@@ -0,0 +1,199 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:AccountLock/get` and `/set` under `urn:inbuxa:jmap`: an account
//! locked, and the people it is handed to (audit-hold-lock spec, AL-1 to
//! AL-12). A lock's id is the locked account's id. Creating one locks the
//! account, updating changes its delegates, destroying unlocks it. The set
//! call's `reason` argument says why, for the audit log (AU-12); creating
//! takes it as a property.
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 AccountLock;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AccountLockProperty {
Id,
/// The locked account (on create; afterwards the same as `id`).
AccountId,
Name,
Reason,
LockedAt,
LockedBy,
Delegates,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AccountLockValue {
Id(Id),
}
impl Property for AccountLockProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
// Keys inside a delegate stay plain keys
match parent {
None => AccountLockProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
AccountLockProperty::Id => "id",
AccountLockProperty::AccountId => "accountId",
AccountLockProperty::Name => "name",
AccountLockProperty::Reason => "reason",
AccountLockProperty::LockedAt => "lockedAt",
AccountLockProperty::LockedBy => "lockedBy",
AccountLockProperty::Delegates => "delegates",
}
.into()
}
}
impl AccountLockProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => AccountLockProperty::Id,
b"accountId" => AccountLockProperty::AccountId,
b"name" => AccountLockProperty::Name,
b"reason" => AccountLockProperty::Reason,
b"lockedAt" => AccountLockProperty::LockedAt,
b"lockedBy" => AccountLockProperty::LockedBy,
b"delegates" => AccountLockProperty::Delegates,
)
}
}
impl FromStr for AccountLockProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
AccountLockProperty::parse(s).ok_or(())
}
}
impl Element for AccountLockValue {
type Property = AccountLockProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(AccountLockProperty::Id | AccountLockProperty::AccountId) => {
Id::from_str(value).ok().map(AccountLockValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
AccountLockValue::Id(id) => id.to_string().into(),
}
}
}
/// The set call's own arguments: why (AU-12).
#[derive(Debug, Clone, Default)]
pub struct AccountLockSetArguments {
pub reason: Option<String>,
}
impl<'de> DeserializeArguments<'de> for AccountLockSetArguments {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
if key == "reason" {
self.reason = map.next_value()?;
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(())
}
}
impl JmapObject for AccountLock {
type Property = AccountLockProperty;
type Element = AccountLockValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = AccountLockSetArguments;
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = AccountLockProperty::Id;
}
impl From<Id> for AccountLockValue {
fn from(id: Id) -> Self {
AccountLockValue::Id(id)
}
}
impl JmapObjectId for AccountLockValue {
fn as_id(&self) -> Option<Id> {
match self {
AccountLockValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
AccountLockValue::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 = AccountLockValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for AccountLockProperty {
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
}
}
@@ -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
}
}
+2
View File
@@ -21,7 +21,9 @@ pub mod contact;
pub mod email;
pub mod email_submission;
pub mod fastmail_masked_email; // inbuxa: masked email
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
pub mod inbuxa_audit; // inbuxa: the audit log
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
+9
View File
@@ -61,6 +61,15 @@ impl Response<'_> {
GetResponseMethod::AiLimits(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::AuditEvent(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::AuditSettings(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::AccountLock(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::ProtocolPolicy(response) => {
response.eval_jptr(path, &mut results)
}
@@ -46,6 +46,9 @@ impl Response<'_> {
GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?,
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
GetRequestMethod::TenantProtocolPolicy(request) => {
request.resolve_references(self)?
@@ -96,6 +99,18 @@ impl Response<'_> {
SetRequestMethod::Explanation(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::AuditSettings(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::AuditExport(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::AuditVerification(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::AccountLock(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::ProtocolPolicy(request) => {
request.resolve_references(self, 1, false)?
}
@@ -133,9 +133,31 @@ pub enum Capabilities {
FileNode(FileNodeCapabilities),
WebPush(WebPushCapabilities),
Inbuxa(InbuxaAccountCapabilities),
// inbuxa: AL-7
InbuxaDelegated(InbuxaDelegatedCapabilities),
Empty(EmptyCapabilities),
}
/// inbuxa: `urn:inbuxa:jmap` on a locked account delegated to the signed-in
/// principal (audit-hold-lock spec, AL-7), so a client can tell it from an
/// ordinary share without guessing from `isReadOnly`.
#[derive(Debug, Clone, serde::Serialize)]
pub struct InbuxaDelegatedCapabilities {
pub delegation: DelegationInfo,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DelegationInfo {
/// Always true: only locked accounts are delegated.
pub locked: bool,
/// `read`, `organize` or `full`.
pub access: &'static str,
#[serde(rename(serialize = "sendAs"))]
pub send_as: bool,
/// When the delegation ends, if it does (UTC).
pub until: Option<String>,
}
/// inbuxa: `urn:inbuxa:jmap` on the signed-in principal's own account.
#[derive(Debug, Clone, serde::Serialize)]
pub struct InbuxaAccountCapabilities {
+35
View File
@@ -51,6 +51,13 @@ pub enum MethodObject {
AiLimits,
// inbuxa: "Explain this" with the local model
Explanation,
// inbuxa: the audit log
AuditEvent,
AuditSettings,
AuditExport,
AuditVerification,
// inbuxa: account lock with delegation
AccountLock,
ProtocolPolicy,
TenantProtocolPolicy,
}
@@ -80,6 +87,11 @@ impl MethodObject {
MethodObject::DeletedAccount => Capability::Inbuxa,
MethodObject::AiLimits => Capability::Inbuxa,
MethodObject::Explanation => Capability::Inbuxa,
MethodObject::AuditEvent
| MethodObject::AuditSettings
| MethodObject::AuditExport
| MethodObject::AuditVerification
| MethodObject::AccountLock => Capability::Inbuxa,
MethodObject::ProtocolPolicy => Capability::Inbuxa,
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
}
@@ -260,6 +272,16 @@ impl MethodName {
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
(MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set",
(MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get",
(MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query",
(MethodFunction::Get, MethodObject::AuditSettings) => "inbuxa:AuditSettings/get",
(MethodFunction::Set, MethodObject::AuditSettings) => "inbuxa:AuditSettings/set",
(MethodFunction::Set, MethodObject::AuditExport) => "inbuxa:AuditExport/set",
(MethodFunction::Get, MethodObject::AccountLock) => "inbuxa:AccountLock/get",
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
(MethodFunction::Set, MethodObject::AuditVerification) => {
"inbuxa:AuditVerification/set"
}
(MethodFunction::Get, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/get",
(MethodFunction::Set, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/set",
(MethodFunction::Get, MethodObject::TenantProtocolPolicy) => {
@@ -394,6 +416,14 @@ impl MethodName {
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
"inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set),
"inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get),
"inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query),
"inbuxa:AuditSettings/get" => (MethodObject::AuditSettings, MethodFunction::Get),
"inbuxa:AuditSettings/set" => (MethodObject::AuditSettings, MethodFunction::Set),
"inbuxa:AuditExport/set" => (MethodObject::AuditExport, MethodFunction::Set),
"inbuxa:AccountLock/get" => (MethodObject::AccountLock, MethodFunction::Get),
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
@@ -452,6 +482,11 @@ impl Display for MethodObject {
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
MethodObject::AiLimits => "inbuxa:AiLimits",
MethodObject::Explanation => "inbuxa:Explanation",
MethodObject::AuditEvent => "inbuxa:AuditEvent",
MethodObject::AuditSettings => "inbuxa:AuditSettings",
MethodObject::AuditExport => "inbuxa:AuditExport",
MethodObject::AuditVerification => "inbuxa:AuditVerification",
MethodObject::AccountLock => "inbuxa:AccountLock",
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
MethodObject::Registry(obj) => {
+8
View File
@@ -116,6 +116,9 @@ pub enum GetRequestMethod {
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
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>>),
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy(
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
@@ -144,6 +147,10 @@ pub enum SetRequestMethod<'x> {
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
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>>),
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy(
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
@@ -175,6 +182,7 @@ pub enum QueryRequestMethod {
CalendarEventNotification(Box<QueryRequest<CalendarEventNotification>>),
ShareNotification(Box<QueryRequest<ShareNotification>>),
Registry(Box<QueryRequest<Registry>>),
AuditEvent(Box<QueryRequest<crate::object::inbuxa_audit::AuditEvent>>),
}
#[derive(Debug)]
+58
View File
@@ -551,6 +551,64 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self));
}
},
// inbuxa: account lock with delegation
(MethodFunction::Get, MethodObject::AccountLock) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AccountLock(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::AccountLock) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::AccountLock(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
// inbuxa: the audit log
(MethodFunction::Get, MethodObject::AuditEvent) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AuditEvent(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Query, MethodObject::AuditEvent) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::AuditEvent(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::AuditSettings) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AuditSettings(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::AuditSettings) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::AuditSettings(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::AuditExport) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::AuditExport(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::AuditVerification) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::AuditVerification(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Query, MethodObject::Registry(_)) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Registry(value)),
Err(err) => RequestMethod::invalid(err),
+51
View File
@@ -103,6 +103,9 @@ pub enum GetResponseMethod {
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
TenantProtocolPolicy(
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
@@ -131,6 +134,10 @@ pub enum SetResponseMethod {
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
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>>),
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy(
@@ -714,3 +721,47 @@ 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)))
}
}
// inbuxa: account lock with delegation
impl<'x> From<GetResponse<crate::object::inbuxa_account_lock::AccountLock>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_account_lock::AccountLock>) -> Self {
ResponseMethod::Get(GetResponseMethod::AccountLock(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_account_lock::AccountLock>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_account_lock::AccountLock>) -> Self {
ResponseMethod::Set(SetResponseMethod::AccountLock(Box::new(value)))
}
}
+56
View File
@@ -21,6 +21,8 @@ use types::{collection::Collection, id::Id};
pub trait JmapAuthorization {
fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self>;
/// inbuxa: AL-8: the account's own, or a delegate allowed to send as it.
fn assert_can_send(&self, account_id: Id) -> trc::Result<&Self>;
fn assert_has_jmap_permission(
&self,
request: &RequestMethod,
@@ -31,6 +33,17 @@ pub trait JmapAuthorization {
}
impl JmapAuthorization for AccessToken {
fn assert_can_send(&self, account_id: Id) -> trc::Result<&Self> {
if self
.delegation(account_id.document_id())
.is_some_and(|delegation| delegation.send_as)
{
Ok(self)
} else {
self.assert_is_member(account_id)
}
}
fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self> {
if self.is_member(account_id.document_id()) {
Ok(self)
@@ -77,6 +90,12 @@ impl JmapAuthorization for AccessToken {
GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet,
// inbuxa: AI call limits, with the classifier's permissions
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
// inbuxa: the audit log (AU-9)
GetRequestMethod::AuditEvent(_) | GetRequestMethod::AuditSettings(_) => {
Permission::SysAuditGet
}
// inbuxa: account lock (AL-12)
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
// inbuxa: legacy protocols off. It takes listeners away and
// puts them back, so it takes the listener's permissions
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
@@ -180,6 +199,36 @@ impl JmapAuthorization for AccessToken {
Permission::SysSpamLlmUpdate,
Permission::SysSpamLlmUpdate,
),
// inbuxa: the audit log (AU-7, AU-9, AU-11)
SetRequestMethod::AuditSettings(s) => validate_set(
s,
self,
Permission::SysAuditSettingsUpdate,
Permission::SysAuditSettingsUpdate,
Permission::SysAuditSettingsUpdate,
),
SetRequestMethod::AuditExport(s) => validate_set(
s,
self,
Permission::SysAuditExport,
Permission::SysAuditExport,
Permission::SysAuditExport,
),
// inbuxa: account lock (AL-12)
SetRequestMethod::AccountLock(s) => validate_set(
s,
self,
Permission::SysAccountLockCreate,
Permission::SysAccountLockUpdate,
Permission::SysAccountLockDestroy,
),
SetRequestMethod::AuditVerification(s) => validate_set(
s,
self,
Permission::SysAuditGet,
Permission::SysAuditGet,
Permission::SysAuditGet,
),
// inbuxa: "Explain this" (EX-4)
SetRequestMethod::Explanation(s) => validate_set(
s,
@@ -315,6 +364,11 @@ impl JmapAuthorization for AccessToken {
| MethodObject::DeletedAccount
| MethodObject::AiLimits
| MethodObject::Explanation
| MethodObject::AuditEvent
| MethodObject::AuditSettings
| MethodObject::AuditExport
| MethodObject::AuditVerification
| MethodObject::AccountLock
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
// inbuxa: x:MaskedEmail/changes reads what /get reads
@@ -371,6 +425,8 @@ impl JmapAuthorization for AccessToken {
Permission::JmapCalendarEventNotificationQuery
}
QueryRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationQuery,
// inbuxa: the audit log (AU-9)
QueryRequestMethod::AuditEvent(_) => Permission::SysAuditGet,
QueryRequestMethod::Registry(_) => {
let MethodObject::Registry(object_type) = object else {
unreachable!()
+5 -2
View File
@@ -188,10 +188,13 @@ impl ToRequestError for trc::Error {
trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => {
RequestError::forbidden()
}
// inbuxa: legacy-protocols LP-8 is an event, never an error
// inbuxa: legacy-protocols LP-8 is an event, never an error;
// a failed audit write refuses the change (AU-3)
trc::SecurityEvent::IpBlockExpired
| trc::SecurityEvent::IpAllowExpired
| trc::SecurityEvent::LegacyProtocolsChanged => {
| trc::SecurityEvent::LegacyProtocolsChanged
| trc::SecurityEvent::AuditRecorded
| trc::SecurityEvent::AuditWriteFailed => {
RequestError::internal_server_error()
}
},
+214 -17
View File
@@ -143,15 +143,27 @@ impl RequestHandler for Server {
| RequestMethod::Changes(_)
| RequestMethod::QueryChanges(_)
);
if matches!(
let is_write = matches!(
call.method,
RequestMethod::Set(_)
| RequestMethod::Copy(_)
| RequestMethod::ImportEmail(_)
| RequestMethod::UploadBlob(_)
) {
);
if is_write {
has_written = true;
}
// inbuxa: AL-7: what a delegate makes in a locked account
// may need the lock's grants
let makes_containers = is_write
&& matches!(
call.name.obj,
MethodObject::Mailbox
| MethodObject::Calendar
| MethodObject::AddressBook
| MethodObject::FileNode
);
let call_name = call.name.as_str().into_owned();
let presented = match &call.method {
RequestMethod::Changes(changes) => match &changes.since_state {
jmap_proto::types::state::State::Exact(change_id) => {
@@ -161,13 +173,16 @@ impl RequestHandler for Server {
},
_ => 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.name,
access_token,
&mut next_call,
session,
);
),
));
let result = if eligible {
store::backend::scaleout::replica::replica_read(
access_token.all_ids().map(|account_id| {
@@ -184,6 +199,31 @@ impl RequestHandler for Server {
} else {
method_call.await
};
let (result, reached) = result;
for account_id in reached {
// inbuxa: AL-9: a delegate's access, and what it
// changes, are recorded; anyone else here impersonated
if let Some(delegation) = access_token.delegation(account_id) {
let access = delegation.access.as_str();
self.audit_delegate(
access_token,
account_id,
access,
is_write.then_some(call_name.as_str()),
result.as_ref().err(),
)
.await;
if makes_containers
&& result.is_ok()
&& let Err(err) =
email::inbuxa_lock::reconcile(self, account_id).await
{
trc::error!(err.details("Failed to grant a lock's delegates on new folders"));
}
} else {
self.audit_foreign_access(access_token, account_id, false).await;
}
}
match result
{
Ok(mut method_response) => {
@@ -221,6 +261,18 @@ impl RequestHandler for Server {
SetResponseMethod::AiLimits(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AuditSettings(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AuditExport(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AuditVerification(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AccountLock(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::Explanation(set_response) => {
set_response.update_created_ids(&mut response);
}
@@ -338,13 +390,15 @@ impl RequestHandler for Server {
}
GetRequestMethod::Identity(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
access_token.assert_is_member(req.account_id)?;
// inbuxa: AL-8: a delegate may send as a locked account
access_token.assert_can_send(req.account_id)?;
self.identity_get(*req).await?.into()
}
GetRequestMethod::EmailSubmission(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
access_token.assert_is_member(req.account_id)?;
// inbuxa: AL-8: a delegate may send as a locked account
access_token.assert_can_send(req.account_id)?;
self.email_submission_get(*req).await?.into()
}
@@ -385,6 +439,26 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: account lock with delegation (AL-1)
GetRequestMethod::AccountLock(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::account_lock::get(self, access_token, *req)
.await?
.into()
}
// inbuxa: the audit log (AU-9)
GetRequestMethod::AuditEvent(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::audit_log::event_get(self, access_token, *req)
.await?
.into()
}
GetRequestMethod::AuditSettings(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::audit_log::settings_get(self, *req)
.await?
.into()
}
// inbuxa: inbuxa:ProtocolPolicy/get (legacy protocols off)
GetRequestMethod::ProtocolPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
@@ -497,7 +571,8 @@ impl RequestHandler for Server {
}
QueryRequestMethod::EmailSubmission(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
access_token.assert_is_member(req.account_id)?;
// inbuxa: AL-8: a delegate may send as a locked account
access_token.assert_can_send(req.account_id)?;
self.email_submission_query(*req).await?.into()
}
@@ -560,6 +635,13 @@ impl RequestHandler for Server {
self.share_notification_query(*req).await?.into()
}
// inbuxa: the audit log (AU-9)
QueryRequestMethod::AuditEvent(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::audit_log::event_query(self, access_token, *req)
.await?
.into()
}
QueryRequestMethod::Registry(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
assert_registry_account(self, method_name.obj, access_token, req.account_id)
@@ -595,7 +677,8 @@ impl RequestHandler for Server {
}
SetRequestMethod::EmailSubmission(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
access_token.assert_is_member(req.account_id)?;
// inbuxa: AL-8: a delegate may send as a locked account
access_token.assert_can_send(req.account_id)?;
self.email_submission_set(*req, &session.instance, next_call)
.await?
@@ -622,21 +705,107 @@ impl RequestHandler for Server {
// inbuxa: Fastmail's MaskedEmail/set
SetRequestMethod::MaskedEmail(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::fastmail::set(self, access_token, *req)
// inbuxa: AU-1.2, AU-3
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
None,
*req,
|req| Box::pin(crate::inbuxa::fastmail::set(self, access_token, req)),
)
.await?
.into()
}
// inbuxa: inbuxa:DeletedAccount/set (UD-17)
SetRequestMethod::DeletedAccount(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::deleted_account::set(self, access_token, *req)
// inbuxa: AU-1.2, AU-3
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
None,
*req,
|req| Box::pin(crate::inbuxa::deleted_account::set(self, access_token, req)),
)
.await?
.into()
}
// inbuxa: inbuxa:AiLimits/set
SetRequestMethod::AiLimits(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::ai_limits::set(self, access_token, *req)
// inbuxa: AU-1.2, AU-3
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
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,
None,
*req,
|req| Box::pin(crate::inbuxa::audit_log::settings_set(self, access_token, req)),
)
.await?
.into()
}
// inbuxa: account lock with delegation, recorded with its
// reason (AL-1, AU-12)
SetRequestMethod::AccountLock(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
let reason = req.arguments.reason.clone().or_else(|| {
req.create.as_ref().and_then(|create| {
create.values().find_map(|value| {
serde_json::to_value(value)
.ok()?
.get("reason")?
.as_str()
.map(str::to_string)
})
})
});
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
reason,
*req,
|req| Box::pin(crate::inbuxa::account_lock::set(self, access_token, req)),
)
.await?
.into()
}
SetRequestMethod::AuditExport(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
.await?
.into()
}
SetRequestMethod::AuditVerification(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::audit_log::verification_set(self, access_token, session, *req)
.await?
.into()
}
@@ -650,14 +819,34 @@ impl RequestHandler for Server {
// inbuxa: inbuxa:ProtocolPolicy/set (legacy protocols off)
SetRequestMethod::ProtocolPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::protocol_policy::set(self, access_token, *req)
// inbuxa: AU-1.2, AU-3
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
None,
*req,
|req| Box::pin(crate::inbuxa::protocol_policy::set(self, access_token, req)),
)
.await?
.into()
}
// inbuxa: inbuxa:TenantProtocolPolicy/set (legacy protocols off, per tenant)
SetRequestMethod::TenantProtocolPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::tenant_protocol_policy::set(self, access_token, *req)
// inbuxa: AU-1.2, AU-3
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
None,
*req,
|req| Box::pin(crate::inbuxa::tenant_protocol_policy::set(self, access_token, req)),
)
.await?
.into()
}
@@ -724,12 +913,18 @@ impl RequestHandler for Server {
assert_registry_account(self, method_name.obj, access_token, req.account_id)
.await?;
Box::pin(self.registry_set(
method_name.obj.unwrap_registry(),
*req,
// inbuxa: AU-1.1, AU-3: recorded before and after
let object_type = method_name.obj.unwrap_registry();
crate::inbuxa::audit::recorded(
self,
access_token,
session,
))
&method_name.obj.to_string(),
Some(object_type),
None,
*req,
|req| Box::pin(self.registry_set(object_type, req, access_token, session)),
)
.await?
.into()
}
@@ -906,6 +1101,8 @@ pub(crate) fn resolve_account_id(
access_token: &AccessToken,
) -> trc::Result<()> {
if account_id.id() < INVALID_ACCOUNT_ID {
// inbuxa: AU-1.6
crate::inbuxa::audit::note_access(account_id.document_id(), access_token);
Ok(())
} else if matches!(
obj,
+23 -2
View File
@@ -8,7 +8,7 @@
use common::{Server, auth::AccessToken};
use jmap_proto::request::capability::{
Account, Capabilities, Capability, EmptyCapabilities, InbuxaAccountCapabilities, Session,
Account, Capabilities, Capability, EmptyCapabilities, InbuxaAccountCapabilities, InbuxaDelegatedCapabilities, DelegationInfo, Session,
};
use registry::schema::enums::Permission;
use std::future::Future;
@@ -116,11 +116,16 @@ impl SessionHandler for Server {
continue;
};
// inbuxa: AL-6, AL-7: a delegated locked account says so, and is
// read-only at the read level
let delegation = access_token.delegation(account_id).cloned();
let account_id = Id::from(account_id);
let mut account = Account {
name: account.name().to_string(),
is_personal: false,
is_read_only: false,
is_read_only: delegation
.as_ref()
.is_some_and(|d| d.access == inbuxa_features::lock::Access::Read),
account_capabilities: VecMap::with_capacity(account_capabilities.len()),
};
for capability in access_token.account_capabilities() {
@@ -132,6 +137,22 @@ impl SessionHandler for Server {
.unwrap_or_else(|| Capabilities::Empty(EmptyCapabilities::default())),
);
}
if let Some(delegation) = delegation {
account.account_capabilities.append(
Capability::Inbuxa,
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
delegation: DelegationInfo {
locked: true,
access: delegation.access.as_str(),
send_as: delegation.send_as,
until: delegation.until.map(|until| {
jmap_proto::types::date::UTCDate::from_timestamp(until as i64)
.to_string()
}),
},
}),
);
}
session.accounts.append(account_id, account);
}
+5
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use common::{Server, auth::AccessToken};
@@ -115,6 +117,9 @@ impl BlobDownload for Server {
document_id,
} => {
if access_token.is_member(*account_id) {
// inbuxa: AU-1.6: another account's blob
self.audit_foreign_access(access_token, *account_id, true)
.await;
true
} else {
match Collection::from(*collection) {
+5
View File
@@ -419,6 +419,11 @@ impl IntermediateChangesResponse {
| MethodObject::DeletedAccount
| MethodObject::AiLimits
| MethodObject::Explanation
| MethodObject::AuditEvent
| MethodObject::AuditSettings
| MethodObject::AuditExport
| MethodObject::AuditVerification
| MethodObject::AccountLock
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy
| MethodObject::Registry(_) => unreachable!(),
+16 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
@@ -1141,7 +1143,20 @@ impl EmailSet for Server {
}
// Process deletions
if !will_destroy.is_empty() {
// inbuxa: AL-6: a delegate below full may move mail, never delete it
if !will_destroy.is_empty()
&& access_token
.delegation(account_id)
.is_some_and(|delegation| !delegation.access.may_destroy())
{
for destroy_id in will_destroy {
response.not_destroyed.append(
destroy_id,
SetError::forbidden()
.with_description("A delegate at this level can move mail but not delete it."),
);
}
} else if !will_destroy.is_empty() {
let email_ids = cache.email_document_ids();
let can_destroy_message_ids = if access_token.is_shared(account_id) {
cache.shared_messages(access_token, Acl::RemoveItems).into()
+437
View File
@@ -0,0 +1,437 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:AccountLock` (audit-hold-lock spec, AL-1 to AL-12): locking an
//! account, handing it to delegates, and unlocking it. The grants
//! themselves are `email::inbuxa_lock`'s.
use common::{
Server,
auth::AccessToken,
ipc::{BroadcastEvent, PushEvent},
};
use email::inbuxa_lock::apply_grants;
use groupware::inbuxa_lock::invalidate;
use inbuxa_features::lock::{self, Access, Delegate, Lock, MAX_DELEGATES};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_account_lock::{
AccountLock, AccountLockProperty as P, AccountLockSetArguments, AccountLockValue,
},
request::IntoValid,
types::date::UTCDate,
};
use jmap_tools::{Key, Map, Value};
use std::{borrow::Cow, str::FromStr};
use store::write::now;
use types::id::Id;
type LValue = Value<'static, P, AccountLockValue>;
const ALL: &[P] = &[
P::Id,
P::AccountId,
P::Name,
P::Reason,
P::LockedAt,
P::LockedBy,
P::Delegates,
];
/// Whether the caller may lock, change or unlock `account_id` (AL-12): an
/// administrator for an account in reach, never its own, never a group.
async fn assert_reach(
server: &Server,
access_token: &AccessToken,
account_id: u32,
) -> Result<(), SetError<P>> {
if access_token.is_account_id(account_id) {
return Err(SetError::forbidden().with_description("You can't lock your own account."));
}
let Ok(account) = server.account(account_id).await else {
return Err(SetError::not_found());
};
if !account.is_user_account() {
return Err(SetError::invalid_properties()
.with_property(P::AccountId)
.with_description("Only a person's account can be locked."));
}
match access_token.tenant_id() {
// A tenant administrator reaches its own tenant's accounts only
Some(tenant_id) if account.id_tenant != Some(tenant_id) => Err(SetError::not_found()),
_ => Ok(()),
}
}
/// Reads and checks the delegates asked for (AL-5, AL-6, AL-8).
async fn parse_delegates(
server: &Server,
access_token: &AccessToken,
locked_id: u32,
value: LValue,
) -> Result<Vec<Delegate>, SetError<P>> {
let invalid = |why: String| {
SetError::invalid_properties()
.with_property(P::Delegates)
.with_description(why)
};
let json: serde_json::Value = value.into();
let Some(items) = json.as_array() else {
return Err(invalid("delegates must be a list.".into()));
};
if items.len() > MAX_DELEGATES {
return Err(invalid(format!("At most {MAX_DELEGATES} delegates.")));
}
let locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant);
let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len());
for item in items {
let account_id = item["accountId"]
.as_str()
.and_then(|id| Id::from_str(id).ok())
.map(|id| id.document_id())
.ok_or_else(|| invalid("Each delegate needs an accountId.".into()))?;
let access = item["access"]
.as_str()
.and_then(Access::parse)
.ok_or_else(|| invalid("access must be read, organize or full.".into()))?;
let send_as = item["sendAs"].as_bool().unwrap_or(false);
let until = match item.get("until").filter(|v| !v.is_null()) {
None => None,
Some(value) => Some(
value
.as_str()
.and_then(|d| UTCDate::from_str(d).ok())
.map(|d| d.timestamp().max(0) as u64)
.ok_or_else(|| invalid("until must be a UTC date.".into()))?,
),
};
if account_id == locked_id {
return Err(invalid("An account can't be its own delegate.".into()));
}
if access_token.is_account_id(account_id) && access_token.tenant_id().is_some() {
return Err(invalid(
"Only a server administrator may make themselves a delegate.".into(),
));
}
if send_as && access == Access::Read {
return Err(invalid(
"Sending as the account needs organize or full access: the message is made in its Drafts first."
.into(),
));
}
let Ok(delegate) = server.account(account_id).await else {
return Err(invalid(format!("No account {}.", Id::from(account_id))));
};
if !delegate.is_user_account() {
return Err(invalid("A delegate must be a person, not a group.".into()));
}
// Delegates stay in the locked account's tenant, unless a server
// administrator says otherwise (AL-5)
if access_token.tenant_id().is_some() && delegate.id_tenant != locked_tenant {
return Err(invalid("A delegate must be in the same organization.".into()));
}
if delegates.iter().any(|d| d.account_id == account_id) {
return Err(invalid("A delegate is listed twice.".into()));
}
delegates.push(Delegate {
account_id,
access,
send_as,
until,
});
}
Ok(delegates)
}
/// Ends the account's open sessions, here and on every node (AL-3).
async fn end_sessions(server: &Server, account_id: u32) {
let _ = server
.inner
.ipc
.push_tx
.send(PushEvent::Revoke { account_id })
.await;
server
.cluster_broadcast(BroadcastEvent::EndSessions(account_id))
.await;
}
fn date(seconds: u64) -> LValue {
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
}
async fn to_value(server: &Server, lock: &Lock, properties: &[P]) -> LValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))),
P::Name => Value::Str(server.audit_account_name(lock.account_id).await.into()),
P::Reason => Value::Str(lock.reason.clone().into()),
P::LockedAt => date(lock.locked_at),
P::LockedBy => Value::Str(lock.locked_by.clone().into()),
P::Delegates => {
let mut items = Vec::with_capacity(lock.delegates.len());
for delegate in &lock.delegates {
let mut item = Map::with_capacity(5);
item.insert_unchecked(
Key::Borrowed("accountId"),
Value::Str(Id::from(delegate.account_id).to_string().into()),
);
item.insert_unchecked(
Key::Borrowed("name"),
Value::Str(server.audit_account_name(delegate.account_id).await.into()),
);
item.insert_unchecked(
Key::Borrowed("access"),
Value::Str(Cow::Borrowed(delegate.access.as_str())),
);
item.insert_unchecked(Key::Borrowed("sendAs"), Value::Bool(delegate.send_as));
item.insert_unchecked(
Key::Borrowed("until"),
delegate.until.map_or(Value::Null, date),
);
items.push(Value::Object(item));
}
Value::Array(items)
}
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// Whether a lock is in the caller's reach: every lock at server level, the
/// tenant's own inside one.
async fn in_reach(server: &Server, access_token: &AccessToken, account_id: u32) -> bool {
match access_token.tenant_id() {
None => true,
Some(tenant_id) => server
.account(account_id)
.await
.is_ok_and(|a| a.id_tenant == Some(tenant_id)),
}
}
/// `inbuxa:AccountLock/get`: the locks in reach.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<AccountLock>,
) -> trc::Result<GetResponse<AccountLock>> {
let properties = request.unwrap_properties(ALL);
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 data = server.store();
match ids {
None => {
for current in lock::all(data).await? {
if in_reach(server, access_token, current.account_id).await {
response.list.push(to_value(server, &current, &properties).await);
}
}
}
Some(ids) => {
for id in ids {
match lock::get(data, id.document_id()).await? {
Some(current) if in_reach(server, access_token, current.account_id).await => {
response.list.push(to_value(server, &current, &properties).await);
}
_ => response.push_not_found(id),
}
}
}
}
Ok(response)
}
fn reason_of(reason: Option<&str>) -> Option<String> {
reason
.map(str::trim)
.filter(|r| !r.is_empty())
.map(|r| r.chars().take(500).collect())
}
fn reason_required() -> SetError<P> {
SetError::invalid_properties()
.with_property(P::Reason)
.with_description("Say why: a reason is required and is kept in the audit log.")
}
/// `inbuxa:AccountLock/set`: create locks, update changes delegates or the
/// reason, destroy unlocks. The request layer records each.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, AccountLock>,
) -> trc::Result<SetResponse<AccountLock>> {
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
let arguments: AccountLockSetArguments = std::mem::take(&mut request.arguments);
let data = server.store();
let actor = server.audit_actor(access_token).await;
for (client_id, value) in request.unwrap_create() {
let mut account_id = None;
let mut reason = None;
let mut delegates_value = None;
let mut invalid = None;
for (key, value) in value.into_expanded_object() {
match (&key, value) {
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
account_id = Some(id.document_id())
}
(Key::Property(P::Reason), Value::Str(r)) => reason = reason_of(Some(&r)),
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
_ => {
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 Some(account_id) = account_id else {
response.not_created.append(
client_id,
SetError::invalid_properties().with_property(P::AccountId),
);
continue;
};
let Some(reason) = reason.or_else(|| reason_of(arguments.reason.as_deref())) else {
response.not_created.append(client_id, reason_required());
continue;
};
if let Err(error) = assert_reach(server, access_token, account_id).await {
response.not_created.append(client_id, error);
continue;
}
if lock::get(data, account_id).await?.is_some() {
response.not_created.append(
client_id,
SetError::already_exists().with_description("That account is already locked."),
);
continue;
}
let delegates = match delegates_value {
Some(value) => match parse_delegates(server, access_token, account_id, value).await {
Ok(delegates) => delegates,
Err(error) => {
response.not_created.append(client_id, error);
continue;
}
},
None => Vec::new(),
};
let mut created = Lock {
account_id,
reason,
locked_at: now(),
locked_by: actor.name.clone(),
locked_by_id: actor.account_id,
delegates,
replaced: Vec::new(),
};
// The lock is written first: from here the account can't sign in,
// whatever happens to the grants
lock::set(data, &created, None).await?;
created.replaced = apply_grants(server, account_id, None, Some(&created)).await?;
lock::set(data, &created, Some(&created)).await?;
invalidate(server, account_id, None, Some(&created)).await?;
end_sessions(server, account_id).await;
let mut out = Map::with_capacity(1);
out.insert_unchecked(
Key::Property(P::Id),
Value::Element(AccountLockValue::Id(Id::from(account_id))),
);
response.created.insert(client_id, Value::Object(out));
}
for (id, value) in request.unwrap_update().into_valid() {
let account_id = id.document_id();
if let Err(error) = assert_reach(server, access_token, account_id).await {
response.not_updated.append(id, error);
continue;
}
let Some(current) = lock::get(data, account_id).await? else {
response.not_updated.append(id, SetError::not_found());
continue;
};
if reason_of(arguments.reason.as_deref()).is_none() {
response.not_updated.append(id, reason_required());
continue;
}
let mut updated = current.clone();
let mut invalid = None;
for (key, value) in value.into_expanded_object() {
match (&key, value) {
(Key::Property(P::Delegates), value) => {
match parse_delegates(server, access_token, account_id, value.into_owned()).await {
Ok(delegates) => updated.delegates = delegates,
Err(error) => {
invalid = Some(error);
break;
}
}
}
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
Some(r) => updated.reason = r,
None => {
invalid = Some(reason_required());
break;
}
},
_ => {
invalid = Some(SetError::invalid_properties().with_property(key.into_owned()));
break;
}
}
}
if let Some(error) = invalid {
response.not_updated.append(id, error);
continue;
}
updated.replaced = apply_grants(server, account_id, Some(&current), Some(&updated)).await?;
lock::set(data, &updated, Some(&current)).await?;
invalidate(server, account_id, Some(&current), Some(&updated)).await?;
response.updated.append(id, None);
}
for id in request.unwrap_destroy().into_valid() {
let account_id = id.document_id();
if let Err(error) = assert_reach(server, access_token, account_id).await {
response.not_destroyed.append(id, error);
continue;
}
let Some(current) = lock::get(data, account_id).await? else {
response.not_destroyed.append(id, SetError::not_found());
continue;
};
if reason_of(arguments.reason.as_deref()).is_none() {
response.not_destroyed.append(id, reason_required());
continue;
}
// Grants go first: an unlocked account never keeps its delegates
apply_grants(server, account_id, Some(&current), None).await?;
lock::remove(data, &current).await?;
// Delegates lose the account on their next request: their tokens
// are rebuilt without it, on every node
invalidate(server, account_id, Some(&current), None).await?;
response.destroyed.push(id);
}
Ok(response)
}
+419
View File
@@ -0,0 +1,419 @@
/*
* 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): through
/// impersonation, or as a locked account's delegate (AL-9).
pub fn note_access(account_id: u32, access_token: &AccessToken) {
if access_token.delegation(account_id).is_some()
|| (!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>,
reason: Option<String>,
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,
reason,
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>,
reason: Option<String>,
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, reason, &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>,
reason: Option<String>,
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: reason.clone(),
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.
fn schema() -> 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();
GzDecoder::new(SCHEMA_JSON).read_to_end(&mut json).ok()?;
serde_json::from_slice(&json).ok().map(Schema::new)
})
.as_ref()
inbuxa_features::ai::explain::schema::embedded()
}
fn server_fail(why: &'static str) -> SetError<P> {
+3
View File
@@ -8,6 +8,9 @@
//! `crates/features`; this module only speaks JMAP for them.
pub mod access;
pub mod account_lock;
pub mod audit;
pub mod audit_log;
pub mod ai_limits;
pub mod explanation;
pub mod protocol_policy;
+9
View File
@@ -1730,6 +1730,15 @@ pub enum Permission {
ScimAccess = 660,
// inbuxa: "Explain this" (ai-explain spec)
SysAiExplain = 661,
// inbuxa: the audit log (audit-hold-lock spec, AU-9)
SysAuditGet = 662,
SysAuditExport = 663,
SysAuditSettingsUpdate = 664,
// inbuxa: account lock with delegation (audit-hold-lock spec, AL-12)
SysAccountLockGet = 665,
SysAccountLockCreate = 666,
SysAccountLockUpdate = 667,
SysAccountLockDestroy = 668,
SysAccountGet = 219,
SysAccountCreate = 220,
SysAccountUpdate = 221,
+22 -1
View File
@@ -7073,6 +7073,13 @@ impl EnumImpl for Permission {
b"liveDeliveryTest" => Permission::LiveDeliveryTest,
b"scimAccess" => Permission::ScimAccess,
b"sysAiExplain" => Permission::SysAiExplain,
b"sysAuditGet" => Permission::SysAuditGet,
b"sysAuditExport" => Permission::SysAuditExport,
b"sysAuditSettingsUpdate" => Permission::SysAuditSettingsUpdate,
b"sysAccountLockGet" => Permission::SysAccountLockGet,
b"sysAccountLockCreate" => Permission::SysAccountLockCreate,
b"sysAccountLockUpdate" => Permission::SysAccountLockUpdate,
b"sysAccountLockDestroy" => Permission::SysAccountLockDestroy,
b"sysAccountGet" => Permission::SysAccountGet,
b"sysAccountCreate" => Permission::SysAccountCreate,
b"sysAccountUpdate" => Permission::SysAccountUpdate,
@@ -7751,6 +7758,13 @@ impl EnumImpl for Permission {
Permission::LiveDeliveryTest => "liveDeliveryTest",
Permission::ScimAccess => "scimAccess",
Permission::SysAiExplain => "sysAiExplain",
Permission::SysAuditGet => "sysAuditGet",
Permission::SysAuditExport => "sysAuditExport",
Permission::SysAuditSettingsUpdate => "sysAuditSettingsUpdate",
Permission::SysAccountLockGet => "sysAccountLockGet",
Permission::SysAccountLockCreate => "sysAccountLockCreate",
Permission::SysAccountLockUpdate => "sysAccountLockUpdate",
Permission::SysAccountLockDestroy => "sysAccountLockDestroy",
Permission::SysAccountGet => "sysAccountGet",
Permission::SysAccountCreate => "sysAccountCreate",
Permission::SysAccountUpdate => "sysAccountUpdate",
@@ -8422,6 +8436,13 @@ impl EnumImpl for Permission {
218 => Some(Permission::LiveDeliveryTest),
660 => Some(Permission::ScimAccess),
661 => Some(Permission::SysAiExplain),
662 => Some(Permission::SysAuditGet),
663 => Some(Permission::SysAuditExport),
664 => Some(Permission::SysAuditSettingsUpdate),
665 => Some(Permission::SysAccountLockGet),
666 => Some(Permission::SysAccountLockCreate),
667 => Some(Permission::SysAccountLockUpdate),
668 => Some(Permission::SysAccountLockDestroy),
219 => Some(Permission::SysAccountGet),
220 => Some(Permission::SysAccountCreate),
221 => Some(Permission::SysAccountUpdate),
@@ -8866,7 +8887,7 @@ impl EnumImpl for Permission {
}
}
const COUNT: usize = 662;
const COUNT: usize = 669;
}
impl serde::Serialize for Permission {
+12
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use common::ipc::{
@@ -139,6 +141,11 @@ impl BroadcastBatch<Vec<BroadcastEvent>> {
BroadcastEvent::QueueRefresh => {
serialized.push(12u8);
}
// inbuxa: AL-3
BroadcastEvent::EndSessions(account_id) => {
serialized.push(13u8);
let _ = serialized.write_leb128(*account_id);
}
}
}
serialized
@@ -272,6 +279,11 @@ where
10 => Ok(Some(BroadcastEvent::MtaQueueStatus { is_running: true })),
11 => Ok(Some(BroadcastEvent::MtaQueueStatus { is_running: false })),
12 => Ok(Some(BroadcastEvent::QueueRefresh)),
// inbuxa: AL-3
13 => {
let account_id = self.messages.next_leb128().ok_or(())?;
Ok(Some(BroadcastEvent::EndSessions(account_id)))
}
_ => Err(()),
}
} else {
@@ -180,6 +180,15 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
.send(QueueEvent::Paused(!is_running))
.await;
}
// inbuxa: AL-3: sessions an account has
// open here end too
BroadcastEvent::EndSessions(account_id) => {
let _ = inner
.ipc
.push_tx
.send(PushEvent::Revoke { account_id })
.await;
}
BroadcastEvent::QueueRefresh => {
if inner.shared_core.load().network.roles.outbound_mta {
let _ = inner
@@ -266,6 +275,9 @@ fn log_event(event: &BroadcastEvent) -> trc::Value {
BroadcastEvent::PushServerUpdate(account_id) => {
trc::Value::Array(vec!["PushServerUpdate".into(), (*account_id).into()])
}
BroadcastEvent::EndSessions(account_id) => {
trc::Value::Array(vec!["EndSessions".into(), (*account_id).into()])
}
BroadcastEvent::RegistryChange(change) => match change {
RegistryChange::Insert(id) => trc::Value::Array(vec![
"RegistryInsert".into(),
+71
View File
@@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Ends a locked account's delegations at their `until` (AL-5), rather than
//! at the next daily sweep. Each node sleeps until the soonest `until`, wakes
//! early when a lock is written here, and checks at least hourly for locks
//! written on other nodes. One node re-applies each lock; the others find it
//! claimed.
use common::{BuildServer, Inner, KV_LOCK_TASK, Server};
use inbuxa_features::lock;
use std::{sync::Arc, time::Duration};
use store::write::now;
/// The longest the timer sleeps, so an `until` set on another node is seen.
const CEILING: u64 = 3600;
pub fn spawn_lock_expiry(inner: Arc<Inner>) {
tokio::spawn(async move {
// Since boot: anything that ended while the server was down
let mut checked = 0;
loop {
let server = inner.build_server();
let now = now();
let wait = match lock::all(server.store()).await {
Ok(locks) => {
for account_id in lock::ended_between(&locks, checked, now).collect::<Vec<_>>()
{
end_delegations(&server, account_id).await;
}
checked = now;
lock::next_until(&locks, now)
.map_or(CEILING, |until| (until - now).min(CEILING))
}
Err(err) => {
trc::error!(err.details("Failed to read account locks for their end dates"));
60
}
};
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(wait.max(1))) => {}
_ = lock::UNTIL_CHANGED.notified() => {}
}
}
});
}
async fn end_delegations(server: &Server, account_id: u32) {
let key = [b"lock-until:".as_slice(), &account_id.to_be_bytes()].concat();
match server
.in_memory_store()
.try_lock(KV_LOCK_TASK, &key, 60)
.await
{
Ok(true) => {
if let Err(err) = email::inbuxa_lock::reconcile(server, account_id).await {
trc::error!(
err.account_id(account_id)
.details("Failed to end a delegation at its date")
);
}
}
Ok(false) => {}
Err(err) => {
trc::error!(err.details("Failed to claim a delegation's end"));
}
}
}
+5
View File
@@ -23,6 +23,8 @@ use std::sync::Arc;
use crate::task_manager::{manager::spawn_task_manager, scheduler::spawn_task_scheduler};
pub mod broadcast;
// inbuxa: AL-5, delegations end at their date
pub mod inbuxa_lock_expiry;
pub mod state_manager;
pub mod task_manager;
@@ -65,6 +67,9 @@ impl SpawnServices for IpcReceivers {
// Spawn task manager
spawn_task_manager(inner.clone());
// inbuxa: AL-5, end delegations at their `until`
inbuxa_lock_expiry::spawn_lock_expiry(inner.clone());
// Spawn task scheduler
spawn_task_scheduler(inner);
}
@@ -263,6 +263,18 @@ async fn store_maintenance(
}
}
// inbuxa: AL-7: locks' grants reach folders the server made on
// its own (a Sieve fileinto :create)
if let Err(err) = email::inbuxa_lock::reconcile_all(server).await {
trc::error!(err.details("Failed to re-apply account locks"));
}
// inbuxa: AU-7: audit records past their retention go; a
// failure leaves them for the next run
if let Err(err) = server.audit_purge().await {
trc::error!(err.details("Failed to purge audit records"));
}
trc::event!(
Store(StoreEvent::DataStorePurged),
Elapsed = started.elapsed()
@@ -514,6 +514,16 @@ async fn run_task(
server: &Server,
task: &Task,
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 {
match task {
Task::CalendarAlarmEmail(task) => {
@@ -77,7 +77,8 @@ async fn spam_filter_maintenance(
}
}
TaskSpamFilterMaintenanceType::UpdateRules => {
return update_spam_rules(server).await;
// inbuxa: AU-1.10: one summary record, not one per rule
return inbuxa_features::audit::scope::quiet(update_spam_rules(server)).await;
}
}
@@ -276,6 +277,37 @@ async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
.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!(
Spam(SpamEvent::RulesUpdated),
Details = stats
+1
View File
@@ -172,6 +172,7 @@ impl RegistryStore {
env_hostname: hostname,
env_public_url: None,
id_generator: utils::snowflake::SnowflakeIdGenerator::new(),
write_hook: Default::default(),
},
true,
)
+2
View File
@@ -212,6 +212,8 @@ pub struct RegistryStoreInner {
pub(crate) env_hostname: String,
pub(crate) env_public_url: Option<String>,
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")]
+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,
write_hook: Default::default(),
}
}
+6
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
pub mod bootstrap;
@@ -10,6 +12,10 @@ pub mod local;
pub mod query;
pub mod write;
// inbuxa: the audit log's view of registry writes (audit-hold-lock spec,
// AU-1.10)
pub mod hook;
use crate::{
Deserialize, SerializeInfallible, U16_LEN, U32_LEN, U64_LEN,
write::key::{DeserializeBigEndian, KeySerializer},
+37
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
@@ -73,7 +75,42 @@ pub enum RegistryWrite<'x> {
}
impl RegistryStore {
/// inbuxa: installs the audit log's hook (AU-1.10). Only the first one
/// installed is kept.
pub fn set_write_hook(&self, hook: std::sync::Arc<dyn super::hook::RegistryWriteHook>) {
let _ = self.0.write_hook.set(hook);
}
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 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
// auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is
// security.legacy-protocols-changed (LP-8); 644 to 646 are the cluster
// coordinator's connection events
pub const TOTAL_EVENT_COUNT: usize = 647;
// coordinator's connection events; 647 and 648 are the audit log's
// (audit-hold-lock spec, AU-3, AU-8)
pub const TOTAL_EVENT_COUNT: usize = 649;
pub const TOTAL_METRIC_COUNT: usize = 369;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -663,6 +664,9 @@ pub enum SecurityEvent {
Unauthorized = 552,
// inbuxa: legacy-protocols LP-8
LegacyProtocolsChanged = 643,
// inbuxa: the audit log (AU-3, AU-8)
AuditRecorded = 647,
AuditWriteFailed = 648,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+28
View File
@@ -452,6 +452,9 @@ impl EventType {
b"security.unauthorized" => EventType::Security(SecurityEvent::Unauthorized),
// inbuxa: legacy-protocols LP-8
b"security.legacy-protocols-changed" => EventType::Security(SecurityEvent::LegacyProtocolsChanged),
// inbuxa: the audit log (AU-3, AU-8)
b"security.audit-recorded" => EventType::Security(SecurityEvent::AuditRecorded),
b"security.audit-write-failed" => EventType::Security(SecurityEvent::AuditWriteFailed),
b"server.startup" => EventType::Server(ServerEvent::Startup),
b"server.shutdown" => EventType::Server(ServerEvent::Shutdown),
b"server.startup-error" => EventType::Server(ServerEvent::StartupError),
@@ -1229,6 +1232,9 @@ impl EventType {
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
"security.legacy-protocols-changed"
}
// inbuxa: the audit log (AU-3, AU-8)
EventType::Security(SecurityEvent::AuditRecorded) => "security.audit-recorded",
EventType::Security(SecurityEvent::AuditWriteFailed) => "security.audit-write-failed",
EventType::Server(ServerEvent::Startup) => "server.startup",
EventType::Server(ServerEvent::Shutdown) => "server.shutdown",
EventType::Server(ServerEvent::StartupError) => "server.startup-error",
@@ -1907,6 +1913,9 @@ impl EventType {
EventType::Security(SecurityEvent::Unauthorized) => 552,
// inbuxa: legacy-protocols LP-8
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => 643,
// inbuxa: the audit log (AU-3, AU-8)
EventType::Security(SecurityEvent::AuditRecorded) => 647,
EventType::Security(SecurityEvent::AuditWriteFailed) => 648,
EventType::Server(ServerEvent::Startup) => 393,
EventType::Server(ServerEvent::Shutdown) => 392,
EventType::Server(ServerEvent::StartupError) => 394,
@@ -2601,6 +2610,9 @@ impl EventType {
552 => Some(EventType::Security(SecurityEvent::Unauthorized)),
// inbuxa: legacy-protocols LP-8
643 => Some(EventType::Security(SecurityEvent::LegacyProtocolsChanged)),
// inbuxa: the audit log (AU-3, AU-8)
647 => Some(EventType::Security(SecurityEvent::AuditRecorded)),
648 => Some(EventType::Security(SecurityEvent::AuditWriteFailed)),
393 => Some(EventType::Server(ServerEvent::Startup)),
392 => Some(EventType::Server(ServerEvent::Shutdown)),
394 => Some(EventType::Server(ServerEvent::StartupError)),
@@ -3022,6 +3034,9 @@ impl EventType {
EventType::Security(SecurityEvent::Unauthorized) => Level::Info,
// inbuxa: legacy-protocols LP-8
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => Level::Info,
// inbuxa: the audit log (AU-3, AU-8)
EventType::Security(SecurityEvent::AuditRecorded) => Level::Info,
EventType::Security(SecurityEvent::AuditWriteFailed) => Level::Error,
EventType::Server(ServerEvent::Startup) => Level::Info,
EventType::Server(ServerEvent::Shutdown) => Level::Info,
EventType::Server(ServerEvent::Licensing) => Level::Info,
@@ -3757,6 +3772,9 @@ impl EventType {
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
"Legacy mail protocols switch changed"
}
// inbuxa: the audit log (AU-3, AU-8)
EventType::Security(SecurityEvent::AuditRecorded) => "Audit record written",
EventType::Security(SecurityEvent::AuditWriteFailed) => "Audit record not written",
EventType::Server(ServerEvent::Startup) => "Starting inbuxa Server",
EventType::Server(ServerEvent::Shutdown) => "Shutting down inbuxa Server",
EventType::Server(ServerEvent::StartupError) => "Server startup error",
@@ -4154,6 +4172,13 @@ impl EventType {
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
"Legacy mail protocols switch changed"
}
// inbuxa: the audit log (AU-3, AU-8)
EventType::Security(SecurityEvent::AuditRecorded) => {
"An administrator's action or a sign-in was written to the audit log"
}
EventType::Security(SecurityEvent::AuditWriteFailed) => {
"The audit log couldn't be written, so the change was refused"
}
EventType::Smtp(SmtpEvent::ConnectionStart) => "SMTP error",
EventType::Smtp(SmtpEvent::ConnectionEnd) => "SMTP error",
EventType::Smtp(SmtpEvent::Error) => "SMTP error",
@@ -4721,6 +4746,9 @@ impl EventType {
EventType::Security(SecurityEvent::Unauthorized),
// inbuxa: legacy-protocols LP-8
EventType::Security(SecurityEvent::LegacyProtocolsChanged),
// inbuxa: the audit log (AU-3, AU-8)
EventType::Security(SecurityEvent::AuditRecorded),
EventType::Security(SecurityEvent::AuditWriteFailed),
EventType::Server(ServerEvent::Startup),
EventType::Server(ServerEvent::Shutdown),
EventType::Server(ServerEvent::StartupError),
+1 -1
View File
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
#[macro_export]
macro_rules! brand_version {
() => {
"2026.9.26.1"
"2026.9.27.1"
};
}
+16 -4
View File
@@ -1,11 +1,18 @@
inbuxa logo bundle
===================
Mark: reused from ihasmail's actual cat-and-envelope artwork (ihasmail-mark.png),
unmodified. Colors sampled directly from that file:
Mark (since 2026-09-27): inbuxa's own kitten -- the ihasmail cat peeking over
a server box with three bays, one for each piece of the suite: the letter
(webmail), a >_ prompt (console) and status lights (server). The face, paws
and whiskers are the family's, so it reads beside ihasmail's envelope cat and
ihasvpn's shield cat without being mistaken for either. Pure vector: no
embedded bitmap anywhere in the SVGs.
navy outline #1C4053
cat orange #F9A34C (inner ear #F47F35)
envelope teal #46CAC3
server teal #46CAC3 (bays #1C4053, lower band #2B8A86, lights #9FF0E9)
The previous set, with ihasmail's cat-and-envelope reused unchanged, is kept
in previous-ihasmail-cat/.
Wordmark set in Space Grotesk (Bold for "inbuxa", Medium for the "MAIL SERVER"
tag line), rendered as true vector paths in the SVGs -- no font install needed
@@ -13,7 +20,7 @@ to view or edit them correctly in any vector app.
Files
-----
inbuxa-icon.svg / .png Mark only, square, for favicons/app icons
inbuxa-icon.svg / .png Mark only, square, transparent, for favicons/app icons
inbuxa-lockup-light.svg / .png Full hero lockup, white background
inbuxa-lockup-dark.svg / .png Full hero lockup, dark background
inbuxa-lockup-compact-light.svg/.png Compact lockup (icon + "inbuxa" only),
@@ -26,3 +33,8 @@ inbuxa-lockup-190x40-dark.png Same, dark background
Dark backgrounds use #0B1720 rather than pure black -- a shade darker than the
mark's own navy outline, so the outline still reads as a lighter edge instead
of disappearing into the page.
The name
--------
"inbuxa" is "inbox" the way Tiny Tina would say it. Hearing her say
"Gearbox" in the Borderlands games inspired the name.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 38 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 66 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 66 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

+123 -218
View File
@@ -1,218 +1,123 @@
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
SUVORK5CYII=
iVBORw0KGgoAAAANSUhEUgAAAXwAAABQCAIAAACVnFlkAAAABmJLR0QA/wD/AP+gvaeTAAAbDUlE
QVR4nO2dB1QT2ffHpyQhtNB7RxAUASk2XAsWlFVX1rWuBctasAt2l1VRFGTtiKti730VewG7ogLS
ld47SA9pM/8T4p/FZBKCYOQ3vM/hePDNmzcDZL5z33333gfjOA4BAACArEBkdiUAAAAAogMAAGQN
sHQAAIBMAaIDAABkChAdAAAgU4DoAAAAmQJEBwAAyBQgOgAAQKYA0QEAADIFiA4AAJApQHQAAIBM
AaIDAABkChAdAAAgU4DoAAAAmQJEBwAAyBQgOgAAQKZ0FtHBMDw6KeXJ25gGFrtVJ37KzAmPjC6v
rP5ON8bl8Wrrmd9pcACgA0KBOgEYhi/esjvsySsIghhKCpuXzBnvNqjFs/KKS30CD7yKSYAgSENV
+dzfG7t3MW3Hu3oeFbfn1OWohE88DFNVVho9uN/K2VM0VBnQD+VFdLxoMUl9bc0uRvo/6I4AZAPu
DOVKH72OmrV+u70WZqeFX/xEYfPwnwf23e49X11FWdwpl+8/2bj/aE0d00kbs9XCTySiA5ztzwX5
ttctnb/9eO3Of7Cvf/mGuto3D2zTUleFfhxmwydxuTyhxpm/um9ZOucH3RGAbHSK6dXN8BcQBK3t
zdvUj3tjLKubOn7n2Zths5c/fhMFQVBRWUVCauaHj2npuQUsNqe8snruX0HeAcHsBub63twLozm+
fbndNbAXUXEVVTXtcj+FpeW++0KFFIdvWxWV+IWcbJdLAAAdlk4xvYpKTJFDIQcNLs7GuqpRro9l
74mmHImrmrU+QEVJsbKmtqknhYLSabTaemZ3DWznIG5XNRzCcayB00ePklSOxySnDO3r1Pb7ufc8
ksXmEB96EcnhcqmUTvF3AXROyG/plH2uzCksttXEKQjEzS3nldZQIGyVM/fcKLalKo6za34ywH6z
xKZY8dzNMBMlLsRhetnzrv3C6aqGY0wON7ccr2M7aGEC8WqXW8otKhV3qIHFLq2obJerAAAdE/K/
URNSMyEIstPCYBSBYRirqsfqWRQthrMO7e44lrizcB7OK6nBqvnrSogqaq/FnwolNg7VdhTl6RKP
yrfLVQCAjgn5LZ2cwmIIgkwYjQ4UCsr/l8PDapiQZAc6m4PXfpEkmELRU+IbSrlFJe1yS317dhd3
yNrcWEVZsV2uAgB0TMgvOnlFZRAEGSg1agyVAsEQqqGM6qhAsKSzYHkaaqQG0/iWIExFUBjSU8Rz
i0raZbGvv4PtACc7govC8KrZU9o+PgDQkSG/6OSXlDaJDkKnoPpqiJqCNCfCVApqqIYo0wX2kaES
3+HSXgtY/2zyGe7Sq3mLojw90GeBW/+vGgEA8kF+n05dfQM/JpDG/x5Ra93MBUYQvk3UiLIcDkFw
HZPZLvF7DCXFY/5r4lMzImOTauuZhrraQ/s6qjHExg0BAKSB/KLDZPFdM/Jt/kHlUb6txGwQ63v+
BmwtzW0tzdtxQACg40P+6ZVAJuSpbfXFKDTKFrOVqVsAAIDkls5vy3xr65hmhnqmBrqNX3pcLpc/
UWrW520RcioJpSLQH7ZcGw1hMWLx4JBYJKEUcdTB5tthFORLB7hxiIzcgoKSsqz8osy8wqz8wpzC
kj3rlvTraQN1AFhsTnJGdmp2XnVtHYvNUZSna2uo2VqaGepqt++FcByPSkp58yExPiWjoqq6pq4e
QRBVhrKFsYGzjdWQvo5KCmDV/ysSUjMj45KS07Nzi0pq6uq5XJ6CvBxDSVFHQ92hm6WTTVcrM2Oo
00C23Kv41IzYj+lZ+YUCXcguKEIRpL6BFTOdJXDr3MtClkdQVek4lwezMejwME5ffX7gnwAmD5r3
gPqmADFXxdOr4MGGWMgwDq3RHFz1lHItje9R1tVU/39R0zMz1HPt7UCXaxy6New6cWn3yUui7Um3
TikrCvu5p/hsfhEdL9Q43MX5mP9awfcFJWU7T1y88/QNYcK6lZnxlFFDp41xk6NR2557FRbxcvfJ
y6nZeeJGoMvRxrsNWjR1nKGOluRrZeUXDZi2WLR9y9I5M391b/FWIQhyHj+vuLxCqHGMa/+Qv1Y0
/TciMsZz3TbCz/lfC2fOnTBamgut/vvg+duPRdvlaNSbIdvFZQJjGH714dOQc9fTcvIlj29jYerp
4T5hxGCKIKqD1JDN0hHyknC5vOlrtr6Ijm/gfvElX09Fe2pjoW5cFhf3vEe7lYE0F52cKiSqGA4a
xPWw4F34iPq9QQtqYFOVRm8Oj2/q3DiwzbF7V6gj8SY2aa7vjubJHEJ8yszZFHw89MrtPesW97ET
GyLUIlwuz2fHgWsPn0nu1sBinwl7ePXBs7Xzps761R0WmIg/Dtc+Dp4eI09cvyt6KODI2YHOdi1a
Gc/ex164E054aOPCmeIUJyoxxXdvaHxqhjQ3mZiWtfrvg6dv3N+7YamliSFEasgmOuWV1eFvotJz
CzJyC1Jz8rPziwRmSD0HhhrDAQ8N/5L0pESFbv3Kd9BgOFTD5j8YcihupY4lzfzitZlszZts/d87
n9l43thF67XUVS2MDboY6ZsbGTh27+pk8yM16HlU3Mx129kc4kyu5uQVlUz23hy0eqE0ZT0IWREQ
/O/j51J2ZrJYG/cfi/+UHrR6IQX9wW9vX68Zb+OSk9KzhNrZHM7y7fvDQgIk2BfVtXUrd4QQGkpD
+jhO+8WN8KzL9yJW7/xH1GaUTHxqxqj5a87s+LO3XTeIvJDNkfzo9fvgs9dTsvJMDHTnTxxzcdcm
j6E/8ScgdWLftxlVcO9zNJfztM1vJElwfi1MoaAPj+7yXz53oLM9i80Ji3i59eAPSwqvZ7JSs/N8
Ag9IozgCuDyeT0Dwo9f83PpW8SomYcGmndIrThNXHjxduSME+tHQqNRg3+XycnKihxJSM/efvSrh
3LW7DheWlou262qq7163hNCOe/Q6alXQwdYqjgAmizVrQ0BKVi5EXshm6UxyHzLJfUjzFsH7LbMa
dhGpQoXjUAULslDFN7lwziZRNrtw2RjUNBFrDoZDOTWwib6OtbmxtXmH8Pm9jIkfMnN5a8/CcHz5
9n1PTu7VVGtF1Z6UrNxvfgyuPnjaw9Lsj/FSuU6+H5Ymhr4LPdfvPix6aN/pq0P6ONpbW4geuhn+
MizipWg7AsO71y0hrMdUXlm9KuggD/tvzt5aqmvr1u8+cmWvH0RSyGbpiCKYcr/KJ/hJo0qQARdo
p5PQh9loXi30bxoy7TZ1wwsCIX5XjLB4kI2lGdSxUVFW1FJXpVElOYyrauqCjl1o44UoFNTcUN/e
2sLSxLDFtaodoedzC9snba0tTP/FbYxrf9F2Lo+3PCBYtI5tcXnFn3tDCYdaNHXcT462hIf8Qk6U
fSauE2BvbbFq9uSQjd4nA9YH/7l88dRxxno6hD0j45IiImMgkkI2S0cUJxsrQx2t8NyyvFru4xwk
sQyGINhBB/Ow4DnrYMudeMEfUFMG5GGBBb2jdNfA/+rLt4qjSpB7mUgZE9ZRxH825f2byp/zC2Zq
HZBettbzJowZ4GwvyF/n8ngJqZlnwx5evhdB+Mq9cv/pqjlTNFW/BFu3Cntri4VTPAb16tmUK8/l
8WKSU09cv3sr4pVoZTLBlCHw6LngP1ttl7U7Ad7zYpJS8oqFS4ukZeftOHr+r4WezRtXB/3zuZog
68Xe2sLbcyLh+EVlFTeJLCMKiu5Y5TVhxODmjWMhaOXsyVtCTh69elv0lLCIl659HCAyQn7RQRB4
wkjX3ScvuV+j1XOgxrgb+GoqciQOPe3OnmfLm2f7Ze69qR//XxyHtkZSjifwVYZO4c+2QuNRGIK0
1FUG9+6IH4J186Z5TR7b3LlAQdGe1hY9rS3cB/b5488dnMZIpeawOZy7zyKni3GCigNFkHXzps2b
OEbIkUFB0V49rHv1sJ46evhCv12ERexvP3m9bt40A21N6IfCUFLcu37pxBUbRbX46JVbw12cm0Ku
zt56GB4ZLTqCojx9/4Zl4hzPyenZGioqoqv4GxZMF1IcASiCbFw082NGzssY4ZCIp+9jcRz/4Wt/
3wPyT68gCJozfhSt8VMSMoybNJOd4MnaMZBbWAsvi6CJvphvpKPHE9BhxtjLyexET9azSaz+ehiG
Q+PdBnXAgn4LJo9dOMVD3EdzSB9Hn1mTCA89fx/b2mttXT53/qRfJDwGLg49zu/cqEAn8NdyeTxB
0dgfTm+7boumjhNtx3DcO/CAINApt7Bk68FThKdv955vZqgnbnDXPg5vLx26stfP02NkU61rLXXV
WeN+FncKDMNzxhMcLSn/XF71vfYg+bF0uKeojVTV1MWlpFfV1DVvjP2YxubyNrhwR5jwjRoUhn6z
5OXUQMExlJBYilljGE4Te6JRbXloryuH3vi7MVCCgodyB1ykhr+JsbOybN6TgiKWpkZmBnoI8mNe
R4a62itnTZbcx9Nj5P4zV+uY/KzX5nzMzGnVtX4e2HfamOEtdutmbrJu3jTffUdFD4W/ifaa7AF1
AFZ4TngVHf8+8ZNQe15RycbgY8umj/cO+KI+QkwYMfjXYQMkD44gcB+77n3sum9eMvttXHLYk1c6
6mooIunt3kNM/l1FZfW3TYE7OOQRnfoG1rZDp0/duC8uxvpns68s6tHmWHAMtCuKwE7+3ZonUBwB
yjTc1QgPS8/12rxTtLOFieGetYsJ1z6+N7+PHtZikLGSgryTjdUzEbsmT3zJVEKWzRgvZc8po4bt
O3NVtOhq7Md0DMN/lEA3h4KiB/5aMeKPlaIRlZfuRly6G0F4lom+rl9rtsRAEaRfTxtpUmSUxHji
26uOSkeDPKKzbNu+e88jaRqqyj26oIpf/RWrYz4iZWXqdDwiF3HUxlk8foqDQ2MFUnkTPWWbLk09
uXXMiqdRRgy8hg2/LYKHGmP3s9EBBpiRMl/JNIf0RujNltN5PGZucVpy5vjlG++HBpkbynpnqGH9
pKoS38XYQFR0OFwui82RJjECgiBTA13p9/ySo1EH9+p5+f4ToXYmi5VfXGqk186JYN+GvrZmgM/8
BZsI3iKEUCjo/j+XfXNOWW09s6KquuxzlajJKaF0AZf3LZE+HR+SiM7Tdx/uPY9UMNYznj8OEVkw
ZuWVcMr49QOPJ6DKjrwKJnwnC3XU5rtX6QbamoOdm3qyyz5XPOXHzqVVQkcT0KHG2MEPiBnji4mk
7mJPYQhX5Kl8m1hw5aHv3qNn229XLGmQo1GljJcXTeYSwOZIKzrONlatujfnHtaiosMP0Swt6yCi
A0HQqEH9Jrq7irNrhFj7x1SHbl9NrlukvLL6RviLR6+jYpJSwCauJBSdyNgk/j6cQ3uLKg7/+dTX
rIpL2RNNwXB+sjgCQzwMD3jHn1jJ63/1DFBVGRQ67dxHSIPORRpT0xEYSq5ArqSiVIYCqkTw9Kr2
til7FvUyOp7N4UgOkGlfNFRVpEwvkOxQkAYdTfVW9dfWUCNs72jP3palf0QnprSYjTnA2X7uhDHS
D8tksXaduHTy+j1BLScAOUUnu7H6Ok2D2Oum/pNjfUb+/phsCgqveQpzcbigDuJhEKNnVxUn6+Y9
YQqq/7t7/unbq59BKnLwiGu0EibsHYGgdKrhNHdYjD+CqqrMLqmorq1rVZhvG5Fl+QhxtpI4xNWW
r2/XEmhtR4Eut3vdkjFeX5L1CVGUp+9dt0R6V1R+SdmMNf7kzmNoIyQRnS++42aruTiOFx0PY+YW
NrVQ6TSMi2ULkrAQWFFbTf+34bCIsaBkbWa+ckblu8SG/JL8ugaKnry2sa6KU3eqipK4qwtWkWVc
I6Tt9ov08FrpXMDEJAEg7R11wuFKm3cmjoev3kvuUMdseBEd3+KilYCqmrrfffwy8graeFfkhiSi
I0pDViE7t5ghJzdh5OCRA/o2PaIYhodHRp8Je8hhcevScpp7kZugqjG03BojBQGNVH4dgtAin8Us
u7SvdYbjeHVtfVtGeBuXfODstRa7bdhzxNnGShpvlP+hU0BxOqPo4Fxexp6zrJIKbXW124cC95y8
vCIg2GfmpNGD+8Ukp24+cFyBTj//t6/X5l0pJ8O++SowBTWYPJJh1zrn4v8oea3c8EvcFqaKX4uO
OLuHI11+dlFZRVvWd6pr65Zt2ydNZmZNXb2X365/9/tLrrBVVFYhzi2trKgw3MXZ1ECX0NvVwGJv
Cj4OdRpIKDrcmjpWyZc4dF1N9QCf+THJqX4HTuw8foEuR/P18nRx6PHNg/cb7TpgnNvxv/aW5hUx
cwo6iei8jU9uVUj+m9hEwnZT/a/yG+VoxBUXq2rFFiRrzoePaVAbWLf7sGgSljhiP6btOX1Zcijm
veeRhBLWx677Yb9VhCnpAorKKoDokA2HbpbX9m+NT8noYWnexuA0Kl2OriSPoJ0ifeSr0miR0UP7
ShUWVFpRKRoWxF/mU1YScrSL808npWVLc6GwcILUSik5f/vxTaLTlRTkHbpZvv6QKGpDBZ+5NsDJ
TkLpxbgUgiKBKILs/3OZBMWBGrMuoM4ECS0dVEmeqqLEqaqtb2DeevK6+aGcZn9dwgTiFnl25d7z
q41BzzAs12FCTmTAzuMXBzrbS5N9tvvUZdEyEY2bKQvH5irK03U01EXTI19/SKiurWMoSdqkLD23
4N6Lt9A3kZVf5BdygvCQ//K544YPDAw9Fyzi6+Fh2JKtex8c3amqTLykUEGU6Wqir6unpSH5fh69
bsGZTTJIKDoIlWqxfg7WwP788M36Y2fFdaNaGHZ1nwR/6xoQTEEIY4LISnxKxoY9RwJ9FkieZF2+
/+T0jfvSx0/bWZk/fCUsOrX1zH1nrv65YIa4qzSw2D6BwaLZ89LA5nC8Nu8kjBga7tJr3PCBEAR5
z5z4+HVUcoawwVVYWr466OBhv1WEIxPm39TUt+DqLiwtP/nvPekHJAEkFB3+hzKvmPO5hmaqTzOV
lJpQny52S4MWgVFEqasJTCXnL5CQ87cfl1dWB61aSDhZYHM4e09f3X+GuPSnsqLCqEEEC4KufRwJ
F60PXwrTVFUhTGovLC1fsnVvVGLKt/0UAUfOJaRmirarqygH+swXfE+lUATxO6K6dvd55Pnbj6eM
Gio6ghrRr0Uw2RzobE94M+WV1Z5rtxHmRvAtsrxCwj3v/9ch4TPDq2vIOnAJxzDXyaNQBAm/eAdr
vxwWLSNdK2fbN7cjuGyulls/rWF9oM7Eg5fvXkZ7TRk1bGg/JytTIzUV5era+tzC4mdRcWduPigo
4eeaEDL9FzfC9fKxQ/pvPXhSNGgQx3H/Q6evP3o+0d21p7WluopyA5udnV/09N2Haw+ffXOQ4bP3
saFXbhEe8l8+r6kYhWBPmKXTf9t5/KJoz03Bx3rZWlsYGwi1d+9iQqi4i7fs2bdhqVAxJi6XdyP8
hf+h06KZsU1sO3wmI69QcFdu/XuJXvF/FBKKDsZm442LCE8v3Z3tv6J/A+v5tQftNfgAj+EOQ/sV
ZxekRidiDcQvKHJTx2wIvXJL3KNLiI6G+mKiEjaCqlozPEb+c+EG4dGk9Kx2XNYpr6xeERBMOGf5
ZUj/0YOFDbHFU8c9eh0VK7JGVt/A8tq889bBQKHMtWEuzluIqvB8rq6ZvsbfwsTQytRIX1uDw+Xl
F5e9i0+WsGuQgNp6ZtPv2UhXC4hOxwWh02AUxXm8gRNGfHoX/+L6w3HLZjAaXxd3jl4pyWlTtGjE
pbt5qVnpcR/5HmuF1iUHdE4oKLp73WIJiRTLpo8PC3+ZL95KEoehjlZJRaWUm2HgOL4qKKSk/LPo
IQ1VxuYls4nvfO1i93mrWGzhS3zMyNlx9Lyv11deJ3ND/WH9nMRttpGWnZcmZodCGIb7O/T48DGt
o+WmfSdIaOmg8nTzFVM5VbWCBUyjP359X18L1fPfKvQRfdu+k0MRBBnOGgsjsKIpSczdFlk4xaO/
o+3sDQGij59kYBjetHiWZMeEkoL8kS2rxy/zbdWkya1/r6Nb14xdtD46SSrnzrFrd8RlPGz3ni+u
VpaliaH3zEnbD58RPXTkcpiLg41QGIHfkjnvEj4K1ZBrkRE/9T7it2rGWn8SF2NvDkniTWgChy73
S2gWokCjaDK+75cG4z8zvXE2h8gwGUrGqDKUBjrbB/osaFXyFAVFA30WeHqMbLGnbVfzM0G+4pai
RenexXTHSi/p7+RTZk7AEeJ1zAkjBrsPkOSYWzBpbC/br7KCBeA4vjIwRMgjY6SnHbplTauyPdQY
ypsWz2p0b3XQsv/tDkksHevGnWHrswvk9DRwLrc06gOO42c1YwOru0DmTgZ6Og+evVjw+5Rjl6+I
K5j0DTDMTJRNjDE2t6GwTElBXo0hKQCMBPzmNkhDlbHUf580IU5WZsa71iyysyJIbSOkVw/r+6F/
r911qMW3/eDeDgc3ekv/YLPYnMVb9xCGDuloqG9cxH/gJYAgcNCqhSPnrhQdoayyyjvwwKmA9c2X
2Prad7++33+p/17RFXdRNNVUTwduENSrHzd8YGp23sHz/xLuqEEmSCI6w1ycdxy7UPbgFV1fU95Y
T8WyC6+hYTOuVqIgD7MaSvLzFQz0b7x/R9HWaj9hgOV1dHgsduGVx9za+km/jWpVrLOxvg7hpAMl
KpHTo6u56MqxuC2T2nitnxztRHPKDXW/hEEO7u1wP/TvXScu3Xj8grBYDAzDzj2sPMeOHOPav7XB
3/ramqcCNrxP/HTxTnh4ZLSQ/0VJQb5fT5uJI11HNjNM7K0tmnbCaaLb17sh3nrySktNVcuJoOqI
1+Sx4qpwNKeLkf6WpXMII5h5PF5kXHJf+6/ClK3Nje8eCbr+6PmZm/fFLe3T5WgTR7qunvN70w3A
MLx27lSPoQNuPX2VmVcolDQry6op3xuYNAFIoVdu+YXwN/mlaanJLHyGU17Fa2BZmRvfPLCdcBcE
stLAYr+LT07Jzisp/1xTz6RRKKoMpa6mRk42XXU0WlfxSxzlldXF5RVsDpeCoppqKrqtLCTWQSit
qIxOSikoKSuvqq6oqlFXUVZXYRjr6fS17y7LikgdCvKIjmBblYDQc4mpmW3Z1LVVaKmrjncb7D1z
Il2OOHcRAACQWXQEYBheU9emMitSQqNR5OU6kXUDALQLJBQdAADQkSHtKi8AAOiYANEBAAAyBYgO
AACQKUB0AACATAGiAwAAZAoQHQAAIFOA6AAAAJkCRAcAAMgUIDoAAECmANEBAAAyBYgOAACQKUB0
AACATAGiAwAAZAoQHQAAIFOA6AAAAEiW/B9wort0+ukS6gAAAABJRU5ErkJggg==
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More