From c6b3674ccdeff39d7a039ad5310a2b41a9b6ad4a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 18 Sep 2026 00:36:30 -0700 Subject: [PATCH] Multi-tenancy feature spec; use upstream's ossify.py and UI schema --- SPEC.md | 30 ++++- features/multi-tenancy.md | 274 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 features/multi-tenancy.md diff --git a/SPEC.md b/SPEC.md index f71f4f3..cff0b18 100644 --- a/SPEC.md +++ b/SPEC.md @@ -47,10 +47,14 @@ Upstream marks Enterprise-only code two ways, and both must go: ### 2.2 The strip step -A script, run on every upstream sync, that: +Upstream publishes its own remover, `resources/scripts/ossify.py`, licensed +AGPL-3.0-only. It deletes Enterprise-only files and snippets by their markers, +which is this step's core. The fork runs it on every sync, wrapped by a +script of its own that: -1. Deletes every whole Enterprise-only file. -2. Removes every Enterprise-only snippet, markers included. +1. Runs `ossify.py` over `crates/` and `tests/`. +2. Checks independently that no `LicenseRef-SEL`-only marker survives + anywhere in the tree, not just in `.rs` files. 3. Fails the sync if a marker is malformed, e.g. a snippet with no end, a file whose header can't be read, or a new license identifier. 4. Writes a report listing what was removed and where, committed with the @@ -62,6 +66,12 @@ never committed to any branch of the fork, including history. ### 2.3 Where the rebuilt features go +Upstream's published registry schema (`resources/schema/schema.json.gz`) +flags every Enterprise object and field with `"enterprise": true`: 9 objects +and 30 fields at `d9dee0a`. That list is the checklist for §4, and the wrapper +in §2.2 reports it on every sync, so a newly flagged field is noticed. + + Rebuilt features live in one new crate of the fork's own, licensed AGPL-3.0-only, plus the smallest possible hooks in upstream files. The hooks sit where upstream's `enterprise` Cargo feature and `is_enterprise_edition()` @@ -193,10 +203,13 @@ dashboard, credentials and a few supporting types. - **Hand-built screens** for what operators change often: domains and DNS, DKIM, certificates and ACME, spam settings, the queue, tenants and quotas, reports. -- **Schema-generated forms** for the rest. The registry schema describes every - object's fields, types and constraints, and it's dual-licensed code the - fork has under the AGPL. The fork exposes it over JMAP, and ihasmail renders - a correct, if plain, editor for any object type from it. New types upstream +- **Schema-generated forms** for the rest. Upstream already publishes a + complete UI schema, `resources/schema/schema.json.gz`, with objects, fields + (with descriptions), forms, lists, enums, dashboards and layouts. It's what + Stalwart's own web interface draws from, and it ships under the repository's + dual license, so the fork has it under the AGPL. The fork serves it over + JMAP, and ihasmail renders a correct, if plain, editor for any object type + from it. New types upstream adds then appear without an ihasmail release. - Everything stays permission-gated, as Administration is today. @@ -266,6 +279,9 @@ bad certificate, a lockout), there has to be a way back in on the host: - Minimum it covers: list and edit listeners and certificates, reset an administrator's password, lift IP bans, and export or import the configuration. +- Upstream documents a CLI with `get`, `describe` and `snapshot` commands + (public docs, `management/cli`). Check how much of the above it already + covers before building anything. - Open: whether this lives in the server binary or in the installer as `oneshot recover`. diff --git a/features/multi-tenancy.md b/features/multi-tenancy.md new file mode 100644 index 0000000..de45161 --- /dev/null +++ b/features/multi-tenancy.md @@ -0,0 +1,274 @@ +# Feature spec: multi-tenancy + +Status: draft, 2026-09-18. Feature 1 in SPEC.md §4. + +## Provenance + +Written for the clean room (SPEC.md §3). Everything here comes from these +sources, and nothing else: + +| Source | License | Used for | +|---|---|---| +| Stalwart's registry schema: `crates/registry/src/schema/*.rs` and `resources/schema/schema.json.gz`, upstream `d9dee0a` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | Object shapes, field meanings, enum values, permission names, what upstream flags as Enterprise | +| 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` | + +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 +writes specs only. Where this spec needs a behavior no public source settles, +it says so under "To observe" or makes a decision of its own, marked +**Decision**. It never fills a gap from memory of upstream code. + +## What it is + +A tenant is a separate organization on one server: its own accounts, groups, +mailing lists, domains and settings, and an administrator who manages only +what is in it. One tenant can't see or reach another's resources. The server +operator decides how much of the server each tenant may use: which +permissions its people can hold, how many of each thing it can create, and +how much storage it can consume. + +Upstream ships this only in its Enterprise Edition. inbuxa-server ships it to +everybody, always on. There is no edition check. + +## Data model + +Unchanged from upstream, so existing data opens as it is (SPEC.md §7). + +### The tenant object, `x:Tenant` + +| Field | Type | Set by | Meaning | +|---|---|---|---| +| `name` | string | admin | The tenant's name. There is no separate description | +| `createdAt` | UTC date-time | server | When it was created | +| `logo` | string, nullable | admin | An image URL or a data URL, shown to the tenant's people | +| `roles` | `Roles`: `Default`, or `Custom` with a list of roles | admin | The roles the tenant holds, which are the most its people can be given | +| `permissions` | `Permissions`: `Inherit`, `Merge` or `Replace`, with enabled and disabled lists | admin | The permissions the tenant may grant | +| `quotas` | map from `TenantStorageQuota` to an unsigned number | admin | Limits; a missing key is no limit | +| `usedDiskQuota` | size in bytes | server | Storage used by all members together | + +Permission prefix: `sysTenant`. Permissions: `sysTenantGet`, +`sysTenantCreate`, `sysTenantUpdate`, `sysTenantDestroy`, `sysTenantQuery`, +and `taskTenantMaintenance`. + +### Quota keys, `TenantStorageQuota` + +`maxAccounts`, `maxGroups`, `maxDomains`, `maxMailingLists`, `maxRoles`, +`maxOauthClients`, `maxDkimKeys`, `maxDnsServers`, `maxDirectories`, +`maxAcmeProviders`, and `maxDiskQuota` (bytes). + +### Membership, `memberTenantId` + +A nullable reference to an `x:Tenant`. Null means the object belongs to no +tenant and is managed at server level. It appears on: + +- principals: `UserAccount`, `GroupAccount`, `MailingList`, `Role`; +- `Domain`; +- signing and DNS: `Dkim1Signature`, `Dkim2Signature`, every `DnsServer*` + provider, `AcmeProvider`; +- directories: `LdapDirectory`, `SqlDirectory`, `OidcDirectory`; +- `OAuthClient`; +- incoming reports: `ArfExternalReport`, `DmarcExternalReport`, + `TlsExternalReport`. + +### Elsewhere + +- `x:Authentication.defaultTenantRoleIds`: the roles a tenant gets when its + `roles` is `Default`. +- Task `TenantMaintenance`, with `tenantId` and `maintenanceType`. The only + type is `recalculateQuota`. +- Store maintenance type `resetTenantQuotas`: reset every tenant's usage + figure. +- Event and metric `limit.tenant-quota`: a tenant limit was reached and the + operation was refused. +- `x:Cache.tenants`: cache size for tenants. + +Upstream flags these fields as Enterprise, and in inbuxa-server they're +ordinary: every `memberTenantId` listed above, +`Authentication.defaultTenantRoleIds` and `Domain.logo`. The other flagged +fields belong to other features' specs. + +## Required behavior + +Each requirement has an ID, and tests name the IDs they check. + +### Isolation + +- **MT-1.** A principal in a tenant can reach only objects with the same + `memberTenantId`. Reach covers JMAP `/get`, `/query`, `/changes` and `/set` + on every object type that carries the field, directory lookups, sharing and + access rights, and any list an admin screen shows. +- **MT-2.** Objects in no tenant (`memberTenantId` null) are server-level. A + 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-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. + +### Membership + +- **MT-6.** Only an administrator in no tenant, with the matching `sys*` + permission, can create a tenant, delete one, or change any object's + `memberTenantId`. A tenant administrator can't move anything into or out of + a tenant, their own included. +- **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`. +- **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. +- **MT-9.** Creating a domain inside a tenant also puts its DKIM keys in that + tenant (observed). +- **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 + should too. + +### 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 + 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. +- **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. + +### The permission ceiling + +- **MT-13.** A principal's effective permissions are its own, cut down to + what its tenant allows. A permission the tenant doesn't hold has no effect + on anyone in the tenant, whoever granted it. Granting it isn't an error. It + just does nothing. +- **MT-14.** What a tenant allows is computed from its `roles` and its + `permissions`: + 1. **Roles.** `Default` means the roles in + `Authentication.defaultTenantRoleIds`; `Custom` means the listed roles. + The permissions of all those roles, taken together, are the base. + 2. **Permissions.** `Inherit` uses the base unchanged. `Merge` adds the + enabled list to the base. `Replace` uses only the enabled list and + ignores the base. + 3. **Disabled wins.** In `Merge` and `Replace`, the disabled list is removed + last. A disabled permission is never allowed, however it was reached. +- **MT-15.** The ceiling can never exceed what the server grants. No setting + on a tenant can give its people a permission the server has disabled for + 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. + +### Quotas + +- **MT-17.** Count quotas. Creating an object that would take a tenant past + its count limit for that kind is refused with `overQuota`, and the server + emits `limit.tenant-quota`. Moving a domain into a tenant counts its + principals and keys against the tenant's limits first, and is refused the + same way if any limit would be crossed. +- **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. +- **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. + +### Branding + +- **MT-22.** A signed-in principal can read the logo that applies to it: its + domain's `logo` if set, else its tenant's `logo` if set, else none. + **Decision** on the order: a domain is more specific than a tenant. Exposed + over JMAP so ihasmail can draw it (see "Interfaces"). +- **MT-23.** The server never fetches a logo URL itself. ihasmail draws URL + logos through its image proxy, as it does today. + +## Interfaces + +- **Existing, unchanged:** `x:Tenant/get`, `/set`, `/query`, `/changes`; the + `memberTenantId` field on the types listed above; the permission names; the + task and maintenance types; the event. +- **New:** the signed-in principal's applicable logo (MT-22). Proposed as a + read-only property on the principal's own account object in the fork's + namespace, with the exact shape settled in the contract spec (SPEC.md §5.2). +- **Errors:** RFC 8620 `SetError` types, as named above. Each refusal names + the property or limit involved, so ihasmail can say which one. + +## ihasmail changes + +- Drop the "Tenants are a Stalwart Enterprise feature" notice and the edition + check in front of the Tenants page. Keep `SHOW_ENTERPRISE_NOTICES` for + talking to upstream Stalwart. +- Show the applicable logo (MT-22) in the app for the signed-in user. +- Show `overQuota` and `limit.tenant-quota` refusals in plain words, naming the + limit. +- The domain-leaves-tenant guard it enforces today (MT-8) stays, and becomes a + second line of defense rather than the only one. + +## Acceptance tests + +Every test runs against inbuxa-server built with no Enterprise code. The ones +marked **(compat)** also run against a copy of INBUXA's data. + +1. Tenant admin in T lists accounts: sees only T's (MT-1). +2. Tenant admin in T gets a U account by id: `notFound`, not `forbidden`. + **Decision**: don't confirm that another tenant's object exists. +3. Tenant admin adds a U account to a T group: `forbidden` (MT-3). +4. Mail from a T address to a U address is delivered (MT-4). +5. Tenant admin sets `memberTenantId` on anything: refused (MT-6, MT-11). +6. Account created on a T domain is in T with no `memberTenantId` sent (MT-7). +7. Domain with T accounts moved out of T: refused (MT-8). +8. Tenant with one account deleted: `objectIsLinked` (MT-10). +9. User in T granted a permission T lacks: the permission has no effect + (MT-13). +10. `Replace` with an enabled permission that's also disabled: not allowed + (MT-14). +11. Lowering T's roles takes effect on the user's next request with no new + sign-in (MT-16). +12. `maxAccounts` 2: the third account is refused `overQuota` and the event is + emitted (MT-17). Lowering it to 1 keeps both existing accounts (MT-18). +13. `maxDiskQuota` reached across two accounts: delivery to a third member is + refused (MT-19). +14. `recalculateQuota` after hand-corrupting `usedDiskQuota`: restored + (MT-21). +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 + +Behavior no public source settles. Settled by watching INBUXA's live +Enterprise server with throwaway objects, never by reading upstream code: + +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. + +Record each answer here with its date and how it was observed, before +implementation starts.