Compare commits

...
40 Commits
Author SHA1 Message Date
jcoffey-dev de275bac60 Merge pull request 'Release 2026.9.28.3' (#80) from release-2026.9.28.3 into main
ci / fork-checks (push) Successful in 1m1s
publish / version (push) Successful in 56s
publish / publish-amd64 (push) Successful in 30m35s
publish / release (push) Successful in 6s
ci / build (push) Successful in 32m44s
publish / publish-arm64 (push) Successful in 36m4s
publish / binaries (push) Successful in 35s
publish / announce (push) Successful in 22s
2026-09-28 07:23:19 +00:00
jcoffey-dev 305406a331 Release 2026.9.28.3
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 7m25s
Per-protocol legacy switches (#79) and the hold export's exceptions
list (#75). The prepared Explain answers are relabeled for this
release; 706 carry over unchanged.
2026-09-28 00:15:33 -07:00
jcoffey-dev f5888d79b0 Merge pull request 'Give IMAP, POP3 and ManageSieve a switch each' (#79) from feature/per-protocol-switches into main
ci / fork-checks (push) Successful in 38s
ci / build (push) Successful in 35m58s
2026-09-28 06:32:41 +00:00
jcoffey-dev 8e9cedbe97 Give IMAP, POP3 and ManageSieve a switch each
ci / fork-checks (pull_request) Successful in 43s
ci / build (pull_request) Successful in 7m40s
The legacy-protocols switch was all or nothing. An operator can now stop
POP3 and keep IMAP: each of IMAP, POP3 and ManageSieve has its own
switch, server-wide on inbuxa:ProtocolPolicy and per tenant on
inbuxa:TenantProtocolPolicy (properties imap, pop3, manageSieve).

legacyProtocols stays as the kill-all: setting it sets all three, and it
reads "disabled" exactly when all three are off. A policy stored before
this has only legacyProtocols and reads as all three at that value, so
existing servers and tenants carry over unchanged. In one /set, a
protocol named beside legacyProtocols overrides it.

SMTP submission keeps no switch of its own: sign-in over it is refused
only when all three are off, as the single switch did (LP-6), so
turning one protocol off never stops a mail app sending. For a tenant,
the server's switches and the tenant's count together.

Server-wide, a change closes the listeners of whatever is now off and
puts back the saved listeners of whatever is on again, both in one
change if asked; listeners of a protocol still off stay saved. Sign-in,
autoconfig, autodiscover, PACC (now prepared once per combination) and
the suggested DNS records all follow each protocol separately. A tenant
may turn a protocol on only while the server has it on (LP-9), and the
refusal names which. The JMAP session adds legacyAllowed, the protocols
still allowed for the account; legacyProtocols there keeps its meaning
for older webmail builds. Events name the switches ("pop3 disabled"),
and audit before/after reads every switch even from an older policy.

Tested: unit tests for the switches, the old-policy reading, the
server/tenant combination, the tenant refusal and listener refusal; and
tests/e2e/legacy_protocols.py against a running server, all 100 checks,
including new ones: POP3 alone off closes only its port and refuses
only its sign-in while IMAP and sending go on; only POP3 stops being
advertised; one change closes IMAP and reopens POP3; a tenant turns
POP3 off for itself, and can't turn IMAP on while the server has it off.
2026-09-27 23:12:32 -07:00
jcoffey-dev 5ba54e8fb7 Merge pull request 'List what a hold export can't read instead of skipping it (LH-12)' (#75) from fix/hold-export-exceptions into main
ci / fork-checks (push) Successful in 54s
ci / build (push) Canceled after 23m21s
Reviewed-on: #75
2026-09-28 06:09:20 +00:00
jcoffey-dev db817dd507 Merge pull request 'Release 2026.9.28.2' (#77) from release-2026.9.28.2 into main
publish / version (push) Successful in 31s
ci / fork-checks (push) Successful in 52s
ci / build (push) Canceled after 9m20s
publish / publish-amd64 (push) Successful in 34m25s
publish / release (push) Successful in 45s
publish / publish-arm64 (push) Successful in 36m5s
publish / binaries (push) Successful in 34s
publish / announce (push) Successful in 22s
2026-09-28 05:59:59 +00:00
jcoffey-dev b7e3a765ca Release 2026.9.28.2
ci / fork-checks (pull_request) Successful in 56s
ci / build (pull_request) Successful in 5m22s
2026-09-27 22:54:18 -07:00
jcoffey-dev 7ba9ec9fa0 Merge pull request 'Send "none" instead of "pass" as the DMARC report disposition' (#76) from fix/dmarc-disposition-compat into main
ci / build (push) Canceled after 11m35s
ci / fork-checks (push) Successful in 15s
2026-09-28 05:48:22 +00:00
jcoffey-dev 4c07779c16 Merge pull request 'Recheck DNSSEC lookups that hickory wrongly calls bogus' (#72) from fix/dnssec-insecure-fallback into main
ci / fork-checks (push) Successful in 2m2s
ci / build (push) Canceled after 14m59s
2026-09-28 05:33:21 +00:00
jcoffey-dev e1e8a9aeb0 Send "none" instead of "pass" as the DMARC report disposition
ci / build (pull_request) Successful in 16m34s
ci / fork-checks (pull_request) Successful in 52s
Cloudflare's DMARC report intake rejects every aggregate report we
send with "555 5.7.1 invalid_report_schema". Bisected against the live
endpoint: the only element it objects to is <disposition>pass</disposition>,
the value RFC 9990 added for mail that passed DMARC under an enforcing
policy. The RFC 9990 namespace, <np>, <discovery_method>, <testing> and
a missing <pct> are all accepted, and a report that differs only in
using "none" there goes through.

"none" (no action taken) is valid under both RFC 9990 and RFC 7489 and
says the same thing to the reader, so reports now go out with it. The
stored report keeps "pass"; only the serialized copy changes.
2026-09-27 22:31:13 -07:00
jcoffey-dev 5c506b9d2b List what a hold export can't read instead of skipping it (LH-12)
ci / fork-checks (pull_request) Successful in 49s
ci / build (pull_request) Successful in 11m32s
An item the hold covers whose stored record or content can't be read
goes in exceptions.csv with the path it would have had and the reason,
rather than being left out silently. The file is always in the ZIP, so a
header-only one shows nothing was missed, and manifest.sha256 carries
its hash beside the manifest's.
2026-09-27 22:15:05 -07:00
jcoffey-dev 3978cf5785 Merge pull request 'Release 2026.9.28.1' (#74) from release-2026.9.28.1 into main
ci / build (push) Canceled after 29m44s
ci / fork-checks (push) Successful in 14s
publish / version (push) Successful in 32s
publish / publish-amd64 (push) Successful in 28m55s
publish / release (push) Successful in 15s
publish / publish-arm64 (push) Successful in 1h2m48s
publish / binaries (push) Successful in 51s
publish / announce (push) Successful in 23s
2026-09-28 05:03:38 +00:00
jcoffey-dev 815a642cc4 Release 2026.9.28.1
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 7m29s
Legal hold exports (LH-12, #73). The prepared Explain answers are
relabeled for this release; 706 carry over unchanged.
2026-09-27 21:55:55 -07:00
jcoffey-dev 1d5f4a2cd3 Merge pull request 'Export what a legal hold keeps as a ZIP (LH-12)' (#73) from feature/hold-export into main
ci / fork-checks (push) Successful in 50s
ci / build (push) Canceled after 12m21s
2026-09-28 04:51:16 +00:00
jcoffey-dev 68dd749291 Export what a legal hold keeps as a ZIP (LH-12)
ci / build (pull_request) Successful in 4m47s
ci / fork-checks (pull_request) Successful in 14s
inbuxa:HoldExport/set takes a hold, optionally some of the accounts it
covers, and a reason; the collection runs in the background and get
says when it's ready. The ZIP has, per account, mail as .eml under its
folders, calendars as .ics, contacts as .vcf, files as stored, and the
archived items the hold keeps under archived/; a manifest.csv gives each
entry's account, kind, folder, date, whether it was archived, size and
SHA-256, and manifest.sha256 hashes the manifest. Accounts the hold
doesn't cover are left out, and items outside its date range are too:
live mail by arrival, events by start, and archived items the same way,
so an export doesn't carry deleted items that only another hold keeps.

The finished file is a blob of whoever started the export, so only they
download it, and it lasts as long as any upload (uploadTtl). Exports
are records under the hold (SUBSPACE_INBUXA H/e): never changed or
destroyed, each with its status, counts, size and checksum. Starting
one needs sysLegalHoldExport, an active hold and a reason, and is
recorded in the audit log like the audit log's own export.

The build is in memory and capped at 2 GB; bigger holds fail with a
message saying so, and are split by picking accounts.

Tested: unit tests for safe ZIP names and the manifest and its hash;
the legal_hold system test, on RocksDB, PostgreSQL and MySQL, exports a
hold end to end (live and archived mail, the manifest's hash, an asked-
for account the hold doesn't cover left out) and checks the refusals
(no reason, a user without the permission, a released hold) and the
audit record; and by hand from the console on a local server. Not
covered by a test: the archived-item date range with two holds of
different ranges over one account.
2026-09-27 21:45:52 -07:00
jcoffey-dev b1bc5ed6e0 Recheck DNSSEC lookups that hickory wrongly calls bogus
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 7m34s
hickory 0.26.3 rejects two kinds of valid answers, and outbound
delivery then retries those hosts until the message expires:

- A zone delegated beneath an unsigned zone (l.google.com under
  google.com). Proving the delegation insecure needs an SOA record in
  the DS reply, and public resolvers often leave it out. Every Google
  MX host behind a signed MX record was unreachable.
- A signed CNAME to a signed name that lacks the queried type. The
  NSEC denial is checked against the original name, not the target's.

On a bogus verdict, follow a signed CNAME and repeat the lookup at its
target; otherwise look up the name's zone and its parents, nearest
first. A zone that validates as unsigned means nothing below it can be
signed, so the plain resolver answers and the result is insecure. A
zone that validates as signed first leaves the verdict standing.
2026-09-27 21:45:13 -07:00
jcoffey-dev b074c73219 Merge pull request 'Release 2026.9.28' (#71) from release-2026.9.28 into main
publish / publish-amd64 (push) Successful in 25m6s
publish / release (push) Successful in 9s
publish / publish-arm64 (push) Successful in 36m18s
publish / binaries (push) Successful in 34s
publish / announce (push) Successful in 34s
ci / build (push) Canceled after 1h33m5s
publish / version (push) Successful in 10s
ci / fork-checks (push) Successful in 54s
2026-09-28 03:18:09 +00:00
jcoffey-dev 3046c418cd Release 2026.9.28
ci / fork-checks (pull_request) Successful in 50s
ci / build (pull_request) Successful in 13m2s
Legal holds (#70): a hold on people, groups, domains, tenants or the
whole server keeps everything it covers from being destroyed, by anyone,
until it's released; deleted accounts keep their data. Audit records
name accounts by their full address and holds by their case name. The
daily clean-up of expired archived items works again.

Prepared Explain answers relabeled for this release; no setting changed
since 2026.9.27.2, so all 706 carry over.
2026-09-27 20:04:44 -07:00
jcoffey-dev faeb1fed86 Merge pull request 'Legal holds (phase 3)' (#70) from feature/legal-hold into main
ci / fork-checks (push) Successful in 1m12s
ci / build (push) Canceled after 17m24s
2026-09-28 03:00:43 +00:00
jcoffey-dev c1b5bf956c Audit records name accounts in full, and holds by name
ci / build (pull_request) Successful in 6m24s
ci / fork-checks (pull_request) Successful in 1m17s
An account's or mailing list's name is only its local part, so the log
said "Account ken.gosling" where two domains could each have one; it
now says [email protected]. A change to a legal hold was
recorded under its id; the hold's current state is now read first, so
the record carries its case name and each change reads before/after.
2026-09-27 19:33:07 -07:00
jcoffey-dev 3217aae4e8 LegalHold/get takes coveringAccount
Only the active holds covering one account, live or deleted and kept,
through any route: for the console's Held badge (LH-14).
2026-09-27 19:33:07 -07:00
jcoffey-dev 538ae107d7 Legal holds, step 6: what each hold keeps
inbuxa:LegalHold/get answers accountsCovered, itemsHeld and sizeHeld
when asked: the accounts a hold reaches now (deleted ones it keeps
included) and the archived items it keeps, with their size. Worked out
in one pass over accounts and archive, only for requests that name them.
Held items stay out of the user's quota, as all archived copies do
(LH-9).
2026-09-27 19:33:07 -07:00
jcoffey-dev 39707cd2e8 Legal holds, step 5: held accounts can't be destroyed
Destroying a held account removes the login, as offboarding needs, but
keeps its data as a deleted account with no expiry, whether or not
undelete keeps accounts; its addresses stay reserved and its holds name
it from then on. Destroy-now refuses it, and its DestroyAccount task
defers itself while it's held or its time hasn't come. Holds placed or
released later freeze or free kept accounts in the same settle pass,
with 30 days' grace after the last release (LH-8, LH-10).
2026-09-27 19:33:07 -07:00
jcoffey-dev 8d3e99bc00 Legal holds, step 4: freezing, release, and the audit log
Placing or widening a hold freezes what's already archived in its scope
and range, its old deadline noted; releasing one gives each item no other
hold covers that deadline back, or release plus 30 days if later. One
pass over the archive does both and changes nothing twice (LH-6, LH-10,
LH-11). A held archived item can't be destroyed; restoring still can,
and the hold is named only to callers who may see holds (LH-7). Audit
records about a held account survive the purge (AU-7).

Fixes the daily clean-up of expired archived items (UD-13), which never
found any: the registry's unfiltered query reads an all-ids index that
archived items aren't in. Items are now walked account by account, kept
deleted accounts included. Expired items were still removed whenever
their account's archive was read.
2026-09-27 19:33:07 -07:00
jcoffey-dev 7b97efbb7f Legal holds, step 3: deleted items in a held account are kept
Every way of deleting mail (JMAP, IMAP EXPUNGE, POP3, mailbox removal,
Trash emptying) and Sieve scripts, events, contacts and files now asks
how the account's deletions are kept: a hold keeps them with no expiry
(archivedUntil 9999-12-31), even with undelete off; otherwise undelete's
period applies as before (LH-4).

A hold's date range decides by the item's own date (LH-3). Mail is noted
as held at deletion and settled when it's archived, once its received
date is known; outside the range it gets undelete's deadline or isn't
kept. Events go by their start, with a day's slack for time zones;
recurring events, contacts, files and scripts are held whole.

A groupware item's note now stays until its archive succeeds, and a
failure retries the task instead of being logged and lost (LH-5).
2026-09-27 19:33:07 -07:00
jcoffey-dev 318783f444 Legal holds, step 2: who a hold covers
A hold reaches an account by name, through any of its addresses'
domains, its groups or its tenant, as they are now, so an account added
to a held domain later is held too. An account that leaves a held
domain, group or tenant stays held: the registry write hook adds it to
the hold by name on every account change, whoever makes it (LH-2).
Server::holds_on answers for the deletion paths, from the store each
time so a hold binds every node at once.
2026-09-27 19:33:06 -07:00
jcoffey-dev 5d2e35b2dc Legal holds, step 1: the hold itself
inbuxa:LegalHold get/set places a hold on accounts, groups, domains,
tenants or the whole server, with an optional date range. A hold's range
and scope can only widen, a released hold is read-only, and none is ever
deleted. Placing, changing and releasing each need a reason and are
audited (LH-1, LH-3, LH-10, AU-12).

Permissions 669-672 (see, place, widen or release, export held data)
go to server administrators only; the tenant ceiling always strips them,
as it does Impersonate (LH-13). Schema: Compliance > Legal Holds.

What a hold keeps comes next, through the undelete hooks.

Also moves the lock expiry helpers below the lock module's imports.
2026-09-27 19:33:06 -07:00
jcoffey-dev 621ebdff74 Merge pull request 'Release 2026.9.27.2' (#69) from release-2026.9.27.2 into main
publish / publish-arm64 (push) Successful in 39m17s
publish / binaries (push) Successful in 33s
publish / announce (push) Successful in 23s
ci / fork-checks (push) Successful in 14s
publish / version (push) Successful in 32s
publish / publish-amd64 (push) Successful in 25m41s
publish / release (push) Successful in 1s
ci / build (push) Successful in 37m4s
2026-09-28 01:35:26 +00:00
jcoffey-dev 355bd3a40e Release 2026.9.27.2
ci / fork-checks (pull_request) Successful in 1m23s
ci / build (pull_request) Successful in 7m16s
Delegates reach the whole locked account (#68): its calendars, contacts
and files as well as its mail, even a kind it holds none of yet, and
writing delegates may add at the top of its Files.

Prepared Explain answers relabeled for this release; no setting changed
since 2026.9.27.1, so all 706 carry over.
2026-09-27 18:27:33 -07:00
jcoffey-dev f7a63b9ed0 Merge pull request 'Delegates reach the whole locked account' (#68) from fix/delegate-whole-account into main
ci / fork-checks (push) Successful in 48s
ci / build (push) Canceled after 12m39s
2026-09-28 01:22:48 +00:00
jcoffey-dev 9f6761c9dd Writing delegates may add at the top of a locked account's Files
ci / fork-checks (pull_request) Successful in 17s
ci / build (pull_request) Successful in 17m56s
A shared account refuses top-level folders, so an organize or full
delegate couldn't add anything to a locked account with no folders. A
delegate who may write now can, as the owner could; the reconcile after
the create grants it the new folder. Read delegates still can't (AL-6,
AL-7).
2026-09-27 18:04:24 -07:00
jcoffey-dev d4d127fa7d Delegates reach the whole locked account
ci / build (pull_request) Canceled after 5m27s
ci / fork-checks (pull_request) Successful in 14s
A delegate's token listed the locked account only for kinds of data it
held grants on, so one with no files (or no calendar) was refused to the
delegate outright: "You do not have access to account". The token now
lists the locked account for mail, calendars, contacts and files alike,
so an empty kind reads as empty. What the delegate may see or change is
still each container's grant (AL-7).
2026-09-27 17:58:50 -07:00
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
121 changed files with 13352 additions and 381 deletions
Generated
+4
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",
@@ -4256,6 +4258,7 @@ dependencies = [
"tungstenite 0.30.0",
"types",
"utils",
"zip",
]
[[package]]
@@ -8622,6 +8625,7 @@ dependencies = [
"types",
"utils",
"x509-parser",
"zip",
]
[[package]]
+588
View File
@@ -0,0 +1,588 @@
/*
* 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::hold::{self, Member};
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));
// LH-6, AU-7: a record about a held account stays while it's held.
// Worked out before the purge, which can't wait on lookups.
let held = self.held_accounts().await?;
log::purge(self.store(), cutoff, |record| {
record
.target
.account_id
.is_some_and(|account_id| held.contains(&account_id))
})
.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 {
// LH-2: every change to an account, whoever makes it: one that
// leaves a held domain, group or tenant stays held by name
if change.object_type == ObjectType::Account
&& let (Some(before), Some(after)) = (change.before, change.after)
&& let (Some(before), Some(after)) = (
Member::of(change.id.document_id(), &before.inner),
Member::of(change.id.document_id(), &after.inner),
)
&& let Err(err) = hold::keep_moved(&self.data, &before, &after).await
{
trc::error!(err
.account_id(after.account)
.details("Failed to keep a moved account under its legal hold"));
}
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(),
),
}
})
}
}
+140 -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);
@@ -122,6 +143,29 @@ impl Server {
}
}
}
// inbuxa: AL-7: a delegate reaches the whole locked account,
// mail, calendars, contacts and files, even a kind it holds
// none of yet, so an empty one reads as empty rather than
// refused. What it may see or change there is still each
// container's grant.
for delegation in delegations.iter() {
let whole: Bitmap<Collection> = Bitmap::from_iter([
Collection::Mailbox,
Collection::Email,
Collection::Calendar,
Collection::CalendarEvent,
Collection::AddressBook,
Collection::ContactCard,
Collection::FileNode,
]);
match access_to.iter_mut().find(|a| a.account_id == delegation.account_id) {
Some(entry) => entry.collections.union(&whole),
None => access_to.push(AccessTo {
account_id: delegation.account_id,
collections: whole,
}),
}
}
let now = now();
let mut credential_version = 0;
@@ -202,6 +246,8 @@ impl Server {
.upload_max_concurrent
.map(ConcurrencyLimiter::new),
obj_size: 0,
locked,
delegations: delegations.clone(),
revision,
revision_account,
credential_version,
@@ -211,7 +257,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 +299,8 @@ impl Server {
.upload_max_concurrent
.map(ConcurrencyLimiter::new),
obj_size: 0,
locked,
delegations: delegations.clone(),
revision,
revision_account,
credential_version: 0,
@@ -376,6 +432,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 +441,7 @@ impl AccessToken {
pub fn new_maybe_invalid(inner: Arc<AccessTokenInner>) -> Self {
AccessToken {
scope_idx: 0,
origin: None,
inner,
}
}
@@ -404,7 +462,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 +480,7 @@ impl AccessToken {
} else {
AccessToken {
scope_idx: 0,
origin: None,
inner,
}
.assert_is_valid(remote_ip)
@@ -481,6 +544,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 +647,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 +833,62 @@ 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.
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
/// the locked account as its owner could, top-level folders included.
pub fn delegate_may_write(&self, account_id: u32) -> bool {
self.delegation(account_id)
.is_some_and(|d| d.access != inbuxa_features::lock::Access::Read)
}
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 +903,7 @@ impl AccessToken {
}
AccessToken {
scope_idx: 0,
origin: None,
inner: Arc::new(AccessTokenInner {
account_id,
tenant_id: Default::default(),
@@ -788,6 +917,8 @@ impl AccessToken {
revision_account: Default::default(),
credential_version: Default::default(),
obj_size: Default::default(),
locked: false,
delegations: Default::default(),
}),
}
}
@@ -798,6 +929,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 +977,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,
}
}
+30
View File
@@ -104,6 +104,16 @@ impl Server {
ceiling(base, policy).apply(&mut permissions.enabled, &mut permissions.disabled);
// inbuxa: MT-1, MT-15: impersonation would reach beyond the tenant
permissions.disabled.set(Permission::Impersonate as usize);
// inbuxa: LH-13: only server-level administrators see or place
// holds, and a hold may concern the tenant's own administrator
for permission in [
Permission::SysLegalHoldGet,
Permission::SysLegalHoldCreate,
Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldExport,
] {
permissions.disabled.set(permission as usize);
}
Ok(())
}
@@ -254,6 +264,11 @@ impl Default for DefaultPermissions {
default.tenant.push(permission);
}
Permission::Impersonate
// inbuxa: LH-13: holds are the server administrator's alone
| Permission::SysLegalHoldGet
| Permission::SysLegalHoldCreate
| Permission::SysLegalHoldUpdate
| Permission::SysLegalHoldExport
| Permission::UnlimitedRequests
| Permission::UnlimitedUploads
| Permission::LiveMetrics
@@ -269,6 +284,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(),
}
+20 -10
View File
@@ -46,10 +46,10 @@ pub struct Network {
#[derive(Clone)]
pub struct NetworkInfo {
pub pacc: Pacc,
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve,
/// served while legacy protocols are off (legacy-protocols LP-7).
pub pacc_jmap_only: Pacc,
/// inbuxa: the document once per combination of legacy protocols off,
/// indexed by `LegacyOff::index` (legacy-protocols LP-7, one switch per
/// protocol); index 0 is the full document.
pub pacc: Vec<Pacc>,
pub mxs: Vec<MailExchanger>,
pub services: VecMap<ServiceProtocol, Service>,
}
@@ -333,16 +333,27 @@ impl Network {
})
.unwrap()
};
// inbuxa: legacy-protocols LP-7
let pacc_jmap_only = {
// inbuxa: legacy-protocols LP-7, one document per combination of
// protocols off, bits as `LegacyOff::index`: IMAP, POP3, ManageSieve,
// submission.
let pacc = (0..16usize)
.map(|off| {
let mut pacc = pacc.clone();
if off & 1 != 0 {
pacc.protocols.imap = None;
}
if off & 2 != 0 {
pacc.protocols.pop3 = None;
pacc.protocols.smtp = None;
}
if off & 4 != 0 {
pacc.protocols.managesieve = None;
}
if off & 8 != 0 {
pacc.protocols.smtp = None;
}
split(&pacc)
};
let pacc = split(&pacc);
})
.collect();
let mut network = Network {
node_id: bp.node_id() as u64,
server_name: default_hostname.to_string(),
@@ -358,7 +369,6 @@ impl Network {
mxs: system.mail_exchangers.into_iter().collect(),
services: system.services,
pacc,
pacc_jmap_only,
},
};
+282
View File
@@ -0,0 +1,282 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: which legal holds cover an account (audit-hold-lock spec, LH-2,
//! LH-11), for the paths that destroy data. Read from the store every time,
//! not cached: a hold placed on one node must bind every node at once, and
//! there are few holds.
use crate::Server;
use ahash::AHashMap;
use inbuxa_features::{
hold::{self, HELD_UNTIL, Hold, Keeping, Member, is_held_until},
undelete::records,
};
use inbuxa_features::undelete::data::{self as undelete_data, KeptAccount};
use registry::{
pickle::PickledStream,
schema::{
prelude::{ObjectInner, ObjectType},
structs::ArchivedItem,
},
};
use store::{registry::RegistryQuery, write::now};
use trc::AddContext;
use types::id::Id;
/// The grace a released item gets at least (LH-10): a release made in error
/// can be undone by placing a new hold within it.
const RELEASE_GRACE: u64 = 30 * 86_400;
/// What a settle pass changed.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Settled {
pub frozen: usize,
pub released: usize,
/// Deleted accounts kept by a hold, or let go by a release (LH-8, LH-10).
pub accounts_frozen: usize,
pub accounts_released: usize,
}
/// What one hold keeps (LH-9).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct HoldSummary {
pub accounts: u64,
pub items: u64,
pub size: u64,
}
/// A kept account as it was when deleted, for a hold's scope: its record
/// still names its domain, groups and tenant.
pub fn kept_member(account_id: u32, kept: &KeptAccount) -> Member {
PickledStream::new(&kept.record)
.and_then(|mut stream| ObjectInner::unpickle(ObjectType::Account, &mut stream))
.and_then(|inner| Member::of(account_id, &inner))
.unwrap_or(Member {
account: account_id,
..Default::default()
})
}
impl Server {
/// What decides whether a hold reaches a live account; None if it's gone.
pub async fn member_of(&self, account_id: u32) -> Option<Member> {
let account = self.account(account_id).await.ok()?;
let mut domains = account
.addresses
.iter()
.map(|address| address.domain_id)
.collect::<Vec<_>>();
domains.sort_unstable();
domains.dedup();
Some(Member {
account: account_id,
domains,
groups: account.id_member_of.iter().copied().collect(),
tenant: account.id_tenant,
})
}
/// LH-9, the console's "what's held": per active hold, the accounts it
/// covers now (deleted ones it keeps included), and the archived items
/// it keeps with their size. One pass over accounts and archive.
pub async fn hold_summaries(&self) -> trc::Result<AHashMap<u32, HoldSummary>> {
let data = self.store();
let registry = self.registry();
let holds = hold::active(data).await?;
let mut summaries: AHashMap<u32, HoldSummary> =
holds.iter().map(|h| (h.id, HoldSummary::default())).collect();
if holds.is_empty() {
return Ok(summaries);
}
let mut members: AHashMap<u32, Member> = AHashMap::new();
for id in registry
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
.await
.caused_by(trc::location!())?
{
if let Some(member) = self.member_of(id.document_id()).await {
members.insert(id.document_id(), member);
}
}
for (account_id, kept) in undelete_data::kept_accounts(data).await? {
members.insert(account_id, kept_member(account_id, &kept));
}
for member in members.values() {
for hold in holds.iter().filter(|h| h.scope.covers(member)) {
summaries.entry(hold.id).or_default().accounts += 1;
}
}
for id in records::all(data, registry).await? {
let Some(item) = registry.object::<ArchivedItem>(id).await? else {
continue;
};
if !is_held_until(item.archived_until().timestamp().max(0) as u64) {
continue;
}
let Some(member) = members.get(&item.account_id().document_id()) else {
continue;
};
let size = match &item {
ArchivedItem::Email(email) => email.size,
ArchivedItem::FileNode(_) => match undelete_data::extra(data, id).await? {
Some(inbuxa_features::undelete::data::Extra::FileNode { size, .. }) => size as u64,
_ => 0,
},
_ => 0,
};
for hold in holds.iter().filter(|h| h.scope.covers(member)) {
let summary = summaries.entry(hold.id).or_default();
summary.items += 1;
summary.size += size;
}
}
Ok(summaries)
}
/// The active holds covering `account_id`, through its own name, its
/// addresses' domains, its groups or its tenant. Empty for an account
/// that no longer exists: a deleted one is kept by LH-8's own check.
pub async fn holds_on(&self, account_id: u32) -> trc::Result<Vec<Hold>> {
let Ok(account) = self.account(account_id).await else {
return Ok(Vec::new());
};
let mut domains = account
.addresses
.iter()
.map(|address| address.domain_id)
.collect::<Vec<_>>();
domains.sort_unstable();
domains.dedup();
let member = Member {
account: account_id,
domains,
groups: account.id_member_of.iter().copied().collect(),
tenant: account.id_tenant,
};
hold::covering(self.store(), &member).await
}
/// How `account_id`'s deleted items are kept: its holds' ranges and the
/// undelete period in force now (LH-4, UD-6a).
pub async fn keeping(&self, account_id: u32) -> trc::Result<Keeping> {
let retention = inbuxa_features::undelete::settings::retention(self.registry())
.await?
.items;
Ok(Keeping::new(retention, &self.holds_on(account_id).await?))
}
/// LH-6, LH-10, LH-11: brings the whole archive in line with the active
/// holds. An archived item a hold covers is frozen (no deadline), its
/// old deadline noted; a frozen one no hold covers any more gets that
/// deadline back, or release plus 30 days if later. Run after every
/// change to a hold; it changes nothing twice.
pub async fn settle_archive(&self) -> trc::Result<Settled> {
let data = self.store();
let registry = self.registry();
let any_active = !hold::active(data).await?.is_empty();
let now = now();
let mut keeping: AHashMap<u32, Option<Keeping>> = AHashMap::new();
let mut settled = Settled::default();
for id in records::all(data, registry).await? {
let Some(item) = registry.object::<ArchivedItem>(id).await? else {
continue;
};
let account_id = item.account_id().document_id();
if !keeping.contains_key(&account_id) {
// An account that's gone can't be placed in a domain or
// tenant any more: None, and its items are left as they are
let known = self.account(account_id).await.is_ok();
let value = if known { Some(self.keeping(account_id).await?) } else { None };
keeping.insert(account_id, value);
}
let until = item.archived_until().timestamp().max(0) as u64;
let held = is_held_until(until);
let covered = match keeping.get(&account_id).and_then(Option::as_ref) {
Some(keeping) => match &item {
ArchivedItem::Email(email) => {
keeping.covers(Some(email.received_at.timestamp().max(0) as u64))
}
ArchivedItem::CalendarEvent(event) => keeping
.covers_event(event.start_time.map(|t| t.timestamp().max(0) as u64)),
_ => keeping.covers(None),
},
// Gone: release only once no hold is active anywhere
None => held && any_active,
};
if covered && !held {
hold::set_original_deadline(data, id.id(), Some(until)).await?;
records::set_deadline(data, registry, id, &item, HELD_UNTIL).await?;
settled.frozen += 1;
} else if !covered && held {
let original = hold::original_deadline(data, id.id()).await?.unwrap_or(0);
records::set_deadline(data, registry, id, &item, original.max(now + RELEASE_GRACE))
.await?;
hold::set_original_deadline(data, id.id(), None).await?;
settled.released += 1;
}
}
// LH-8, LH-10: deleted accounts kept by undelete follow the holds
// too. Their DestroyAccount task defers itself while they're kept.
let retention = inbuxa_features::undelete::settings::retention(registry)
.await?
.accounts;
for (account_id, mut kept) in undelete_data::kept_accounts(data).await? {
let covered = !hold::covering(data, &kept_member(account_id, &kept)).await?.is_empty();
let held = is_held_until(kept.kept_until);
let until = if covered && !held {
settled.accounts_frozen += 1;
HELD_UNTIL
} else if !covered && held {
settled.accounts_released += 1;
(kept.deleted_at + retention.unwrap_or(0)).max(now + RELEASE_GRACE)
} else {
continue;
};
kept.kept_until = until;
let mut batch = store::write::BatchBuilder::new();
undelete_data::set_kept_account(&mut batch, account_id, &kept)?;
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
}
Ok(settled)
}
/// LH-8: whether a hold covers a deleted account undelete keeps.
pub async fn is_kept_held(&self, account_id: u32, kept: &KeptAccount) -> trc::Result<bool> {
Ok(!hold::covering(self.store(), &kept_member(account_id, kept))
.await?
.is_empty())
}
/// Every account an active hold covers now. Empty, without looking at
/// accounts, when nothing is held.
pub async fn held_accounts(&self) -> trc::Result<ahash::AHashSet<u32>> {
let mut held = ahash::AHashSet::new();
if hold::active(self.store()).await?.is_empty() {
return Ok(held);
}
for id in self
.registry()
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
.await
.caused_by(trc::location!())?
{
let account_id = id.document_id();
if self.is_held(account_id).await? {
held.insert(account_id);
}
}
Ok(held)
}
/// Whether any active hold covers `account_id` at all.
pub async fn is_held(&self, account_id: u32) -> trc::Result<bool> {
Ok(!self.holds_on(account_id).await?.is_empty())
}
}
+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)]
+7
View File
@@ -67,6 +67,8 @@ use utils::{
pub mod auth;
pub mod cache;
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
pub mod hold; // inbuxa: legal holds (audit-hold-lock spec, LH)
pub mod config;
pub mod expr;
pub mod i18n;
@@ -174,6 +176,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 +287,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,47 @@ 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), the audit log, account
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13).
const ADMIN_GRANTS: &[Permission] = &[
Permission::SysAiExplain,
Permission::SysAuditGet,
Permission::SysAuditExport,
Permission::SysAuditSettingsUpdate,
Permission::SysAccountLockGet,
Permission::SysAccountLockCreate,
Permission::SysAccountLockUpdate,
Permission::SysAccountLockDestroy,
Permission::SysLegalHoldGet,
Permission::SysLegalHoldCreate,
Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldExport,
];
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 +78,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 +99,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 +167,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())
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
use crate::{Server, manager::application::Resource};
use quick_xml::Reader;
use quick_xml::XmlVersion;
use quick_xml::events::Event;
@@ -59,11 +59,11 @@ impl Server {
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
// inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = match emailaddress.rsplit_once('@') {
Some((_, domain)) => self.legacy_protocols_off_for(domain).await?,
None => self.legacy_protocols_off_for("").await?,
Some((_, domain)) => self.legacy_off_for(domain).await?,
None => self.legacy_off_for("").await?,
};
for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
if legacy_off.service(protocol) {
continue;
}
let (protocol, ports) = match protocol {
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
use crate::{Server, manager::application::Resource};
use registry::schema::enums::ServiceProtocol;
use std::fmt::Write;
use utils::url_params::UrlParams;
@@ -31,7 +31,7 @@ impl Server {
};
// inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = self.legacy_protocols_off_for(domain).await?;
let legacy_off = self.legacy_off_for(domain).await?;
// Build XML response
let mut config = String::with_capacity(1024);
@@ -45,7 +45,7 @@ impl Server {
"\t\t<displayShortName>{domain}</displayShortName>"
);
for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
if legacy_off.service(protocol) {
continue;
}
let (protocol, tag, ports) = match protocol {
+7 -14
View File
@@ -6,11 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
Server,
config::network::Pacc,
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
};
use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
use ahash::{AHashMap, AHashSet};
use base64::{Engine, engine::general_purpose};
use dns_update::{
@@ -41,7 +37,7 @@ impl Server {
let default_host = network.server_name.as_str();
let domain_name = domain.name.as_str();
// inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = self.legacy_protocols_off_for(domain_name).await?;
let legacy_off = self.legacy_off_for(domain_name).await?;
let domain_name_suffix = format!(".{domain_name}");
for record_type in record_types {
@@ -205,7 +201,7 @@ impl Server {
// name says "not offered" -- target "." (RFC 6186 section
// 3.4) -- rather than vanishing, so a client that looks
// is told, and an old record left in the zone is replaced.
if legacy_off && is_legacy_service(protocol) {
if legacy_off.service(protocol) {
for (service_name, _) in services {
records.push(NamedDnsRecord {
name: format!("_{service_name}._tcp.{domain_name}."),
@@ -307,8 +303,8 @@ impl Server {
// inbuxa: legacy-protocols LP-7. No TLS pin for a port
// the switch has closed. Submission's port stays open
// (the SMTP lock), so its record stays.
if legacy_off
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
if matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
&& legacy_off.service(protocol)
{
continue;
}
@@ -418,11 +414,8 @@ impl Server {
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
// inbuxa: legacy-protocols LP-7, LP-14a
let pacc = if self.legacy_protocols_off_for(domain_name).await? {
&self.core.network.info.pacc_jmap_only
} else {
&self.core.network.info.pacc
};
let off = self.legacy_off_for(domain_name).await?;
let pacc = &self.core.network.info.pacc[off.index()];
self.get_directory_for_domain(domain_name)
.await
.caused_by(trc::location!())
+186 -58
View File
@@ -35,8 +35,8 @@ use directory::Credentials;
use inbuxa_features::security::{
legacy_use::{self, LegacyUse},
listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener},
tenant_protocol_policy,
protocol_policy::{self, ProtocolPolicy, SUBMISSION, SWITCHED, SavedListener, Switches},
tenant_protocol_policy::{self, OffBy, TenantProtocolPolicy},
};
use registry::schema::enums::ServiceProtocol;
use registry::types::{error::Error, id::ObjectId};
@@ -97,40 +97,48 @@ impl Server {
// this, and a /set that omitted it must not lose the listeners still
// waiting to come back.
let previous = self.protocol_policy().await?;
policy.saved_listeners = previous.saved_listeners;
policy.saved_listeners = previous.saved_listeners.clone();
policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = changed_by;
policy.normalize();
if policy.legacy_protocols.is_disabled() {
self.close_legacy_listeners(&mut policy, &mut change).await?;
} else {
// Each protocol on its own switch: close what is off now, and put
// back what was saved for a protocol that is on again. Either may
// happen in one change, when one protocol goes off as another comes
// back.
self.close_legacy_listeners(&mut policy, &mut change)
.await?;
self.reopen_legacy_listeners(&mut policy, &mut change)
.await?;
}
protocol_policy::set(&self.core.storage.data, &policy).await?;
// LP-8. Raised here rather than by the JMAP method, so whatever turns
// the switch is reported. A /set that changed nothing -- the switch
// a switch is reported. A /set that changed nothing -- every switch
// already where it was asked to be, nothing to close or reopen -- is
// not a change.
if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() {
let (moved, direction) = if policy.legacy_protocols.is_disabled() {
(&change.closed, "closed")
} else {
(&change.reopened, "reopened")
};
let mut before = previous;
before.normalize();
if before.off() != policy.off() || !change.is_empty() {
// The closed first, then the reopened; `Details` says which.
let moved = change
.closed
.iter()
.chain(change.reopened.iter())
.map(|l| l.id.clone());
trc::event!(
Security(trc::SecurityEvent::LegacyProtocolsChanged),
Policy = "server",
Value = if policy.legacy_protocols.is_disabled() {
"disabled"
} else {
"enabled"
},
Value = switches_value(&policy),
AccountId = policy.changed_by.clone(),
Details = direction,
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())),
Details = if change.closed.is_empty() {
"reopened"
} else if change.reopened.is_empty() {
"closed"
} else {
"closed and reopened"
},
ListenerId = listener_names(moved),
// Only when a listener could not be put back (LP-5).
Reason = (!change.failed.is_empty()).then(|| listener_names(
change
@@ -165,21 +173,27 @@ impl Server {
Ok(())
}
/// Puts back every saved listener and starts it again (LP-5).
/// Puts back every saved listener whose protocol is on again, and starts
/// it (LP-5). The rest stay saved.
async fn reopen_legacy_listeners(
&self,
policy: &mut ProtocolPolicy,
change: &mut PolicyChange,
) -> trc::Result<()> {
if policy.saved_listeners.is_empty() {
let (wanted, still_closed): (Vec<_>, Vec<_>) = std::mem::take(&mut policy.saved_listeners)
.into_iter()
.partition(|saved| !policy.closes(&saved.protocol, &saved.ports));
policy.saved_listeners = still_closed;
if wanted.is_empty() {
return Ok(());
}
let saved = std::mem::take(&mut policy.saved_listeners);
let (restored, failed) = listeners::reopen(self.registry(), &saved).await?;
let (restored, failed) = listeners::reopen(self.registry(), &wanted).await?;
// A listener that could not be put back stays saved for another try.
policy.saved_listeners = failed.iter().map(|(listener, _)| listener.clone()).collect();
policy
.saved_listeners
.extend(failed.iter().map(|(listener, _)| listener.clone()));
change.failed = failed;
if !restored.is_empty() {
@@ -254,6 +268,17 @@ impl Server {
}
}
/// The switches as an event value: `disabled` or `enabled` when all three
/// agree, otherwise which are off, such as `pop3 disabled` (LP-8).
pub fn switches_value(policy: &impl Switches) -> String {
let off = policy.off();
match off.len() {
0 => "enabled".to_string(),
n if n == SWITCHED.len() => "disabled".to_string(),
_ => format!("{} disabled", off.join(", ")),
}
}
/// Names for an event field: the listeners a change closed, reopened or
/// failed to reopen (LP-8).
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
@@ -395,16 +420,19 @@ impl Server {
credentials: &Credentials,
) -> trc::Result<()> {
let domain = domain_of(credentials);
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
let server = self.protocol_policy().await?;
if server.is_off(protocol.as_str()) {
return Err(protocol.refused(RefusalScope::Server, domain));
}
if let Some(name) = &domain
&& let Some(domain) = self.domain(name).await?
&& let Some(tenant_id) = domain.id_tenant
&& self.tenant_legacy_protocols_off(tenant_id).await?
{
let tenant = self.tenant_protocol_policy(tenant_id).await?;
if tenant_protocol_policy::off_by(&server, Some(&tenant), protocol.as_str()).is_some() {
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
}
}
Ok(())
}
@@ -422,11 +450,17 @@ impl Server {
protocol: LegacyProtocol,
access_token: &AccessToken,
) -> trc::Result<()> {
if let Some(tenant_id) = access_token.tenant_id()
&& self.tenant_legacy_protocols_off(tenant_id).await?
{
if let Some(tenant_id) = access_token.tenant_id() {
let server = self.protocol_policy().await?;
let tenant = self.tenant_protocol_policy(tenant_id).await?;
match tenant_protocol_policy::off_by(&server, Some(&tenant), protocol.as_str()) {
Some(OffBy::Server) => return Err(protocol.refused(RefusalScope::Server, None)),
Some(OffBy::Tenant) => {
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
}
None => {}
}
}
if let Err(err) = legacy_use::record(
&self.core.storage.data,
access_token.account_id(),
@@ -463,31 +497,96 @@ impl Server {
Ok(recent)
}
/// Whether legacy protocols are off for this account: the stricter of the
/// server's switch and its tenant's. What the JMAP session tells the
/// Which legacy protocols are off for this account: each the stricter of
/// the server's switch and its tenant's. What the JMAP session tells the
/// account's apps (legacy-protocols spec, Interfaces), so the webmail can
/// say why a mail app won't connect (LP-19).
pub async fn legacy_protocols_off_for_account(
pub async fn legacy_off_for_account(
&self,
access_token: &AccessToken,
) -> trc::Result<bool> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
return Ok(true);
) -> trc::Result<LegacyOff> {
let server = self.protocol_policy().await?;
let tenant = match access_token.tenant_id() {
Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
None => None,
};
Ok(LegacyOff::of(&server, tenant.as_ref()))
}
match access_token.tenant_id() {
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
None => Ok(false),
/// A tenant's switches, or all on when it has never set them (LP-10).
pub async fn tenant_protocol_policy(
&self,
tenant_id: u32,
) -> trc::Result<TenantProtocolPolicy> {
tenant_protocol_policy::get(&self.core.storage.data, tenant_id).await
}
}
/// Whether a tenant has turned legacy protocols off for itself (LP-10).
pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result<bool> {
Ok(
tenant_protocol_policy::get(&self.core.storage.data, tenant_id)
.await?
.legacy_protocols
.is_disabled(),
)
/// Which legacy protocols are off, for one account or one domain: the server's
/// switches and the tenant's together. Submission is off only when all three
/// are.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LegacyOff {
pub imap: bool,
pub pop3: bool,
pub manage_sieve: bool,
pub submission: bool,
}
impl LegacyOff {
pub fn of(server: &ProtocolPolicy, tenant: Option<&TenantProtocolPolicy>) -> Self {
let off = |protocol| tenant_protocol_policy::off_by(server, tenant, protocol).is_some();
LegacyOff {
imap: off("imap"),
pop3: off("pop3"),
manage_sieve: off("manageSieve"),
submission: off(SUBMISSION),
}
}
/// Whether this configured service must not be offered (LP-7). SMTP here
/// is submission; inbound mail is never a configured service.
pub fn service(&self, protocol: &ServiceProtocol) -> bool {
match protocol {
ServiceProtocol::Imap => self.imap,
ServiceProtocol::Pop3 => self.pop3,
ServiceProtocol::Managesieve => self.manage_sieve,
ServiceProtocol::Smtp => self.submission,
_ => false,
}
}
/// Whether anything is off.
pub fn any(&self) -> bool {
self.imap || self.pop3 || self.manage_sieve || self.submission
}
/// Whether everything is off: the kill-all's effect.
pub fn all(&self) -> bool {
self.imap && self.pop3 && self.manage_sieve && self.submission
}
/// An index for answers prepared once per combination (the PACC
/// document): one bit per protocol.
pub fn index(&self) -> usize {
(self.imap as usize)
| (self.pop3 as usize) << 1
| (self.manage_sieve as usize) << 2
| (self.submission as usize) << 3
}
/// The protocols that are still allowed, by JMAP name, for the session.
pub fn allowed(&self) -> Vec<&'static str> {
[
("imap", self.imap),
("pop3", self.pop3),
("manageSieve", self.manage_sieve),
(SUBMISSION, self.submission),
]
.into_iter()
.filter(|(_, off)| !off)
.map(|(name, _)| name)
.collect()
}
}
@@ -505,21 +604,20 @@ pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
}
impl Server {
/// Whether legacy services are off for this domain, for the answers that
/// Which legacy services are off for this domain, for the answers that
/// must stop offering them: off for the whole server (LP-7), or for the
/// tenant the domain belongs to (LP-14a). Read per answer, as sign-in
/// reads it. A name that is no domain here answers for the server alone.
pub async fn legacy_protocols_off_for(&self, domain_name: &str) -> trc::Result<bool> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
return Ok(true);
}
match self.domain(domain_name).await? {
pub async fn legacy_off_for(&self, domain_name: &str) -> trc::Result<LegacyOff> {
let server = self.protocol_policy().await?;
let tenant = match self.domain(domain_name).await? {
Some(domain) => match domain.id_tenant {
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
None => Ok(false),
Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
None => None,
},
None => Ok(false),
}
None => None,
};
Ok(LegacyOff::of(&server, tenant.as_ref()))
}
}
@@ -619,6 +717,36 @@ mod tests {
}
}
#[test]
fn what_is_off_for_one_account_or_domain() {
use inbuxa_features::security::protocol_policy::LegacyProtocols;
let mut server = ProtocolPolicy::default();
server.set("pop3", LegacyProtocols::Disabled);
let mut tenant = TenantProtocolPolicy::default();
tenant.set("manageSieve", LegacyProtocols::Disabled);
let off = LegacyOff::of(&server, Some(&tenant));
assert!(off.pop3 && off.manage_sieve && !off.imap && !off.submission);
assert!(off.service(&ServiceProtocol::Pop3));
assert!(!off.service(&ServiceProtocol::Imap));
assert!(
!off.service(&ServiceProtocol::Smtp),
"sending is still offered"
);
assert!(!off.service(&ServiceProtocol::Jmap));
assert_eq!(off.allowed(), vec!["imap", "submission"]);
assert!(off.any() && !off.all());
let off = LegacyOff::of(&server, None);
assert_eq!(off.index(), 0b0010);
server.set_all(LegacyProtocols::Disabled);
let off = LegacyOff::of(&server, None);
assert!(off.all());
assert_eq!(off.index(), 0b1111);
assert!(off.allowed().is_empty());
}
#[test]
fn the_domain_comes_from_the_name_given() {
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
+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;
+4 -6
View File
@@ -92,10 +92,8 @@ impl MailboxDestroy for Server {
let mut deleted_ids = RoaringBitmap::new();
let mut thread_ids = RoaringBitmap::new();
// inbuxa: UD-1, UD-6a: the retention in force now
let retention = inbuxa_features::undelete::settings::retention(self.registry())
.await?
.items;
// inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
let keeping = self.keeping(account_id).await?;
self.archives(
account_id,
Collection::Email,
@@ -125,10 +123,10 @@ impl MailboxDestroy for Server {
deleted_ids.insert(message_id);
thread_ids.insert(prev_message_data.inner.thread_id.to_native());
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
if let Some(retention) = retention {
if keeping.keeps_anything() {
inbuxa_features::undelete::email::note(
&mut batch,
retention,
&keeping,
account_id,
message_id,
prev_message_data.inner.size.to_native() as u64,
+4 -6
View File
@@ -69,10 +69,8 @@ impl EmailDeletion for Server {
batch
.with_account_id(account_id)
.with_collection(Collection::Email);
// inbuxa: UD-1, UD-6a: the retention in force now
let retention = inbuxa_features::undelete::settings::retention(self.registry())
.await?
.items;
// inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
let keeping = self.keeping(account_id).await?;
self.archives(
account_id,
Collection::Email,
@@ -90,10 +88,10 @@ impl EmailDeletion for Server {
}
thread_ids.insert(metadata.inner.thread_id.to_native());
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
if let Some(retention) = retention {
if keeping.keeps_anything() {
inbuxa_features::undelete::email::note(
batch,
retention,
&keeping,
account_id,
document_id,
metadata.inner.size.to_native() as u64,
+6 -6
View File
@@ -44,12 +44,12 @@ impl SieveScriptDelete for Server {
))
.await?
{
// inbuxa: UD-1: a deleted script is kept, when archiving is on
if let Some(retention) =
inbuxa_features::undelete::settings::retention(self.registry())
.await?
.items
{
// inbuxa: UD-1, LH-4: a deleted script is kept, when archiving
// is on or a hold covers the account (whole: scripts have no date)
let keeping = self.keeping(account_id).await?;
let now = store::write::now();
if let Some(until) = keeping.until(now, keeping.is_held()) {
let retention = until.saturating_sub(now);
let script = obj_
.deserialize::<SieveScript>()
.caused_by(trc::location!())?;
+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);
}
}
+772
View File
@@ -0,0 +1,772 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Legal holds (audit-hold-lock spec, LH-1 to LH-14).
//!
//! A hold names a case and what it covers: accounts, groups, domains,
//! tenants or the whole server, optionally only items dated inside a range.
//! While any active hold covers an item, nothing may destroy it. A hold is
//! never deleted: releasing it keeps it, read-only, for the audit trail.
//!
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
//! with `H`, then one byte for the kind:
//!
//! - `h` + hold id (u32): the hold, as JSON.
//!
//! Numbers are big-endian. There are few holds, so they're read whole.
use registry::schema::{prelude::ObjectInner, structs::Account};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
};
use trc::AddContext;
/// The deadline a held archived item carries: the last second of 9999. It
/// never passes, so every expiry check keeps the item without knowing about
/// holds (LH-4, LH-5); releasing a hold gives it a real deadline (LH-10).
pub const HELD_UNTIL: u64 = 253_402_300_799;
/// Whether an archived item's deadline marks it as held. Anything past the
/// year 9000 counts, so a deadline computed from a hold a moment earlier or
/// later still reads as held.
pub fn is_held_until(until: u64) -> bool {
until >= 221_845_392_000
}
/// A day, in seconds: the slack either side of a range for an event's start,
/// whose time zone isn't known here.
const DAY: u64 = 86_400;
/// How an account's deleted items are kept: its holds' ranges, and the
/// undelete period for whatever no hold covers (LH-3, LH-4).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Keeping {
/// `archiveDeletedItemsFor`, in seconds, if undelete is on.
pub retention: Option<u64>,
/// Each active hold's range on this account; `(None, None)` is a whole
/// account. Empty when nothing holds it.
pub ranges: Vec<(Option<u64>, Option<u64>)>,
}
impl Keeping {
pub fn new(retention: Option<u64>, holds: &[Hold]) -> Keeping {
Keeping {
retention,
ranges: holds.iter().map(|h| (h.from, h.to)).collect(),
}
}
/// Whether any hold reaches the account at all.
pub fn is_held(&self) -> bool {
!self.ranges.is_empty()
}
/// Whether deleted items need noting: something may keep them.
pub fn keeps_anything(&self) -> bool {
self.is_held() || self.retention.is_some()
}
/// Whether a hold covers an item dated `date`. No date means the item is
/// held whole, whatever the range (LH-3).
pub fn covers(&self, date: Option<u64>) -> bool {
self.ranges.iter().any(|(from, to)| match date {
None => true,
Some(at) => {
from.is_none_or(|from| at >= from) && to.is_none_or(|to| at <= to)
}
})
}
/// Like `covers`, for an event's start: a day of slack either side, since
/// its time zone isn't known here.
pub fn covers_event(&self, start: Option<u64>) -> bool {
self.ranges.iter().any(|(from, to)| match start {
None => true,
Some(at) => {
from.is_none_or(|from| at + DAY >= from)
&& to.is_none_or(|to| at <= to.saturating_add(DAY))
}
})
}
/// Until when an item deleted at `now` is kept: held, the undelete
/// period, or not at all.
pub fn until(&self, now: u64, held: bool) -> Option<u64> {
if held {
Some(HELD_UNTIL)
} else {
self.retention.map(|retention| now + retention)
}
}
}
const FEATURE: u8 = b'H';
const KIND_HOLD: u8 = b'h';
const KIND_ORIGINAL: u8 = b'o';
const KIND_EXPORT: u8 = b'e';
/// How far a hold export has got (LH-12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExportStatus {
Running,
Ready,
Failed,
}
/// A collection of what a hold keeps, as a ZIP (LH-12).
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Export {
pub id: u32,
pub hold_id: u32,
/// The accounts asked for; empty for every account the hold covers.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub accounts: Vec<u32>,
pub reason: String,
pub created_at: u64,
pub created_by: String,
/// Whose blob the ZIP is, so only they download it.
pub created_by_id: u32,
pub status: ExportStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob_id: Option<String>,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub items: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// How many times creating a hold retries when another node took its id.
const CREATE_ATTEMPTS: usize = 5;
/// What a hold covers (LH-1, LH-2). Domains and tenants are resolved live,
/// so an account added to one later is held too.
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Scope {
/// Every account on the server.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub server: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub accounts: Vec<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub domains: Vec<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tenants: Vec<u32>,
}
impl Scope {
pub fn is_empty(&self) -> bool {
!self.server
&& self.accounts.is_empty()
&& self.groups.is_empty()
&& self.domains.is_empty()
&& self.tenants.is_empty()
}
/// Whether this scope covers everything `other` does, entry by entry.
/// A scope may only grow (LH-3's rule for ranges, applied to scope):
/// taking something out would free what it held.
pub fn contains(&self, other: &Scope) -> bool {
let all = |mine: &[u32], theirs: &[u32]| theirs.iter().all(|id| mine.contains(id));
(self.server || !other.server)
&& all(&self.accounts, &other.accounts)
&& all(&self.groups, &other.groups)
&& all(&self.domains, &other.domains)
&& all(&self.tenants, &other.tenants)
}
fn normalize(&mut self) {
for list in [
&mut self.accounts,
&mut self.groups,
&mut self.domains,
&mut self.tenants,
] {
list.sort_unstable();
list.dedup();
}
}
}
/// What decides whether a hold's scope reaches an account: the domains of
/// its addresses, its groups and its tenant (LH-2).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Member {
pub account: u32,
pub domains: Vec<u32>,
pub groups: Vec<u32>,
pub tenant: Option<u32>,
}
impl Member {
/// A person's account as the registry stores it; `None` for a group,
/// whose own data is held through its members.
pub fn of(account_id: u32, object: &ObjectInner) -> Option<Member> {
let ObjectInner::Account(Account::User(user)) = object else {
return None;
};
let mut domains = vec![user.domain_id.document_id()];
domains.extend(user.aliases.iter().map(|alias| alias.domain_id.document_id()));
domains.sort_unstable();
domains.dedup();
Some(Member {
account: account_id,
domains,
groups: user.member_group_ids.iter().map(|id| id.document_id()).collect(),
tenant: user.member_tenant_id.map(|id| id.document_id()),
})
}
}
impl Scope {
/// Whether this scope reaches `member`, directly or through its domains,
/// groups or tenant, as they are now (LH-2).
pub fn covers(&self, member: &Member) -> bool {
self.server
|| self.accounts.contains(&member.account)
|| member.domains.iter().any(|d| self.domains.contains(d))
|| member.groups.iter().any(|g| self.groups.contains(g))
|| member.tenant.is_some_and(|t| self.tenants.contains(&t))
}
}
/// When and why a hold was released (LH-10).
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Release {
pub at: u64,
pub by: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by_id: Option<u32>,
pub reason: String,
}
/// A legal hold (LH-1).
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Hold {
pub id: u32,
/// The case name.
pub name: String,
/// A matter or ticket number.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub scope: Scope,
/// Seconds since the epoch. Items dated before aren't held (LH-3).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<u64>,
/// Seconds since the epoch. Items dated after aren't held (LH-3).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<u64>,
pub placed_at: u64,
pub placed_by: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placed_by_id: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub released: Option<Release>,
}
/// Why a change to a hold is refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
/// A released hold is read-only (LH-1).
Released,
/// The range may only widen (LH-3).
Narrowed,
/// The scope may only grow.
ScopeShrunk,
/// A hold has to cover something.
EmptyScope,
/// `from` after `to`.
Backwards,
}
impl Refusal {
pub fn describe(self) -> &'static str {
match self {
Refusal::Released => "A released hold can't be changed; place a new one instead.",
Refusal::Narrowed => {
"A hold's date range can only be widened. To hold less, release it and place a new hold."
}
Refusal::ScopeShrunk => {
"Nothing can be taken out of a hold's scope. To hold less, release it and place a new hold."
}
Refusal::EmptyScope => "A hold has to cover at least one account, group, domain or tenant, or the whole server.",
Refusal::Backwards => "The range starts after it ends.",
}
}
}
impl Hold {
pub fn is_active(&self) -> bool {
self.released.is_none()
}
/// Whether an item dated `at` (seconds) falls in the hold's range. With
/// no range, everything does (LH-3).
pub fn covers_date(&self, at: u64) -> bool {
self.from.is_none_or(|from| at >= from) && self.to.is_none_or(|to| at <= to)
}
/// Checks a new hold, and tidies its scope.
pub fn check_new(&mut self) -> Result<(), Refusal> {
self.scope.normalize();
if self.scope.is_empty() {
return Err(Refusal::EmptyScope);
}
if let (Some(from), Some(to)) = (self.from, self.to)
&& from > to
{
return Err(Refusal::Backwards);
}
Ok(())
}
/// Checks that `next` is an allowed change of `self`: names and notes
/// may change, the range may only widen, the scope may only grow, and a
/// released hold may not change at all.
pub fn check_update(&self, next: &mut Hold) -> Result<(), Refusal> {
if !self.is_active() {
return Err(Refusal::Released);
}
next.check_new()?;
// An open end can't be closed, and a set end can only move outward
let from_ok = match (self.from, next.from) {
(None, Some(_)) => false,
(Some(old), Some(new)) => new <= old,
(_, None) => true,
};
let to_ok = match (self.to, next.to) {
(None, Some(_)) => false,
(Some(old), Some(new)) => new >= old,
(_, None) => true,
};
if !from_ok || !to_ok {
return Err(Refusal::Narrowed);
}
if !next.scope.contains(&self.scope) {
return Err(Refusal::ScopeShrunk);
}
Ok(())
}
}
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 legal hold")
.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 legal hold")
.reason(err)
})
}
}
fn class(id: u32) -> ValueClass {
let mut key = Vec::with_capacity(6);
key.push(FEATURE);
key.push(KIND_HOLD);
key.extend_from_slice(&id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
fn key(id: u32) -> ValueKey<ValueClass> {
ValueKey::from(class(id))
}
fn original_class(item_id: u64) -> ValueClass {
let mut key = Vec::with_capacity(10);
key.push(FEATURE);
key.push(KIND_ORIGINAL);
key.extend_from_slice(&item_id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
/// LH-10: an archived item's deadline from before a hold froze it, so a
/// release can give it back (or a later one). None for an item held from
/// its deletion, which never had one.
pub async fn original_deadline(data: &Store, item_id: u64) -> trc::Result<Option<u64>> {
data.get_value::<u64>(ValueKey::from(original_class(item_id)))
.await
.caused_by(trc::location!())
}
/// Notes (`Some`) or forgets (`None`) an item's deadline from before it
/// was frozen.
pub async fn set_original_deadline(data: &Store, item_id: u64, until: Option<u64>) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
match until {
Some(until) => batch.set(original_class(item_id), until.to_be_bytes().to_vec()),
None => batch.clear(original_class(item_id)),
};
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
fn export_class(id: u32) -> ValueClass {
let mut key = Vec::with_capacity(6);
key.push(FEATURE);
key.push(KIND_EXPORT);
key.extend_from_slice(&id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
/// Every hold export, oldest first.
pub async fn exports(data: &Store) -> trc::Result<Vec<Export>> {
let mut exports = Vec::new();
data.iterate(
IterateParams::new(ValueKey::from(export_class(0)), ValueKey::from(export_class(u32::MAX))),
|_, value| {
if let Ok(Json(export)) = Json::<Export>::deserialize(value) {
exports.push(export);
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
Ok(exports)
}
/// Writes a new export under the next free id, which it returns.
pub async fn create_export(data: &Store, export: &Export) -> trc::Result<u32> {
let mut attempt = 0;
loop {
attempt += 1;
let id = exports(data).await?.iter().map(|e| e.id).max().unwrap_or(0) + 1;
let stored = Export {
id,
..export.clone()
};
let mut batch = BatchBuilder::new();
batch.assert_value(export_class(id), AssertValue::None);
batch.set(export_class(id), Json(&stored).serialize()?);
match data.write(batch.build_all()).await {
Ok(_) => return Ok(id),
Err(err)
if attempt < CREATE_ATTEMPTS
&& matches!(
err.as_ref(),
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
) => {}
Err(err) => return Err(err.caused_by(trc::location!())),
}
}
}
/// Saves an export's progress.
pub async fn update_export(data: &Store, export: &Export) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(export_class(export.id), Json(export).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// One hold, released or not.
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Hold>> {
Ok(data
.get_value::<Json<Hold>>(key(id))
.await
.caused_by(trc::location!())?
.map(|Json(hold)| hold))
}
/// Every hold, released ones included, oldest first.
pub async fn all(data: &Store) -> trc::Result<Vec<Hold>> {
let mut holds = Vec::new();
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
if let Ok(Json(hold)) = Json::<Hold>::deserialize(value) {
holds.push(hold);
}
Ok(true)
})
.await
.caused_by(trc::location!())?;
Ok(holds)
}
/// The holds still in force.
pub async fn active(data: &Store) -> trc::Result<Vec<Hold>> {
Ok(all(data).await?.into_iter().filter(Hold::is_active).collect())
}
/// Writes a new hold under the next free id, which it returns. Two nodes
/// placing holds at once can't take the same id: the key must be absent.
pub async fn create(data: &Store, hold: &Hold) -> trc::Result<u32> {
let mut attempt = 0;
loop {
attempt += 1;
let id = all(data).await?.iter().map(|h| h.id).max().unwrap_or(0) + 1;
let stored = Hold {
id,
..hold.clone()
};
let mut batch = BatchBuilder::new();
batch.assert_value(class(id), AssertValue::None);
batch.set(class(id), Json(&stored).serialize()?);
match data.write(batch.build_all()).await {
Ok(_) => return Ok(id),
Err(err)
if attempt < CREATE_ATTEMPTS
&& matches!(
err.as_ref(),
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
) => {}
Err(err) => return Err(err.caused_by(trc::location!())),
}
}
}
/// The active holds that reach `member` (LH-2, LH-11).
pub async fn covering(data: &Store, member: &Member) -> trc::Result<Vec<Hold>> {
Ok(active(data)
.await?
.into_iter()
.filter(|hold| hold.scope.covers(member))
.collect())
}
/// LH-2: an account a hold reached through its domain, group or tenant stays
/// held when it leaves them: it is added to the hold by name. Called for
/// every change to an account, so no move escapes a hold.
pub async fn keep_moved(data: &Store, before: &Member, after: &Member) -> trc::Result<()> {
if before == after {
return Ok(());
}
for mut hold in active(data).await? {
if hold.scope.covers(before) && !hold.scope.covers(after) {
hold.scope.accounts.push(after.account);
hold.scope.accounts.sort_unstable();
hold.scope.accounts.dedup();
update(data, &hold).await?;
}
}
Ok(())
}
/// LH-8: names `account_id` in every hold that reaches it, so a deleted
/// account, no longer in any domain or tenant, stays held.
pub async fn pin_account(data: &Store, member: &Member) -> trc::Result<()> {
for mut hold in covering(data, member).await? {
if !hold.scope.accounts.contains(&member.account) {
hold.scope.accounts.push(member.account);
hold.scope.accounts.sort_unstable();
update(data, &hold).await?;
}
}
Ok(())
}
/// Replaces a hold that `check_update` allowed.
pub async fn update(data: &Store, hold: &Hold) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(class(hold.id), Json(hold).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
fn hold(scope: Scope, from: Option<u64>, to: Option<u64>) -> Hold {
Hold {
id: 1,
name: "Matter 4411".into(),
reference: Some("4411".into()),
description: None,
scope,
from,
to,
placed_at: 10,
placed_by: "admin".into(),
placed_by_id: None,
released: None,
}
}
fn accounts(ids: &[u32]) -> Scope {
Scope {
accounts: ids.to_vec(),
..Default::default()
}
}
#[test]
fn a_hold_needs_a_scope_and_a_forward_range() {
assert_eq!(hold(Scope::default(), None, None).check_new(), Err(Refusal::EmptyScope));
assert_eq!(hold(accounts(&[2]), Some(20), Some(10)).check_new(), Err(Refusal::Backwards));
let mut ok = hold(accounts(&[3, 2, 3]), None, None);
assert_eq!(ok.check_new(), Ok(()));
assert_eq!(ok.scope.accounts, vec![2, 3], "sorted, once each");
}
#[test]
fn the_range_only_widens() {
let current = hold(accounts(&[2]), Some(100), Some(200));
let widened = |from, to| {
let mut next = hold(accounts(&[2]), from, to);
current.check_update(&mut next)
};
assert_eq!(widened(Some(50), Some(300)), Ok(()));
assert_eq!(widened(None, None), Ok(()), "opening both ends widens");
assert_eq!(widened(Some(150), Some(200)), Err(Refusal::Narrowed));
assert_eq!(widened(Some(100), Some(150)), Err(Refusal::Narrowed));
let open = hold(accounts(&[2]), None, None);
let mut closed = hold(accounts(&[2]), Some(1), None);
assert_eq!(open.check_update(&mut closed), Err(Refusal::Narrowed), "an open end stays open");
}
#[test]
fn the_scope_only_grows() {
let current = hold(
Scope {
accounts: vec![2],
domains: vec![7],
..Default::default()
},
None,
None,
);
let mut grown = hold(
Scope {
accounts: vec![2, 3],
domains: vec![7],
tenants: vec![1],
..Default::default()
},
None,
None,
);
assert_eq!(current.check_update(&mut grown), Ok(()));
let mut shrunk = hold(accounts(&[2, 3]), None, None);
assert_eq!(current.check_update(&mut shrunk), Err(Refusal::ScopeShrunk));
let server = hold(Scope { server: true, ..Default::default() }, None, None);
let mut less = hold(accounts(&[2]), None, None);
assert_eq!(server.check_update(&mut less), Err(Refusal::ScopeShrunk));
}
#[test]
fn a_released_hold_is_read_only() {
let mut released = hold(accounts(&[2]), None, None);
released.released = Some(Release {
at: 50,
by: "admin".into(),
by_id: None,
reason: "Settled".into(),
});
let mut next = released.clone();
next.name = "Renamed".into();
assert_eq!(released.check_update(&mut next), Err(Refusal::Released));
assert!(!released.is_active());
}
#[test]
fn dates_in_range() {
let whole = hold(accounts(&[2]), None, None);
assert!(whole.covers_date(0) && whole.covers_date(u64::MAX));
let ranged = hold(accounts(&[2]), Some(100), Some(200));
assert!(ranged.covers_date(100) && ranged.covers_date(200));
assert!(!ranged.covers_date(99) && !ranged.covers_date(201));
let open_ended = hold(accounts(&[2]), Some(100), None);
assert!(open_ended.covers_date(u64::MAX), "no `to` also catches mail still to come");
}
#[test]
fn a_scope_reaches_members_through_domain_group_and_tenant() {
let member = Member {
account: 9,
domains: vec![3, 4],
groups: vec![20],
tenant: Some(7),
};
let reaches = |scope: Scope| scope.covers(&member);
assert!(reaches(accounts(&[9])));
assert!(reaches(Scope { domains: vec![4], ..Default::default() }), "an alias's domain counts");
assert!(reaches(Scope { groups: vec![20], ..Default::default() }));
assert!(reaches(Scope { tenants: vec![7], ..Default::default() }));
assert!(reaches(Scope { server: true, ..Default::default() }));
assert!(!reaches(Scope { domains: vec![5], tenants: vec![8], ..Default::default() }));
// LH-2: leaving the held domain would free it, so the hold must name it
let held = hold(Scope { domains: vec![3], ..Default::default() }, None, None);
let moved = Member { domains: vec![6], ..member.clone() };
assert!(held.scope.covers(&member) && !held.scope.covers(&moved));
}
#[test]
fn keeping_deleted_items() {
let whole = Keeping::new(None, &[hold(accounts(&[2]), None, None)]);
assert!(whole.covers(Some(5)) && whole.covers(None));
assert_eq!(whole.until(100, whole.covers(Some(5))), Some(HELD_UNTIL));
assert!(is_held_until(whole.until(100, true).unwrap()));
// LH-3: a range holds only what's inside it; outside, undelete's rules
let ranged = Keeping::new(Some(30), &[hold(accounts(&[2]), Some(1_000), Some(2_000))]);
assert!(ranged.covers(Some(1_500)) && !ranged.covers(Some(2_500)));
assert!(ranged.covers(None), "contacts, files and scripts are held whole");
assert_eq!(ranged.until(100, ranged.covers(Some(2_500))), Some(130));
assert!(ranged.covers_event(Some(2_000 + 3_600)), "a day of slack for an event");
// Neither held nor undelete: nothing is kept
let none = Keeping::new(None, &[]);
assert!(!none.keeps_anything());
assert_eq!(none.until(100, false), None);
assert!(!is_held_until(100 + 30 * 365 * 86_400));
}
#[test]
fn stored_as_json() {
let current = hold(accounts(&[2]), Some(100), None);
let json = serde_json::to_string(&current).unwrap();
assert_eq!(serde_json::from_str::<Hold>(&json).unwrap(), current);
assert!(json.contains("\"scope\":{\"accounts\":[2]}"), "{json}");
}
}
+3
View File
@@ -19,7 +19,10 @@
//! `common::Server`.
pub mod ai;
pub mod audit;
pub mod branding;
pub mod hold;
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;
use types::{
acl::{Acl, AclGrant},
collection::Collection,
};
use utils::map::bitmap::Bitmap;
/// 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)
}
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\""));
}
}
+229 -11
View File
@@ -8,6 +8,17 @@
//! (legacy-protocols spec, data model and LP-1 to LP-8). Stored as JSON under
//! `P` + `p` in the fork's subspace; unset fields read as the defaults.
//!
//! Each mail-app protocol has its own switch (legacy-protocols spec,
//! "Revisit: one switch per protocol"): IMAP, POP3 and ManageSieve.
//! `legacyProtocols` is the kill-all: setting it sets all three, and it reads
//! `disabled` exactly when all three are off. A policy stored before the
//! per-protocol switches has only `legacyProtocols`, and reads as all three
//! at that value.
//!
//! SMTP submission has no switch of its own here: sign-in over it is refused
//! only when all three are off, as it was by the single switch (LP-6), so
//! turning off one protocol never stops a mail app sending.
//!
//! This module is the fact, not the act. It holds what the operator chose and
//! which listeners were taken away to honour it. Closing sockets belongs to
//! `common`, which owns the listener registry, and removing the listener
@@ -59,12 +70,30 @@ pub struct SavedListener {
pub object: serde_json::Value,
}
/// The server-wide switch.
/// The protocols with a switch of their own, as the schema and JMAP spell
/// them.
pub const SWITCHED: &[&str] = &["imap", "pop3", "manageSieve"];
/// The name sign-in uses for SMTP AUTH, which follows the kill-all.
pub const SUBMISSION: &str = "submission";
/// The server-wide switches.
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ProtocolPolicy {
/// The switch itself.
/// The kill-all: `disabled` exactly when all three protocols are off,
/// once [`ProtocolPolicy::normalize`] has run. In a policy stored before
/// the per-protocol switches, it is the value of all three.
pub legacy_protocols: LegacyProtocols,
/// IMAP's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub imap: Option<LegacyProtocols>,
/// POP3's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub pop3: Option<LegacyProtocols>,
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub manage_sieve: Option<LegacyProtocols>,
/// With `disabled`, also close SMTP submission (LP-3). The inbound
/// listener on port 25 is never closed, whatever this says.
pub close_submission: bool,
@@ -80,6 +109,9 @@ impl Default for ProtocolPolicy {
fn default() -> Self {
ProtocolPolicy {
legacy_protocols: LegacyProtocols::Enabled,
imap: None,
pop3: None,
manage_sieve: None,
close_submission: true,
saved_listeners: Vec::new(),
changed_at: None,
@@ -91,6 +123,9 @@ impl Default for ProtocolPolicy {
/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP.
pub const PROPERTIES: &[&str] = &[
"legacyProtocols",
"imap",
"pop3",
"manageSieve",
"closeSubmission",
"savedListeners",
"changedAt",
@@ -129,23 +164,137 @@ pub fn is_locked(protocol: &str) -> bool {
.any(|locked| locked.eq_ignore_ascii_case(protocol))
}
impl ProtocolPolicy {
/// Whether a listener of this protocol and these ports is one the switch
/// closes. A listener bound to port 25 is inbound whatever its name, and
/// any other SMTP listener counts as submission (LP-3).
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
if !self.legacy_protocols.is_disabled() {
return false;
/// The switch fields, by protocol name.
pub trait Switches {
/// The kill-all, which an unset per-protocol switch reads as.
fn all(&self) -> LegacyProtocols;
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>>;
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>>;
fn set_all_field(&mut self, value: LegacyProtocols);
/// One protocol's switch. `submission` follows the kill-all: it is off
/// only when all three are. Anything else has no switch and is on.
fn switch(&self, protocol: &str) -> LegacyProtocols {
if protocol == SUBMISSION {
return if self.all_off() {
LegacyProtocols::Disabled
} else {
LegacyProtocols::Enabled
};
}
match self.slot(protocol) {
Some(value) => value.unwrap_or(self.all()),
None => LegacyProtocols::Enabled,
}
}
/// Whether this protocol is off.
fn is_off(&self, protocol: &str) -> bool {
self.switch(protocol).is_disabled()
}
/// Whether all three protocols are off.
fn all_off(&self) -> bool {
SWITCHED.iter().all(|protocol| {
self.slot(protocol)
.and_then(|value| *value)
.unwrap_or(self.all())
.is_disabled()
})
}
/// Sets one protocol's switch; false if it has none.
fn set(&mut self, protocol: &str, value: LegacyProtocols) -> bool {
match self.slot_mut(protocol) {
Some(slot) => {
*slot = Some(value);
true
}
None => false,
}
}
/// The kill-all: all three at once.
fn set_all(&mut self, value: LegacyProtocols) {
for protocol in SWITCHED {
self.set(protocol, value);
}
self.set_all_field(value);
}
/// Writes out every switch and derives the kill-all from them, so what is
/// stored and shown never depends on how it was reached.
fn normalize(&mut self) {
let values: Vec<_> = SWITCHED.iter().map(|p| self.switch(p)).collect();
for (protocol, value) in SWITCHED.iter().zip(values) {
self.set(protocol, value);
}
let all = if self.all_off() {
LegacyProtocols::Disabled
} else {
LegacyProtocols::Enabled
};
self.set_all_field(all);
}
/// The protocols that are off.
fn off(&self) -> Vec<&'static str> {
SWITCHED
.iter()
.copied()
.filter(|p| self.is_off(p))
.collect()
}
}
macro_rules! switches {
($t:ty) => {
impl Switches for $t {
fn all(&self) -> LegacyProtocols {
self.legacy_protocols
}
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>> {
match protocol {
"imap" => Some(&self.imap),
"pop3" => Some(&self.pop3),
"manageSieve" => Some(&self.manage_sieve),
_ => None,
}
}
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>> {
match protocol {
"imap" => Some(&mut self.imap),
"pop3" => Some(&mut self.pop3),
"manageSieve" => Some(&mut self.manage_sieve),
_ => None,
}
}
fn set_all_field(&mut self, value: LegacyProtocols) {
self.legacy_protocols = value;
}
}
};
}
pub(crate) use switches;
switches!(ProtocolPolicy);
impl ProtocolPolicy {
/// Whether a listener of this protocol and these ports is one the
/// switches close. A listener bound to port 25 is inbound whatever its
/// name, and any other SMTP listener counts as submission (LP-3), closed
/// only with all three off and `closeSubmission`.
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
// The lock is checked first and answers for every caller, so no
// request phrasing can reach past it (LP-21).
if is_locked(protocol) {
return false;
}
if LEGACY_PROTOCOLS.contains(&protocol) {
return true;
return self.is_off(protocol);
}
protocol.eq_ignore_ascii_case("smtp")
&& self.all_off()
&& self.close_submission
&& !ports.contains(&INBOUND_SMTP_PORT)
}
@@ -258,7 +407,10 @@ mod tests {
"an unset closeSubmission reads as the default, true"
);
let json = serde_json::to_value(&policy).unwrap();
// As shown: normalized, every switch written out.
let mut shown = policy.clone();
shown.normalize();
let json = serde_json::to_value(&shown).unwrap();
for property in PROPERTIES {
assert!(json.get(property).is_some(), "{property}");
}
@@ -410,6 +562,72 @@ mod tests {
);
}
/// A policy stored before the per-protocol switches reads as all three
/// at its one value.
#[test]
fn an_old_policy_reads_as_all_three() {
let old: ProtocolPolicy =
serde_json::from_str(r#"{"legacyProtocols": "disabled"}"#).unwrap();
for p in SWITCHED {
assert!(old.is_off(p), "{p}");
}
assert!(old.all_off() && old.is_off(SUBMISSION));
let old: ProtocolPolicy =
serde_json::from_str(r#"{"legacyProtocols": "enabled"}"#).unwrap();
assert!(old.off().is_empty() && !old.is_off(SUBMISSION));
}
/// One protocol off closes only its listeners, and leaves sending alone.
#[test]
fn one_protocol_off() {
let mut policy = ProtocolPolicy::default();
policy.set("pop3", LegacyProtocols::Disabled);
policy.normalize();
assert!(policy.closes("pop3", &[995]));
assert!(!policy.closes("imap", &[993]));
assert!(!policy.closes("manageSieve", &[4190]));
assert!(!policy.is_off(SUBMISSION), "sending goes on");
assert_eq!(policy.legacy_protocols, LegacyProtocols::Enabled);
assert_eq!(policy.off(), vec!["pop3"]);
let json = serde_json::to_value(&policy).unwrap();
assert_eq!(json["pop3"], "disabled");
assert_eq!(json["imap"], "enabled");
}
/// Turning the three off one at a time is the kill-all, and the kill-all
/// back on turns all three on.
#[test]
fn the_kill_all_is_all_three() {
let mut policy = ProtocolPolicy::default();
for p in SWITCHED {
policy.set(p, LegacyProtocols::Disabled);
}
policy.normalize();
assert!(policy.legacy_protocols.is_disabled());
assert!(policy.is_off(SUBMISSION));
policy.set_all(LegacyProtocols::Enabled);
policy.normalize();
assert!(policy.off().is_empty());
assert!(!policy.legacy_protocols.is_disabled());
// The kill-all then one back on: no longer all off.
policy.set_all(LegacyProtocols::Disabled);
policy.set("imap", LegacyProtocols::Enabled);
policy.normalize();
assert!(!policy.legacy_protocols.is_disabled());
assert_eq!(policy.off(), vec!["pop3", "manageSieve"]);
}
/// Protocols without a switch are never off.
#[test]
fn unswitched_protocols_are_on() {
let policy = disabled();
for p in ["smtp", "http", "lmtp", "jmap"] {
assert!(!policy.is_off(p), "{p}");
}
}
/// A saved listener with no id is refused, naming the property.
#[test]
fn a_nameless_saved_listener_is_refused() {
@@ -9,12 +9,18 @@
//! the tenant id in the fork's subspace; a tenant with nothing stored has
//! legacy protocols on.
//!
//! A tenant has the same three switches as the server (IMAP, POP3,
//! ManageSieve) and the same kill-all; a protocol off server-wide is off for
//! every tenant whatever the tenant's own switch says.
//!
//! A tenant's switch closes no port -- other tenants share them (LP-13). It
//! refuses sign-in on the tenant's domains, and keeps client configuration
//! for them from offering what's refused. That is all it is: one fact per
//! tenant, easy to turn back, touching no listener, role or permission.
use crate::security::protocol_policy::{LegacyProtocols, ProtocolPolicy};
use crate::security::protocol_policy::{
LegacyProtocols, ProtocolPolicy, SUBMISSION, SWITCHED, Switches, switches,
};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
@@ -26,24 +32,92 @@ use trc::AddContext;
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct TenantProtocolPolicy {
/// The switch itself.
/// The kill-all, as on the server's policy.
pub legacy_protocols: LegacyProtocols,
/// IMAP's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub imap: Option<LegacyProtocols>,
/// POP3's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub pop3: Option<LegacyProtocols>,
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub manage_sieve: Option<LegacyProtocols>,
/// When it last changed, in milliseconds since the epoch.
pub changed_at: Option<u64>,
/// The account that last changed it.
pub changed_by: Option<String>,
}
/// Why a tenant's switch can't be set this way, if it can't (LP-9).
switches!(TenantProtocolPolicy);
/// Why a tenant's switches can't be set this way, if they can't (LP-9).
///
/// A tenant can always turn legacy protocols off for itself. It can turn
/// them back on only while the server has them on: server off means off for
/// everyone.
pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> {
(server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some(
"Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \
so they can't be turned back on for one organization.",
/// A tenant can always turn a protocol off for itself. It can turn one on
/// only while the server has it on: server off means off for everyone.
/// `turned_on` is what the request sets to `enabled`, by protocol name.
pub fn refusal(server: &ProtocolPolicy, turned_on: &[&str]) -> Option<String> {
let blocked: Vec<&str> = turned_on
.iter()
.copied()
.filter(|protocol| server.is_off(protocol))
.collect();
(!blocked.is_empty()).then(|| {
format!(
"{} off for the whole server (inbuxa:ProtocolPolicy), so {} can't be turned \
back on for one organization.",
names(&blocked),
if blocked.len() == 1 { "it" } else { "they" }
)
})
}
/// Protocol names as people read them: "IMAP and POP3 are", "POP3 is".
fn names(protocols: &[&str]) -> String {
let named: Vec<&str> = protocols
.iter()
.map(|p| match *p {
"imap" => "IMAP",
"pop3" => "POP3",
"manageSieve" => "ManageSieve",
other => other,
})
.collect();
let list = match named.as_slice() {
[one] => one.to_string(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
[] => String::new(),
};
format!("{list} {}", if named.len() == 1 { "is" } else { "are" })
}
/// Whose switch turns a protocol off, if any.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OffBy {
Server,
Tenant,
}
/// Whether this protocol is off for an account or domain, and by whose
/// switch: the server's first (LP-6), then the tenant's (LP-10). Submission
/// is off when all three protocols are, counting both switches together.
pub fn off_by(
server: &ProtocolPolicy,
tenant: Option<&TenantProtocolPolicy>,
protocol: &str,
) -> Option<OffBy> {
if server.is_off(protocol) {
return Some(OffBy::Server);
}
let tenant = tenant?;
let off = if protocol == SUBMISSION {
SWITCHED
.iter()
.all(|p| server.is_off(p) || tenant.is_off(p))
} else {
tenant.is_off(protocol)
};
off.then_some(OffBy::Tenant)
}
fn key(tenant_id: u32) -> ValueClass {
@@ -115,6 +189,15 @@ mod tests {
}
}
fn tenant_off(protocols: &[&str]) -> TenantProtocolPolicy {
let mut policy = TenantProtocolPolicy::default();
for p in protocols {
policy.set(p, LegacyProtocols::Disabled);
}
policy.normalize();
policy
}
#[test]
fn a_tenant_starts_with_legacy_protocols_on() {
assert!(
@@ -127,20 +210,59 @@ mod tests {
#[test]
fn a_tenant_can_always_turn_them_off() {
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None);
assert_eq!(refusal(&server(s), &[]), None);
}
}
#[test]
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
// LP-9, acceptance test 9.
assert_eq!(
refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled),
None
);
let why =
refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused");
assert_eq!(refusal(&server(LegacyProtocols::Enabled), SWITCHED), None);
let why = refusal(&server(LegacyProtocols::Disabled), SWITCHED).expect("refused");
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
assert!(
why.starts_with("IMAP, POP3 and ManageSieve are off"),
"{why}"
);
}
#[test]
fn a_tenant_can_turn_on_what_the_server_allows() {
// The server has only POP3 off: IMAP may come back, POP3 may not.
let mut s = ProtocolPolicy::default();
s.set("pop3", LegacyProtocols::Disabled);
assert_eq!(refusal(&s, &["imap"]), None);
let why = refusal(&s, &["imap", "pop3"]).expect("refused");
assert!(why.starts_with("POP3 is off"), "{why}");
}
#[test]
fn whose_switch_turns_a_protocol_off() {
let mut s = ProtocolPolicy::default();
s.set("pop3", LegacyProtocols::Disabled);
let t = tenant_off(&["imap"]);
assert_eq!(off_by(&s, Some(&t), "pop3"), Some(OffBy::Server));
assert_eq!(off_by(&s, Some(&t), "imap"), Some(OffBy::Tenant));
assert_eq!(off_by(&s, Some(&t), "manageSieve"), None);
assert_eq!(off_by(&s, None, "imap"), None);
// Sending goes on while any protocol is still allowed.
assert_eq!(off_by(&s, Some(&t), SUBMISSION), None);
// Between them, all three off: submission follows (LP-6, LP-10).
let t = tenant_off(&["imap", "manageSieve"]);
assert_eq!(off_by(&s, Some(&t), SUBMISSION), Some(OffBy::Tenant));
assert_eq!(
off_by(&server(LegacyProtocols::Disabled), None, SUBMISSION),
Some(OffBy::Server)
);
}
#[test]
fn an_old_tenant_policy_reads_as_all_three() {
let Json(old) = Json::deserialize(br#"{"legacyProtocols":"disabled"}"#).unwrap();
for p in SWITCHED {
assert!(old.is_off(p), "{p}");
}
assert!(old.is_off(SUBMISSION));
}
#[test]
@@ -158,6 +280,7 @@ mod tests {
legacy_protocols: LegacyProtocols::Disabled,
changed_at: Some(1),
changed_by: Some("b".into()),
..Default::default()
};
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
assert_eq!(back, policy);
+15
View File
@@ -123,6 +123,14 @@ pub struct EmailNote {
pub size: u64,
pub mailboxes: Vec<u32>,
pub keywords: Vec<String>,
/// LH-3: the ranges of the holds on the account when it was deleted.
/// Its received date is only known when it's archived, which decides
/// whether a hold keeps it after all.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub held_ranges: Vec<(Option<u64>, Option<u64>)>,
/// The undelete deadline for when no range covers it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub otherwise_until: Option<u64>,
}
/// What restore needs beyond the kept copy (UD-4, UD-8).
@@ -462,8 +470,15 @@ mod tests {
size: 3,
mailboxes: vec![1],
keywords: vec![],
held_ranges: vec![(Some(10), None)],
otherwise_until: Some(20),
};
let bytes = Json(&note).serialize().unwrap();
assert_eq!(Json::<EmailNote>::deserialize(&bytes).unwrap().0, note);
// A note written before legal holds still reads, as not held
let old = br#"{"archived_at":1,"archived_until":2,"size":3,"mailboxes":[1],"keywords":[]}"#;
let read = Json::<EmailNote>::deserialize(old).unwrap().0;
assert!(read.held_ranges.is_empty() && read.otherwise_until.is_none());
}
}
+35 -5
View File
@@ -12,9 +12,12 @@
//! is made if archiving is on, fixing the deadline then. When the data is
//! finally removed, a noted message becomes an archived item.
use crate::undelete::{
use crate::{
hold::Keeping,
undelete::{
data::{self, EmailNote, Extra},
records,
},
};
use registry::{
schema::structs::{ArchivedEmail, ArchivedItem},
@@ -26,10 +29,12 @@ use store::{
};
use types::{blob::BlobId, blob_hash::BlobHash};
/// Notes a deleted message, when archiving is on (`retention` seconds).
/// Notes a deleted message, when anything keeps it: undelete, or a legal
/// hold on the account (LH-4). A held note keeps it until it's archived,
/// when its received date says whether the hold's range covers it.
pub fn note(
batch: &mut BatchBuilder,
retention: u64,
keeping: &Keeping,
account_id: u32,
document_id: u32,
size: u64,
@@ -37,16 +42,23 @@ pub fn note(
keywords: Vec<String>,
) -> trc::Result<()> {
let archived_at = now();
// Held until the date is known; the undelete deadline otherwise
let otherwise_until = keeping.until(archived_at, false);
let Some(archived_until) = keeping.until(archived_at, keeping.is_held()) else {
return Ok(());
};
data::note_email(
batch,
account_id,
document_id,
&EmailNote {
archived_at,
archived_until: archived_at + retention,
archived_until,
size,
mailboxes,
keywords,
held_ranges: keeping.ranges.clone(),
otherwise_until: if keeping.is_held() { otherwise_until } else { None },
},
)
}
@@ -78,9 +90,27 @@ pub async fn archive(
document_id: u32,
summary: Summary<'_>,
) -> trc::Result<bool> {
let Some(note) = data::email_note(data, account_id, document_id).await? else {
let Some(mut note) = data::email_note(data, account_id, document_id).await? else {
return Ok(false);
};
// LH-3: a held note's range decides now that the date is known; outside
// it, undelete's deadline, or nothing kept at all
if !note.held_ranges.is_empty() {
let keeping = Keeping {
retention: None,
ranges: std::mem::take(&mut note.held_ranges),
};
if !keeping.covers(Some(summary.received_at)) {
match note.otherwise_until {
Some(until) => note.archived_until = until,
None => {
let mut batch = BatchBuilder::new();
data::clear_email_note(&mut batch, account_id, document_id);
return data.write(batch.build_all()).await.map(|_| false);
}
}
}
}
let item = ArchivedItem::Email(ArchivedEmail {
from: summary.from.unwrap_or_default().to_string(),
subject: summary.subject.unwrap_or_default().to_string(),
+33
View File
@@ -97,6 +97,39 @@ pub async fn take(
Ok(Some(note))
}
/// A note, left in place: for a held account it's cleared only once its item
/// is archived, so a failure leaves it for the retry (LH-5).
pub async fn peek(
data: &Store,
kind: Kind,
account_id: u32,
document_id: u32,
) -> trc::Result<Option<Note>> {
Ok(data
.get_value::<Json<Note>>(ValueKey::from(note_class(kind, account_id, document_id)))
.await?
.map(|Json(note)| note))
}
/// Removes a note once its item is archived or needn't be.
pub async fn clear(data: &Store, kind: Kind, account_id: u32, document_id: u32) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.clear(note_class(kind, account_id, document_id));
data.write(batch.build_all()).await.map(|_| ())
}
/// An event's start, for a hold's range (LH-3). None for a recurring event,
/// which may have an occurrence anywhere, so a hold keeps it whole.
pub fn event_start(note: &Note) -> Option<u64> {
let text = note.content.as_deref()?;
if property(text, "RRULE").is_some() || property(text, "RDATE").is_some() {
return None;
}
property(text, "DTSTART")
.and_then(|v| ical_time(&v))
.map(|t| t.max(0) as u64)
}
/// The value of the first line starting with `name` (as `NAME:` or
/// `NAME;params:`) in iCalendar or vCard text, unfolded.
fn property(text: &str, name: &str) -> Option<String> {
+76 -5
View File
@@ -89,6 +89,54 @@ pub async fn insert(
Ok(id)
}
/// Moves an archived item's deadline, and its kept copy's with it: frozen
/// by a hold (LH-6) or given a real one on release (LH-10). Returns the
/// item as it now is.
pub async fn set_deadline(
data: &Store,
registry: &RegistryStore,
id: Id,
item: &ArchivedItem,
until: u64,
) -> trc::Result<ArchivedItem> {
let account_id = item.account_id().document_id();
let blob_hash = item.blob_id().hash.clone();
let before = item.archived_until().timestamp() as u64;
let mut updated = item.clone();
updated.set_archived_until(registry::types::datetime::UTCDateTime::from_timestamp(until as i64));
// The new link first, so the kept copy is never unlinked in between
let mut batch = BatchBuilder::new();
batch
.with_account_id(account_id)
.set(
BlobOp::Link {
hash: blob_hash.clone(),
to: BlobLink::Temporary { until },
},
vec![],
);
if before != until {
batch.clear(BlobOp::Link {
hash: blob_hash,
to: BlobLink::Temporary { until: before },
});
}
data::log_change(&mut batch, account_id, registry.assign_id(), id, Change::Updated);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
let mut batch = BatchBuilder::new();
batch.set(item_class(id.id()), updated.to_pickled_vec());
registry
.store()
.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(updated)
}
/// Removes an archived item and releases its kept copy: on restore (UD-9),
/// on destroy (UD-12) and past its deadline (UD-13).
pub async fn remove(
@@ -184,15 +232,38 @@ pub async fn get(
}
}
/// Every archived item on the server, account by account. Items are
/// indexed by account only, so the registry's query without a filter,
/// which reads its all-ids index, finds none of them.
pub async fn all(data: &Store, registry: &RegistryStore) -> trc::Result<Vec<Id>> {
let mut accounts = registry
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
.await
.caused_by(trc::location!())?
.into_iter()
.map(|id| id.document_id())
.collect::<Vec<_>>();
// Deleted accounts still kept have archived items too
accounts.extend(data::kept_accounts(data).await?.into_iter().map(|(id, _)| id));
accounts.sort_unstable();
accounts.dedup();
let mut items = Vec::new();
for account_id in accounts {
items.extend(
registry
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::ArchivedItem).with_account(account_id))
.await
.caused_by(trc::location!())?,
);
}
Ok(items)
}
/// Removes every expired archived item on the server (UD-13), for the
/// scheduled clean-up.
pub async fn remove_expired(data: &Store, registry: &RegistryStore) -> trc::Result<usize> {
let mut removed = 0;
for id in registry
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::ArchivedItem))
.await
.caused_by(trc::location!())?
{
for id in all(data, registry).await? {
if let Some(item) = registry.object::<ArchivedItem>(id).await?
&& is_expired(&item)
{
+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,
+23 -7
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)
@@ -225,10 +243,8 @@ impl<T: SessionStream> SessionData<T> {
let mut fully_deleted = RoaringBitmap::new();
let mut thread_ids = RoaringBitmap::new();
// inbuxa: UD-1, UD-6a: the retention in force now
let retention = inbuxa_features::undelete::settings::retention(self.server.registry())
.await?
.items;
// inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
let keeping = self.server.keeping(account_id).await?;
self.server
.archives(
account_id,
@@ -252,10 +268,10 @@ impl<T: SessionStream> SessionData<T> {
fully_deleted.insert(document_id);
thread_ids.insert(metadata.inner.thread_id.to_native());
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
if let Some(retention) = retention {
if keeping.keeps_anything() {
inbuxa_features::undelete::email::note(
batch,
retention,
&keeping,
account_id,
document_id,
metadata.inner.size.to_native() as u64,
@@ -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
}
}
@@ -0,0 +1,211 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:HoldExport/get` and `/set` under `urn:inbuxa:jmap`: collecting
//! what a legal hold keeps as a ZIP (audit-hold-lock spec, LH-12). Creating
//! one starts it; it runs in the background, and `get` says when it's ready
//! and which blob to download. The set call's `reason` says why (AU-12).
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 HoldExport;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HoldExportProperty {
Id,
HoldId,
AccountIds,
Reason,
Status,
CreatedAt,
CreatedBy,
FinishedAt,
BlobId,
Size,
Items,
Sha256,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HoldExportValue {
Id(Id),
}
impl Property for HoldExportProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
match parent {
None => HoldExportProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
HoldExportProperty::Id => "id",
HoldExportProperty::HoldId => "holdId",
HoldExportProperty::AccountIds => "accountIds",
HoldExportProperty::Reason => "reason",
HoldExportProperty::Status => "status",
HoldExportProperty::CreatedAt => "createdAt",
HoldExportProperty::CreatedBy => "createdBy",
HoldExportProperty::FinishedAt => "finishedAt",
HoldExportProperty::BlobId => "blobId",
HoldExportProperty::Size => "size",
HoldExportProperty::Items => "items",
HoldExportProperty::Sha256 => "sha256",
HoldExportProperty::Error => "error",
}
.into()
}
}
impl HoldExportProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => HoldExportProperty::Id,
b"holdId" => HoldExportProperty::HoldId,
b"accountIds" => HoldExportProperty::AccountIds,
b"reason" => HoldExportProperty::Reason,
b"status" => HoldExportProperty::Status,
b"createdAt" => HoldExportProperty::CreatedAt,
b"createdBy" => HoldExportProperty::CreatedBy,
b"finishedAt" => HoldExportProperty::FinishedAt,
b"blobId" => HoldExportProperty::BlobId,
b"size" => HoldExportProperty::Size,
b"items" => HoldExportProperty::Items,
b"sha256" => HoldExportProperty::Sha256,
b"error" => HoldExportProperty::Error,
)
}
}
impl FromStr for HoldExportProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
HoldExportProperty::parse(s).ok_or(())
}
}
impl Element for HoldExportValue {
type Property = HoldExportProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(HoldExportProperty::Id) => Id::from_str(value).ok().map(HoldExportValue::Id),
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
HoldExportValue::Id(id) => id.to_string().into(),
}
}
}
/// The set call's own arguments: why (AU-12).
#[derive(Debug, Clone, Default)]
pub struct HoldExportSetArguments {
pub reason: Option<String>,
}
impl<'de> DeserializeArguments<'de> for HoldExportSetArguments {
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 HoldExport {
type Property = HoldExportProperty;
type Element = HoldExportValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = HoldExportSetArguments;
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = HoldExportProperty::Id;
}
impl From<Id> for HoldExportValue {
fn from(id: Id) -> Self {
HoldExportValue::Id(id)
}
}
impl JmapObjectId for HoldExportValue {
fn as_id(&self) -> Option<Id> {
match self {
HoldExportValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
HoldExportValue::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 = HoldExportValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for HoldExportProperty {
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,256 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:LegalHold/get` and `/set` under `urn:inbuxa:jmap`: legal holds
//! (audit-hold-lock spec, LH-1 to LH-14). Creating one places the hold;
//! updating renames it, widens its range or scope, or releases it with
//! `released: true`. There is no destroy: a released hold stays listed. The
//! set call's `reason` argument says why, for the audit log (AU-12);
//! creating takes it as a property too.
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 LegalHold;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum LegalHoldProperty {
Id,
/// The case name.
Name,
/// A matter or ticket number.
Reference,
Description,
/// `{server, accounts, groups, domains, tenants}`.
Scope,
/// The range's start, a UTC date, or null.
From,
/// The range's end, a UTC date, or null.
To,
/// Why it was placed (create only; later reasons are the audit log's).
Reason,
PlacedAt,
PlacedBy,
/// Set to true to release it.
Released,
ReleasedAt,
ReleasedBy,
ReleaseReason,
/// LH-9: accounts it covers now, deleted ones it keeps included.
AccountsCovered,
/// LH-9: archived items it keeps, and their size in bytes.
ItemsHeld,
SizeHeld,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum LegalHoldValue {
Id(Id),
}
impl Property for LegalHoldProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
// Keys inside the scope stay plain keys
match parent {
None => LegalHoldProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
LegalHoldProperty::Id => "id",
LegalHoldProperty::Name => "name",
LegalHoldProperty::Reference => "reference",
LegalHoldProperty::Description => "description",
LegalHoldProperty::Scope => "scope",
LegalHoldProperty::From => "from",
LegalHoldProperty::To => "to",
LegalHoldProperty::Reason => "reason",
LegalHoldProperty::PlacedAt => "placedAt",
LegalHoldProperty::PlacedBy => "placedBy",
LegalHoldProperty::Released => "released",
LegalHoldProperty::ReleasedAt => "releasedAt",
LegalHoldProperty::ReleasedBy => "releasedBy",
LegalHoldProperty::ReleaseReason => "releaseReason",
LegalHoldProperty::AccountsCovered => "accountsCovered",
LegalHoldProperty::ItemsHeld => "itemsHeld",
LegalHoldProperty::SizeHeld => "sizeHeld",
}
.into()
}
}
impl LegalHoldProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => LegalHoldProperty::Id,
b"name" => LegalHoldProperty::Name,
b"reference" => LegalHoldProperty::Reference,
b"description" => LegalHoldProperty::Description,
b"scope" => LegalHoldProperty::Scope,
b"from" => LegalHoldProperty::From,
b"to" => LegalHoldProperty::To,
b"reason" => LegalHoldProperty::Reason,
b"placedAt" => LegalHoldProperty::PlacedAt,
b"placedBy" => LegalHoldProperty::PlacedBy,
b"released" => LegalHoldProperty::Released,
b"releasedAt" => LegalHoldProperty::ReleasedAt,
b"releasedBy" => LegalHoldProperty::ReleasedBy,
b"releaseReason" => LegalHoldProperty::ReleaseReason,
b"accountsCovered" => LegalHoldProperty::AccountsCovered,
b"itemsHeld" => LegalHoldProperty::ItemsHeld,
b"sizeHeld" => LegalHoldProperty::SizeHeld,
)
}
}
impl FromStr for LegalHoldProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
LegalHoldProperty::parse(s).ok_or(())
}
}
impl Element for LegalHoldValue {
type Property = LegalHoldProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(LegalHoldProperty::Id) => Id::from_str(value).ok().map(LegalHoldValue::Id),
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
LegalHoldValue::Id(id) => id.to_string().into(),
}
}
}
/// The get call's own argument: only the active holds covering an account,
/// through any route (LH-2), for the console's Held badge (LH-14).
#[derive(Debug, Clone, Default)]
pub struct LegalHoldGetArguments {
pub covering_account: Option<Id>,
}
impl<'de> DeserializeArguments<'de> for LegalHoldGetArguments {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
if key == "coveringAccount" {
self.covering_account = map.next_value()?;
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(())
}
}
/// The set call's own arguments: why (AU-12).
#[derive(Debug, Clone, Default)]
pub struct LegalHoldSetArguments {
pub reason: Option<String>,
}
impl<'de> DeserializeArguments<'de> for LegalHoldSetArguments {
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 LegalHold {
type Property = LegalHoldProperty;
type Element = LegalHoldValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = LegalHoldGetArguments;
type SetArguments<'de> = LegalHoldSetArguments;
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = LegalHoldProperty::Id;
}
impl From<Id> for LegalHoldValue {
fn from(id: Id) -> Self {
LegalHoldValue::Id(id)
}
}
impl JmapObjectId for LegalHoldValue {
fn as_id(&self) -> Option<Id> {
match self {
LegalHoldValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
LegalHoldValue::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 = LegalHoldValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for LegalHoldProperty {
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
}
}
@@ -23,8 +23,13 @@ pub struct ProtocolPolicy;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ProtocolPolicyProperty {
Id,
/// The switch: `enabled` or `disabled`.
/// The kill-all: `enabled` or `disabled`; reads `disabled` when all
/// three protocols are off, and sets all three.
LegacyProtocols,
/// Each protocol's own switch: `enabled` or `disabled`.
Imap,
Pop3,
ManageSieve,
/// Whether submission closes with it. Forced false while SMTP is locked.
CloseSubmission,
/// Server-set: the listeners taken away, for LP-5.
@@ -56,6 +61,9 @@ impl Property for ProtocolPolicyProperty {
match self {
ProtocolPolicyProperty::Id => "id",
ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
ProtocolPolicyProperty::Imap => "imap",
ProtocolPolicyProperty::Pop3 => "pop3",
ProtocolPolicyProperty::ManageSieve => "manageSieve",
ProtocolPolicyProperty::CloseSubmission => "closeSubmission",
ProtocolPolicyProperty::SavedListeners => "savedListeners",
ProtocolPolicyProperty::ChangedAt => "changedAt",
@@ -73,6 +81,9 @@ impl ProtocolPolicyProperty {
hashify::tiny_map!(value.as_bytes(),
b"id" => ProtocolPolicyProperty::Id,
b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols,
b"imap" => ProtocolPolicyProperty::Imap,
b"pop3" => ProtocolPolicyProperty::Pop3,
b"manageSieve" => ProtocolPolicyProperty::ManageSieve,
b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission,
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
@@ -24,8 +24,13 @@ pub enum TenantProtocolPolicyProperty {
Id,
/// Server-set: the tenant this is the switch of.
TenantId,
/// The switch: `enabled` or `disabled`.
/// The kill-all: `enabled` or `disabled`; reads `disabled` when all
/// three protocols are off, and sets all three.
LegacyProtocols,
/// Each protocol's own switch: `enabled` or `disabled`.
Imap,
Pop3,
ManageSieve,
ChangedAt,
ChangedBy,
/// Server-set: who signed in over a legacy protocol in the last 30
@@ -48,6 +53,9 @@ impl Property for TenantProtocolPolicyProperty {
TenantProtocolPolicyProperty::Id => "id",
TenantProtocolPolicyProperty::TenantId => "tenantId",
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
TenantProtocolPolicyProperty::Imap => "imap",
TenantProtocolPolicyProperty::Pop3 => "pop3",
TenantProtocolPolicyProperty::ManageSieve => "manageSieve",
TenantProtocolPolicyProperty::ChangedAt => "changedAt",
TenantProtocolPolicyProperty::ChangedBy => "changedBy",
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
@@ -62,6 +70,9 @@ impl TenantProtocolPolicyProperty {
b"id" => TenantProtocolPolicyProperty::Id,
b"tenantId" => TenantProtocolPolicyProperty::TenantId,
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
b"imap" => TenantProtocolPolicyProperty::Imap,
b"pop3" => TenantProtocolPolicyProperty::Pop3,
b"manageSieve" => TenantProtocolPolicyProperty::ManageSieve,
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
+4
View File
@@ -21,7 +21,11 @@ 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_legal_hold; // inbuxa: legal hold
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
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
+15
View File
@@ -61,6 +61,21 @@ 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::LegalHold(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::HoldExport(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::ProtocolPolicy(response) => {
response.eval_jptr(path, &mut results)
}
@@ -46,6 +46,11 @@ 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::LegalHold(request) => request.resolve_references(self)?,
GetRequestMethod::HoldExport(request) => request.resolve_references(self)?,
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
GetRequestMethod::TenantProtocolPolicy(request) => {
request.resolve_references(self)?
@@ -96,6 +101,24 @@ 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::LegalHold(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::HoldExport(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 {
@@ -147,6 +169,11 @@ pub struct InbuxaAccountCapabilities {
/// (legacy-protocols spec, Interfaces; LP-19).
#[serde(rename(serialize = "legacyProtocols"))]
pub legacy_protocols: &'static str,
/// The legacy protocols still allowed for the principal, each the
/// stricter of the two switches: `imap`, `pop3`, `manageSieve`,
/// `submission` (legacy-protocols spec, one switch per protocol).
#[serde(rename(serialize = "legacyAllowed"))]
pub legacy_allowed: Vec<&'static str>,
/// Whether the principal may use "Explain this" now: it holds
/// `sysAiExplain`, is server-level, and a model resolves (ai-explain
/// spec, EX-1 to EX-4).
+50
View File
@@ -51,6 +51,16 @@ 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,
// inbuxa: legal hold
LegalHold,
HoldExport,
ProtocolPolicy,
TenantProtocolPolicy,
}
@@ -80,6 +90,13 @@ impl MethodObject {
MethodObject::DeletedAccount => Capability::Inbuxa,
MethodObject::AiLimits => Capability::Inbuxa,
MethodObject::Explanation => Capability::Inbuxa,
MethodObject::AuditEvent
| MethodObject::AuditSettings
| MethodObject::AuditExport
| MethodObject::AuditVerification
| MethodObject::AccountLock
| MethodObject::LegalHold
| MethodObject::HoldExport => Capability::Inbuxa,
MethodObject::ProtocolPolicy => Capability::Inbuxa,
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
}
@@ -260,6 +277,20 @@ 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::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
(MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get",
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/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 +425,18 @@ 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:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
"inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get),
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, 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 +495,13 @@ 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::LegalHold => "inbuxa:LegalHold",
MethodObject::HoldExport => "inbuxa:HoldExport",
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
MethodObject::Registry(obj) => {
+12
View File
@@ -116,6 +116,11 @@ 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>>),
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy(
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
@@ -144,6 +149,12 @@ 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>>),
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy(
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
@@ -175,6 +186,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)]
+88
View File
@@ -551,6 +551,94 @@ 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: legal hold exports
(MethodFunction::Get, MethodObject::HoldExport) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::HoldExport(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::HoldExport) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::HoldExport(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
// inbuxa: legal hold
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::LegalHold) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::LegalHold(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),
+81
View File
@@ -103,6 +103,11 @@ 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>),
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
TenantProtocolPolicy(
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
@@ -131,6 +136,12 @@ 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>>),
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy(
@@ -714,3 +725,73 @@ 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)))
}
}
// inbuxa: legal hold
impl<'x> From<GetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
ResponseMethod::Get(GetResponseMethod::LegalHold(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
ResponseMethod::Set(SetResponseMethod::LegalHold(Box::new(value)))
}
}
// inbuxa: legal hold exports
impl<'x> From<GetResponse<crate::object::inbuxa_hold_export::HoldExport>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_hold_export::HoldExport>) -> Self {
ResponseMethod::Get(GetResponseMethod::HoldExport(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_hold_export::HoldExport>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_hold_export::HoldExport>) -> Self {
ResponseMethod::Set(SetResponseMethod::HoldExport(Box::new(value)))
}
}
+1
View File
@@ -39,6 +39,7 @@ base64 = "0.23"
p256 = { version = "0.13", features = ["ecdh"] }
sha1 = "0.11"
sha2 = "0.11"
zip = "8.6" # inbuxa: legal hold exports (LH-12)
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]}
tokio-tungstenite = "0.30"
tungstenite = "0.30"
+77
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,14 @@ 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,
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet,
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
// 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 +201,53 @@ 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,
),
// inbuxa: legal hold (LH-13); holds are never destroyed,
// and the handler refuses a destroy outright
SetRequestMethod::LegalHold(s) => validate_set(
s,
self,
Permission::SysLegalHoldCreate,
Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldUpdate,
),
// inbuxa: LH-12, exporting held data
SetRequestMethod::HoldExport(s) => validate_set(
s,
self,
Permission::SysLegalHoldExport,
Permission::SysLegalHoldExport,
Permission::SysLegalHoldExport,
),
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 +383,13 @@ impl JmapAuthorization for AccessToken {
| MethodObject::DeletedAccount
| MethodObject::AiLimits
| MethodObject::Explanation
| MethodObject::AuditEvent
| MethodObject::AuditSettings
| MethodObject::AuditExport
| MethodObject::AuditVerification
| MethodObject::AccountLock
| MethodObject::LegalHold
| MethodObject::HoldExport
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
// inbuxa: x:MaskedEmail/changes reads what /get reads
@@ -371,6 +446,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()
}
},
+286 -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,24 @@ 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::LegalHold(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::HoldExport(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::Explanation(set_response) => {
set_response.update_created_ids(&mut response);
}
@@ -338,13 +396,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 +445,36 @@ 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: legal hold (LH-1)
// inbuxa: legal hold exports (LH-12)
GetRequestMethod::HoldExport(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::hold_export_api::get(self, *req).await?.into()
}
GetRequestMethod::LegalHold(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::legal_hold::get(self, *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 +587,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 +651,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 +693,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 +721,163 @@ 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()
}
// inbuxa: legal hold (LH-1), each change recorded with its
// reason (AU-12)
// inbuxa: legal hold exports, recorded with their reason
// (AU-1.9, AU-12)
SetRequestMethod::HoldExport(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::hold_export_api::set(self, access_token, req)),
)
.await?
.into()
}
SetRequestMethod::LegalHold(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::legal_hold::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 +891,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 +985,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 +1173,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,
+30 -4
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;
@@ -66,12 +66,16 @@ impl SessionHandler for Server {
Capability::Inbuxa,
Capabilities::Empty(EmptyCapabilities::default()),
);
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter
let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? {
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter,
// per protocol. `legacyProtocols` stays for older webmail builds:
// `disabled` only when every protocol is off.
let legacy_off = self.legacy_off_for_account(access_token).await?;
let legacy_protocols = if legacy_off.all() {
"disabled"
} else {
"enabled"
};
let legacy_allowed = legacy_off.allowed();
// inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
&& access_token.tenant_id().is_none()
@@ -81,6 +85,7 @@ impl SessionHandler for Server {
Capabilities::Inbuxa(InbuxaAccountCapabilities {
logo,
legacy_protocols,
legacy_allowed,
ai_explain,
}),
);
@@ -116,11 +121,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 +142,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) {
+7
View File
@@ -419,6 +419,13 @@ impl IntermediateChangesResponse {
| MethodObject::DeletedAccount
| MethodObject::AiLimits
| MethodObject::Explanation
| MethodObject::AuditEvent
| MethodObject::AuditSettings
| MethodObject::AuditExport
| MethodObject::AuditVerification
| MethodObject::AccountLock
| MethodObject::LegalHold
| MethodObject::HoldExport
| 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()
+9 -4
View File
@@ -226,9 +226,14 @@ impl FileNodeCopy for Server {
}
};
if let Err(err) =
validate_file_node_hierarchy(None, &file_node, is_shared, &cache, &created_folders)
{
// inbuxa: AL-7: a writing delegate may add at the top
if let Err(err) = validate_file_node_hierarchy(
None,
&file_node,
is_shared && !access_token.delegate_may_write(account_id),
&cache,
&created_folders,
) {
response.not_created.append(id, err);
continue 'create;
}
@@ -362,7 +367,7 @@ impl FileNodeCopy for Server {
);
continue 'create;
}
} else if is_shared {
} else if is_shared && !access_token.delegate_may_write(account_id) {
response.not_created.append(
id,
SetError::forbidden()
+9 -3
View File
@@ -149,9 +149,15 @@ impl FileNodeSet for Server {
};
// Validate hierarchy
if let Err(err) =
validate_file_node_hierarchy(None, &file_node, is_shared, &cache, &created_folders)
{
// inbuxa: AL-7: a writing delegate may add at the top of a
// locked account, which may hold no folders at all
if let Err(err) = validate_file_node_hierarchy(
None,
&file_node,
is_shared && !access_token.delegate_may_write(account_id),
&cache,
&created_folders,
) {
response.not_created.append(id, err);
continue 'create;
}
+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)
}
+491
View File
@@ -0,0 +1,491 @@
/*
* 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 mut described = diff::describe(&after);
described.name = full_name(server, object, &after, described.name).await;
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 mut described = before.as_ref().map(diff::describe).unwrap_or_default();
if let Some(before) = &before {
described.name = full_name(server, object, before, described.name).await;
}
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 mut described = before.as_ref().map(diff::describe).unwrap_or_default();
if let Some(before) = &before {
described.name = full_name(server, object, before, described.name).await;
}
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.
/// An account's or a mailing list's name is only its local part, and two
/// domains' "leslie" would read alike: records name it by its full address.
async fn full_name(server: &Server, object: &str, value: &Value, name: Option<String>) -> Option<String> {
let name = name?;
if !matches!(object, "x:Account" | "x:MailingList") || name.contains('@') {
return Some(name);
}
let domain = value
.get("domainId")
.and_then(Value::as_str)
.and_then(|id| <Id as std::str::FromStr>::from_str(id).ok());
match domain {
Some(domain) => match server.domain_by_id(domain.document_id()).await {
Ok(Some(domain)) => match domain.names.first() {
Some(domain) => Some(format!("{name}@{domain}")),
None => Some(name),
},
_ => Some(name),
},
None => Some(name),
}
}
async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> {
use inbuxa_features::{
ai::limits,
audit::log,
security::{self, protocol_policy::Switches},
};
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()),
// Normalized, so every switch reads before and after, even from a
// policy stored before the per-protocol switches
"inbuxa:ProtocolPolicy" => {
security::protocol_policy::get(data)
.await
.ok()
.and_then(|mut policy| {
policy.normalize();
serde_json::to_value(policy).ok()
})
}
// LH-1: a hold as the API shows it, so a change reads before/after
"inbuxa:LegalHold" => match id {
MaybeInvalid::Value(id) => {
let hold = inbuxa_features::hold::get(data, u32::try_from(id.id()).ok()?)
.await
.ok()??;
let ids = |list: &[u32]| list.iter().map(|id| Id::from(*id).to_string()).collect::<Vec<_>>();
let date = |at: Option<u64>| {
at.map(|at| jmap_proto::types::date::UTCDate::from_timestamp(at as i64).to_string())
};
Some(serde_json::json!({
"name": hold.name,
"reference": hold.reference,
"description": hold.description,
"scope": {
"server": hold.scope.server,
"accounts": ids(&hold.scope.accounts),
"groups": ids(&hold.scope.groups),
"domains": ids(&hold.scope.domains),
"tenants": ids(&hold.scope.tenants),
},
"from": date(hold.from),
"to": date(hold.to),
"released": !hold.is_active(),
}))
}
MaybeInvalid::Invalid(_) => None,
},
"inbuxa:TenantProtocolPolicy" => match id {
MaybeInvalid::Value(id) => {
security::tenant_protocol_policy::get(data, id.document_id())
.await
.ok()
.and_then(|mut policy| {
policy.normalize();
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);
}
}
+33 -5
View File
@@ -124,13 +124,29 @@ pub async fn reserved(
/// of upstream's immediate destruction. Returns the other accounts whose
/// access changed, or `None` when nothing is kept.
pub async fn keep(server: &Server, id: Id, account: &Account) -> trc::Result<Option<Vec<u32>>> {
let Some(period) = retention(server.registry()).await?.accounts else {
return Ok(None);
};
let account_id = id.document_id();
let deleted_at = now();
let kept_until = deleted_at + period;
let inner = ObjectInner::Account(account.clone());
// inbuxa: LH-8: a held account's data stays, with no expiry, whether or
// not undelete keeps accounts; its holds name it from now on
let member = inbuxa_features::hold::Member::of(account_id, &inner);
let held = match &member {
Some(member) => {
!inbuxa_features::hold::covering(server.store(), member)
.await?
.is_empty()
}
None => false,
};
let period = retention(server.registry()).await?.accounts;
let deleted_at = now();
let kept_until = match (held, period) {
(true, _) => inbuxa_features::hold::HELD_UNTIL,
(false, Some(period)) => deleted_at + period,
(false, None) => return Ok(None),
};
if held && let Some(member) = &member {
inbuxa_features::hold::pin_account(server.store(), member).await?;
}
let addresses = addresses_of(server, &inner)
.await?
.into_iter()
@@ -324,6 +340,18 @@ pub async fn set(
for id in will_destroy {
match data::kept_account(data, id.document_id()).await? {
// inbuxa: LH-8: a held account's data can't be destroyed
Some(kept)
if may_reach(access_token, &kept, Permission::SysAccountDestroy)
&& (inbuxa_features::hold::is_held_until(kept.kept_until)
|| server.is_kept_held(id.document_id(), &kept).await?) =>
{
response.not_destroyed.append(
id,
SetError::forbidden()
.with_description("A legal hold applies to this account, so its data stays."),
);
}
Some(kept) if may_reach(access_token, &kept, Permission::SysAccountDestroy) => {
destroy_now(server, id, &kept).await?;
response.destroyed.push(id);
+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> {
+535
View File
@@ -0,0 +1,535 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Collecting what a legal hold keeps, as a ZIP (audit-hold-lock spec,
//! LH-12). For each account the hold covers (or those asked for): its mail,
//! calendars, contacts and files, and the deleted items the hold keeps, each
//! with its SHA-256 in `manifest.csv`, and the manifest's own hash beside
//! it. The hold's date range applies as it does to what's kept (LH-3).
//! Anything the hold covers that can't be read goes in `exceptions.csv`
//! with the reason, never silently left out; the file is always there, so an
//! empty one says nothing was missed.
use common::{Server, hold::kept_member};
use email::{
cache::MessageCacheFetch,
message::metadata::{MESSAGE_RECEIVED_MASK, MessageMetadata},
};
use groupware::{cache::GroupwareCache, calendar::CalendarEvent, contact::ContactCard, file::FileNode};
use inbuxa_features::{
hold::{Hold, Keeping, is_held_until},
undelete::records,
};
use registry::schema::{prelude::ObjectType, structs::ArchivedItem};
use sha2::{Digest, Sha256};
use std::io::{Cursor, Write};
use store::{
ValueKey,
registry::RegistryQuery,
write::{AlignedBytes, Archive},
};
use trc::AddContext;
use types::{
collection::{Collection, SyncCollection},
field::EmailField,
id::Id,
};
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
/// The largest ZIP built in memory. A bigger collection is refused with a
/// clear error rather than taking the node down; export fewer accounts.
pub const MAX_EXPORT: u64 = 2 * 1024 * 1024 * 1024;
/// One line of `manifest.csv`.
struct Entry {
path: String,
account: String,
kind: &'static str,
folder: String,
date: Option<i64>,
archived: bool,
size: usize,
sha256: String,
}
/// One line of `exceptions.csv`: an item the hold covers that couldn't be
/// read, where it would have gone and why.
struct Missing {
path: String,
account: String,
kind: &'static str,
folder: String,
date: Option<i64>,
archived: bool,
reason: &'static str,
}
const NO_BLOB: &str = "content not found in the blob store";
const NO_RECORD: &str = "stored record not found";
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn csv(field: &str) -> String {
if field.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
/// A path segment that's safe in a ZIP: no separators, no leading dots.
fn segment(name: &str) -> String {
let cleaned: String = name
.chars()
.map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c })
.collect();
let trimmed = cleaned.trim_start_matches('.').trim();
if trimmed.is_empty() { "_".into() } else { trimmed.chars().take(120).collect() }
}
fn date_text(at: Option<i64>) -> String {
at.map(|at| jmap_proto::types::date::UTCDate::from_timestamp(at).to_string())
.unwrap_or_default()
}
struct Builder {
zip: ZipWriter<Cursor<Vec<u8>>>,
entries: Vec<Entry>,
missing: Vec<Missing>,
written: u64,
}
impl Builder {
fn new() -> Self {
Builder {
zip: ZipWriter::new(Cursor::new(Vec::new())),
entries: Vec::new(),
missing: Vec::new(),
written: 0,
}
}
#[allow(clippy::too_many_arguments)]
fn add(
&mut self,
path: String,
bytes: &[u8],
account: &str,
kind: &'static str,
folder: &str,
date: Option<i64>,
archived: bool,
) -> trc::Result<()> {
self.written += bytes.len() as u64;
if self.written > MAX_EXPORT {
return Err(trc::StoreEvent::UnexpectedError
.into_err()
.details("The collection is larger than one export can hold (2 GB). Export fewer accounts at a time."));
}
// Unique within the ZIP, however names collide
let mut name = path.clone();
let mut n = 1;
while self.entries.iter().any(|e| e.path == name) {
n += 1;
name = match path.rsplit_once('.') {
Some((stem, ext)) if !stem.ends_with('/') => format!("{stem} ({n}).{ext}"),
_ => format!("{path} ({n})"),
};
}
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
self.zip
.start_file(name.as_str(), options)
.and_then(|_| self.zip.write_all(bytes).map_err(Into::into))
.map_err(|err| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to write the export")
.reason(err)
})?;
self.entries.push(Entry {
path: name,
account: account.to_string(),
kind,
folder: folder.to_string(),
date,
archived,
size: bytes.len(),
sha256: hex(&Sha256::digest(bytes)),
});
Ok(())
}
/// Records an item the hold covers that couldn't be read.
#[allow(clippy::too_many_arguments)]
fn missing(
&mut self,
path: String,
account: &str,
kind: &'static str,
folder: &str,
date: Option<i64>,
archived: bool,
reason: &'static str,
) {
self.missing.push(Missing {
path,
account: account.to_string(),
kind,
folder: folder.to_string(),
date,
archived,
reason,
});
}
/// Closes the ZIP with its manifest, the exceptions and both hashes.
/// Returns the bytes and how many items went in.
fn finish(mut self) -> trc::Result<(Vec<u8>, usize)> {
let mut manifest = String::from("path,account,kind,folder,date,archived,size,sha256\n");
for e in &self.entries {
manifest.push_str(&format!(
"{},{},{},{},{},{},{},{}\n",
csv(&e.path),
csv(&e.account),
e.kind,
csv(&e.folder),
date_text(e.date),
e.archived,
e.size,
e.sha256
));
}
let mut exceptions = String::from("path,account,kind,folder,date,archived,reason\n");
for m in &self.missing {
exceptions.push_str(&format!(
"{},{},{},{},{},{},{}\n",
csv(&m.path),
csv(&m.account),
m.kind,
csv(&m.folder),
date_text(m.date),
m.archived,
csv(m.reason)
));
}
let manifest_hash = hex(&Sha256::digest(manifest.as_bytes()));
let exceptions_hash = hex(&Sha256::digest(exceptions.as_bytes()));
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
let fail = |err: zip::result::ZipError| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to write the export")
.reason(err)
};
self.zip.start_file("manifest.csv", options).map_err(fail)?;
self.zip.write_all(manifest.as_bytes()).map_err(|e| fail(e.into()))?;
self.zip.start_file("exceptions.csv", options).map_err(fail)?;
self.zip.write_all(exceptions.as_bytes()).map_err(|e| fail(e.into()))?;
self.zip.start_file("manifest.sha256", options).map_err(fail)?;
self.zip
.write_all(format!("{manifest_hash} manifest.csv\n{exceptions_hash} exceptions.csv\n").as_bytes())
.map_err(|e| fail(e.into()))?;
let items = self.entries.len();
let bytes = self.zip.finish().map_err(fail)?.into_inner();
Ok((bytes, items))
}
}
/// The accounts to collect: those asked for that the hold covers, or every
/// account it covers, deleted ones it keeps included.
async fn accounts(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<Vec<u32>> {
let mut covered = Vec::new();
for id in server
.registry()
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
.await
.caused_by(trc::location!())?
{
let account_id = id.document_id();
if let Some(member) = server.member_of(account_id).await
&& hold.scope.covers(&member)
{
covered.push(account_id);
}
}
for (account_id, kept) in inbuxa_features::undelete::data::kept_accounts(server.store()).await? {
if hold.scope.covers(&kept_member(account_id, &kept)) {
covered.push(account_id);
}
}
covered.sort_unstable();
covered.dedup();
if !asked.is_empty() {
covered.retain(|id| asked.contains(id));
}
Ok(covered)
}
async fn blob(server: &Server, hash: &[u8]) -> trc::Result<Option<Vec<u8>>> {
server.blob_store().get_blob(hash, 0..usize::MAX).await
}
/// Collects `accounts` under `hold` into a ZIP. Returns its bytes and item
/// count.
pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(Vec<u8>, usize)> {
let keeping = Keeping::new(None, std::slice::from_ref(hold));
let data = server.store();
let mut out = Builder::new();
for account_id in accounts(server, hold, asked).await? {
let address = server.audit_account_name(account_id).await;
let base = format!("{}/", segment(&address));
let live = server.account(account_id).await.is_ok();
if live {
// Mail, by the folder it's in
let cache = server
.get_cached_messages(account_id)
.await
.caused_by(trc::location!())?;
for message in cache.emails.items.iter() {
let mail_path = |folder: &str| {
format!(
"{base}mail/{}/{}.eml",
folder.split('/').map(segment).collect::<Vec<_>>().join("/"),
Id::from(message.document_id)
)
};
let folder = message
.mailboxes
.first()
.and_then(|m| cache.mailboxes.items.iter().find(|b| b.document_id == m.mailbox_id))
.map(|b| b.path.clone())
.unwrap_or_default();
let Some(metadata_) = data
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
account_id,
Collection::Email,
message.document_id,
EmailField::Metadata,
))
.await?
else {
// No date to check against the hold's range, so it's listed
out.missing(mail_path(&folder), &address, "email", &folder, None, false, NO_RECORD);
continue;
};
let metadata = metadata_
.unarchive::<MessageMetadata>()
.caused_by(trc::location!())?;
let received = metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK;
if !keeping.covers(Some(received)) {
continue;
}
let hash = types::blob_hash::BlobHash::from(&metadata.blob_hash);
match blob(server, hash.as_slice()).await? {
Some(bytes) => {
out.add(mail_path(&folder), &bytes, &address, "email", &folder, Some(received as i64), false)?
}
None => out.missing(
mail_path(&folder),
&address,
"email",
&folder,
Some(received as i64),
false,
NO_BLOB,
),
}
}
// Calendars, contacts and files, by their DAV paths
for (sync, kind) in [
(SyncCollection::Calendar, "event"),
(SyncCollection::AddressBook, "contact"),
(SyncCollection::FileNode, "file"),
] {
let resources = server
.fetch_dav_resources(account_id, account_id, sync)
.await
.caused_by(trc::location!())?;
for path in resources.paths.iter() {
let Some(resource) = resources.resources.get(path.resource_idx) else {
continue;
};
let folder = path.path.rsplit_once('/').map(|(f, _)| f).unwrap_or_default();
let zip_path = |ext: Option<&str>| {
let mut p = format!(
"{base}{}/{}",
match kind {
"event" => "calendar",
"contact" => "contacts",
_ => "files",
},
path.path.split('/').map(segment).collect::<Vec<_>>().join("/")
);
if let Some(ext) = ext
&& !p.ends_with(ext)
{
p.push_str(ext);
}
p
};
use common::DavResourceMetadata as M;
match &resource.data {
M::CalendarEvent { start, .. } => {
if !keeping.covers_event(Some((*start).max(0) as u64)) {
continue;
}
let Some(event_) = data
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::CalendarEvent,
resource.document_id,
))
.await?
else {
out.missing(zip_path(Some(".ics")), &address, kind, folder, Some(*start), false, NO_RECORD);
continue;
};
let event = event_.unarchive::<CalendarEvent>().caused_by(trc::location!())?;
let text = event.data.event.to_string();
out.add(zip_path(Some(".ics")), text.as_bytes(), &address, kind, folder, Some(*start), false)?;
}
M::ContactCard { .. } => {
let Some(card_) = data
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::ContactCard,
resource.document_id,
))
.await?
else {
out.missing(zip_path(Some(".vcf")), &address, kind, folder, None, false, NO_RECORD);
continue;
};
let card = card_.unarchive::<ContactCard>().caused_by(trc::location!())?;
let mut text = String::with_capacity(256);
let _ = card.card.write_to(&mut text, server.core.groupware.vcard_version);
out.add(zip_path(Some(".vcf")), text.as_bytes(), &address, kind, folder, None, false)?;
}
M::File { size: Some(_), .. } => {
let Some(file_) = data
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::FileNode,
resource.document_id,
))
.await?
else {
out.missing(zip_path(None), &address, kind, folder, None, false, NO_RECORD);
continue;
};
let file = file_.unarchive::<FileNode>().caused_by(trc::location!())?;
let Some(props) = file.file.as_ref() else {
out.missing(zip_path(None), &address, kind, folder, None, false, NO_RECORD);
continue;
};
let hash = types::blob_hash::BlobHash::from(&props.blob_hash);
match blob(server, hash.as_slice()).await? {
Some(bytes) => out.add(zip_path(None), &bytes, &address, kind, folder, None, false)?,
None => out.missing(zip_path(None), &address, kind, folder, None, false, NO_BLOB),
}
}
_ => {}
}
}
}
}
// What the hold keeps of what was deleted
for (id, item) in records::of_account(data, server.registry(), account_id).await? {
if !is_held_until(item.archived_until().timestamp().max(0) as u64) {
continue;
}
let (kind, ext, date) = match &item {
ArchivedItem::Email(e) => ("email", ".eml", Some(e.received_at.timestamp())),
ArchivedItem::CalendarEvent(e) => ("event", ".ics", e.start_time.map(|t| t.timestamp())),
ArchivedItem::ContactCard(_) => ("contact", ".vcf", None),
ArchivedItem::FileNode(_) => ("file", "", None),
ArchivedItem::SieveScript(_) => ("sieve", ".sieve", None),
};
// Kept by this hold, not only by another one over the same account
let in_range = match kind {
"event" => keeping.covers_event(date.map(|d| d.max(0) as u64)),
_ => keeping.covers(date.map(|d| d.max(0) as u64)),
};
if !in_range {
continue;
}
let name = match &item {
ArchivedItem::FileNode(f) => segment(&f.name),
ArchivedItem::SieveScript(s) => format!("{}{ext}", segment(&s.name)),
_ => format!("{id}{ext}"),
};
let path = format!("{base}archived/{kind}/{name}");
match blob(server, item.blob_id().hash.as_slice()).await? {
Some(bytes) => out.add(path, &bytes, &address, kind, "", date, true)?,
None => out.missing(path, &address, kind, "", date, true, NO_BLOB),
}
}
}
out.finish()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_are_safe_in_a_zip() {
assert_eq!(segment("../etc/passwd"), "_etc_passwd");
assert_eq!(segment(" "), "_");
assert_eq!(segment("Q3 report.pdf"), "Q3 report.pdf");
assert_eq!(csv("a,b"), "\"a,b\"");
assert_eq!(csv("say \"hi\""), "\"say \"\"hi\"\"\"");
}
#[test]
fn a_zip_carries_its_manifest_and_its_hash() {
let mut b = Builder::new();
b.add("[email protected]/mail/INBOX/b.eml".into(), b"Subject: x\r\n\r\ny", "[email protected]", "email", "INBOX", Some(0), false)
.unwrap();
b.add("[email protected]/mail/INBOX/b.eml".into(), b"other", "[email protected]", "email", "INBOX", None, true)
.unwrap();
let (bytes, items) = b.finish().unwrap();
assert_eq!(items, 2);
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut manifest = String::new();
std::io::Read::read_to_string(&mut zip.by_name("manifest.csv").unwrap(), &mut manifest).unwrap();
assert!(manifest.contains("[email protected]/mail/INBOX/b (2).eml"), "{manifest}");
let mut hash = String::new();
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
assert!(hash.starts_with(&hex(&Sha256::digest(manifest.as_bytes()))));
// Nothing missed, and the file says so
let mut exceptions = String::new();
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
assert_eq!(exceptions, "path,account,kind,folder,date,archived,reason\n");
}
#[test]
fn what_cant_be_read_is_listed_not_dropped() {
let mut b = Builder::new();
b.add("[email protected]/mail/INBOX/1.eml".into(), b"Subject: x\r\n\r\ny", "[email protected]", "email", "INBOX", Some(0), false)
.unwrap();
b.missing("[email protected]/mail/INBOX/2.eml".into(), "[email protected]", "email", "INBOX", Some(0), false, NO_BLOB);
let (bytes, items) = b.finish().unwrap();
assert_eq!(items, 1, "a missing item isn't counted as collected");
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
assert!(zip.by_name("[email protected]/mail/INBOX/2.eml").is_err());
let mut exceptions = String::new();
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
assert!(
exceptions.contains("[email protected]/mail/INBOX/2.eml,[email protected],email,INBOX,") && exceptions.contains(NO_BLOB),
"{exceptions}"
);
let mut hash = String::new();
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
assert!(hash.contains(&format!("{} exceptions.csv", hex(&Sha256::digest(exceptions.as_bytes())))));
}
}
+294
View File
@@ -0,0 +1,294 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:HoldExport` (audit-hold-lock spec, LH-12): starting a collection
//! of what a hold keeps, and seeing how it went. The ZIP is the creator's
//! blob, to download once it's ready. Creating one is audited, with its
//! reason (AU-1.9, AU-12).
use common::{Server, auth::AccessToken};
use inbuxa_features::hold::{self, Export, ExportStatus};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_hold_export::{
HoldExport, HoldExportProperty as P, HoldExportSetArguments, HoldExportValue,
},
types::date::UTCDate,
};
use jmap_tools::{Key, Map, Value};
use sha2::{Digest, Sha256};
use std::str::FromStr;
use store::write::now;
use types::id::Id;
type LValue = Value<'static, P, HoldExportValue>;
const ALL: &[P] = &[
P::Id,
P::HoldId,
P::AccountIds,
P::Reason,
P::Status,
P::CreatedAt,
P::CreatedBy,
P::FinishedAt,
P::BlobId,
P::Size,
P::Items,
P::Sha256,
P::Error,
];
fn date(seconds: u64) -> LValue {
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
}
fn opt_text(value: &Option<String>) -> LValue {
value.as_ref().map_or(Value::Null, |v| Value::Str(v.clone().into()))
}
fn to_value(export: &Export, properties: &[P]) -> LValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(HoldExportValue::Id(Id::from(export.id))),
P::HoldId => Value::Str(Id::from(export.hold_id).to_string().into()),
P::AccountIds => Value::Array(
export
.accounts
.iter()
.map(|id| Value::Str(Id::from(*id).to_string().into()))
.collect(),
),
P::Reason => Value::Str(export.reason.clone().into()),
P::Status => Value::Str(
match export.status {
ExportStatus::Running => "running",
ExportStatus::Ready => "ready",
ExportStatus::Failed => "failed",
}
.into(),
),
P::CreatedAt => date(export.created_at),
P::CreatedBy => Value::Str(export.created_by.clone().into()),
P::FinishedAt => export.finished_at.map_or(Value::Null, date),
P::BlobId => opt_text(&export.blob_id),
P::Size => Value::Number(export.size.into()),
P::Items => Value::Number(export.items.into()),
P::Sha256 => opt_text(&export.sha256),
P::Error => opt_text(&export.error),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:HoldExport/get`: every export, newest first.
pub async fn get(
server: &Server,
mut request: GetRequest<HoldExport>,
) -> trc::Result<GetResponse<HoldExport>> {
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 mut exports = hold::exports(server.store()).await?;
exports.reverse();
match ids {
None => response
.list
.extend(exports.iter().map(|e| to_value(e, &properties))),
Some(ids) => {
for id in ids {
match exports.iter().find(|e| u64::from(e.id) == id.id()) {
Some(export) => response.list.push(to_value(export, &properties)),
None => response.push_not_found(id),
}
}
}
}
Ok(response)
}
fn invalid(property: P, why: &str) -> SetError<P> {
SetError::invalid_properties()
.with_property(property)
.with_description(why.to_string())
}
/// `inbuxa:HoldExport/set`: create starts an export; nothing else is
/// allowed. The request layer records it with its reason.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, HoldExport>,
) -> trc::Result<SetResponse<HoldExport>> {
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
let arguments: HoldExportSetArguments = std::mem::take(&mut request.arguments);
let data = server.store();
let actor = server.audit_actor(access_token).await;
'create: for (client_id, value) in request.unwrap_create() {
let mut hold_id = None;
let mut accounts = Vec::new();
let mut reason = arguments.reason.clone();
for (key, value) in value.into_expanded_object() {
match (&key, &value) {
(Key::Property(P::HoldId), Value::Str(id)) => {
hold_id = Id::from_str(id).ok().and_then(|id| u32::try_from(id.id()).ok())
}
(Key::Property(P::AccountIds), Value::Array(items)) => {
for item in items {
match item {
Value::Str(id) => match Id::from_str(id) {
Ok(id) => accounts.push(id.document_id()),
Err(_) => {
response.not_created.append(
client_id,
invalid(P::AccountIds, "accountIds must be account ids."),
);
continue 'create;
}
},
_ => {
response.not_created.append(
client_id,
invalid(P::AccountIds, "accountIds must be account ids."),
);
continue 'create;
}
}
}
}
(Key::Property(P::Reason), Value::Str(r)) => reason = Some(r.to_string()),
_ => {
response.not_created.append(
client_id,
SetError::invalid_properties().with_property(key.clone().into_owned()),
);
continue 'create;
}
}
}
let Some(reason) = reason
.map(|r| r.trim().chars().take(500).collect::<String>())
.filter(|r| !r.is_empty())
else {
response.not_created.append(
client_id,
invalid(P::Reason, "Say why: a reason is required and is kept in the audit log."),
);
continue;
};
let hold = match hold_id {
Some(id) => hold::get(data, id).await?,
None => None,
};
let Some(hold) = hold else {
response
.not_created
.append(client_id, invalid(P::HoldId, "No such legal hold."));
continue;
};
if !hold.is_active() {
response.not_created.append(
client_id,
invalid(P::HoldId, "That hold was released; export while a hold is in place."),
);
continue;
}
let Some(created_by_id) = actor.account_id else {
response
.not_created
.append(client_id, SetError::forbidden().with_description("Sign in as a person to export."));
continue;
};
accounts.sort_unstable();
accounts.dedup();
let export = Export {
id: 0,
hold_id: hold.id,
accounts,
reason,
created_at: now(),
created_by: actor.name.clone(),
created_by_id,
status: ExportStatus::Running,
finished_at: None,
blob_id: None,
size: 0,
items: 0,
sha256: None,
error: None,
};
let id = hold::create_export(data, &export).await?;
let export = Export { id, ..export };
// The collection runs on its own; get says when it's ready
let server = server.clone();
tokio::spawn(async move {
let mut done = export.clone();
match crate::inbuxa::hold_export::build(&server, &hold, &export.accounts).await {
Ok((bytes, items)) => match server.put_jmap_blob(export.created_by_id, &bytes).await {
Ok(blob) => {
done.status = ExportStatus::Ready;
done.blob_id = Some(blob.to_string());
done.size = bytes.len() as u64;
done.items = items as u64;
done.sha256 = Some(
Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(),
);
}
Err(err) => {
done.status = ExportStatus::Failed;
done.error = Some(err.to_string());
}
},
Err(err) => {
done.status = ExportStatus::Failed;
done.error = Some(
err.value_as_str(trc::Key::Details)
.map(str::to_string)
.unwrap_or_else(|| err.to_string()),
);
}
}
done.finished_at = Some(now());
if let Err(err) = hold::update_export(server.store(), &done).await {
trc::error!(err.details("Failed to save a legal hold export's result"));
}
});
let mut out = Map::with_capacity(1);
out.insert_unchecked(
Key::Property(P::Id),
Value::Element(HoldExportValue::Id(Id::from(id))),
);
response.created.insert(client_id, Value::Object(out));
}
for (id, _) in request.unwrap_update() {
response.not_updated.append(
id,
SetError::forbidden().with_description("An export can't be changed; start a new one."),
);
}
for id in request.unwrap_destroy() {
response.not_destroyed.append(
id,
SetError::forbidden().with_description("Exports stay listed; the file expires on its own."),
);
}
Ok(response)
}
+459
View File
@@ -0,0 +1,459 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:LegalHold` (audit-hold-lock spec, LH-1 to LH-14): placing,
//! widening and releasing holds. Only server-level administrators reach
//! this: the tenant ceiling strips the permissions from everyone in a
//! tenant (LH-13). What a hold keeps is the undelete hooks' job.
use common::{Server, auth::AccessToken, hold::HoldSummary};
use inbuxa_features::hold::{self, Hold, Refusal, Release, Scope};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_legal_hold::{
LegalHold, LegalHoldProperty as P, LegalHoldSetArguments, LegalHoldValue,
},
request::IntoValid,
types::date::UTCDate,
};
use jmap_tools::{Key, Map, Value};
use std::str::FromStr;
use store::write::now;
use types::id::Id;
type LValue = Value<'static, P, LegalHoldValue>;
const ALL: &[P] = &[
P::Id,
P::Name,
P::Reference,
P::Description,
P::Scope,
P::From,
P::To,
P::PlacedAt,
P::PlacedBy,
P::Released,
P::ReleasedAt,
P::ReleasedBy,
P::ReleaseReason,
];
/// The longest a name, reference or description may be.
const MAX_TEXT: usize = 500;
fn date(seconds: u64) -> LValue {
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
}
fn text(value: &Option<String>) -> LValue {
value
.as_ref()
.map_or(Value::Null, |v| Value::Str(v.clone().into()))
}
fn ids(list: &[u32]) -> LValue {
Value::Array(
list.iter()
.map(|id| Value::Str(Id::from(*id).to_string().into()))
.collect(),
)
}
fn to_value(hold: &Hold, properties: &[P], summary: Option<&HoldSummary>) -> LValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(LegalHoldValue::Id(Id::from(hold.id))),
P::Name => Value::Str(hold.name.clone().into()),
P::Reference => text(&hold.reference),
P::Description => text(&hold.description),
P::Scope => {
let mut scope = Map::with_capacity(5);
scope.insert_unchecked(Key::Borrowed("server"), Value::Bool(hold.scope.server));
scope.insert_unchecked(Key::Borrowed("accounts"), ids(&hold.scope.accounts));
scope.insert_unchecked(Key::Borrowed("groups"), ids(&hold.scope.groups));
scope.insert_unchecked(Key::Borrowed("domains"), ids(&hold.scope.domains));
scope.insert_unchecked(Key::Borrowed("tenants"), ids(&hold.scope.tenants));
Value::Object(scope)
}
P::From => hold.from.map_or(Value::Null, date),
P::To => hold.to.map_or(Value::Null, date),
P::Reason => Value::Null,
P::PlacedAt => date(hold.placed_at),
P::PlacedBy => Value::Str(hold.placed_by.clone().into()),
P::Released => Value::Bool(!hold.is_active()),
P::ReleasedAt => hold.released.as_ref().map_or(Value::Null, |r| date(r.at)),
P::ReleasedBy => hold
.released
.as_ref()
.map_or(Value::Null, |r| Value::Str(r.by.clone().into())),
P::ReleaseReason => hold
.released
.as_ref()
.map_or(Value::Null, |r| Value::Str(r.reason.clone().into())),
P::AccountsCovered => Value::Number(summary.map_or(0, |s| s.accounts).into()),
P::ItemsHeld => Value::Number(summary.map_or(0, |s| s.items).into()),
P::SizeHeld => Value::Number(summary.map_or(0, |s| s.size).into()),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:LegalHold/get`: every hold, released ones included (LH-1).
pub async fn get(
server: &Server,
mut request: GetRequest<LegalHold>,
) -> trc::Result<GetResponse<LegalHold>> {
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();
// LH-9: only when asked for, since it walks the archive
let summaries = if properties
.iter()
.any(|p| matches!(p, P::AccountsCovered | P::ItemsHeld | P::SizeHeld))
{
server.hold_summaries().await?
} else {
Default::default()
};
// LH-14: the holds on one account, whether it's live or deleted and kept
if let Some(account) = request.arguments.covering_account.take() {
let account_id = account.document_id();
let covering = match server.member_of(account_id).await {
Some(member) => hold::covering(data, &member).await?,
None => match inbuxa_features::undelete::data::kept_account(data, account_id).await? {
Some(kept) => {
hold::covering(data, &common::hold::kept_member(account_id, &kept)).await?
}
None => Vec::new(),
},
};
for current in covering {
response
.list
.push(to_value(&current, &properties, summaries.get(&current.id)));
}
return Ok(response);
}
match ids {
None => {
for current in hold::all(data).await? {
response
.list
.push(to_value(&current, &properties, summaries.get(&current.id)));
}
}
Some(ids) => {
for id in ids {
match u32::try_from(id.id())
.ok()
.map(|id| hold::get(data, id))
{
Some(found) => match found.await? {
Some(current) => response.list.push(to_value(
&current,
&properties,
summaries.get(&current.id),
)),
None => response.push_not_found(id),
},
None => 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(MAX_TEXT).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.")
}
fn refused(refusal: Refusal) -> SetError<P> {
let property = match refusal {
Refusal::Released => P::Released,
Refusal::Narrowed | Refusal::Backwards => P::From,
Refusal::ScopeShrunk | Refusal::EmptyScope => P::Scope,
};
SetError::invalid_properties()
.with_property(property)
.with_description(refusal.describe())
}
fn invalid(property: P, why: &str) -> SetError<P> {
SetError::invalid_properties()
.with_property(property)
.with_description(why.to_string())
}
/// A text property: a string, trimmed and capped, or null for none.
fn parse_text(
property: P,
value: &Value<'_, P, LegalHoldValue>,
required: bool,
) -> Result<Option<String>, SetError<P>> {
match value {
Value::Str(s) => {
let s = s.trim();
if s.is_empty() {
if required {
Err(invalid(property, "This can't be empty."))
} else {
Ok(None)
}
} else {
Ok(Some(s.chars().take(MAX_TEXT).collect()))
}
}
Value::Null if !required => Ok(None),
_ => Err(invalid(property, "Expected text.")),
}
}
fn parse_date(property: P, value: &Value<'_, P, LegalHoldValue>) -> Result<Option<u64>, SetError<P>> {
match value {
Value::Null => Ok(None),
Value::Str(s) => UTCDate::from_str(s)
.ok()
.map(|d| Some(d.timestamp().max(0) as u64))
.ok_or_else(|| invalid(property, "Expected a UTC date, or null.")),
_ => Err(invalid(property, "Expected a UTC date, or null.")),
}
}
/// Reads a scope and checks that every account, group, domain and tenant
/// it names exists and is the right kind (LH-1).
async fn parse_scope(server: &Server, value: &Value<'_, P, LegalHoldValue>) -> Result<Scope, SetError<P>> {
let Value::Object(map) = value else {
return Err(invalid(P::Scope, "Expected an object."));
};
let mut scope = Scope::default();
for (key, value) in map.iter() {
let name: String = key.to_string().to_string();
if name == "server" {
match value {
Value::Bool(b) => scope.server = *b,
_ => return Err(invalid(P::Scope, "`server` must be true or false.")),
}
continue;
}
let Value::Array(items) = value else {
return Err(invalid(P::Scope, &format!("`{name}` must be a list of ids.")));
};
let mut list = Vec::with_capacity(items.len());
for item in items {
let id = match item {
Value::Str(s) => Id::from_str(s).ok(),
Value::Element(LegalHoldValue::Id(id)) => Some(*id),
_ => None,
}
.and_then(|id| u32::try_from(id.id()).ok())
.ok_or_else(|| invalid(P::Scope, &format!("`{name}` must be a list of ids.")))?;
list.push(id);
}
for id in &list {
let exists = match name.as_str() {
"accounts" => server.account(*id).await.is_ok_and(|a| a.is_user_account()),
"groups" => server.account(*id).await.is_ok_and(|a| !a.is_user_account()),
"domains" => server.domain_by_id(*id).await.ok().flatten().is_some(),
"tenants" => server.tenant(*id).await.is_ok(),
_ => return Err(invalid(P::Scope, &format!("Unknown scope entry `{name}`."))),
};
if !exists {
return Err(invalid(
P::Scope,
&format!("No such {} as {}.", name.trim_end_matches('s'), Id::from(*id)),
));
}
}
match name.as_str() {
"accounts" => scope.accounts = list,
"groups" => scope.groups = list,
"domains" => scope.domains = list,
_ => scope.tenants = list,
}
}
Ok(scope)
}
/// `inbuxa:LegalHold/set`: create places a hold; update renames it, widens
/// its range or scope, or releases it; destroy is refused (LH-13). The
/// request layer records each, with its reason.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, LegalHold>,
) -> trc::Result<SetResponse<LegalHold>> {
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
let arguments: LegalHoldSetArguments = std::mem::take(&mut request.arguments);
let data = server.store();
let actor = server.audit_actor(access_token).await;
'create: for (client_id, value) in request.unwrap_create() {
let mut new = Hold {
id: 0,
name: String::new(),
reference: None,
description: None,
scope: Scope::default(),
from: None,
to: None,
placed_at: now(),
placed_by: actor.name.clone(),
placed_by_id: actor.account_id,
released: None,
};
let mut reason = reason_of(arguments.reason.as_deref());
for (key, value) in value.into_expanded_object() {
let parsed = match &key {
Key::Property(P::Name) => parse_text(P::Name, &value, true).map(|v| {
new.name = v.unwrap_or_default();
}),
Key::Property(P::Reference) => {
parse_text(P::Reference, &value, false).map(|v| new.reference = v)
}
Key::Property(P::Description) => {
parse_text(P::Description, &value, false).map(|v| new.description = v)
}
Key::Property(P::Scope) => parse_scope(server, &value).await.map(|v| new.scope = v),
Key::Property(P::From) => parse_date(P::From, &value).map(|v| new.from = v),
Key::Property(P::To) => parse_date(P::To, &value).map(|v| new.to = v),
Key::Property(P::Reason) => {
if let Value::Str(r) = &value {
reason = reason_of(Some(r)).or(reason);
}
Ok(())
}
_ => Err(SetError::invalid_properties().with_property(key.clone().into_owned())),
};
if let Err(error) = parsed {
response.not_created.append(client_id, error);
continue 'create;
}
}
if new.name.is_empty() {
response
.not_created
.append(client_id, invalid(P::Name, "A hold needs a case name."));
continue;
}
if reason.is_none() {
response.not_created.append(client_id, reason_required());
continue;
}
if let Err(refusal) = new.check_new() {
response.not_created.append(client_id, refused(refusal));
continue;
}
let id = hold::create(data, &new).await?;
let mut out = Map::with_capacity(1);
out.insert_unchecked(
Key::Property(P::Id),
Value::Element(LegalHoldValue::Id(Id::from(id))),
);
response.created.insert(client_id, Value::Object(out));
}
'update: for (id, value) in request.unwrap_update().into_valid() {
let Some(current) = (match u32::try_from(id.id()) {
Ok(hold_id) => hold::get(data, hold_id).await?,
Err(_) => None,
}) else {
response.not_updated.append(id, SetError::not_found());
continue;
};
let Some(reason) = reason_of(arguments.reason.as_deref()) else {
response.not_updated.append(id, reason_required());
continue;
};
let mut next = current.clone();
let mut release = false;
for (key, value) in value.into_expanded_object() {
let parsed = match &key {
Key::Property(P::Name) => {
parse_text(P::Name, &value, true).map(|v| next.name = v.unwrap_or_default())
}
Key::Property(P::Reference) => {
parse_text(P::Reference, &value, false).map(|v| next.reference = v)
}
Key::Property(P::Description) => {
parse_text(P::Description, &value, false).map(|v| next.description = v)
}
Key::Property(P::Scope) => parse_scope(server, &value).await.map(|v| next.scope = v),
Key::Property(P::From) => parse_date(P::From, &value).map(|v| next.from = v),
Key::Property(P::To) => parse_date(P::To, &value).map(|v| next.to = v),
Key::Property(P::Released) => match value {
Value::Bool(true) => {
release = true;
Ok(())
}
Value::Bool(false) if current.is_active() => Ok(()),
_ => Err(invalid(
P::Released,
"A released hold can't be put back; place a new one instead.",
)),
},
_ => Err(SetError::invalid_properties().with_property(key.clone().into_owned())),
};
if let Err(error) = parsed {
response.not_updated.append(id, error);
continue 'update;
}
}
if let Err(refusal) = current.check_update(&mut next) {
response.not_updated.append(id, refused(refusal));
continue;
}
if release {
next.released = Some(Release {
at: now(),
by: actor.name.clone(),
by_id: actor.account_id,
reason,
});
}
if next != current {
hold::update(data, &next).await?;
}
response.updated.append(id, None);
}
// LH-6, LH-10, LH-11: the archive follows what's now held
if !response.created.is_empty() || !response.updated.is_empty() {
server.settle_archive().await?;
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(
id,
SetError::forbidden()
.with_description("A hold is never deleted. Release it, and it stays listed."),
);
}
Ok(response)
}
+6
View File
@@ -8,6 +8,12 @@
//! `crates/features`; this module only speaks JMAP for them.
pub mod access;
pub mod account_lock;
pub mod legal_hold;
pub mod hold_export;
pub mod hold_export_api;
pub mod audit;
pub mod audit_log;
pub mod ai_limits;
pub mod explanation;
pub mod protocol_policy;
+105 -23
View File
@@ -26,7 +26,9 @@ use common::{
};
use inbuxa_features::security::{
listeners,
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
protocol_policy::{
LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener, Switches,
},
};
use jmap_proto::{
error::set::SetError,
@@ -48,6 +50,9 @@ type PValue = Value<'static, P, ProtocolPolicyValue>;
const ALL: &[P] = &[
P::Id,
P::LegacyProtocols,
P::Imap,
P::Pop3,
P::ManageSieve,
P::CloseSubmission,
P::SavedListeners,
P::ChangedAt,
@@ -91,22 +96,49 @@ fn listener_value(listener: &SavedListener) -> PValue {
Value::Object(out)
}
/// A switch as JMAP spells it.
pub(crate) fn switch_str(value: LegacyProtocols) -> &'static str {
match value {
LegacyProtocols::Enabled => "enabled",
LegacyProtocols::Disabled => "disabled",
}
}
/// A switch from JMAP.
pub(crate) fn parse_switch(value: Option<&str>) -> Result<LegacyProtocols, String> {
match value {
Some("enabled") => Ok(LegacyProtocols::Enabled),
Some("disabled") => Ok(LegacyProtocols::Disabled),
_ => Err(r#"must be "enabled" or "disabled""#.to_string()),
}
}
/// The JMAP name of a per-protocol switch property.
pub(crate) fn switch_name(property: &P) -> Option<&'static str> {
match property {
P::Imap => Some("imap"),
P::Pop3 => Some("pop3"),
P::ManageSieve => Some("manageSieve"),
_ => None,
}
}
fn to_value(
policy: &Policy,
would_close: &[SavedListener],
recent: &[RecentUse],
properties: &[P],
) -> PValue {
let mut policy = policy.clone();
policy.normalize();
let policy = &policy;
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())),
P::LegacyProtocols => Value::Str(
match policy.legacy_protocols {
LegacyProtocols::Enabled => "enabled",
LegacyProtocols::Disabled => "disabled",
}
.into(),
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
),
P::CloseSubmission => Value::Bool(policy.close_submission),
P::SavedListeners => Value::Array(
@@ -176,10 +208,11 @@ where
)
}
/// The listeners turning the switch on would close, whatever it is now.
/// The listeners turning every protocol off would close, whatever the switches
/// are now; each names its protocol, so the console shows one protocol's.
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
let mut hypothetical = policy.clone();
hypothetical.legacy_protocols = LegacyProtocols::Disabled;
hypothetical.set_all(LegacyProtocols::Disabled);
hypothetical.apply_locks();
listeners::would_close(server.registry(), &hypothetical).await
}
@@ -238,12 +271,12 @@ fn apply(
value: &Value<'_, P, ProtocolPolicyValue>,
) -> Result<(), String> {
match property {
P::LegacyProtocols => {
policy.legacy_protocols = match value.as_str().as_deref() {
Some("enabled") => LegacyProtocols::Enabled,
Some("disabled") => LegacyProtocols::Disabled,
_ => return Err(r#"must be "enabled" or "disabled""#.to_string()),
}
// The kill-all sets all three; a protocol named in the same /set is
// applied after it (see `set`), so it wins.
P::LegacyProtocols => policy.set_all(parse_switch(value.as_str().as_deref())?),
P::Imap | P::Pop3 | P::ManageSieve => {
let value = parse_switch(value.as_str().as_deref())?;
policy.set(switch_name(property).unwrap_or_default(), value);
}
P::CloseSubmission => {
policy.close_submission = value
@@ -262,7 +295,13 @@ fn apply(
/// Puts a property back to its default (a `null` in `/set`).
fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> {
match property {
P::LegacyProtocols => policy.legacy_protocols = defaults.legacy_protocols,
P::LegacyProtocols => policy.set_all(defaults.legacy_protocols),
P::Imap | P::Pop3 | P::ManageSieve => {
policy.set(
switch_name(property).unwrap_or_default(),
LegacyProtocols::Enabled,
);
}
P::CloseSubmission => policy.close_submission = defaults.close_submission,
P::Id => return Err("is immutable".to_string()),
other if other.is_server_set() => return Err("is set by the server".to_string()),
@@ -312,10 +351,14 @@ pub async fn set(
}
let mut policy = server.protocol_policy().await?;
policy.normalize();
let defaults = Policy::default();
let mut error = None;
for (key, value) in value.into_expanded_object() {
// The kill-all first, so a protocol named beside it overrides it.
let mut entries: Vec<_> = value.into_expanded_object().collect();
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
for (key, value) in entries {
let Key::Property(property) = &key else {
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
break;
@@ -393,21 +436,30 @@ fn listener_refusal(policy: &Policy, listener: &NetworkListener) -> Option<(Prop
let protocol = listeners::protocol_name(listener.protocol);
// A submission listener closes because of its port, not its protocol
// (LP-3), so the port is what would have to change.
let property = if protocol == "smtp" {
Property::Bind
let (property, what) = if protocol == "smtp" {
(Property::Bind, "Legacy mail protocols are".to_string())
} else {
Property::Protocol
(Property::Protocol, format!("{} is", display_name(protocol)))
};
Some((
property,
format!(
"Legacy mail protocols are off (inbuxa:ProtocolPolicy), and this {protocol} \
listener would reopen a port the switch keeps closed. Turn legacy protocols \
back on first."
"{what} off (inbuxa:ProtocolPolicy), and this {protocol} listener would reopen \
a port the switch keeps closed. Turn it back on first."
),
))
}
/// A protocol's name as people read it.
fn display_name(protocol: &str) -> &str {
match protocol {
"imap" => "IMAP",
"pop3" => "POP3",
"manageSieve" => "ManageSieve",
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -461,6 +513,36 @@ mod tests {
}
}
#[test]
fn one_protocol_off_refuses_only_its_listeners() {
let mut policy = Policy::default();
policy.set("pop3", LegacyProtocols::Disabled);
policy.normalize();
let (property, why) = listener_refusal(
&policy,
&listener(NetworkListenerProtocol::Pop3, "[::]:995"),
)
.expect("refused");
assert_eq!(property, Property::Protocol);
assert!(why.starts_with("POP3 is off"), "{why}");
assert!(
listener_refusal(
&policy,
&listener(NetworkListenerProtocol::Imap, "[::]:993")
)
.is_none()
);
}
#[test]
fn a_switch_reads_and_parses_as_jmap_spells_it() {
assert_eq!(switch_str(LegacyProtocols::Disabled), "disabled");
assert_eq!(parse_switch(Some("enabled")), Ok(LegacyProtocols::Enabled));
assert!(parse_switch(Some("off")).is_err());
assert_eq!(switch_name(&P::ManageSieve), Some("manageSieve"));
assert_eq!(switch_name(&P::CloseSubmission), None);
}
#[test]
fn off_still_allows_what_the_switch_never_closes() {
// Locked (LP-21) and inbound (LP-3): the switch doesn't close them,
@@ -12,16 +12,22 @@
//! with no ids answers with it, and any other id is `notFound`. At server
//! level, `/get` with no ids answers with every tenant's.
//!
//! Turning it off never needs the server's leave; turning it back on is
//! refused with `forbidden` while the server has legacy protocols off (LP-9).
//! Each of IMAP, POP3 and ManageSieve has its own switch, and
//! `legacyProtocols` is the kill-all, as on the server's policy. Turning one
//! off never needs the server's leave; turning one back on is refused with
//! `forbidden` while the server has that protocol off (LP-9).
//! A tenant's switch closes no port (LP-13) -- sign-in and client
//! configuration read it (LP-10, LP-14a).
use crate::inbuxa::protocol_policy::recent_value;
use common::{Server, auth::AccessToken, network::legacy::RecentUse};
use crate::inbuxa::protocol_policy::{parse_switch, recent_value, switch_str};
use common::{
Server,
auth::AccessToken,
network::legacy::{RecentUse, switches_value},
};
use inbuxa_features::{
security::{
protocol_policy::LegacyProtocols,
protocol_policy::{LegacyProtocols, SWITCHED, Switches},
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
},
tenancy::quota::all_tenants,
@@ -46,6 +52,9 @@ const ALL: &[P] = &[
P::Id,
P::TenantId,
P::LegacyProtocols,
P::Imap,
P::Pop3,
P::ManageSieve,
P::ChangedAt,
P::ChangedBy,
P::RecentLegacyUse,
@@ -60,19 +69,29 @@ async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<V
}
}
/// The JMAP name of a per-protocol switch property.
fn switch_name(property: &P) -> Option<&'static str> {
match property {
P::Imap => Some("imap"),
P::Pop3 => Some("pop3"),
P::ManageSieve => Some("manageSieve"),
_ => None,
}
}
fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
let mut policy = policy.clone();
policy.normalize();
let policy = &policy;
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id | P::TenantId => {
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
}
P::LegacyProtocols => Value::Str(
match policy.legacy_protocols {
LegacyProtocols::Enabled => "enabled",
LegacyProtocols::Disabled => "disabled",
}
.into(),
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
),
P::ChangedAt => policy
.changed_at
@@ -165,29 +184,41 @@ pub async fn set(
let data = &server.core.storage.data;
let previous = tenant_protocol_policy::get(data, tenant_id).await?;
let mut policy = previous.clone();
policy.normalize();
let mut error = None;
for (key, value) in value.into_expanded_object() {
// What this request sets to `enabled`, for LP-9.
let mut turned_on: Vec<&'static str> = Vec::new();
// The kill-all first, so a protocol named beside it overrides it.
let mut entries: Vec<_> = value.into_expanded_object().collect();
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
for (key, value) in entries {
// `null` puts a switch back to its default, on.
let parsed = match value {
Value::Null => Ok(LegacyProtocols::Enabled),
value => parse_switch(value.as_str().as_deref()),
};
let result = match &key {
Key::Property(P::LegacyProtocols) => match value {
Value::Null => {
policy.legacy_protocols = LegacyProtocols::Enabled;
Ok(())
Key::Property(P::LegacyProtocols) => parsed.map(|value| {
policy.set_all(value);
if !value.is_disabled() {
turned_on.extend(SWITCHED.iter().copied());
} else {
turned_on.clear();
}
value => match value.as_str().as_deref() {
Some("enabled") => {
policy.legacy_protocols = LegacyProtocols::Enabled;
Ok(())
}),
Key::Property(property @ (P::Imap | P::Pop3 | P::ManageSieve)) => {
parsed.map(|value| {
let name = switch_name(property).unwrap_or_default();
policy.set(name, value);
turned_on.retain(|p| *p != name);
if !value.is_disabled() {
turned_on.push(name);
}
Some("disabled") => {
policy.legacy_protocols = LegacyProtocols::Disabled;
Ok(())
})
}
_ => Err(r#"must be "enabled" or "disabled""#),
},
},
Key::Property(P::Id) => Err("is immutable"),
Key::Property(_) => Err("is set by the server"),
_ => Err("is not a property of inbuxa:TenantProtocolPolicy"),
Key::Property(P::Id) => Err("is immutable".to_string()),
Key::Property(_) => Err("is set by the server".to_string()),
_ => Err("is not a property of inbuxa:TenantProtocolPolicy".to_string()),
};
if let Err(why) = result {
error = Some(
@@ -203,15 +234,18 @@ pub async fn set(
continue;
}
// LP-9: server off means off for everyone.
if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) {
// LP-9: server off means off for everyone, protocol by protocol.
if let Some(why) = refusal(&server.protocol_policy().await?, &turned_on) {
response
.not_updated
.append(id, SetError::forbidden().with_description(why));
continue;
}
if policy.legacy_protocols != previous.legacy_protocols {
policy.normalize();
let mut before = previous;
before.normalize();
if policy.off() != before.off() {
policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
tenant_protocol_policy::set(data, tenant_id, &policy).await?;
@@ -221,11 +255,7 @@ pub async fn set(
Security(trc::SecurityEvent::LegacyProtocolsChanged),
Policy = "tenant",
Id = tenant_id,
Value = if policy.legacy_protocols.is_disabled() {
"disabled"
} else {
"enabled"
},
Value = switches_value(&policy),
AccountId = policy.changed_by.clone(),
);
}
+27
View File
@@ -298,6 +298,33 @@ pub(crate) async fn set(mut set: RegistrySetResponse<'_>) -> trc::Result<Registr
for id in std::mem::take(&mut set.destroy) {
match undelete::records::get(data, registry, account_id, id).await? {
// inbuxa: LH-7: a held item can't be destroyed; restoring it
// still can. The hold is named only to those who may see holds.
Some(item)
if inbuxa_features::hold::is_held_until(
item.archived_until().timestamp().max(0) as u64,
) =>
{
let mut why = "A legal hold applies to this item, so it can't be deleted.".to_string();
if set
.access_token
.has_permission(registry::schema::enums::Permission::SysLegalHoldGet)
{
let names = set
.server
.holds_on(account_id)
.await?
.into_iter()
.map(|hold| hold.name)
.collect::<Vec<_>>();
if !names.is_empty() {
why = format!("Held by {}, so it can't be deleted.", names.join(", "));
}
}
set.response
.not_destroyed
.append(id, SetError::forbidden().with_description(why));
}
Some(item) => {
undelete::records::remove(data, registry, id, &item).await?;
set.response.destroyed.push(id);
+14
View File
@@ -1730,6 +1730,20 @@ 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,
// inbuxa: legal hold (audit-hold-lock spec, LH-13)
SysLegalHoldGet = 669,
SysLegalHoldCreate = 670,
SysLegalHoldUpdate = 671,
SysLegalHoldExport = 672,
SysAccountGet = 219,
SysAccountCreate = 220,
SysAccountUpdate = 221,
+34 -1
View File
@@ -7073,6 +7073,17 @@ 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"sysLegalHoldGet" => Permission::SysLegalHoldGet,
b"sysLegalHoldCreate" => Permission::SysLegalHoldCreate,
b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate,
b"sysLegalHoldExport" => Permission::SysLegalHoldExport,
b"sysAccountGet" => Permission::SysAccountGet,
b"sysAccountCreate" => Permission::SysAccountCreate,
b"sysAccountUpdate" => Permission::SysAccountUpdate,
@@ -7751,6 +7762,17 @@ 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::SysLegalHoldGet => "sysLegalHoldGet",
Permission::SysLegalHoldCreate => "sysLegalHoldCreate",
Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate",
Permission::SysLegalHoldExport => "sysLegalHoldExport",
Permission::SysAccountGet => "sysAccountGet",
Permission::SysAccountCreate => "sysAccountCreate",
Permission::SysAccountUpdate => "sysAccountUpdate",
@@ -8422,6 +8444,17 @@ 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),
669 => Some(Permission::SysLegalHoldGet),
670 => Some(Permission::SysLegalHoldCreate),
671 => Some(Permission::SysLegalHoldUpdate),
672 => Some(Permission::SysLegalHoldExport),
219 => Some(Permission::SysAccountGet),
220 => Some(Permission::SysAccountCreate),
221 => Some(Permission::SysAccountUpdate),
@@ -8866,7 +8899,7 @@ impl EnumImpl for Permission {
}
}
const COUNT: usize = 662;
const COUNT: usize = 673;
}
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);
}
@@ -55,6 +55,23 @@ impl DestroyAccountTask for Server {
async fn destroy_account(server: &Server, task: &TaskDestroyAccount) -> trc::Result<TaskResult> {
let account_id = task.account_id.document_id();
// inbuxa: LH-8, LH-10: a kept account waits for its time, and a held one
// for its release; "destroy now" clears the kept record first
if let Some(kept) =
inbuxa_features::undelete::data::kept_account(&server.core.storage.data, account_id).await?
{
let now = store::write::now();
let held = inbuxa_features::hold::is_held_until(kept.kept_until)
|| server.is_kept_held(account_id, &kept).await?;
if held || kept.kept_until > now {
let retry = if held { now + 86_400 } else { kept.kept_until };
return Ok(TaskResult::deferred(
Some(retry),
"The account is still kept: a legal hold applies, or its time hasn't come.",
));
}
}
// Destroy public keys and masked emails
for object in [ObjectType::PublicKey, ObjectType::MaskedEmail] {
let mut batch = BatchBuilder::new();
+27 -8
View File
@@ -229,11 +229,19 @@ impl SearchIndexTask for Server {
IndexDocumentType::Email => None,
} && let Err(err) = archive_noted(self, kind, account_id, document_id).await
{
// inbuxa: LH-5: the note stays, so the retry archives
// it; nothing a hold keeps is lost to a failure
trc::error!(
err.account_id(account_id)
.document_id(document_id)
.details("Failed to archive a deleted item")
);
results.push(IndexTaskResult {
task_type: TaskType::Delete,
index: task.document_type,
result: TaskResult::temporary("Failed to archive a deleted item"),
});
continue;
}
document_deletions[idx]
@@ -696,8 +704,9 @@ pub fn trace_search_document(
document
}
// inbuxa: UD-1, UD-4: archives a deleted file, event or contact noted at
// deletion, when archiving is on; otherwise its note is dropped
// inbuxa: UD-1, UD-4, LH-4: archives a deleted file, event or contact noted
// at deletion, when archiving is on or a hold covers it; otherwise its note is
// dropped. The note goes only once the item is archived.
async fn archive_noted(
server: &Server,
kind: undelete::groupware::Kind,
@@ -705,12 +714,22 @@ async fn archive_noted(
document_id: u32,
) -> trc::Result<()> {
let data = &server.core.storage.data;
let Some(note) = undelete::groupware::take(data, kind, account_id, document_id).await? else {
let Some(note) = undelete::groupware::peek(data, kind, account_id, document_id).await? else {
return Ok(());
};
let Some(retention) = undelete::settings::retention(server.registry()).await?.items else {
return Ok(());
// LH-3: events by their start; contacts and files whole
let keeping = server.keeping(account_id).await?;
let held = match kind {
undelete::groupware::Kind::CalendarEvent => {
keeping.covers_event(undelete::groupware::event_start(&note))
}
_ => keeping.covers(None),
};
let now = store::write::now();
let Some(until) = keeping.until(now, held) else {
return undelete::groupware::clear(data, kind, account_id, document_id).await;
};
let retention = until.saturating_sub(now);
let blob_hash = match (&note.content, &note.blob_hash) {
(Some(text), _) => {
server
@@ -723,11 +742,11 @@ async fn archive_noted(
.into_err()
.details("Invalid blob hash in undelete note")
})?,
(None, None) => return Ok(()),
(None, None) => return undelete::groupware::clear(data, kind, account_id, document_id).await,
};
undelete::groupware::archive(data, server.registry(), account_id, note, blob_hash, retention)
.await
.map(|_| ())
.await?;
undelete::groupware::clear(data, kind, account_id, document_id).await
}
async fn delete_email_metadata(
@@ -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
+273 -28
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::{
@@ -13,6 +15,8 @@ use mail_auth::{
MX, RecordSet,
common::resolver::ToFqdn,
hickory_resolver::{
TokioResolver,
lookup::Lookup,
net::{DnsError, NetError},
proto::{
dnssec::Proof,
@@ -83,16 +87,15 @@ impl TlsaLookup for Server {
return mail_auth::common::resolver::mock_resolve(key.as_ref());
}
let mx_lookup = match self
.core
.smtp
.resolvers
.dnssec
.resolver
.mx_lookup(Name::from_str_relaxed::<&str>(key.as_ref())?)
let (mx_lookup, forced_insecure) = match validated_lookup(
&self.core.smtp.resolvers.dnssec.resolver,
self.core.smtp.resolvers.dns.resolver(),
Name::from_str_relaxed::<&str>(key.as_ref())?,
RecordType::MX,
)
.await
{
Ok(mx_lookup) => mx_lookup,
Ok(validated) => (validated.lookup, validated.insecure),
Err(err) => {
if let Some(denial) = NegativeAnswer::from_error(&err)
&& denial.response_code == ResponseCode::NoError
@@ -144,7 +147,11 @@ impl TlsaLookup for Server {
.collect::<Arc<[MX]>>();
let records = RecordSet {
rrset,
dnssec_status: dnssec_status.unwrap_or(DnssecStatus::Indeterminate),
dnssec_status: if forced_insecure {
DnssecStatus::Insecure
} else {
dnssec_status.unwrap_or(DnssecStatus::Indeterminate)
},
};
self.inner
@@ -285,16 +292,15 @@ impl TlsaLookup for Server {
}
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
let lookup = match self
.core
.smtp
.resolvers
.dnssec
.resolver
.ipv4_lookup(name.clone())
let (lookup, forced_insecure) = match validated_lookup(
&self.core.smtp.resolvers.dnssec.resolver,
self.core.smtp.resolvers.dns.resolver(),
name.clone(),
RecordType::A,
)
.await
{
Ok(lookup) => lookup,
Ok(validated) => (validated.lookup, validated.insecure),
Err(err) => {
if let Some(denial) = NegativeAnswer::from_error(&err)
&& denial.response_code == ResponseCode::NoError
@@ -325,7 +331,11 @@ impl TlsaLookup for Server {
_ => None,
})
.collect::<Arc<[Ipv4Addr]>>(),
dnssec_status: tlsa_base_status(&name, answers, RecordType::A),
dnssec_status: if forced_insecure {
DnssecStatus::Insecure
} else {
tlsa_base_status(&name, answers, RecordType::A)
},
};
self.inner
@@ -363,16 +373,15 @@ impl TlsaLookup for Server {
}
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
let lookup = match self
.core
.smtp
.resolvers
.dnssec
.resolver
.ipv6_lookup(name.clone())
let (lookup, forced_insecure) = match validated_lookup(
&self.core.smtp.resolvers.dnssec.resolver,
self.core.smtp.resolvers.dns.resolver(),
name.clone(),
RecordType::AAAA,
)
.await
{
Ok(lookup) => lookup,
Ok(validated) => (validated.lookup, validated.insecure),
Err(err) => {
if let Some(denial) = NegativeAnswer::from_error(&err)
&& denial.response_code == ResponseCode::NoError
@@ -403,7 +412,11 @@ impl TlsaLookup for Server {
_ => None,
})
.collect::<Arc<[Ipv6Addr]>>(),
dnssec_status: tlsa_base_status(&name, answers, RecordType::AAAA),
dnssec_status: if forced_insecure {
DnssecStatus::Insecure
} else {
tlsa_base_status(&name, answers, RecordType::AAAA)
},
};
self.inner
@@ -415,6 +428,115 @@ impl TlsaLookup for Server {
}
}
// inbuxa: hickory 0.26.3 calls some valid answers bogus, and the queue then
// retries those hosts until the message expires. Two cases seen in production:
//
// - A zone delegated beneath an unsigned zone, such as `l.google.com` under
// `google.com`. To prove the delegation insecure, hickory wants an SOA
// record in the DS reply, and public resolvers often send none.
// - A signed CNAME to a signed name without the record type queried. Hickory
// checks the denial of existence against the name first asked for, not the
// target's, and rejects it.
//
// When hickory says bogus, check the answer again with lookups it gets right.
// A signed CNAME is followed and the lookup repeated at its target. Otherwise
// the name's zone and its parents are looked up, nearest first. If one
// validates as unsigned, nothing below it can be signed, so the plain resolver
// answers and the result is insecure. If one validates as signed first, the
// verdict stands.
const MAX_BOGUS_ALIASES: usize = 8;
struct ValidatedLookup {
lookup: Lookup,
insecure: bool,
}
enum BogusRecheck {
Alias(Name),
Insecure,
Bogus,
}
async fn validated_lookup(
dnssec: &TokioResolver,
plain: &TokioResolver,
name: Name,
record_type: RecordType,
) -> Result<ValidatedLookup, NetError> {
let mut query = name;
let mut aliases = 0;
loop {
let err = match dnssec.lookup(query.clone(), record_type).await {
Ok(lookup) => {
return Ok(ValidatedLookup {
lookup,
insecure: false,
});
}
Err(err @ NetError::Dns(DnsError::DnssecBogus)) => err,
Err(err) => return Err(err),
};
match recheck_bogus(dnssec, &query).await {
BogusRecheck::Alias(target) if aliases < MAX_BOGUS_ALIASES => {
aliases += 1;
query = target;
}
BogusRecheck::Insecure => {
return plain
.lookup(query, record_type)
.await
.map(|lookup| ValidatedLookup {
lookup,
insecure: true,
});
}
BogusRecheck::Alias(_) | BogusRecheck::Bogus => return Err(err),
}
}
}
async fn recheck_bogus(dnssec: &TokioResolver, name: &Name) -> BogusRecheck {
if let Ok(lookup) = dnssec.lookup(name.clone(), RecordType::CNAME).await
&& let Some(target) = secure_alias(name, lookup.answers())
{
return BogusRecheck::Alias(target);
}
let mut zone = name.clone();
while !zone.is_root() {
if let Ok(lookup) = dnssec.lookup(zone.clone(), RecordType::SOA).await {
match apex_status(&zone, lookup.answers()) {
Some(DnssecStatus::Insecure) => return BogusRecheck::Insecure,
Some(DnssecStatus::Secure) => return BogusRecheck::Bogus,
_ => {}
}
}
zone = zone.base_name();
}
BogusRecheck::Bogus
}
fn secure_alias(query: &Name, answers: &[Record]) -> Option<Name> {
answers.iter().find_map(|record| match &record.data {
RData::CNAME(target) if &record.name == query && record.proof.is_secure() => {
Some(target.0.clone())
}
_ => None,
})
}
fn apex_status(zone: &Name, answers: &[Record]) -> Option<DnssecStatus> {
answers
.iter()
.filter(|record| record.record_type() == RecordType::SOA && &record.name == zone)
.map(|record| proof_to_dnssec_status(record.proof))
.reduce(least_secure)
}
struct NegativeAnswer {
response_code: ResponseCode,
dnssec_status: DnssecStatus,
@@ -511,7 +633,7 @@ pub(crate) fn least_secure(a: DnssecStatus, b: DnssecStatus) -> DnssecStatus {
#[cfg(test)]
mod tests {
use super::*;
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME};
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME, SOA};
use std::net::Ipv4Addr;
fn name(value: &str) -> Name {
@@ -624,4 +746,127 @@ mod tests {
DnssecStatus::Insecure
);
}
fn soa(owner: &str, proof: Proof) -> Record {
let mut record = Record::from_rdata(
name(owner),
3600,
RData::SOA(SOA::new(
name("ns1.example.org."),
name("hostmaster.example.org."),
1,
900,
900,
1800,
60,
)),
);
record.proof = proof;
record
}
#[test]
fn secure_alias_follows_signed_cname() {
assert_eq!(
secure_alias(
&name("mail.example.org."),
&[alias("mail.example.org.", "mx.example.net.", Proof::Secure)]
),
Some(name("mx.example.net."))
);
}
#[test]
fn secure_alias_ignores_unsigned_or_other_cname() {
let query = name("mail.example.org.");
assert_eq!(
secure_alias(
&query,
&[alias(
"mail.example.org.",
"mx.example.net.",
Proof::Insecure
)]
),
None
);
assert_eq!(
secure_alias(
&query,
&[alias(
"other.example.org.",
"mx.example.net.",
Proof::Secure
)]
),
None
);
}
#[test]
fn apex_status_reads_the_zone_soa() {
let zone = name("example.com.");
for (proof, expected) in [
(Proof::Secure, Some(DnssecStatus::Secure)),
(Proof::Insecure, Some(DnssecStatus::Insecure)),
(Proof::Bogus, Some(DnssecStatus::Bogus)),
] {
assert_eq!(
apex_status(&zone, &[soa("example.com.", proof)]),
expected,
"proof {proof}"
);
}
}
#[test]
fn apex_status_ignores_other_records() {
assert_eq!(
apex_status(
&name("example.com."),
&[
soa("sub.example.com.", Proof::Insecure),
address("example.com.", Proof::Insecure),
]
),
None
);
}
// Needs the network: a signed MX pointing into a zone delegated beneath an
// unsigned one. Run with `--ignored` to check a hickory upgrade.
#[tokio::test]
#[ignore]
async fn validated_lookup_proves_delegation_below_unsigned_zone() {
use mail_auth::hickory_resolver::{
config::{CLOUDFLARE, ResolverConfig, ResolverOpts},
net::runtime::TokioRuntimeProvider,
};
let build = |validate: bool| {
// Same options as the server's DNSSEC resolver; hickory fails
// validation with concurrent requests.
let mut opts = ResolverOpts::default();
opts.validate = validate;
opts.num_concurrent_reqs = 1;
opts.cache_size = 0;
TokioResolver::builder_with_config(
ResolverConfig::udp_and_tcp(&CLOUDFLARE),
TokioRuntimeProvider::default(),
)
.with_options(opts)
.build()
.unwrap()
};
let (dnssec, plain) = (build(true), build(false));
let validated =
validated_lookup(&dnssec, &plain, name("aspmx.l.google.com."), RecordType::A)
.await
.unwrap();
assert!(validated.insecure);
assert!(!validated.lookup.answers().is_empty());
}
}
+57 -2
View File
@@ -26,7 +26,10 @@ use mail_auth::{
common::verify::VerifySignature,
dkim2::Dkim2Output,
dmarc::{self},
report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope},
report::{
ActionDisposition, AuthFailureType, IdentityAlignment, PolicyPublished, Record, Report,
SPFDomainScope,
},
};
use registry::{
schema::{
@@ -459,7 +462,7 @@ impl DmarcReporting for Server {
.await
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
let mut message = Vec::with_capacity(2048);
let _ = mail_auth::report::Report::from(report.report).write_rfc5322(
let _ = with_compatible_dispositions(Report::from(report.report)).write_rfc5322(
&self
.eval_if(
&self.core.smtp.report.submitter,
@@ -710,3 +713,55 @@ impl DmarcReporting for Server {
}
}
}
// inbuxa: RFC 9990 added "pass" to the evaluated disposition for mail that
// passed DMARC under an enforcing policy. Cloudflare's report intake rejects
// the whole report with "555 5.7.1 invalid_report_schema" when it sees that
// value, and older parsers built on the RFC 7489 schema do the same. "none"
// (no action taken) is valid under both and says the same thing, so reports
// go out with that instead.
fn with_compatible_dispositions(mut report: Report) -> Report {
for record in &mut report.record {
let disposition = &mut record.row.policy_evaluated.disposition;
if *disposition == ActionDisposition::Pass {
*disposition = ActionDisposition::None;
}
}
report
}
#[cfg(test)]
mod tests {
use super::*;
use mail_auth::report::DmarcResult;
fn record(disposition: ActionDisposition) -> Record {
Record::new()
.with_source_ip("192.0.2.1".parse().unwrap())
.with_count(1)
.with_action_disposition(disposition)
.with_dmarc_dkim_result(DmarcResult::Pass)
.with_dmarc_spf_result(DmarcResult::Fail)
.with_header_from("example.org")
}
#[test]
fn pass_disposition_is_reported_as_none() {
let xml = with_compatible_dispositions(
Report::new()
.with_domain("example.org")
.with_record(record(ActionDisposition::Pass))
.with_record(record(ActionDisposition::Quarantine))
.with_record(record(ActionDisposition::Reject)),
)
.to_xml();
assert!(!xml.contains("<disposition>pass</disposition>"), "{xml}");
assert!(xml.contains("<disposition>none</disposition>"), "{xml}");
assert!(
xml.contains("<disposition>quarantine</disposition>"),
"{xml}"
);
assert!(xml.contains("<disposition>reject</disposition>"), "{xml}");
}
}

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