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:
12 files changed
+534
-177
No files matched your search
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user