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