diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md
index cae2917..a9936e4 100644
--- a/KNOWN-ISSUES.md
+++ b/KNOWN-ISSUES.md
@@ -75,7 +75,16 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
Still from source only: that membership gives a member no permissions (`access_token.rs` builds a user's permissions from their own roles), and that groups cannot nest.
-- **Roles were built from the 0.16.22 source, its schema and the mock, and have not been written to on a live server.** Confirmed live (2026-09-15, read-only): `x:Role` has `description`, `enabledPermissions`, `disabledPermissions`, `roleIds` and `memberTenantId`, and a fresh server carries the four bootstrap roles. From source only: that a denial anywhere in a role's tree wins (`permissions.rs` unions enabled and disabled across the tree, then subtracts); that creating or changing a role is refused as `forbidden` "not authorized to grant" for a permission the caller lacks; that the defaults live on `x:Authentication` as `default{User,Group,Tenant,Admin}RoleIds`; and that deleting a role still in use is `objectIsLinked`. **`GET /api/schema` has not been fetched through ihasmail's server on production** — the route sends the session's Basic credential, which reaches every other endpoint, and the source serves the schema to any signed-in account; until it is seen working there the picker's fallback is a notice that the list could not be loaded.
+- **Roles were built from the 0.16.22 source, its schema and the mock, then confirmed on the live server (2026-09-15)** with throwaway `ihasmail-role-test` roles, created and removed:
+
+ - **A role is created** with `description`, `roleIds`, `enabledPermissions` and `disabledPermissions` as sets, and reads back with them and `memberTenantId`.
+ - **Pointers change one entry each**: `enabledPermissions/
` and `disabledPermissions/
` with `true` or `null`, `roleIds/` likewise, and `description` in the same update, all applied together.
+ - **A name that is not a permission fails the whole update** as `invalidPatch`, *"Invalid value for object property"*, naming the pointer — which is how a probe using the mock's made-up `jmapEmailSet` found that the mock had carried a permission Stalwart does not have since Accounts was built; it is `jmapEmailUpdate` now, and the mock refuses unknown names.
+ - **A grant the caller does not hold is refused**: `forbidden`, *"You are not authorized to grant permissions: scimAccess"*.
+ - **A role another role builds on cannot be deleted**: `objectIsLinked`, `objectId` `{"object": "Role", …}`, `linkedObjects` naming the child.
+ - **The defaults** read from `x:Authentication`: users get User; groups get Group; tenant administrators get Tenant Administrator and User; administrators get System Administrator and User.
+
+ **The picker is stricter than the server for a few permissions.** `GET /api/account` never lists some permissions an administrator holds — `sysLogCreate` among them, which was granted without complaint — so their *Allow* is locked for everyone. That errs towards refusing and can be revisited if it gets in anyone's way. Still from source only: that a denial anywhere in a role's tree wins (`permissions.rs` unions enabled and disabled across the tree, then subtracts). **`GET /api/schema` has not been fetched through ihasmail's server on production** — the route sends the session's Basic credential, which reaches every other endpoint, and the source serves the schema to any signed-in account; until it is seen working there the picker's fallback is a notice that the list could not be loaded.
- **The permission labels in eight languages are machine translations awaiting native review.** 661 labels and 59 headings per language, written against each catalogue's existing terms. The translators flagged the terms they were least sure of, which are the place to start: *principal* (JMAP/DAV), *throttles*, *listeners*, *lookups*, *milters*, *masked emails*, *samples* (spam training), *schedules* (MTA delivery), *email submission*, and the MTA stage settings. Several of Stalwart's own English labels are identical for different permissions (ARF, DMARC and TLS reports are all "Get reports"), and the translations inherit that; the heading above tells them apart.
diff --git a/server/src/mock/directory.test.ts b/server/src/mock/directory.test.ts
index c1f6146..659000d 100644
--- a/server/src/mock/directory.test.ts
+++ b/server/src/mock/directory.test.ts
@@ -248,3 +248,10 @@ test("the default roles are read from the authentication settings", () => {
assert.deepEqual(list[0]!.defaultUserRoleIds, { r1: true });
assert.throws(() => make("tenant-admin").handlers["x:Authentication/get"]!({}), (e: Refused) => e.type === "forbidden");
});
+
+test("a permission name Stalwart does not know fails the whole change", () => {
+ const dir = make("admin");
+ const r = dir.handlers["x:Role/set"]!({ update: { r4: { "enabledPermissions/notARealPermission": true, description: "Renamed" } } }) as { notUpdated?: Record };
+ assert.equal(r.notUpdated?.r4?.type, "invalidPatch");
+ assert.deepEqual(r.notUpdated!.r4!.properties, ["enabledPermissions/notARealPermission"]);
+});
diff --git a/server/src/mock/directory.ts b/server/src/mock/directory.ts
index c844760..cd64e04 100644
--- a/server/src/mock/directory.ts
+++ b/server/src/mock/directory.ts
@@ -27,8 +27,15 @@
* delete them) or `user`.
*/
+import { readFileSync } from "node:fs";
+
type Obj = Record;
+/** Every permission Stalwart 0.16.22 knows, from the snapshot the translations are checked against. */
+const KNOWN_PERMISSIONS = new Set(
+ (JSON.parse(readFileSync(new URL("../../../web/src/locales/permissions/source.json", import.meta.url), "utf8")) as { permissions: Array<{ name: string }> }).permissions.map((p) => p.name),
+);
+
export type MockRole = "admin" | "tenant-admin" | "helpdesk" | "user";
const OPS = ["Get", "Query", "Create", "Update", "Destroy"] as const;
@@ -38,7 +45,7 @@ const all = (...objects: string[]) => objects.flatMap((o) => OPS.map((op) => `sy
const READ_SERVER = ["sysQueuedMessageGet", "sysQueuedMessageQuery", "sysMetricGet", "sysMetricQuery"];
/** A few of the ordinary ones, so the list looks like what a server sends. */
-const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailSet", "jmapMailboxGet", "sysAccountSettingsGet"];
+const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailUpdate", "jmapMailboxGet", "sysAccountSettingsGet"];
export function permissionsFor(role: MockRole): string[] {
switch (role) {
@@ -136,7 +143,7 @@ export function createDirectory(opts: Options) {
{ 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 } },
+ { id: "r4", description: "Read-only auditor", enabledPermissions: flags(["sysAccountGet", "sysAccountQuery", "sysDomainGet", "sysDomainQuery", "sysLogGet"]), disabledPermissions: flags(["jmapEmailUpdate"]), roleIds: { r1: true } },
];
/** Stalwart's defaults: which roles an account gets when it is given no others. */
const authentication: Record = { defaultUserRoleIds: { r1: true }, defaultGroupRoleIds: {}, defaultTenantRoleIds: {}, defaultAdminRoleIds: {} };
@@ -549,6 +556,11 @@ export function createDirectory(opts: Options) {
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"]);
+ // A name that is not a permission fails the whole change, as the live server does.
+ for (const set of ["enabledPermissions", "disabledPermissions"]) {
+ const bad = Object.keys((o[set] as Obj) ?? {}).find((p) => !KNOWN_PERMISSIONS.has(p));
+ if (bad) return setError("invalidProperties", "Invalid value for object property", [`${set}/${bad}`]);
+ }
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));
diff --git a/web/src/lib/__tests__/adminRoles.test.ts b/web/src/lib/__tests__/adminRoles.test.ts
index 3ede39b..b2008e8 100644
--- a/web/src/lib/__tests__/adminRoles.test.ts
+++ b/web/src/lib/__tests__/adminRoles.test.ts
@@ -4,8 +4,8 @@ import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, ty
const flags = (...n: string[]) => Object.fromEntries(n.map((x) => [x, true]));
const roles = new Map([
- ["user", { id: "user", description: "User", enabledPermissions: flags("jmapEmailGet", "jmapEmailSet") }],
- ["help", { id: "help", description: "Helpdesk", enabledPermissions: flags("sysAccountGet"), disabledPermissions: flags("jmapEmailSet"), roleIds: flags("user") }],
+ ["user", { id: "user", description: "User", enabledPermissions: flags("jmapEmailGet", "jmapEmailUpdate") }],
+ ["help", { id: "help", description: "Helpdesk", enabledPermissions: flags("sysAccountGet"), disabledPermissions: flags("jmapEmailUpdate"), roleIds: flags("user") }],
["lead", { id: "lead", description: "Lead", enabledPermissions: flags("sysAccountUpdate"), roleIds: flags("help") }],
]);
@@ -16,7 +16,7 @@ describe("what a role holds", () => {
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");
+ expect(denied.get("jmapEmailUpdate")).toBe("help");
});
it("changes a set one pointer at a time", () => {
@@ -33,9 +33,9 @@ describe("what a role holds", () => {
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
+ // jmapEmailUpdate 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);
+ expect(roleOutranks(permissionSet([...viewer, "jmapEmailUpdate"]), roles.get("lead")!, roles)).toBe(false);
});
});