Multi-tenancy: record what INBUXA's Enterprise server actually does

This commit is contained in:
2026-09-18 07:03:50 -07:00
parent c6b3674ccd
commit 490d11b04a
+102 -40
View File
@@ -13,6 +13,7 @@ sources, and nothing else:
| Stalwart documentation, "Tenants" (`website` repo, `docs/auth/authorization/tenants.md`) | Unlicensed public documentation: facts used, prose not copied | Isolation, the permission ceiling, quota semantics, branding |
| ihasmail FEATURES.md, "Tenants", and its admin code | AGPL-3.0-or-later, ours | Behavior observed against a live Enterprise server |
| RFC 8620 | IETF | JMAP semantics for errors and `/set` |
| Probes of INBUXA's live Enterprise server, 2026-09-18 (Stalwart 0.16.22) | Observation | Everything under "Observed" |
No Enterprise-only file or snippet was used. The session that drafted this
had seen short Enterprise snippets while surveying the license split, so it
@@ -103,19 +104,21 @@ Each requirement has an ID, and tests name the IDs they check.
principal in a tenant can't read or change them, except what every account
already reads about itself: its own account, its own settings, and the
server's public capabilities.
- **MT-3.** Any operation that would link objects across tenants is refused
with `forbidden`: a group member, a list member or owner, a role, a sharing
grant, an alias domain. This includes linking a tenant object to a
server-level one, unless the server-level object is shared infrastructure the
operator made available (see "To observe" 1).
- **MT-3.** Any write that would link an object to one in a different
tenant is refused with `invalidForeignKey`, naming the object it can't
link to: a group member, a list member or owner, a role, a sharing grant, a
domain's ACME or DNS provider. "Different" includes no tenant. A tenant's
domain can't use a server-level provider, even when a server-level admin
makes the change (observed 1). Shared infrastructure is therefore
per-tenant: each tenant brings its own providers.
- **MT-4.** Mail flow isn't isolation. Mail between addresses in different
tenants is delivered like any other mail, through the normal delivery path.
Tenants separate administration and resources, not the network.
- **MT-5.** Queued and outgoing mail are resources. A tenant administrator sees
and acts on queued messages only for senders or recipients on the tenant's
own domains, and never sees another tenant's. **Decision**, derived from
MT-1. Upstream's behavior here isn't publicly documented. Confirm it doesn't
contradict "To observe" 4.
and acts on queued messages whose recipients are on the tenant's own
domains, whoever sent them, and never sees anything else in the queue
(observed 4). **Decision** for the sender side, not yet observed: it also
sees messages its own principals sent, until they leave the queue.
### Membership
@@ -126,15 +129,23 @@ Each requirement has an ID, and tests name the IDs they check.
- **MT-7.** A principal's tenant always matches its domain's tenant. A
principal on a domain in tenant T is in T. A principal on a domain in no
tenant is in no tenant. On create, `memberTenantId` defaults to the domain's
tenant. A write that sets any other value is refused with `invalidProperties`
naming `memberTenantId`.
tenant. A write that sets any other value is refused with
`invalidForeignKey` naming the domain, which is upstream's own refusal
(observed 7).
**Decision**, a deliberate difference: upstream doesn't default the tenant.
An account created on a tenant's domain without one lands in no tenant, a
server-level account on a tenant's domain. inbuxa-server never creates that
state. Existing accounts found in it (none at INBUXA, possible elsewhere)
are left working and listed in the admin dashboard for an operator to
resolve.
- **MT-8.** A domain can move into a tenant only from no tenant, and out of one
only back to no tenant. It moves only when no principal on it belongs to
anything other than the destination. **Decision**, a deliberate improvement
on the observed upstream behavior: upstream lets a domain leave a tenant
while the tenant's accounts are still on it, stranding them.
on upstream (observed 8): upstream lets a domain leave a tenant while the
tenant's accounts are still on it. They stay in the tenant, on a domain
that isn't, and can still sign in.
- **MT-9.** Creating a domain inside a tenant also puts its DKIM keys in that
tenant (observed).
tenant. Upstream generates two per domain (observed 9).
- **MT-10.** A tenant can be deleted only when nothing references it. Otherwise
deletion is refused with `objectIsLinked`, listing what still refers to it.
**Decision**: ihasmail already enforces this in its interface, and the server
@@ -143,13 +154,17 @@ Each requirement has an ID, and tests name the IDs they check.
### Administering a tenant
- **MT-11.** A tenant's administrator is an account inside the tenant that
holds administrative permissions. Within the tenant it can do what those
holds administrative permissions (upstream: the `Admin` role, which inside a
tenant carries 290 permissions against 641 at server level; observed 3). Within the tenant it can do what those
permissions allow, on every object type that carries `memberTenantId`.
Everything it creates lands in its own tenant. It never needs to, and may
not, set `memberTenantId` itself.
Everything it creates lands in its own tenant (observed 1). It never needs
to, and may not, set `memberTenantId` itself. `memberTenantId` isn't
returned to it at all (observed 3).
- **MT-12.** A tenant administrator can read its own tenant's `x:Tenant` object
(name, logo, quotas, usage) but can't change it. Changing the tenant object
itself is server-level.
itself is server-level. **Decision**, a deliberate difference: upstream
refuses the read with `forbidden` (observed 3), so a tenant admin can't see
its own limits or usage. ihasmail needs them to warn before a limit is hit.
### The permission ceiling
@@ -172,7 +187,11 @@ Each requirement has an ID, and tests name the IDs they check.
them.
- **MT-16.** Changing a tenant's roles, permissions or quotas takes effect for
its people without a restart and without them signing in again. The next
request is judged by the new rules.
request is judged by the new rules. **Decision**, a deliberate improvement:
upstream caches each principal's permissions, and a change to the tenant
hadn't reached an already-authenticated admin after seven minutes
(observed 6). A change must invalidate the cached permissions of everyone it
affects.
### Quotas
@@ -184,11 +203,18 @@ Each requirement has an ID, and tests name the IDs they check.
- **MT-18.** Existing objects over a lowered limit stay. Only new ones are
refused.
- **MT-19.** Disk quota. `maxDiskQuota` bounds the total storage of every
member together. Once the total reaches it, no more mail is accepted for
any account in the tenant until usage falls. Per-account quotas still apply
as well, whichever is reached first.
- **MT-20.** `usedDiskQuota` is kept current as members' usage changes, and is
never written by a client.
member together. A message that would take the total past it isn't
delivered, even if usage is still under the limit (observed 2). Per-account
quotas still apply as well, whichever is reached first.
- **MT-19a.** The refusal matches upstream: local delivery answers
`451 4.3.0 Organization over quota.`, a temporary failure, so the message
waits in the queue and retries until the queue gives up. Submission itself
still succeeds, so the sender learns nothing until the delay notice.
**Decision** on top: when a sender inside the server submits to a
recipient whose tenant is over quota, say so at submission time too (a
JMAP `EmailSubmission` warning, not a refusal).
- **MT-20.** `usedDiskQuota` is kept current as members' usage changes: it
moved on the delivery itself (observed 2). It is never written by a client.
- **MT-21.** The `recalculateQuota` task recomputes one tenant's
`usedDiskQuota` from its members' actual usage. `resetTenantQuotas`
recomputes every tenant. Both can run while the server is live.
@@ -253,22 +279,58 @@ marked **(compat)** also run against a copy of INBUXA's data.
15. **(compat)** INBUXA's existing tenants, their members and quotas read back
unchanged, and each tenant admin sees exactly what it saw before.
## To observe
## Observed
Behavior no public source settles. Settled by watching INBUXA's live
Enterprise server with throwaway objects, never by reading upstream code:
Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart
0.16.22), over JMAP and SMTP submission. The probes ran as a temporary
server-level admin, and created two tenants, two domains under `ttlhost.com`
with manual certificates, four accounts, three ACME providers pointed at
Let's Encrypt staging, and two probe messages. Everything was deleted
afterwards, and a sweep of every affected object type found nothing left.
No upstream code was read.
1. Which server-level objects a tenant's things may reference: a shared DNS
provider, ACME provider or directory in no tenant, used by a domain in a
tenant.
2. The SMTP reply when delivery is refused for a full tenant disk quota: the
code, whether temporary or permanent, and the text.
3. Whether a tenant administrator can read server-level settings at all, even
read-only.
4. What a tenant administrator sees in the mail queue.
5. Whether a principal's directory search (JMAP principals) crosses tenants.
6. What happens to active sessions when a principal's tenant is changed by a
server-level admin.
1. **References across tenants.** A tenant's domain can't use an ACME
provider in no tenant, or one in another tenant. Both are refused with
`invalidForeignKey`, even when a server-level admin makes the change. A
provider the tenant admin creates lands in its tenant and works. The tenant
admin can't see server-level providers at all.
2. **Full tenant disk quota.** With usage at 1,191 bytes and a 1,200-byte
limit, the next message wasn't delivered: the check is on what delivery
would bring the total to. Local delivery answered `451 4.3.0 Organization
over quota.` at `RCPT TO` (temporary). Submission was accepted, and the
message stayed queued with a retry due in 2 minutes, a delay notice due
after 1 day, and expiry after 3 days. Usage rose on the delivery itself.
3. **What a tenant admin can read.** Its tenant's accounts, domain, DKIM keys
and its own ACME providers, roles, DNS providers and queue. It gets
`forbidden` for listeners, certificates, system settings and every tenant
object, its own included. Objects in no tenant or another tenant, fetched by
id, come back in `notFound`. `memberTenantId` isn't returned to it. Its
`Admin` role carried 290 permissions (server-level admin: 641).
4. **The queue.** Tenant 1's admin saw exactly the two queued messages
addressed to its domain, both sent from tenant 2. It saw none of the other
23 entries in the server's queue. The sender side (a tenant admin seeing its
own people's outgoing mail) wasn't tested: there was no admin in tenant 2.
5. **Directory search.** A tenant user's JMAP `Principal/query`, with and
without a text filter, returned only its own tenant's two principals: not
the other tenant's, and not the server-level accounts.
6. **Changes to a tenant's roles and permissions.** Setting tenant 1's
`permissions` to `Replace` with nothing enabled, then its `roles` to an
empty custom set, left its already-signed-in admin with all 290
permissions for 2.5 minutes. A new admin created during the change was
refused everything, including its JMAP session (HTTP 403): the ceiling
works, but permissions are cached per principal. After the roles were
restored, the new admin was still refused 6.7 minutes later, when polling
stopped. The cache lifetime wasn't measured. The `permissions` field on its
own wasn't isolated from the `roles` field, because only cached principals
saw that change.
7. **An account's tenant.** Creating accounts on a tenant's domain without
`memberTenantId` put them in no tenant. Giving one a tenant other than its
domain's was refused with `invalidForeignKey` naming the domain. The same
happened for a no-tenant domain with a tenant set.
8. **A domain leaving its tenant.** Allowed while one of the tenant's accounts
was on it. The account kept its tenant and could still sign in.
9. **DKIM.** Each new domain got two DKIM keys, each in the domain's tenant.
Record each answer here with its date and how it was observed, before
implementation starts.
Not yet settled: whether deleting a tenant that still holds objects is
refused (the probe accounts were deleted first), and the sender side of 4.
Both are low-risk to leave for implementation-time tests.