fix(permissions): keep create permissions grantable and stop silent escalation

The matrix could not represent board-create, app-create, integration-create,
search-engine-create or media-upload, and savePermissions force-bundled every
create key with its modify/full parent. Together that meant a group with
board-create was silently upgraded to board-full-all on the next save, and the
common "members may create their own boards" setup became unreachable.

- permissionMatrix models create/upload as an orthogonal capability next to the
  ordered access levels, so all 20 permission keys round-trip losslessly
- savePermissions stores exactly what was selected
- the matrix UI gets a per-category create checkbox, auto-checked and disabled
  when the chosen level already implies it
- a management section is reachable when the user can do at least one thing in
  it; nav, pages and stat cards now share one access helper
This commit is contained in:
Thomas Camlong committed 2026-08-18 15:40:28 +02:00
1 parent 8caf00da79
commit 6728d0a63d
12 files changed
+534 -177

No files matched your search

+53 -13
View File
@@ -119,23 +119,53 @@ export const groupPermissionKeys = objectKeys(groupPermissions).reduce((acc, key
}, [] as GroupPermissionKey[]);
/**
* Ordered access levels per category used by the group permission matrix UI.
* Each level entry is a full {@link GroupPermissionKey}; higher levels imply the lower ones
* through {@link groupPermissionParents}.
* Describes how each permission category is presented in the group permission matrix UI.
*
* `levels` is an ordered escalation of access: picking level N grants every key up to N.
* `create` is orthogonal to `levels` - it is the "may add new resources" capability and can be
* granted on its own (e.g. "members may create their own boards but not see anyone else's").
* Some levels already imply the create key through {@link groupPermissionParents}; use
* {@link isCreateImpliedByLevel} to detect that instead of hard-coding it.
*
* Together, `levels` and `create` cover every key in {@link groupPermissionKeys}, so the matrix
* is a lossless representation of a group's permissions.
*/
export const permissionMatrix = {
board: { levels: ["board-view-all", "board-modify-all", "board-full-all"] },
app: { levels: ["app-use-all", "app-modify-all", "app-full-all"] },
integration: { levels: ["integration-use-all", "integration-interact-all", "integration-full-all"] },
"search-engine": { levels: ["search-engine-modify-all", "search-engine-full-all"] },
media: { levels: ["media-view-all", "media-full-all"] },
other: { levels: ["other-view-logs"] },
admin: { levels: ["admin"] },
} as const satisfies Record<string, { levels: readonly GroupPermissionKey[] }>;
board: { levels: ["board-view-all", "board-modify-all", "board-full-all"], create: "board-create" },
app: { levels: ["app-use-all", "app-modify-all", "app-full-all"], create: "app-create" },
integration: {
levels: ["integration-use-all", "integration-interact-all", "integration-full-all"],
create: "integration-create",
},
"search-engine": { levels: ["search-engine-modify-all", "search-engine-full-all"], create: "search-engine-create" },
media: { levels: ["media-view-all", "media-full-all"], create: "media-upload" },
other: { levels: ["other-view-logs"], create: null },
admin: { levels: ["admin"], create: null },
} as const satisfies Record<string, { levels: readonly GroupPermissionKey[]; create: GroupPermissionKey | null }>;
export type PermissionMatrixCategory = keyof typeof permissionMatrix;
export type PermissionMatrixState = Record<PermissionMatrixCategory, { level: number }>;
export interface PermissionMatrixCategoryState {
/** 0 = no access, N = every key up to `permissionMatrix[category].levels[N - 1]` */
level: number;
/** Whether the category's create/upload key is granted */
create: boolean;
}
export type PermissionMatrixState = Record<PermissionMatrixCategory, PermissionMatrixCategoryState>;
/**
* Whether the given level already grants the category's create key through permission inheritance.
* For example board level 3 (`board-full-all`) implies `board-create`.
*/
export const isCreateImpliedByLevel = (category: PermissionMatrixCategory, level: number) => {
const createKey = permissionMatrix[category].create;
if (createKey === null || level <= 0) {
return false;
}
return getPermissionsWithChildren([...permissionMatrix[category].levels.slice(0, level)]).includes(createKey);
};
export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): PermissionMatrixState => {
const permissionSet = new Set(permissions);
@@ -147,7 +177,11 @@ export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): Per
level = index + 1;
}
});
acc[category] = { level };
const createKey = permissionMatrix[category].create;
acc[category] = {
level,
create: createKey !== null && (permissionSet.has(createKey) || isCreateImpliedByLevel(category, level)),
};
return acc;
}, {} as PermissionMatrixState);
};
@@ -155,6 +189,12 @@ export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): Per
export const matrixStateToPermissions = (state: PermissionMatrixState): GroupPermissionKey[] => {
return objectKeys(permissionMatrix).reduce((acc, category) => {
acc.push(...permissionMatrix[category].levels.slice(0, state[category].level));
// Skip the create key when the chosen level already implies it, so the stored permissions
// stay minimal and a state -> permissions -> state round-trip is exact.
const createKey = permissionMatrix[category].create;
if (createKey !== null && state[category].create && !isCreateImpliedByLevel(category, state[category].level)) {
acc.push(createKey);
}
return acc;
}, [] as GroupPermissionKey[]);
};
@@ -1,13 +1,27 @@
import { describe, expect, test } from "vitest";
import type { GroupPermissionKey } from "../permissions";
import type { GroupPermissionKey, PermissionMatrixState } from "../permissions";
import {
getPermissionsWithChildren,
getPermissionsWithParents,
groupPermissionKeys,
isCreateImpliedByLevel,
matrixStateToPermissions,
permissionMatrix,
permissionsToMatrixState,
} from "../permissions";
const emptyState = (overrides: Partial<PermissionMatrixState> = {}): PermissionMatrixState => ({
board: { level: 0, create: false },
app: { level: 0, create: false },
integration: { level: 0, create: false },
"search-engine": { level: 0, create: false },
media: { level: 0, create: false },
other: { level: 0, create: false },
admin: { level: 0, create: false },
...overrides,
});
describe("getPermissionsWithParents should return the correct permissions", () => {
test.each([
[["board-view-all"], ["board-view-all", "board-modify-all", "board-full-all", "admin"]],
@@ -51,10 +65,23 @@ describe("getPermissionsWithChildren should return the correct permissions", ()
});
});
describe("the matrix must be able to represent every permission", () => {
test("every group permission key is covered by exactly one category", () => {
const covered = Object.values(permissionMatrix).flatMap(({ levels, create }) => [
...levels,
...(create === null ? [] : [create]),
]);
expect(covered.toSorted()).toEqual(groupPermissionKeys.toSorted());
});
test.each(groupPermissionKeys.map((key) => [key]))("expect %s to survive a round-trip on its own", (key) => {
expect(matrixStateToPermissions(permissionsToMatrixState([key]))).toContain(key);
});
});
describe("permissionsToMatrixState should resolve the highest present level per category", () => {
test("resolves the highest present level key", () => {
const state = permissionsToMatrixState(["board-view-all", "board-modify-all"]);
expect(state.board.level).toBe(2);
expect(permissionsToMatrixState(["board-view-all", "board-modify-all"]).board.level).toBe(2);
});
test("resolves the admin category", () => {
@@ -63,35 +90,74 @@ describe("permissionsToMatrixState should resolve the highest present level per
test("resolves an empty selection to all zero", () => {
const state = permissionsToMatrixState([]);
expect(Object.values(state).every(({ level }) => level === 0)).toBe(true);
expect(Object.values(state).every(({ level, create }) => level === 0 && !create)).toBe(true);
});
test("resolves a standalone create key without granting any level", () => {
expect(permissionsToMatrixState(["board-create"]).board).toEqual({ level: 0, create: true });
});
test("marks create as granted when the level implies it", () => {
expect(permissionsToMatrixState(["board-full-all"]).board.create).toBe(true);
expect(permissionsToMatrixState(["app-modify-all"]).app.create).toBe(true);
expect(permissionsToMatrixState(["media-full-all"]).media.create).toBe(true);
});
test("does not mark create as granted for a level that does not imply it", () => {
expect(permissionsToMatrixState(["board-modify-all"]).board.create).toBe(false);
expect(permissionsToMatrixState(["integration-interact-all"]).integration.create).toBe(false);
});
});
describe("isCreateImpliedByLevel", () => {
test.each([
["board", 0, false],
["board", 1, false],
["board", 2, false],
["board", 3, true],
["app", 1, false],
["app", 2, true],
["integration", 2, false],
["integration", 3, true],
["search-engine", 1, true],
["media", 1, false],
["media", 2, true],
["other", 1, false],
["admin", 1, false],
] satisfies [keyof typeof permissionMatrix, number, boolean][])(
"expect %s at level %i to imply create: %s",
(category, level, expected) => {
expect(isCreateImpliedByLevel(category, level)).toBe(expected);
},
);
});
describe("matrixStateToPermissions should expand levels into permission keys", () => {
test("expands a level into the ordered level keys", () => {
const permissions = matrixStateToPermissions({
board: { level: 2 },
app: { level: 0 },
integration: { level: 0 },
"search-engine": { level: 0 },
media: { level: 0 },
other: { level: 0 },
admin: { level: 0 },
});
expect(permissions).toEqual(["board-view-all", "board-modify-all"]);
expect(matrixStateToPermissions(emptyState({ board: { level: 2, create: false } }))).toEqual([
"board-view-all",
"board-modify-all",
]);
});
test("expands the admin category into the admin key", () => {
const permissions = matrixStateToPermissions({
board: { level: 0 },
app: { level: 0 },
integration: { level: 0 },
"search-engine": { level: 0 },
media: { level: 0 },
other: { level: 0 },
admin: { level: 1 },
});
expect(permissions).toEqual(["admin"]);
expect(matrixStateToPermissions(emptyState({ admin: { level: 1, create: false } }))).toEqual(["admin"]);
});
test("emits a standalone create key without any level", () => {
expect(matrixStateToPermissions(emptyState({ board: { level: 0, create: true } }))).toEqual(["board-create"]);
});
test("emits media-upload as the create key of the media category", () => {
expect(matrixStateToPermissions(emptyState({ media: { level: 0, create: true } }))).toEqual(["media-upload"]);
});
test("omits a create key that the chosen level already implies", () => {
expect(matrixStateToPermissions(emptyState({ board: { level: 3, create: true } }))).toEqual([
"board-view-all",
"board-modify-all",
"board-full-all",
]);
});
});
@@ -111,6 +177,10 @@ describe("matrix helpers should round-trip stably", () => {
"other-view-logs",
],
],
[["board-create"]],
[["media-upload"]],
[["board-create", "app-create", "integration-create", "search-engine-create", "media-upload"]],
[["board-create", "board-view-all"]],
] satisfies [GroupPermissionKey[]][])("expect %s to round-trip", (permissions) => {
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(permissions));
expect(roundTripped).toEqual(expect.arrayContaining(permissions));
@@ -118,7 +188,10 @@ describe("matrix helpers should round-trip stably", () => {
});
test("expect a single higher-level key to expand to its full set on round-trip", () => {
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(["board-full-all"]));
expect(roundTripped).toEqual(["board-view-all", "board-modify-all", "board-full-all"]);
expect(matrixStateToPermissions(permissionsToMatrixState(["board-full-all"]))).toEqual([
"board-view-all",
"board-modify-all",
"board-full-all",
]);
});
});