Add Roles to Administration, with Stalwart's permissions in every language
A role is a named set of permissions given to accounts, groups and tenants. It gets its own section under a new Access heading: every role listed with the permissions it grants once its bases are followed, and a panel to create, edit and delete one. A role builds on others and has everything they grant; a denial anywhere in the tree wins, which is how Stalwart resolves it (permissions.rs unions enabled and disabled across the tree, then subtracts). The picker is Stalwart's own list of permissions, under its headings, searchable and filterable to what is granted or set here. Each permission is not set, allowed or denied, and one that is inherited says which role it comes from. Only permissions the viewer holds can be allowed, because Stalwart refuses the rest, and a role carrying anything the viewer lacks opens read-only with no delete, because Stalwart checks a grant but not a delete. Saving sends a pointer for each permission and base role that changed. The roles Stalwart hands out by default, read from x:Authentication, say so before they are changed and cannot be deleted here; a role still in use is kept by the server, and the refusal names what uses it. The permission list is Stalwart's schema. A new route, GET /api/admin/permissions, fetches /api/schema as the signed-in account and returns only names and labels, behind the same two gates as the registry methods and held in memory for an hour. Its labels are English only, so every one of the 661 has a translation in each of the eight other languages, in its own file keyed by permission name and loaded only when Roles opens. A permission a later Stalwart adds shows its English label. A test holds every language to the 0.16.22 snapshot: nothing missing, nothing stale. The mock answers x:Role/set with the grant check, loops and in-use refusals, reads the defaults from x:Authentication, and serves the schema gzipped as the real one is. Fifty-two new strings and two plurals in all nine catalogues, and 661 permission labels with 59 headings in each of the eight translations.
This commit is contained in:
@@ -8,6 +8,7 @@ import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
|
||||
import { attach as pushAttach, attachRelay as pushAttachRelay, prepare as pushPrepare, receive as pushReceive, pushStatus } from "./push.js";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { config } from "./config.js";
|
||||
import { fetchPermissions } from "./permissionSchema.js";
|
||||
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
|
||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
@@ -688,6 +689,30 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Administration: Stalwart's permission list ----------
|
||||
/*
|
||||
* The one administration read that is not a JMAP call: the labelled list of
|
||||
* permissions from Stalwart's schema, for the Roles picker. Behind the same
|
||||
* two gates as the registry methods, so a session that may not administer
|
||||
* learns nothing from it.
|
||||
*/
|
||||
api.get("/admin/permissions", requireSession, apiRateLimited, async (c) => {
|
||||
const session = c.get("session");
|
||||
if (!administrationAllowed(config.administration, session.remember)) {
|
||||
return config.administration
|
||||
? c.json({ error: "administration_needs_own_device" }, 403)
|
||||
: c.json({ error: "administration_disabled" }, 403);
|
||||
}
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const permissions = await fetchPermissions(session.authorization, upstream.baseUrl);
|
||||
if (!permissions) return c.json({ error: "upstream_error" }, 502);
|
||||
return c.json({ permissions });
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Blob upload ----------
|
||||
api.post("/upload/:accountId", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
|
||||
@@ -218,3 +218,33 @@ test("a list's address cannot be one an account already has, and a role without
|
||||
assert.equal(clash.notCreated?.n?.type, "primaryKeyViolation");
|
||||
assert.throws(() => make("helpdesk").handlers["x:MailingList/query"]!({}), (e: Refused) => e.type === "forbidden");
|
||||
});
|
||||
|
||||
/** Roles: Stalwart's grant check, loops, and a role still in use. */
|
||||
test("a role is refused a permission the caller does not hold, directly or through a base", () => {
|
||||
const helpdesk = make("helpdesk");
|
||||
// Helpdesk cannot create roles at all.
|
||||
assert.throws(() => helpdesk.handlers["x:Role/set"]!({ create: { n: { description: "x" } } }), (e: Refused) => e.type === "forbidden");
|
||||
const tenant = make("tenant-admin");
|
||||
const direct = tenant.handlers["x:Role/set"]!({ create: { n: { description: "Too much", enabledPermissions: { sysTenantCreate: true } } } }) as { notCreated?: Record<string, { type: string; description: string }> };
|
||||
assert.equal(direct.notCreated?.n?.type, "forbidden");
|
||||
assert.match(direct.notCreated!.n!.description, /not authorized to grant/);
|
||||
const fine = tenant.handlers["x:Role/set"]!({ create: { n: { description: "Accounts only", enabledPermissions: { sysAccountGet: true }, roleIds: { r1: true } } } }) as { created: Record<string, { id: string }> };
|
||||
assert.ok(fine.created.n!.id);
|
||||
});
|
||||
|
||||
test("a role cannot build on itself through another, and one in use is kept", () => {
|
||||
const dir = make("admin");
|
||||
const loop = dir.handlers["x:Role/set"]!({ update: { r1: { "roleIds/r3": true } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||
assert.equal(loop.notUpdated?.r1?.type, "invalidPatch");
|
||||
const inUse = dir.handlers["x:Role/set"]!({ destroy: ["r1"] }) as { notDestroyed?: Record<string, { type: string; linkedObjects: Array<{ object: string }> }> };
|
||||
assert.equal(inUse.notDestroyed?.r1?.type, "objectIsLinked");
|
||||
assert.deepEqual([...new Set(inUse.notDestroyed!.r1!.linkedObjects.map((l) => l.object))].sort(), ["Authentication", "Role"]);
|
||||
const free = dir.handlers["x:Role/set"]!({ destroy: ["r4"] }) as { destroyed: string[] };
|
||||
assert.deepEqual(free.destroyed, ["r4"]);
|
||||
});
|
||||
|
||||
test("the default roles are read from the authentication settings", () => {
|
||||
const { list } = make("admin").handlers["x:Authentication/get"]!({ ids: ["singleton"] }) as { list: Array<{ defaultUserRoleIds: Record<string, boolean> }> };
|
||||
assert.deepEqual(list[0]!.defaultUserRoleIds, { r1: true });
|
||||
assert.throws(() => make("tenant-admin").handlers["x:Authentication/get"]!({}), (e: Refused) => e.type === "forbidden");
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailSet", "jmapMailboxGet", "sys
|
||||
export function permissionsFor(role: MockRole): string[] {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer", "Tenant"), ...READ_SERVER, "impersonate"];
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer", "Tenant"), ...READ_SERVER, "sysAuthenticationGet", "impersonate"];
|
||||
case "tenant-admin":
|
||||
// The queue but not the metric history: Stalwart scopes the one to a
|
||||
// tenant's domains, and the other has no tenant to scope it by.
|
||||
@@ -133,10 +133,13 @@ export function createDirectory(opts: Options) {
|
||||
};
|
||||
|
||||
const roles: Obj[] = [
|
||||
{ id: "r1", description: "User", enabledPermissions: flags(USER_PERMISSIONS), disabledPermissions: {}, roleIds: {} },
|
||||
{ id: "r1", description: "User", enabledPermissions: flags(USER_PERMISSIONS), disabledPermissions: {}, roleIds: {}, memberTenantId: null },
|
||||
{ id: "r2", description: "Helpdesk", enabledPermissions: flags(permissionsFor("helpdesk").filter((p) => p.startsWith("sys"))), disabledPermissions: {}, roleIds: { r1: true } },
|
||||
{ id: "r3", description: "Directory manager", enabledPermissions: flags(all("Account")), disabledPermissions: {}, roleIds: { r1: true } },
|
||||
{ id: "r4", description: "Read-only auditor", enabledPermissions: flags(["sysAccountGet", "sysAccountQuery", "sysDomainGet", "sysDomainQuery", "sysLogGet"]), disabledPermissions: flags(["jmapEmailSet"]), roleIds: { r1: true } },
|
||||
];
|
||||
/** Stalwart's defaults: which roles an account gets when it is given no others. */
|
||||
const authentication: Record<string, Obj> = { defaultUserRoleIds: { r1: true }, defaultGroupRoleIds: {}, defaultTenantRoleIds: {}, defaultAdminRoleIds: {} };
|
||||
|
||||
const ownRoles = opts.role === "admin" || opts.role === "tenant-admin" ? { "@type": "Admin" } : opts.role === "helpdesk" ? { "@type": "Custom", roleIds: { r2: true } } : { "@type": "User" };
|
||||
|
||||
@@ -520,6 +523,72 @@ 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 } : {}) };
|
||||
},
|
||||
// Which roles Stalwart hands out by default. Its own settings object; the
|
||||
// Roles screen reads it to warn before a default role is changed.
|
||||
"x:Authentication/get": (a) => {
|
||||
demand("sysAuthenticationGet");
|
||||
const ids = (a.ids as string[] | null | undefined) ?? ["singleton"];
|
||||
return { accountId: opts.accountId, state: "1", list: ids.filter((id) => id === "singleton").map((id) => ({ id, ...authentication })), notFound: ids.filter((id) => id !== "singleton") };
|
||||
},
|
||||
"x:Role/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Obj = {};
|
||||
/** Stalwart refuses a role whose permissions -- its own or inherited -- the caller does not hold. */
|
||||
const check = (o: Obj, id?: string): Obj | null => {
|
||||
if (typeof o.description !== "string" || !o.description.trim()) return setError("invalidProperties", "String cannot be empty", ["description"]);
|
||||
const seen = new Set<string>();
|
||||
const walk = (rid: string): boolean => {
|
||||
if (rid === id) return false;
|
||||
if (seen.has(rid)) return true;
|
||||
seen.add(rid);
|
||||
const r = roles_(rid);
|
||||
return !!r && Object.keys((r.roleIds as Obj) ?? {}).every(walk);
|
||||
};
|
||||
if (!Object.keys((o.roleIds as Obj) ?? {}).every(walk)) return setError("invalidProperties", "A role cannot inherit from itself or from a role that does not exist.", ["roleIds"]);
|
||||
const granted = new Set(Object.keys((o.enabledPermissions as Obj) ?? {}));
|
||||
for (const rid of seen) for (const p of Object.keys((roles_(rid)!.enabledPermissions as Obj) ?? {})) granted.add(p);
|
||||
const missing = [...granted].filter((p) => !permissions.has(p));
|
||||
if (missing.length) return setError("forbidden", `You are not authorized to grant permissions: ${missing.slice(0, 5).join(", ")}.`);
|
||||
return null;
|
||||
};
|
||||
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||
demand("sysRoleCreate");
|
||||
const o: Obj = { enabledPermissions: {}, disabledPermissions: {}, roleIds: {}, ...(raw as Obj) };
|
||||
const failure = check(o);
|
||||
if (failure) { notCreated[cid] = failure; continue; }
|
||||
const id = `r${counter++}`;
|
||||
roles.push({ ...o, id, memberTenantId: null });
|
||||
created[cid] = { id };
|
||||
}
|
||||
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||
demand("sysRoleUpdate");
|
||||
const target = roles_(id);
|
||||
if (!target) { notUpdated[id] = setError("notFound", "Role not found."); continue; }
|
||||
const next = structuredClone(target);
|
||||
for (const [path, value] of Object.entries(raw as Obj)) setPointer(next, path, value);
|
||||
const failure = check(next, id);
|
||||
if (failure) { notUpdated[id] = failure.type === "invalidProperties" ? { ...failure, type: "invalidPatch" } : failure; continue; }
|
||||
Object.assign(target, next);
|
||||
updated[id] = null;
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
demand("sysRoleDestroy");
|
||||
if (!roles_(id)) { notDestroyed[id] = setError("notFound", "Role not found."); continue; }
|
||||
const linked = [
|
||||
...accounts.filter((x) => ((x.roles as Obj | undefined)?.roleIds as Obj | undefined)?.[id]).map((x) => ({ object: "Account", id: x.id })),
|
||||
...roles.filter((x) => (x.roleIds as Obj | undefined)?.[id]).map((x) => ({ object: "Role", id: x.id })),
|
||||
...(Object.values(authentication).some((set) => (set as Obj)[id]) ? [{ object: "Authentication", id: "singleton" }] : []),
|
||||
];
|
||||
if (linked.length) { notDestroyed[id] = { type: "objectIsLinked", objectId: { object: "Role", id }, linkedObjects: linked }; continue; }
|
||||
roles.splice(roles.findIndex((x) => x.id === id), 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
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:Role/get": get(roles, "sysRoleGet"),
|
||||
"x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => matchText(o, f.description)),
|
||||
};
|
||||
|
||||
@@ -10,6 +10,10 @@ import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyn
|
||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||
import { createDirectory, mockRole } from "./directory.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../web/src/locales/permissions/source.json", import.meta.url), "utf8")) as { permissions: Array<{ name: string; label: string }> }).permissions;
|
||||
|
||||
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
/**
|
||||
@@ -1428,6 +1432,13 @@ export const server = createServer(async (req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ permissions: directory.permissions, edition: "oss", locale: MOCK_LOCALE }));
|
||||
}
|
||||
// The registry schema, cut down to the permission list the Roles picker
|
||||
// reads. Gzipped as the real file is, from the 0.16.22 snapshot the
|
||||
// translations are checked against.
|
||||
if (url.pathname === "/api/schema" && req.method === "GET") {
|
||||
res.writeHead(200, { "content-type": "application/json", "content-encoding": "gzip" });
|
||||
return res.end(gzipSync(JSON.stringify({ enums: { Permission: PERMISSION_SNAPSHOT } })));
|
||||
}
|
||||
if (url.pathname === "/jmap/" && req.method === "POST") {
|
||||
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] };
|
||||
// A capability the server cannot parse fails the whole request, not the one
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { extractPermissions, parseSchemaBody } from "./permissionSchema.js";
|
||||
|
||||
/** Stalwart's permission list, out of the registry schema it serves at /api/schema. */
|
||||
test("the permission list is enums.Permission, names and labels, once each", () => {
|
||||
const schema = { objects: {}, enums: { Permission: [
|
||||
{ name: "sysAccountGet", label: "Accounts Management: Get accounts" },
|
||||
{ name: "authenticate", label: "" },
|
||||
{ name: "sysAccountGet", label: "a repeat" },
|
||||
{ label: "no name" },
|
||||
"not an object",
|
||||
] } };
|
||||
assert.deepEqual(extractPermissions(schema), [
|
||||
{ name: "sysAccountGet", label: "Accounts Management: Get accounts" },
|
||||
{ name: "authenticate", label: "authenticate" },
|
||||
]);
|
||||
assert.deepEqual(extractPermissions({ enums: {} }), []);
|
||||
assert.deepEqual(extractPermissions(null), []);
|
||||
});
|
||||
|
||||
test("the schema reads whether or not the transport already inflated it", () => {
|
||||
const doc = { enums: { Permission: [{ name: "impersonate", label: "Act on behalf of another user" }] } };
|
||||
const plain = new TextEncoder().encode(JSON.stringify(doc));
|
||||
assert.deepEqual(parseSchemaBody(plain), doc);
|
||||
assert.deepEqual(parseSchemaBody(new Uint8Array(gzipSync(plain))), doc);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { config } from "./config.js";
|
||||
|
||||
/**
|
||||
* Stalwart's list of permissions, for the Roles screen's picker.
|
||||
*
|
||||
* Stalwart publishes its whole registry schema at `GET /api/schema` to any
|
||||
* signed-in account -- objects, forms, layouts and `enums.Permission`, a label
|
||||
* for each permission. Its own administration interface is built from it. The
|
||||
* browser cannot fetch it (no credentials there, and another origin), so this
|
||||
* fetches it as the signed-in account and hands back the one part the client
|
||||
* needs: a list of names and English labels, a few dozen kilobytes rather than
|
||||
* the whole document.
|
||||
*
|
||||
* Held in memory for an hour per server, because it changes only when Stalwart
|
||||
* is upgraded. Nothing is written anywhere.
|
||||
*/
|
||||
|
||||
export interface PermissionInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const CACHE_MS = 60 * 60 * 1000;
|
||||
const cache = new Map<string, { at: number; list: PermissionInfo[] }>();
|
||||
|
||||
/** The permission list out of a schema document, or an empty list if it is not where 0.16 keeps it. */
|
||||
export function extractPermissions(schema: unknown): PermissionInfo[] {
|
||||
const list = (schema as { enums?: { Permission?: unknown } } | null)?.enums?.Permission;
|
||||
if (!Array.isArray(list)) return [];
|
||||
const out: PermissionInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of list) {
|
||||
const { name, label } = (item ?? {}) as { name?: unknown; label?: unknown };
|
||||
if (typeof name !== "string" || !name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
out.push({ name, label: typeof label === "string" && label ? label : name });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The schema's bytes as JSON. The file is shipped gzipped; whether the server
|
||||
* says so in Content-Encoding (so fetch has already inflated it) or serves the
|
||||
* .gz as it is, the magic number settles which this is.
|
||||
*/
|
||||
export function parseSchemaBody(bytes: Uint8Array): unknown {
|
||||
const raw = bytes[0] === 0x1f && bytes[1] === 0x8b ? gunzipSync(bytes) : Buffer.from(bytes);
|
||||
return JSON.parse(raw.toString("utf8"));
|
||||
}
|
||||
|
||||
export async function fetchPermissions(authorization: string, baseUrl: string): Promise<PermissionInfo[] | null> {
|
||||
const hit = cache.get(baseUrl);
|
||||
if (hit && Date.now() - hit.at < CACHE_MS) return hit.list;
|
||||
const res = await fetch(`${baseUrl}/api/schema`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const list = extractPermissions(parseSchemaBody(new Uint8Array(await res.arrayBuffer())));
|
||||
if (list.length) cache.set(baseUrl, { at: Date.now(), list });
|
||||
return list;
|
||||
}
|
||||
Reference in New Issue
Block a user