feat: recalculate permission scope & permission matrix UI (#6612)
This commit is contained in:
1 parent
78b3dc1922
commit
ff14613f95
24 files changed
+1635
-314
No files matched your search
@@ -117,3 +117,139 @@ export const groupPermissionKeys = objectKeys(groupPermissions).reduce((acc, key
|
||||
}
|
||||
return acc;
|
||||
}, [] as GroupPermissionKey[]);
|
||||
|
||||
/**
|
||||
* Describes how each permission category is presented in the group permission matrix UI.
|
||||
*
|
||||
* `levels` is an ordered escalation of access: every level implies the one below it through
|
||||
* {@link groupPermissionParents}, so picking level N grants everything up to N.
|
||||
*
|
||||
* `capabilities` are the keys that are NOT part of that ladder - "may add new resources", "may
|
||||
* upload", "may put any app on a board". They can be granted on their own (e.g. "members may create
|
||||
* their own boards but not see anyone else's") and some levels already imply them; use
|
||||
* {@link isCapabilityImpliedByLevel} to detect that instead of hard-coding it.
|
||||
*
|
||||
* Only keys that genuinely imply each other may share a ladder. `app-modify-all` implies
|
||||
* `app-create` but NOT `app-use-all` (only `app-full-all` does), so `app-use-all` is a capability -
|
||||
* putting it in the ladder would make picking "Modify" silently grant use of every app.
|
||||
*
|
||||
* Together, `levels` and `capabilities` 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"], capabilities: ["board-create"] },
|
||||
app: { levels: ["app-modify-all", "app-full-all"], capabilities: ["app-use-all", "app-create"] },
|
||||
integration: {
|
||||
levels: ["integration-use-all", "integration-interact-all", "integration-full-all"],
|
||||
capabilities: ["integration-create"],
|
||||
},
|
||||
"search-engine": {
|
||||
levels: ["search-engine-modify-all", "search-engine-full-all"],
|
||||
capabilities: ["search-engine-create"],
|
||||
},
|
||||
media: { levels: ["media-view-all", "media-full-all"], capabilities: ["media-upload"] },
|
||||
other: { levels: ["other-view-logs"], capabilities: [] },
|
||||
admin: { levels: ["admin"], capabilities: [] },
|
||||
} as const satisfies Record<
|
||||
string,
|
||||
{ levels: readonly GroupPermissionKey[]; capabilities: readonly GroupPermissionKey[] }
|
||||
>;
|
||||
|
||||
export type PermissionMatrixCategory = keyof typeof permissionMatrix;
|
||||
|
||||
export interface PermissionMatrixCategoryState {
|
||||
/** 0 = no access, N = every key up to `permissionMatrix[category].levels[N - 1]` */
|
||||
level: number;
|
||||
/** The category's granted capability keys, including the ones the level already implies */
|
||||
capabilities: readonly GroupPermissionKey[];
|
||||
}
|
||||
|
||||
export type PermissionMatrixState = Record<PermissionMatrixCategory, PermissionMatrixCategoryState>;
|
||||
|
||||
/**
|
||||
* Whether the given level already grants a capability through permission inheritance.
|
||||
* For example board level 3 (`board-full-all`) implies `board-create`.
|
||||
*/
|
||||
export const isCapabilityImpliedByLevel = (
|
||||
category: PermissionMatrixCategory,
|
||||
capability: GroupPermissionKey,
|
||||
level: number,
|
||||
) => {
|
||||
if (level <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getPermissionsWithChildren([...permissionMatrix[category].levels.slice(0, level)]).includes(capability);
|
||||
};
|
||||
|
||||
export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): PermissionMatrixState => {
|
||||
const permissionSet = new Set(permissions);
|
||||
|
||||
return objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
let level = 0;
|
||||
permissionMatrix[category].levels.forEach((key, index) => {
|
||||
if (permissionSet.has(key)) {
|
||||
level = index + 1;
|
||||
}
|
||||
});
|
||||
|
||||
acc[category] = {
|
||||
level,
|
||||
capabilities: permissionMatrix[category].capabilities.filter(
|
||||
(capability) => permissionSet.has(capability) || isCapabilityImpliedByLevel(category, capability, level),
|
||||
),
|
||||
};
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
};
|
||||
|
||||
export const matrixStateToPermissions = (state: PermissionMatrixState): GroupPermissionKey[] => {
|
||||
return objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
const { level, capabilities } = state[category];
|
||||
acc.push(...permissionMatrix[category].levels.slice(0, level));
|
||||
// Skip capabilities the chosen level already implies, so the stored permissions stay minimal
|
||||
// and a state -> permissions -> state round-trip is exact.
|
||||
acc.push(...capabilities.filter((capability) => !isCapabilityImpliedByLevel(category, capability, level)));
|
||||
return acc;
|
||||
}, [] as GroupPermissionKey[]);
|
||||
};
|
||||
|
||||
export interface ManagementSectionAccess {
|
||||
/** May see and manage every resource of the section */
|
||||
canManageAll: boolean;
|
||||
/** May add new resources to the section */
|
||||
canCreate: boolean;
|
||||
/** May open the section at all */
|
||||
canAccess: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A management section is reachable when the user can do at least one thing in it. Both rules live
|
||||
* here so the navigation, the pages themselves and the management home statistics cannot drift
|
||||
* apart - each of them consumes the same function.
|
||||
*/
|
||||
export const getAppManagementAccess = (permissions: readonly GroupPermissionKey[]): ManagementSectionAccess => {
|
||||
const canManageAll = permissions.includes("app-modify-all");
|
||||
const canCreate = permissions.includes("app-create");
|
||||
|
||||
return { canManageAll, canCreate, canAccess: canManageAll || canCreate };
|
||||
};
|
||||
|
||||
/**
|
||||
* `hasDelegatedFullAccess` is whether the user was granted full access to at least one specific
|
||||
* integration; it has to be looked up per request, so the caller resolves it.
|
||||
*/
|
||||
export const getIntegrationManagementAccess = (
|
||||
permissions: readonly GroupPermissionKey[],
|
||||
hasDelegatedFullAccess: boolean,
|
||||
): ManagementSectionAccess & { hasDelegatedFullAccess: boolean } => {
|
||||
const canManageAll = permissions.includes("integration-full-all");
|
||||
const canCreate = permissions.includes("integration-create");
|
||||
|
||||
return {
|
||||
canManageAll,
|
||||
canCreate,
|
||||
hasDelegatedFullAccess,
|
||||
canAccess: canManageAll || canCreate || hasDelegatedFullAccess,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,28 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GroupPermissionKey } from "../permissions";
|
||||
import { getPermissionsWithChildren, getPermissionsWithParents } from "../permissions";
|
||||
import type { GroupPermissionKey, PermissionMatrixState } from "../permissions";
|
||||
import {
|
||||
getAppManagementAccess,
|
||||
getIntegrationManagementAccess,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
groupPermissionKeys,
|
||||
isCapabilityImpliedByLevel,
|
||||
matrixStateToPermissions,
|
||||
permissionMatrix,
|
||||
permissionsToMatrixState,
|
||||
} from "../permissions";
|
||||
|
||||
const emptyState = (overrides: Partial<PermissionMatrixState> = {}): PermissionMatrixState => ({
|
||||
board: { level: 0, capabilities: [] },
|
||||
app: { level: 0, capabilities: [] },
|
||||
integration: { level: 0, capabilities: [] },
|
||||
"search-engine": { level: 0, capabilities: [] },
|
||||
media: { level: 0, capabilities: [] },
|
||||
other: { level: 0, capabilities: [] },
|
||||
admin: { level: 0, capabilities: [] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("getPermissionsWithParents should return the correct permissions", () => {
|
||||
test.each([
|
||||
@@ -45,3 +66,204 @@ describe("getPermissionsWithChildren should return the correct permissions", ()
|
||||
expect(getPermissionsWithChildren(input)).toEqual(expect.arrayContaining(expectedOutput));
|
||||
});
|
||||
});
|
||||
|
||||
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, capabilities }) => [...levels, ...capabilities]);
|
||||
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);
|
||||
});
|
||||
|
||||
test("every ladder is a real chain, so no level can fabricate a sibling permission", () => {
|
||||
Object.values(permissionMatrix).forEach(({ levels }) => {
|
||||
levels.forEach((key, index) => {
|
||||
// Selecting a level must never grant more than that level's key already implies.
|
||||
const implied = getPermissionsWithChildren([key]);
|
||||
expect(levels.slice(0, index + 1).every((lower) => implied.includes(lower))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissionsToMatrixState should resolve the highest present level per category", () => {
|
||||
test("resolves the highest present level key", () => {
|
||||
expect(permissionsToMatrixState(["board-view-all", "board-modify-all"]).board.level).toBe(2);
|
||||
});
|
||||
|
||||
test("resolves the admin category", () => {
|
||||
expect(permissionsToMatrixState(["admin"]).admin.level).toBe(1);
|
||||
});
|
||||
|
||||
test("resolves an empty selection to all zero", () => {
|
||||
const state = permissionsToMatrixState([]);
|
||||
expect(Object.values(state).every(({ level, capabilities }) => level === 0 && capabilities.length === 0)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves a standalone create key without granting any level", () => {
|
||||
expect(permissionsToMatrixState(["board-create"]).board).toEqual({ level: 0, capabilities: ["board-create"] });
|
||||
});
|
||||
|
||||
test("marks a capability as granted when the level implies it", () => {
|
||||
expect(permissionsToMatrixState(["board-full-all"]).board.capabilities).toEqual(["board-create"]);
|
||||
expect(permissionsToMatrixState(["app-modify-all"]).app.capabilities).toEqual(["app-create"]);
|
||||
expect(permissionsToMatrixState(["media-full-all"]).media.capabilities).toEqual(["media-upload"]);
|
||||
});
|
||||
|
||||
test("does not mark a capability as granted for a level that does not imply it", () => {
|
||||
expect(permissionsToMatrixState(["board-modify-all"]).board.capabilities).toEqual([]);
|
||||
expect(permissionsToMatrixState(["integration-interact-all"]).integration.capabilities).toEqual([]);
|
||||
});
|
||||
|
||||
test("app-use-all is a capability, not a level, because app-modify-all does not imply it", () => {
|
||||
// Regression: modelling use as the level below modify made picking Modify silently grant the
|
||||
// use of every app, which the permission graph does not do.
|
||||
const state = permissionsToMatrixState(["app-modify-all"]);
|
||||
expect(state.app).toEqual({ level: 1, capabilities: ["app-create"] });
|
||||
expect(matrixStateToPermissions(state)).toEqual(["app-modify-all"]);
|
||||
});
|
||||
|
||||
test("app-full-all implies both app capabilities", () => {
|
||||
expect(permissionsToMatrixState(["app-full-all"]).app).toEqual({
|
||||
level: 2,
|
||||
capabilities: ["app-use-all", "app-create"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCapabilityImpliedByLevel", () => {
|
||||
test.each([
|
||||
["board", "board-create", 0, false],
|
||||
["board", "board-create", 1, false],
|
||||
["board", "board-create", 2, false],
|
||||
["board", "board-create", 3, true],
|
||||
["app", "app-create", 1, true],
|
||||
["app", "app-use-all", 1, false],
|
||||
["app", "app-use-all", 2, true],
|
||||
["integration", "integration-create", 2, false],
|
||||
["integration", "integration-create", 3, true],
|
||||
["search-engine", "search-engine-create", 1, true],
|
||||
["media", "media-upload", 1, false],
|
||||
["media", "media-upload", 2, true],
|
||||
] satisfies [keyof typeof permissionMatrix, GroupPermissionKey, number, boolean][])(
|
||||
"expect %s at level %i to imply %s: %s",
|
||||
(category, capability, level, expected) => {
|
||||
expect(isCapabilityImpliedByLevel(category, capability, level)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("matrixStateToPermissions should expand levels into permission keys", () => {
|
||||
test("expands a level into the ordered level keys", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ board: { level: 2, capabilities: [] } }))).toEqual([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
]);
|
||||
});
|
||||
|
||||
test("expands the admin category into the admin key", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ admin: { level: 1, capabilities: [] } }))).toEqual(["admin"]);
|
||||
});
|
||||
|
||||
test("emits a standalone capability without any level", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ board: { level: 0, capabilities: ["board-create"] } }))).toEqual([
|
||||
"board-create",
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits media-upload as the capability of the media category", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ media: { level: 0, capabilities: ["media-upload"] } }))).toEqual([
|
||||
"media-upload",
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits app-use-all without granting any app level", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ app: { level: 0, capabilities: ["app-use-all"] } }))).toEqual([
|
||||
"app-use-all",
|
||||
]);
|
||||
});
|
||||
|
||||
test("omits a capability that the chosen level already implies", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ board: { level: 3, capabilities: ["board-create"] } }))).toEqual([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
"board-full-all",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix helpers should round-trip stably", () => {
|
||||
test.each([
|
||||
[["board-view-all", "board-modify-all"]],
|
||||
[["integration-use-all", "integration-interact-all", "integration-full-all"]],
|
||||
[["admin"]],
|
||||
[[]],
|
||||
[["search-engine-modify-all", "search-engine-full-all", "media-view-all", "other-view-logs"]],
|
||||
[["board-create"]],
|
||||
[["media-upload"]],
|
||||
[["board-create", "app-create", "integration-create", "search-engine-create", "media-upload"]],
|
||||
[["board-create", "board-view-all"]],
|
||||
[["app-modify-all"]],
|
||||
[["app-use-all"]],
|
||||
[["app-use-all", "app-modify-all"]],
|
||||
] satisfies [GroupPermissionKey[]][])("expect %s to round-trip", (permissions) => {
|
||||
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(permissions));
|
||||
expect(roundTripped).toEqual(expect.arrayContaining(permissions));
|
||||
expect(roundTripped).toHaveLength(permissions.length);
|
||||
});
|
||||
|
||||
test("expect a redundant capability key to be normalised away by the level that implies it", () => {
|
||||
// app-modify-all already grants app-create, so the matrix stores the minimal set. The permission
|
||||
// is not lost: getPermissionsWithChildren re-derives it whenever permissions are read.
|
||||
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(["app-create", "app-modify-all"]));
|
||||
expect(roundTripped).toEqual(["app-modify-all"]);
|
||||
expect(getPermissionsWithChildren(roundTripped)).toContain("app-create");
|
||||
});
|
||||
|
||||
test("expect a single higher-level key to expand to its full set on round-trip", () => {
|
||||
expect(matrixStateToPermissions(permissionsToMatrixState(["board-full-all"]))).toEqual([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
"board-full-all",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("management section access", () => {
|
||||
test.each([
|
||||
[[], false, false],
|
||||
[["app-create"], true, false],
|
||||
[["app-modify-all"], true, true],
|
||||
[["app-full-all", "app-modify-all"], true, true],
|
||||
[["app-use-all"], false, false],
|
||||
[["board-modify-all"], false, false],
|
||||
] satisfies [GroupPermissionKey[], boolean, boolean][])(
|
||||
"expect apps with %s to be reachable: %s, manage-all: %s",
|
||||
(permissions, canAccess, canManageAll) => {
|
||||
const access = getAppManagementAccess(permissions);
|
||||
expect(access.canAccess).toBe(canAccess);
|
||||
expect(access.canManageAll).toBe(canManageAll);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
[[], false, false, false],
|
||||
[[], true, true, false],
|
||||
[["integration-create"], false, true, false],
|
||||
[["integration-full-all"], false, true, true],
|
||||
[["integration-use-all"], false, false, false],
|
||||
[["integration-interact-all"], false, false, false],
|
||||
[["integration-interact-all"], true, true, false],
|
||||
] satisfies [GroupPermissionKey[], boolean, boolean, boolean][])(
|
||||
"expect integrations with %s and delegated access %s to be reachable: %s, manage-all: %s",
|
||||
(permissions, hasDelegatedFullAccess, canAccess, canManageAll) => {
|
||||
const access = getIntegrationManagementAccess(permissions, hasDelegatedFullAccess);
|
||||
expect(access.canAccess).toBe(canAccess);
|
||||
expect(access.canManageAll).toBe(canManageAll);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user