Add Tenants to Administration, and let an account be put in one

A tenant is a separate organisation on one server: its own people,
domains and limits, and an administrator who manages only what is in it.
It gets a section under Access, gated by sysTenantQuery and sysTenantGet,
with a notice on a server that does not report Enterprise, where anyone
inside a tenant is held to an ordinary user's permissions.

The panel edits the tenant's name, logo, role and limits. The logo is an
https address, drawn through the image proxy the strict image policy
requires, or an image data URL. Limits change one quotas/<name> pointer
each, so the four ihasmail does not offer keep their values, and an empty
field is no limit. The role is the most anyone inside can be allowed.

Stalwart keeps no list on a tenant -- each account, group, domain, list and
role names its own -- so what a tenant holds is counted with memberTenantId
queries and shown against its limits. Domains are added and taken out from
the tenant's panel, one memberTenantId change each; only a domain in no
tenant can be added, and its accounts stay where they are. Delete is offered
once every count reads zero.

A tenant does nothing until someone administers it, so the account panel
gains a Tenant choice for an administrator who can read tenants: an
Administrator inside a tenant administers that tenant. Nobody moves their
own account.

The mock has a tenant holding a domain and an administrator, a spare domain
to assign, memberTenantId filters on every query, and Stalwart's rule that
only an administrator outside every tenant may move things into one. A test
of taking a domain back out found that the mock's pointer handling dropped a
top-level null instead of storing it, so nothing had ever been cleared that
way; it stores null now, as the server reads it back.

Nothing about tenants has been written on a live server: production has
none. KNOWN-ISSUES says what was read from source.

Thirty-nine new strings and one plural, in all nine catalogues.
This commit is contained in:
2026-09-15 09:28:39 -07:00
parent 7e46ddeb7d
commit fd104a1f34
29 changed files with 1467 additions and 25 deletions
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/adminTenants";
describe("a tenant's limits", () => {
it("change one pointer each, leaving the quotas ihasmail does not offer alone", () => {
const before = { maxAccounts: 25, maxDomains: 2, maxOauthClients: 7 };
expect(quotasPatch(before, { maxAccounts: 30, maxDomains: null, maxGroups: 5, maxRoles: null })).toEqual({
"quotas/maxAccounts": 30,
"quotas/maxDomains": null,
"quotas/maxGroups": 5,
});
expect(quotasPatch(before, { maxAccounts: 25 })).toEqual({});
});
});
describe("what a tenant holds", () => {
it("is counted with a memberTenantId filter per kind, users and groups apart", async () => {
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
const f = (args as { filter: Record<string, unknown> }).filter;
if (method === "x:Role/query") throw new Error("forbidden");
return { total: method === "x:Account/query" && f["@type"] === "Group" ? 2 : 1 };
});
expect(await countTenantMembers("t1")).toEqual({ accounts: 1, groups: 2, lists: 1, domains: 1 });
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberTenantId: "t1" }, limit: 0, calculateTotal: true });
expect(call).toHaveBeenCalledWith("x:Domain/query", { filter: { memberTenantId: "t1" }, limit: 0, calculateTotal: true });
call.mockRestore();
});
it("moves a domain in and out by its memberTenantId", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ updated: { d4: null } });
await setDomainTenant("d4", "t1");
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: "t1" } } });
await setDomainTenant("d4", null);
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: null } } });
call.mockRestore();
});
});
describe("a tenant's logo", () => {
it("is drawn only from https or an image data URL", () => {
expect(drawableLogo("https://example.com/logo.png")).toBe("https://example.com/logo.png");
expect(drawableLogo("data:image/png;base64,AAAA")).toBe("data:image/png;base64,AAAA");
expect(drawableLogo("http://example.com/logo.png")).toBeNull();
expect(drawableLogo("javascript:alert(1)")).toBeNull();
expect(drawableLogo("data:text/html;base64,AAAA")).toBeNull();
expect(drawableLogo(null)).toBeNull();
});
});
+2 -1
View File
@@ -27,7 +27,7 @@ export function can(perms: Permissions, object: AdminObject, op: AdminOp): boole
return perms.has(`sys${object}${op}`);
}
export type AdminSection = "dashboard" | "accounts" | "groups" | "lists" | "roles" | "domains";
export type AdminSection = "dashboard" | "accounts" | "groups" | "lists" | "tenants" | "roles" | "domains";
export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent";
@@ -63,6 +63,7 @@ export function adminSections(perms: Permissions): AdminSection[] {
// Groups are accounts to the server, behind the same two permissions.
if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts", "groups");
if (can(perms, "MailingList", "Query") && can(perms, "MailingList", "Get")) out.push("lists");
if (can(perms, "Tenant", "Query") && can(perms, "Tenant", "Get")) out.push("tenants");
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) out.push("roles");
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
return out;
+13 -4
View File
@@ -54,6 +54,8 @@ export interface DirectoryAccount {
usedDiskQuota?: number;
aliases?: Record<string, EmailAlias>;
memberGroupIds?: Record<string, boolean>;
/** The tenant the account belongs to; only ever read back to an administrator outside every tenant. */
memberTenantId?: string | null;
credentials?: Record<string, Credential>;
createdAt?: string;
}
@@ -65,7 +67,7 @@ export interface DirectoryDomain {
const ACCOUNT_PROPERTIES = [
"@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas",
"usedDiskQuota", "aliases", "memberGroupIds", "credentials", "createdAt",
"usedDiskQuota", "aliases", "memberGroupIds", "memberTenantId", "credentials", "createdAt",
];
/** The one quota ihasmail edits; the others keep whatever they had. */
@@ -143,6 +145,8 @@ export interface NewAccount {
password: string;
roles: UserRoles;
diskQuotaBytes: number | null;
/** Put the account in a tenant; only an administrator outside every tenant may. */
memberTenantId?: string | null;
}
export async function createAccount(input: NewAccount): Promise<string> {
@@ -159,6 +163,7 @@ export async function createAccount(input: NewAccount): Promise<string> {
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
aliases: {},
memberGroupIds: {},
...(input.memberTenantId ? { memberTenantId: input.memberTenantId } : {}),
// Required on create. Turning it on is one-way and not offered here.
encryptionAtRest: { "@type": "Disabled" },
},
@@ -229,7 +234,7 @@ const VALIDATOR_MESSAGES: Record<string, () => string> = {
};
/** What kind of thing a refusal was about, where the wording has to differ. */
export type DirectoryObject = "account" | "domain" | "group" | "list" | "role";
export type DirectoryObject = "account" | "domain" | "group" | "list" | "role" | "tenant";
/**
* Say what went wrong in terms of the person's own action, in their language.
@@ -282,7 +287,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
? t("Your organisation has reached the number of mailing lists it is allowed.")
: object === "role"
? t("Your organisation has reached the number of roles it is allowed.")
: t("Your organisation has reached the number of accounts it is allowed.");
: object === "tenant"
? t("The server allows no more tenants.")
: t("Your organisation has reached the number of accounts it is allowed.");
case "objectIsLinked":
return t("Something still depends on this, so the server kept it.");
case "notFound":
@@ -294,7 +301,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
? t("This mailing list no longer exists. Someone may have deleted it.")
: object === "role"
? t("This role no longer exists. Someone may have deleted it.")
: t("This account no longer exists. Someone may have deleted it.");
: object === "tenant"
? t("This tenant no longer exists. Someone may have deleted it.")
: t("This account no longer exists. Someone may have deleted it.");
case "rateLimit":
return t("Too many attempts. Please wait a few minutes and try again.");
case "tooLarge":
+1
View File
@@ -228,6 +228,7 @@ export function describeLinked(linked: string[]): string {
else if (kind === "MailingList") parts.push(plural(n, { one: "{n} mailing list", other: "{n} mailing lists" }));
else if (kind === "DkimSignature") parts.push(plural(n, { one: "{n} DKIM key", other: "{n} DKIM keys" }));
else if (kind === "Role") parts.push(plural(n, { one: "{n} role", other: "{n} roles" }));
else if (kind === "Domain") parts.push(plural(n, { one: "{n} domain", other: "{n} domains" }));
else if (kind === "Authentication") parts.push(t("the default roles"));
else parts.push(plural(n, { one: "{n} other item", other: "{n} other items" }));
}
+174
View File
@@ -0,0 +1,174 @@
import { client } from "@/jmap/client";
import { t } from "@/lib/i18n";
import { DirectoryError } from "@/lib/adminDirectory";
import { DomainError } from "@/lib/adminDomains";
/**
* Tenants, from Stalwart 0.16's directory.
*
* `x:Tenant` behind `sysTenant*`, an Enterprise feature: on a Community server
* the objects exist, but anyone inside a tenant is held to a plain user's
* permissions. A tenant is a name, an optional logo, the roles its members may
* at most have, and quotas. It holds no list of what is in it -- membership
* runs the other way, as `memberTenantId` on accounts, groups, domains,
* mailing lists, roles and DKIM keys.
*
* Only an account outside every tenant may set `memberTenantId` (Stalwart
* refuses "Cannot modify memberTenantId property" to anyone else), and inside a
* tenant the server scopes every query to it and fills it in on create. Shapes
* from the 0.16.22 schema:
*
* - `quotas` is a map from a `TenantStorageQuota` name to a number: counts for
* accounts, groups, domains and the rest, bytes for `maxDiskQuota`. A quota
* that is absent is no limit.
* - `logo` is a URL or a data URL, or null.
*/
export type TenantRoles = { "@type": "Default" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
export interface DirectoryTenant {
id: string;
name: string;
logo?: string | null;
roles?: TenantRoles;
quotas?: Record<string, number>;
usedDiskQuota?: number;
createdAt?: string;
}
/** The quotas ihasmail offers, in the order they are shown. Disk space is bytes; the rest are counts. */
export const TENANT_QUOTAS = ["maxAccounts", "maxGroups", "maxMailingLists", "maxDomains", "maxRoles", "maxDkimKeys", "maxDiskQuota"] as const;
export type TenantQuota = (typeof TENANT_QUOTAS)[number];
/** What belongs to a tenant, and how each is counted. */
export const TENANT_MEMBERS = [
{ key: "accounts", method: "x:Account/query", filter: { "@type": "User" }, quota: "maxAccounts" },
{ key: "groups", method: "x:Account/query", filter: { "@type": "Group" }, quota: "maxGroups" },
{ key: "lists", method: "x:MailingList/query", filter: {}, quota: "maxMailingLists" },
{ key: "domains", method: "x:Domain/query", filter: {}, quota: "maxDomains" },
{ key: "roles", method: "x:Role/query", filter: {}, quota: "maxRoles" },
] as const;
export type TenantMemberKind = (typeof TENANT_MEMBERS)[number]["key"];
const TENANT_PROPERTIES = ["name", "logo", "roles", "quotas", "usedDiskQuota", "createdAt"];
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[]; linkedObjects?: Array<{ object?: string; id?: string }> } | null> | undefined> & {
created?: Record<string, { id: string }>;
};
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
const first = Object.values(res[key] ?? {})[0];
if (first) throw new DomainError(first);
}
export async function queryTenants(opts: { text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
const res = await client.call<{ ids?: string[]; total?: number }>("x:Tenant/query", {
...(opts.text?.trim() ? { filter: { text: opts.text.trim() } } : {}),
position: opts.position ?? 0,
...(opts.limit ? { limit: opts.limit } : {}),
calculateTotal: true,
});
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
}
export async function getTenants(ids: string[]): Promise<DirectoryTenant[]> {
if (!ids.length) return [];
const res = await client.call<{ list: DirectoryTenant[] }>("x:Tenant/get", { ids, properties: TENANT_PROPERTIES });
const byId = new Map(res.list.map((x) => [x.id, x]));
return ids.map((id) => byId.get(id)).filter((x): x is DirectoryTenant => Boolean(x));
}
/** Every tenant's id and name, for pickers. */
export async function listTenantNames(): Promise<Array<{ id: string; name: string }>> {
const q = await client.call<{ ids?: string[] }>("x:Tenant/query", { limit: client.maxObjectsInGet });
if (!q.ids?.length) return [];
const res = await client.call<{ list: Array<{ id: string; name: string }> }>("x:Tenant/get", { ids: q.ids, properties: ["name"] });
return res.list.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* How many of each kind of thing a tenant holds. A count that fails -- the
* viewer may not read that kind at all -- is left out rather than shown as
* none, which would read as "safe to delete".
*/
export async function countTenantMembers(tenantId: string): Promise<Partial<Record<TenantMemberKind, number>>> {
const out: Partial<Record<TenantMemberKind, number>> = {};
await Promise.all(
TENANT_MEMBERS.map(async (m) => {
try {
const res = await client.call<{ total?: number }>(m.method, { filter: { ...m.filter, memberTenantId: tenantId }, limit: 0, calculateTotal: true });
if (typeof res.total === "number") out[m.key] = res.total;
} catch {
/* left out */
}
}),
);
return out;
}
/** The domains in a tenant, and those in none, which are the ones that can be added. */
export async function tenantDomains(tenantId: string): Promise<{ inTenant: Array<{ id: string; name: string }>; unassigned: Array<{ id: string; name: string }> }> {
const q = await client.call<{ ids?: string[] }>("x:Domain/query", { limit: client.maxObjectsInGet });
if (!q.ids?.length) return { inTenant: [], unassigned: [] };
const res = await client.call<{ list: Array<{ id: string; name: string; memberTenantId?: string | null }> }>("x:Domain/get", { ids: q.ids, properties: ["name", "memberTenantId"] });
const sorted = res.list.sort((a, b) => a.name.localeCompare(b.name));
return {
inTenant: sorted.filter((d) => d.memberTenantId === tenantId).map(({ id, name }) => ({ id, name })),
unassigned: sorted.filter((d) => !d.memberTenantId).map(({ id, name }) => ({ id, name })),
};
}
/** Put a domain in a tenant, or take it out with null. */
export async function setDomainTenant(domainId: string, tenantId: string | null): Promise<void> {
const res = await client.call<SetResponse>("x:Domain/set", { update: { [domainId]: { memberTenantId: tenantId } } });
throwIfRefused(res, "notUpdated");
}
export interface NewTenant {
name: string;
logo: string | null;
roles: TenantRoles;
quotas: Record<string, number>;
}
export async function createTenant(input: NewTenant): Promise<string> {
const res = await client.call<SetResponse>("x:Tenant/set", {
create: { n: { name: input.name.trim(), logo: input.logo, roles: input.roles, permissions: { "@type": "Inherit" }, quotas: input.quotas } },
});
throwIfRefused(res, "notCreated");
const id = res.created?.n?.id;
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the tenant was created."));
return id;
}
export async function updateTenant(id: string, patch: Record<string, unknown>): Promise<void> {
if (!Object.keys(patch).length) return;
const res = await client.call<SetResponse>("x:Tenant/set", { update: { [id]: patch } });
throwIfRefused(res, "notUpdated");
}
export async function destroyTenant(id: string): Promise<void> {
const res = await client.call<SetResponse>("x:Tenant/set", { destroy: [id] });
throwIfRefused(res, "notDestroyed");
}
/**
* The quota changes as one pointer each, so a quota ihasmail does not offer
* (OAuth clients, DNS servers, directories, ACME providers) keeps its value.
*/
export function quotasPatch(before: Record<string, number> | undefined, after: Partial<Record<TenantQuota, number | null>>): Record<string, number | null> {
const patch: Record<string, number | null> = {};
for (const key of TENANT_QUOTAS) {
if (!(key in after)) continue;
const next = after[key] ?? null;
const was = before?.[key] ?? null;
if (next !== was) patch[`quotas/${key}`] = next;
}
return patch;
}
/** A logo worth showing: an https or data image URL. Anything else is kept but not drawn. */
export function drawableLogo(logo: string | null | undefined): string | null {
if (!logo) return null;
return /^https:\/\//i.test(logo) || /^data:image\/(png|jpe?g|gif|webp|svg\+xml);/i.test(logo) ? logo : null;
}