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:
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { permissionSet } from "@/lib/adminAccess";
|
||||
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/adminRoles";
|
||||
|
||||
const flags = (...n: string[]) => Object.fromEntries(n.map((x) => [x, true]));
|
||||
const roles = new Map<string, DirectoryRole>([
|
||||
["user", { id: "user", description: "User", enabledPermissions: flags("jmapEmailGet", "jmapEmailSet") }],
|
||||
["help", { id: "help", description: "Helpdesk", enabledPermissions: flags("sysAccountGet"), disabledPermissions: flags("jmapEmailSet"), roleIds: flags("user") }],
|
||||
["lead", { id: "lead", description: "Lead", enabledPermissions: flags("sysAccountUpdate"), roleIds: flags("help") }],
|
||||
]);
|
||||
|
||||
describe("what a role holds", () => {
|
||||
it("follows every base, and a denial anywhere in the tree wins", () => {
|
||||
// Stalwart unions enabled with enabled and disabled with disabled across
|
||||
// the tree, then takes the disabled away (permissions.rs).
|
||||
expect([...effectivePermissions(roles.get("lead")!, roles, "lead")].sort()).toEqual(["jmapEmailGet", "sysAccountGet", "sysAccountUpdate"]);
|
||||
const { granted, denied } = inherited(["help"], roles, "lead");
|
||||
expect(granted.get("jmapEmailGet")).toBe("help");
|
||||
expect(denied.get("jmapEmailSet")).toBe("help");
|
||||
});
|
||||
|
||||
it("changes a set one pointer at a time", () => {
|
||||
expect(setPatch("enabledPermissions", ["a", "b"], new Set(["b", "c"]))).toEqual({ "enabledPermissions/a": null, "enabledPermissions/c": true });
|
||||
expect(setPatch("roleIds", [], [])).toEqual({});
|
||||
});
|
||||
|
||||
it("will not build on itself, or on a role already built on it", () => {
|
||||
expect(canBuildOn("help", "help", roles)).toBe(false);
|
||||
expect(canBuildOn("help", "lead", roles)).toBe(false);
|
||||
expect(canBuildOn("lead", "user", roles)).toBe(true);
|
||||
expect(canBuildOn(null, "lead", roles)).toBe(true);
|
||||
});
|
||||
|
||||
it("is read-only to a viewer missing anything enabled in its tree, denied or not", () => {
|
||||
const viewer = permissionSet(["jmapEmailGet", "sysAccountGet", "sysAccountUpdate"]);
|
||||
// jmapEmailSet is denied on Helpdesk but enabled on User beneath it: a
|
||||
// grant Stalwart would check, and a delete it would not.
|
||||
expect(roleOutranks(viewer, roles.get("lead")!, roles)).toBe(true);
|
||||
expect(roleOutranks(permissionSet([...viewer, "jmapEmailSet"]), roles.get("lead")!, roles)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import source from "../../locales/permissions/source.json";
|
||||
import { describePermissions, splitLabel, type PermissionCatalog } from "@/lib/permissionLabels";
|
||||
import { UI_LANGUAGES } from "@/lib/languages";
|
||||
|
||||
|
||||
describe("permission labels", () => {
|
||||
it("split Stalwart's label into its heading and action", () => {
|
||||
expect(splitLabel("Accounts Management: Create accounts")).toEqual({ categoryKey: "Accounts Management", action: "Create accounts" });
|
||||
expect(splitLabel("Action: Reload: TLS certificates")).toEqual({ categoryKey: "Action", action: "Reload: TLS certificates" });
|
||||
expect(splitLabel("Act on behalf of another user")).toEqual({ categoryKey: "General", action: "Act on behalf of another user" });
|
||||
});
|
||||
|
||||
it("fall back to Stalwart's English for a permission a language does not have yet", () => {
|
||||
const catalog: PermissionCatalog = { categories: { "Accounts Management": "Kontenverwaltung" }, labels: { sysAccountGet: "Konten abrufen" } };
|
||||
expect(describePermissions([
|
||||
{ name: "sysAccountGet", label: "Accounts Management: Get accounts" },
|
||||
{ name: "sysBrandNewThing", label: "Novelties: Do something new" },
|
||||
{ name: "impersonate", label: "Act on behalf of another user" },
|
||||
], catalog, "Allgemein")).toEqual([
|
||||
{ name: "sysAccountGet", categoryKey: "Accounts Management", category: "Kontenverwaltung", action: "Konten abrufen" },
|
||||
{ name: "sysBrandNewThing", categoryKey: "Novelties", category: "Novelties", action: "Do something new" },
|
||||
{ name: "impersonate", categoryKey: "General", category: "Allgemein", action: "Act on behalf of another user" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Every language covers every permission the snapshot has, and nothing it
|
||||
* does not: a missing one would show English in the middle of a translated
|
||||
* picker, and a stale one would never be looked up.
|
||||
*/
|
||||
describe("the permission catalogues", () => {
|
||||
const modules = import.meta.glob<{ permissionCatalog: PermissionCatalog }>("../../locales/permissions/*.ts");
|
||||
const tagOf = (path: string) => path.split("/").pop()!.replace(/\.ts$/, "");
|
||||
const languages = UI_LANGUAGES.map((l) => l.tag).filter((tag) => tag !== "en");
|
||||
const names = new Set(source.permissions.map((p) => p.name));
|
||||
const categories = new Set(source.permissions.map((p) => splitLabel(p.label).categoryKey).filter((c) => c !== "General"));
|
||||
|
||||
it("exist for every language the interface ships", () => {
|
||||
expect(Object.keys(modules).map(tagOf).sort()).toEqual([...languages].sort());
|
||||
});
|
||||
|
||||
for (const tag of languages) {
|
||||
it(`${tag} names every permission and heading, and nothing else`, async () => {
|
||||
const load = Object.entries(modules).find(([path]) => tagOf(path) === tag)?.[1];
|
||||
expect(load, `locales/permissions/${tag}.ts`).toBeTruthy();
|
||||
const { permissionCatalog } = await load!();
|
||||
expect(Object.keys(permissionCatalog.labels).filter((n) => !names.has(n))).toEqual([]);
|
||||
expect([...names].filter((n) => !permissionCatalog.labels[n]?.trim())).toEqual([]);
|
||||
expect(Object.keys(permissionCatalog.categories).sort()).toEqual([...categories].sort());
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user