web/src/lib had grown to 85 flat modules -- 42% of the web source, about
12,800 lines -- with one subdirectory (smime/) to its name. The tell was
that a naming prefix had taken over a directory's job: eight adminX.ts
files sat adjacent because alphabetical order put them there, not because
anything said they belonged together.
lib/admin/ adminAccess, adminDashboard, adminDirectory, adminDomains,
adminGroups, adminLists, adminRoles, adminTenants
lib/calendar/ appointment, availabilityWindow, eventDrag, ics, recurrence
Tests move with their modules into lib/admin/__tests__ and
lib/calendar/__tests__, which is what views/ already does. describeRules
stays in lib/__tests__: it checks that sieve's describeRule and
recurrence's agree, so it belongs to neither.
recurrence.ts joins the calendar group and archiveDate.ts does not, which
is the opposite of the first guess from the filenames. archiveDate picks
the Archive/2026/09 mailbox for a message -- mail, not calendar --
while recurrence reads JSCalendarRecurrenceRule. schedule.ts is scheduled
*send*, so it stays put too. birthdays.ts is left alone deliberately: it
is read off the contact cards and only rendered by the calendar, so it
belongs to whichever of the two you ask.
docs/ held no documentation. It held ten JPEGs and the two scripts that
capture them, while the actual documentation is a separate site in the
ihasmail.org repository -- so anyone opening docs/ expecting prose found
a headless-Chrome driver. The images are now screenshots/, and the two
capture scripts join the other .mjs tooling in scripts/, which is where a
generator belongs. Renaming docs/ to screenshots/ wholesale would have
produced screenshots/screenshots/inbox-dark.jpg.
No behavior changes: every import was already on the @/ alias, so this is
path rewrites and nothing else.
184 lines
8.2 KiB
TypeScript
184 lines
8.2 KiB
TypeScript
/**
|
|
* What the signed-in account may administer, read from the permissions Stalwart
|
|
* reported for it at sign-in.
|
|
*
|
|
* None of this is a security boundary, and nothing here should read as one.
|
|
* Every administrative call is a JMAP `x:` method sent through the ordinary
|
|
* proxy, and Stalwart checks each of them against the credential making it --
|
|
* scoping a tenant administrator's queries to their own tenant, and refusing a
|
|
* write the account may not make. What this decides is only what the client
|
|
* *offers*: a menu that appears for the people it can do something for, and
|
|
* buttons that are there when pressing them would work.
|
|
*
|
|
* The one place it is more than presentation is `outranks`, which stands in
|
|
* for a check Stalwart does not make. See there.
|
|
*/
|
|
|
|
export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant" | "QueuedMessage" | "Metric";
|
|
export type AdminOp = "Get" | "Query" | "Create" | "Update" | "Destroy";
|
|
|
|
export type Permissions = ReadonlySet<string>;
|
|
|
|
export function permissionSet(list: readonly string[] | null | undefined): Permissions {
|
|
return new Set(list ?? []);
|
|
}
|
|
|
|
export function can(perms: Permissions, object: AdminObject, op: AdminOp): boolean {
|
|
return perms.has(`sys${object}${op}`);
|
|
}
|
|
|
|
export type AdminSection = "dashboard" | "accounts" | "groups" | "lists" | "tenants" | "roles" | "domains";
|
|
|
|
export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent";
|
|
|
|
/**
|
|
* The dashboard's cards an account may see.
|
|
*
|
|
* A count is a query with `calculateTotal`, so a query alone earns one. The
|
|
* three read from the metric history need the get as well, since the query
|
|
* only finds the records. Stalwart scopes the first three to a tenant
|
|
* administrator's own tenancy; the metric history has no tenant in it at all,
|
|
* and the Tenant Administrator role Stalwart creates does not hold it -- which
|
|
* is how a tenant's dashboard comes to show only what is theirs.
|
|
*/
|
|
export function dashboardCards(perms: Permissions): DashboardCard[] {
|
|
const out: DashboardCard[] = [];
|
|
if (can(perms, "Account", "Query")) out.push("users");
|
|
if (can(perms, "Domain", "Query")) out.push("domains");
|
|
if (can(perms, "QueuedMessage", "Query")) out.push("pending");
|
|
if (can(perms, "Metric", "Query") && can(perms, "Metric", "Get")) out.push("memory", "received", "sent");
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The sections an account may open, in the order they are listed.
|
|
*
|
|
* A list that cannot be read is not worth an entry, so each takes both halves
|
|
* of reading one: the query that finds the objects and the get that shows them.
|
|
* The dashboard comes first, and is there whenever it has a card to show.
|
|
*/
|
|
export function adminSections(perms: Permissions): AdminSection[] {
|
|
const out: AdminSection[] = [];
|
|
if (dashboardCards(perms).length) out.push("dashboard");
|
|
// 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;
|
|
}
|
|
|
|
/** Whether to offer Administration at all: when there is a section to open. */
|
|
export function hasAdministration(perms: Permissions): boolean {
|
|
return adminSections(perms).length > 0;
|
|
}
|
|
|
|
/**
|
|
* What an administrator holds, at the least: Stalwart's built-in Tenant
|
|
* Administrator role, for the parts of it that manage people and domains.
|
|
* Anyone who has all of this can already do anything to the accounts an
|
|
* "Administrator" account could.
|
|
*/
|
|
export const ADMIN_BASELINE: readonly string[] = (["Account", "Domain", "Role", "MailingList"] as const).flatMap((o) =>
|
|
(["Get", "Query", "Create", "Update", "Destroy"] as const).map((op) => `sys${o}${op}`),
|
|
);
|
|
|
|
export type UserRoles = { "@type": "User" } | { "@type": "Admin" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
|
|
|
|
export type PermissionsMode =
|
|
| { "@type": "Inherit" }
|
|
| { "@type": "Merge" | "Replace"; enabledPermissions?: Record<string, boolean>; disabledPermissions?: Record<string, boolean> };
|
|
|
|
export interface RoleDef {
|
|
id: string;
|
|
description?: string | null;
|
|
enabledPermissions?: Record<string, boolean>;
|
|
roleIds?: Record<string, boolean>;
|
|
}
|
|
|
|
/**
|
|
* Whether an account can do something the viewer cannot.
|
|
*
|
|
* Stalwart checks that a caller holds every permission they grant -- when
|
|
* roles or permissions change, and when an account is created. It does not
|
|
* check when only a password changes, and it does not check a delete. So an
|
|
* account allowed to edit accounts could reset the password of one with far
|
|
* more rights than its own and sign in as it. ihasmail refuses to offer that,
|
|
* and treats such an account as read-only.
|
|
*
|
|
* It errs toward refusing. A role that cannot be read -- the viewer lacks
|
|
* `sysRoleGet`, or the id is not in the list -- counts as outranking, because
|
|
* an unknown grant is not a grant the viewer can be shown to hold. What it
|
|
* cannot see is tenancy: an "Administrator" account is a tenant administrator
|
|
* inside a tenant and a server administrator outside one, and a tenant-scoped
|
|
* viewer is not told which it is looking at. It never sees the second kind,
|
|
* which is why comparing against the administrator baseline is enough there.
|
|
*/
|
|
export function outranks(
|
|
viewer: Permissions,
|
|
target: { roles?: UserRoles | null; permissions?: PermissionsMode | null },
|
|
roles: ReadonlyMap<string, RoleDef> | null,
|
|
): boolean {
|
|
let granted = new Set<string>();
|
|
const kind = target.roles?.["@type"] ?? "User";
|
|
if (kind === "Admin") {
|
|
if (!ADMIN_BASELINE.every((p) => viewer.has(p))) return true;
|
|
} else if (kind === "Custom") {
|
|
const ids = Object.keys((target.roles as { roleIds?: Record<string, boolean> }).roleIds ?? {});
|
|
const resolved = resolveRoles(ids, roles);
|
|
if (!resolved) return true;
|
|
granted = resolved;
|
|
}
|
|
const mode = target.permissions;
|
|
if (mode && mode["@type"] !== "Inherit") {
|
|
const enabled = Object.keys(mode.enabledPermissions ?? {});
|
|
granted = mode["@type"] === "Replace" ? new Set(enabled) : new Set([...granted, ...enabled]);
|
|
}
|
|
for (const p of granted) if (!viewer.has(p)) return true;
|
|
return false;
|
|
}
|
|
|
|
/** Every permission a set of roles grants, nested roles included; null if any cannot be read. */
|
|
export function resolveRoles(ids: readonly string[], roles: ReadonlyMap<string, RoleDef> | null): Set<string> | null {
|
|
if (!ids.length) return new Set();
|
|
if (!roles) return null;
|
|
const out = new Set<string>();
|
|
const seen = new Set<string>();
|
|
const walk = (id: string): boolean => {
|
|
if (seen.has(id)) return true;
|
|
seen.add(id);
|
|
const role = roles.get(id);
|
|
if (!role) return false;
|
|
for (const p of Object.keys(role.enabledPermissions ?? {})) out.add(p);
|
|
return Object.keys(role.roleIds ?? {}).every(walk);
|
|
};
|
|
return ids.every(walk) ? out : null;
|
|
}
|
|
|
|
/** Whether the viewer could grant a role: they hold everything it carries. */
|
|
export function canGrantRole(viewer: Permissions, roleId: string, roles: ReadonlyMap<string, RoleDef> | null): boolean {
|
|
const granted = resolveRoles([roleId], roles);
|
|
return granted !== null && [...granted].every((p) => viewer.has(p));
|
|
}
|
|
|
|
/**
|
|
* A password to hand to somebody who will change it.
|
|
*
|
|
* Twenty characters from an alphabet without the ones people misread aloud
|
|
* (0/O, 1/l/I), in groups of five. Rejection sampling, so every character is
|
|
* equally likely rather than the first few of the alphabet slightly more.
|
|
*/
|
|
const ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
|
|
export function generatePassword(random: (n: number) => Uint8Array = (n) => crypto.getRandomValues(new Uint8Array(n))): string {
|
|
const out: string[] = [];
|
|
const limit = 256 - (256 % ALPHABET.length);
|
|
while (out.length < 20) {
|
|
for (const byte of random(32)) {
|
|
if (byte < limit && out.length < 20) out.push(ALPHABET[byte % ALPHABET.length]!);
|
|
}
|
|
}
|
|
return [0, 5, 10, 15].map((i) => out.slice(i, i + 5).join("")).join("-");
|
|
}
|