Follow what the live server does with tenants and domains

A run on the production server with throwaway tenants, a role, lists and a
domain, all removed, found three things the source reading had not:

- Something in a tenant has to be on a domain in that tenant (a list in a
  tenant on an unassigned domain is invalidForeignKey), while something in
  no tenant may be on a tenant's domain. The account panel's tenant choice
  offered every tenant; it offers only the domain's now, and a new account
  starts in the tenant of the domain it is made on. The domain list reads
  memberTenantId for it.
- A domain created in a tenant puts its DKIM keys there too, and they keep
  the tenant from being deleted. They are counted with the rest, so Delete
  is not offered while any remain.
- Stalwart lets a domain leave a tenant while the tenant still has accounts
  on it, stranding them. The panel asks first and refuses while any are
  there.

The refusal to delete a tenant that holds anything was confirmed, as were
tenant create, quota pointers, logo and rename. The mock follows the domain
rule, filters DKIM keys by tenant, and KNOWN-ISSUES records the run.

The non-Enterprise notice is now just "Tenants are a Stalwart Enterprise
feature." Two sentences were reworded and one plural added, in all nine
catalogues, and the old sentences are gone.
This commit is contained in:
2026-09-15 09:37:00 -07:00
parent ce5eb04c2d
commit 40df0f658b
21 changed files with 211 additions and 48 deletions
+14
View File
@@ -286,3 +286,17 @@ test("a tenant administrator cannot move anything into a tenant", () => {
assert.equal(r.notUpdated?.d4?.type, "invalidPatch");
assert.match(r.notUpdated!.d4!.description, /memberTenantId/);
});
test("something in a tenant has to be on a domain in it, and something in none may be anywhere", () => {
const dir = make("admin");
const outside = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "stray", domainId: "d1", memberTenantId: "t1" } } }) as { notCreated?: Record<string, { type: string; objectId: { object: string } }> };
assert.equal(outside.notCreated?.n?.type, "invalidForeignKey");
assert.equal(outside.notCreated!.n!.objectId.object, "Domain");
const inside = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "team", domainId: "d3", memberTenantId: "t1" } } }) as { created?: Record<string, { id: string }> };
assert.ok(inside.created?.n?.id);
const none = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "open", domainId: "d3" } } }) as { created?: Record<string, { id: string }> };
assert.ok(none.created?.n?.id);
const [someone] = (dir.handlers["x:Account/query"]!({ filter: { "@type": "User", domainId: "d1" } }) as { ids: string[] }).ids;
const move = dir.handlers["x:Account/set"]!({ update: { [someone!]: { memberTenantId: "t1" } } }) as { notUpdated?: Record<string, { type: string }> };
assert.equal(move.notUpdated?.[someone!]?.type, "invalidForeignKey");
});
+17 -4
View File
@@ -231,6 +231,17 @@ export function createDirectory(opts: Options) {
{ id: "t1", name: "Acme Corp", logo: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: { maxAccounts: 25, maxDomains: 2, maxDiskQuota: 50 * GIB }, createdAt: "2026-07-01T09:00:00Z" },
];
const tenantUsage = (id: string) => accounts.filter((x) => x.memberTenantId === id).reduce((n, x) => n + Number(x.usedDiskQuota ?? 0), 0);
/**
* Something in a tenant has to be on a domain in that tenant; something in no
* tenant may be on anyone's domain. Both as the live server answered
* (2026-09-15), including the shape of the refusal.
*/
const domainTenantRefused = (o: Obj): Obj | null => {
const tenant = o.memberTenantId ?? null;
const domain = domains.find((d) => d.id === o.domainId);
if (!tenant || !domain || (domain.memberTenantId ?? null) === tenant) return null;
return { type: "invalidForeignKey", objectId: { object: "Domain", id: domain.id } };
};
/** Only an administrator outside every tenant may put things in one; Stalwart refuses anyone else. */
const tenantRefused = (patch: Obj): Obj | null =>
"memberTenantId" in patch && opts.role !== "admin" ? setError("invalidPatch", "Cannot modify memberTenantId property", ["memberTenantId"]) : null;
@@ -355,7 +366,7 @@ export function createDirectory(opts: Options) {
const refused = grantRefused(o.roles);
if (refused) { notCreated[cid] = setError("forbidden", refused); continue; }
if (o.memberTenantId) {
const refusedTenant = tenantRefused(o);
const refusedTenant = tenantRefused(o) ?? domainTenantRefused(o);
if (refusedTenant) { notCreated[cid] = refusedTenant; continue; }
}
const password = Object.values((o.credentials as Obj) ?? {})[0] as Obj | undefined;
@@ -389,6 +400,7 @@ export function createDirectory(opts: Options) {
if (target["@type"] === "Group") failure = setError("invalidProperties", "Groups cannot be members of other groups.", ["memberGroupIds"]);
else if (Object.keys((next.memberGroupIds as Obj) ?? {}).some((g) => accounts.find((x) => x.id === g)?.["@type"] !== "Group")) failure = setError("invalidForeignKey", "Group does not exist.", ["memberGroupIds"]);
}
if (!failure && "memberTenantId" in patch) failure = domainTenantRefused(next);
if (!failure && ("roles" in patch || "permissions" in patch)) {
const refused = grantRefused(next.roles);
if (refused) failure = setError("forbidden", refused);
@@ -477,7 +489,8 @@ export function createDirectory(opts: Options) {
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
},
"x:DkimSignature/get": get(dkimKeys, "sysDkimSignatureGet"),
"x:DkimSignature/query": query(() => dkimKeys, "sysDkimSignatureQuery", ["domainId", "memberTenantId"], (o, f) => f.domainId === undefined || o.domainId === f.domainId),
"x:DkimSignature/query": query(() => dkimKeys, "sysDkimSignatureQuery", ["domainId", "memberTenantId"], (o, f) =>
(f.domainId === undefined || o.domainId === f.domainId) && (f.memberTenantId === undefined || (o.memberTenantId ?? null) === f.memberTenantId)),
"x:DkimSignature/set": (a) => {
const destroyed: string[] = [];
for (const id of (a.destroy as string[]) ?? []) {
@@ -526,10 +539,10 @@ export function createDirectory(opts: Options) {
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
demand("sysMailingListCreate");
const o: Obj = { recipients: {}, aliases: {}, description: null, ...(raw as Obj) };
const failure = check(o);
const failure = check(o) ?? (o.memberTenantId ? (tenantRefused(o) ?? domainTenantRefused(o)) : null);
if (failure) { notCreated[cid] = failure; continue; }
const id = `l${counter++}`;
lists.push({ ...o, id, memberTenantId: null });
lists.push({ memberTenantId: null, ...o, id });
created[cid] = { id, emailAddress: `${o.name}@${domainName(o.domainId)}` };
}
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {