Files
ihasmail/web/src/lib/adminAccess.ts
T

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("-");
}