Add Administration, starting with accounts
An account whose Stalwart role manages accounts now finds Administration in the account menu. It lists, searches, creates and edits accounts -- display name, other addresses, role, storage limit -- sets a new password, and deletes, each offered only when the role holds the matching permission. The server keeps the permissions list from GET /api/account, which it already called for the edition and threw the rest away. Everything else is JMAP x:Account, x:Domain and x:Role calls through the existing /api/jmap proxy, so nothing new is stored and Stalwart decides every call. Stalwart checks a grant against the caller's permissions but not a password change or a delete, so an account that outranks the viewer is shown read-only. Your own password is changed in Settings, which re-seals the session; changing it here would strand it. The mock server gains a directory behind the same permission names, with MOCK_ROLE choosing admin, tenant-admin, helpdesk or user. 68 new strings, translated in all nine catalogues; strings falling back to English stay at 16.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ADMIN_BASELINE, can, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
||||
|
||||
const set = (...p: string[]) => permissionSet(p);
|
||||
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
||||
const helpdesk = set("sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "jmapEmailGet");
|
||||
const roles = new Map<string, RoleDef>([
|
||||
["user", { id: "user", enabledPermissions: { jmapEmailGet: true } }],
|
||||
["helpdesk", { id: "helpdesk", enabledPermissions: { sysAccountGet: true, sysAccountQuery: true, sysAccountUpdate: true }, roleIds: { user: true } }],
|
||||
["dns", { id: "dns", enabledPermissions: { sysDnsServerUpdate: true }, roleIds: { user: true } }],
|
||||
["loop", { id: "loop", enabledPermissions: {}, roleIds: { loop: true } }],
|
||||
]);
|
||||
|
||||
describe("who is offered administration", () => {
|
||||
it("needs both halves of reading the account list", () => {
|
||||
expect(hasAdministration(set("sysAccountQuery", "sysAccountGet"))).toBe(true);
|
||||
expect(hasAdministration(set("sysAccountQuery"))).toBe(false);
|
||||
expect(hasAdministration(set("sysAccountGet"))).toBe(false);
|
||||
expect(hasAdministration(permissionSet(undefined))).toBe(false);
|
||||
});
|
||||
|
||||
it("reads one permission per object and operation", () => {
|
||||
expect(can(helpdesk, "Account", "Update")).toBe(true);
|
||||
expect(can(helpdesk, "Account", "Destroy")).toBe(false);
|
||||
expect(can(helpdesk, "Domain", "Get")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Stalwart checks a grant, but not a password change or a delete. Without this,
|
||||
* anyone allowed to edit accounts could take over one that can do more.
|
||||
*/
|
||||
describe("an account that outranks the viewer", () => {
|
||||
it("an ordinary user never does", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "User" } }, null)).toBe(false);
|
||||
expect(outranks(helpdesk, {}, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("an administrator does, unless the viewer is one too", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Admin" } }, roles)).toBe(true);
|
||||
expect(outranks(everything, { roles: { "@type": "Admin" } }, roles)).toBe(false);
|
||||
});
|
||||
|
||||
it("a custom role does when it carries something the viewer lacks", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, roles)).toBe(false);
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } } }, roles)).toBe(true);
|
||||
});
|
||||
|
||||
it("a role that cannot be read counts against the target, not for it", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, null)).toBe(true);
|
||||
expect(outranks(everything, { roles: { "@type": "Custom", roleIds: { gone: true } } }, roles)).toBe(true);
|
||||
});
|
||||
|
||||
it("extra permissions on the account itself are counted", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "User" }, permissions: { "@type": "Merge", enabledPermissions: { sysDomainDestroy: true } } }, roles)).toBe(true);
|
||||
// Replace ignores the roles entirely, so only what it lists matters.
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } }, permissions: { "@type": "Replace", enabledPermissions: { jmapEmailGet: true } } }, roles)).toBe(false);
|
||||
});
|
||||
|
||||
it("survives a role that names itself", () => {
|
||||
expect(resolveRoles(["loop"], roles)).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe("granting a role", () => {
|
||||
it("is offered only for roles whose every permission the viewer holds", () => {
|
||||
expect(canGrantRole(helpdesk, "helpdesk", roles)).toBe(true);
|
||||
expect(canGrantRole(helpdesk, "dns", roles)).toBe(false);
|
||||
expect(canGrantRole(everything, "missing", roles)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated passwords", () => {
|
||||
it("are four groups of five unambiguous characters", () => {
|
||||
const p = generatePassword();
|
||||
expect(p).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
|
||||
expect(p).not.toMatch(/[01lIO]/);
|
||||
});
|
||||
|
||||
it("skip bytes that would favour the start of the alphabet", () => {
|
||||
// 256 % 55 leaves 36 byte values over; a plain modulo would hand those to
|
||||
// the first 36 characters twice as often. Bytes of 220 and up are dropped
|
||||
// and more are drawn, so a batch of nothing but those costs a draw.
|
||||
let call = 0;
|
||||
const source = (n: number) => (call++ === 0 ? new Uint8Array(n).fill(250) : Uint8Array.from({ length: n }, (_, i) => i));
|
||||
expect(generatePassword(source)).toBe("abcde-fghjk-mnpqr-stuvw");
|
||||
expect(call).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user