From e49e53a32ff747f70cd29f87f30c1297f0a4e8f2 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Wed, 19 Aug 2026 09:42:04 +0200 Subject: [PATCH] fix(permissions): model app use as a capability, not a level below modify Codex review: app-modify-all implies only app-create - app-use-all is implied by app-full-all alone. Putting use beneath modify in the ordered ladder therefore fabricated a grant: a legacy group holding just app-modify-all displayed as Modify, and touching the row or applying a preset silently granted use of every app. Ladders may now only contain keys that genuinely imply each other. Everything else is a capability with its own checkbox, so the app row is No access | Modify | Full plus "Use all apps" and "Create apps". A test asserts every ladder is a real chain, so this class of mismatch fails CI rather than shipping. Also documents in the API docs why app list endpoints can now return FORBIDDEN and why integration lists may return fewer records, as AGENTS.md requires. --- apps/docs/docs/management/api/index.mdx | 28 ++++ apps/docs/docs/management/users/index.mdx | 50 +++--- .../permissions/_group-permission-form.tsx | 98 ++++++------ packages/definitions/src/permissions.ts | 77 +++++---- .../definitions/src/test/permissions.spec.ts | 149 ++++++++++-------- packages/translation/src/lang/en.json | 4 +- 6 files changed, 243 insertions(+), 163 deletions(-) diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index bd2ab1363..e4fe2046d 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -72,6 +72,34 @@ API key authentication enables programmatic access for: - Custom dashboards and monitoring tools - Container orchestration systems that need to sync app definitions +## Permissions + +API keys act as the user that created them, so every request is subject to that user's group +permissions. A call that the user is not allowed to make returns `FORBIDDEN`, and list endpoints +return only the records the user may see rather than failing. + +### App endpoints require app management access + +`GET /api/apps`, `GET /api/apps/paginated` and `GET /api/apps/search` (tRPC `app.all`, +`app.getPaginated`, `app.search`) require the **modify all apps** permission. They previously +returned the full app catalogue — including internal URLs — to any authenticated user. + +If a script that used to work now returns `FORBIDDEN`, grant **modify all apps** to a group the key +owner belongs to, or switch to `GET /api/apps/selectable`, which returns the reduced fields used by +board pickers. + +### Integration endpoints are filtered per key + +`integration.all`, `integration.allThatSupportSearch`, `integration.allOfGivenCategory` and +`integration.search` filter server side. Without a global integration permission, a key only +receives the integrations its owner has been granted access to, so responses may contain fewer +records than before rather than returning an error. `integration.search` applies its `limit` after +filtering, so a scoped key still receives a full page of results. + +Each returned integration carries a `permissions` object — check `hasUseAccess` before reading data +and `hasInteractAccess` before performing actions; `false` means the key owner lacks that level for +that integration, not that the call failed. + ## Board Automation The OpenAPI and MCP APIs include board management endpoints for common automation workflows: diff --git a/apps/docs/docs/management/users/index.mdx b/apps/docs/docs/management/users/index.mdx index 7773eda22..371ec61a7 100644 --- a/apps/docs/docs/management/users/index.mdx +++ b/apps/docs/docs/management/users/index.mdx @@ -164,18 +164,21 @@ Members can be removed from the group using the remove button at the end: In this tab, you manage what permissions the members of this group will have. Permissions are shown as a matrix with one row per category (boards, apps, integrations, and so on). Each row has a -segmented control that lets you pick a single **access level** and, where the category supports it, a separate -**create** checkbox. +segmented control that lets you pick a single **access level**, plus a checkbox for each **capability** the category +has. Levels in a category are cumulative: selecting a level grants that level and every level below it in the same category. Choosing **No access** grants no access level for that category. -##### Create is separate from the access level +##### Capabilities are separate from the access level -Creating is a distinct capability, independent of the access level. It can be granted on its own, so you can for -example let members create their own boards without letting them view or modify anyone else's boards. Some access -levels already include the create capability through permission inheritance (marked in the table below); the -checkbox is then disabled and labeled as already included. +A capability is something you can grant on its own, independently of how much of everyone else's data the members +can see. Creating is the usual example: you can let members create their own boards without letting them view or +modify anyone else's boards. Apps additionally have **use all apps**, which lets members put any app on a board +without being able to edit apps. + +Some access levels already include a capability through permission inheritance (marked in the table below); the +checkbox is then ticked, disabled, and explains which level includes it. ##### Administration @@ -185,23 +188,26 @@ is disabled and explains that administrators already have that access. Setting t ##### Access levels -The access levels below are cumulative, in ascending order, with the category's create capability listed separately. -A level that already includes the create capability is marked: +The access levels below are cumulative, in ascending order, with the category's capabilities listed separately: -| Category | Create capability | Access levels (ascending) | Create included at | -| -------------- | --------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| Boards | Create boards | view all boards → modify all boards → full board access | full board access | -| Apps | Create apps | use all apps → modify all apps → full app access | modify all apps, full app access | -| Integrations | Create integrations | use all integrations → interact with any integration → full integration access | full integration access | -| Search engines | Create search engines | modify all search engines → full search engine access | modify all search engines, full search engine access | -| Medias | Upload medias | view all medias → full media access | full media access | -| Other | — | view logs | — | -| Administrator | — | administrator | — | +| Category | Access levels (ascending) | Capabilities | Already included at | +| -------------- | ---------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------- | +| Boards | view all boards → modify all boards → full board access | create boards | full board access | +| Apps | modify all apps → full app access | use all apps, create apps | create apps: modify all apps and full app access; use all apps: full app access | +| Integrations | use all integrations → interact with any integration → full integration access | create integrations | full integration access | +| Search engines | modify all search engines → full search engine access | create search engines | modify all search engines, full search engine access | +| Medias | view all medias → full media access | upload medias | full media access | +| Other | view logs | — | — | +| Administrator | administrator | — | — | + +Apps are the one category where "use" is not a level. Being able to modify every app does **not** imply being able +to put any app on a board — only full app access grants both — so the two are kept independent rather than stacked, +and picking **Modify** never silently grants use of every app. The levels grant the following: - **Boards**: view all boards; modify all boards (not access control and danger zone); full board access (view, modify and delete every board, including access control and the danger zone). -- **Apps**: use all apps (add any app to a board); modify all apps; full app access (manage, use and delete any app). +- **Apps**: modify all apps; full app access (manage, use and delete any app). - **Integrations**: use all integrations; interact with any integration; full integration access (manage, use and interact with any integration). - **Search engines**: modify all search engines; full search engine access (manage and delete any search engine). - **Medias**: view all medias; full media access (manage and delete any media). @@ -211,8 +217,8 @@ The levels grant the following: Above the rows you can apply a preset to fill in a common configuration in one click: -- **Viewer**: read-only - view boards and medias, use existing apps and integrations. -- **Editor**: day-to-day management - create and modify boards, apps, integrations and search engines, interact with integrations, and upload medias, without deleting resources or managing access. +- **Viewer**: read-only - view all boards and medias, use any app on a board, and use existing integrations. +- **Editor**: day-to-day management - modify all boards and apps, create boards and integrations, interact with any integration, modify search engines, and upload medias, without deleting resources or managing access. - **Admin**: grants the administrator permission (full access to everything). When the current selection does not match any preset, a **Custom** badge is shown. @@ -220,7 +226,7 @@ When the current selection does not match any preset, a **Custom** badge is show ##### Also grants Some levels include additional permissions automatically. When a level implies extra permissions (for example, -full board access also allows creating boards), a dimmed **Also grants: …** hint is shown below the category row, +modifying all apps also allows creating apps), a dimmed **Also grants: …** hint is shown below the category row, listing the implied permissions. ##### Effective permissions diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/permissions/_group-permission-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/permissions/_group-permission-form.tsx index 3a7d4e551..b8ca2c8f2 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/permissions/_group-permission-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/permissions/_group-permission-form.tsx @@ -27,7 +27,7 @@ import type { GroupPermissionKey, PermissionMatrixCategory, PermissionMatrixStat import { getPermissionsWithChildren, groupPermissionKeys, - isCreateImpliedByLevel, + isCapabilityImpliedByLevel, matrixStateToPermissions, permissionMatrix, permissionsToMatrixState, @@ -67,7 +67,7 @@ type MatrixForm = ReturnType; const emptyMatrixState = (): PermissionMatrixState => objectKeys(permissionMatrix).reduce((acc, category) => { - acc[category] = { level: 0, create: false }; + acc[category] = { level: 0, capabilities: [] }; return acc; }, {} as PermissionMatrixState); @@ -76,40 +76,42 @@ const matrixStateOf = (values: FormType) => permissionsToMatrixState(groupPermis export const getMatrixLevel = (values: FormType, category: PermissionMatrixCategory): number => matrixStateOf(values)[category].level; -export const hasMatrixCreate = (values: FormType, category: PermissionMatrixCategory): boolean => - matrixStateOf(values)[category].create; +export const hasMatrixCapability = ( + values: FormType, + category: PermissionMatrixCategory, + capability: GroupPermissionKey, +): boolean => matrixStateOf(values)[category].capabilities.includes(capability); export const applyMatrixLevel = (form: MatrixForm, category: PermissionMatrixCategory, level: number) => { - const targetKeys = new Set(matrixStateToPermissions({ ...emptyMatrixState(), [category]: { level, create: false } })); + const targetKeys = new Set( + matrixStateToPermissions({ ...emptyMatrixState(), [category]: { level, capabilities: [] } }), + ); permissionMatrix[category].levels.forEach((key) => { form.setFieldValue(key, targetKeys.has(key)); }); - // The create key is deliberately left untouched: a level that implies creating already covers it, - // and an explicitly granted create must survive lowering the level again. + // Capability keys are deliberately left untouched: a level that implies one already covers it, + // and an explicitly granted capability must survive lowering the level again. }; -export const applyMatrixCreate = (form: MatrixForm, category: PermissionMatrixCategory, create: boolean) => { - const createKey = permissionMatrix[category].create; - if (createKey !== null) { - form.setFieldValue(createKey, create); - } +export const applyMatrixCapability = (form: MatrixForm, capability: GroupPermissionKey, granted: boolean) => { + form.setFieldValue(capability, granted); }; const presets = { viewer: { - board: { level: 1, create: false }, - app: { level: 1, create: false }, - integration: { level: 1, create: false }, - media: { level: 1, create: false }, + board: { level: 1, capabilities: [] }, + app: { level: 0, capabilities: ["app-use-all"] }, + integration: { level: 1, capabilities: [] }, + media: { level: 1, capabilities: [] }, }, editor: { - board: { level: 2, create: true }, - app: { level: 2, create: true }, - integration: { level: 2, create: true }, - "search-engine": { level: 1, create: true }, - media: { level: 1, create: true }, + board: { level: 2, capabilities: ["board-create"] }, + app: { level: 1, capabilities: ["app-use-all"] }, + integration: { level: 2, capabilities: ["integration-create"] }, + "search-engine": { level: 1, capabilities: [] }, + media: { level: 1, capabilities: ["media-upload"] }, }, - admin: { admin: { level: 1, create: false } }, + admin: { admin: { level: 1, capabilities: [] } }, } satisfies Record>; export type PermissionPreset = keyof typeof presets; @@ -117,7 +119,7 @@ export type PermissionPreset = keyof typeof presets; const presetToFormState = (preset: PermissionPreset): FormType => { const presetState: Partial = presets[preset]; const state = objectKeys(permissionMatrix).reduce((acc, category) => { - acc[category] = presetState[category] ?? { level: 0, create: false }; + acc[category] = presetState[category] ?? { level: 0, capabilities: [] }; return acc; }, {} as PermissionMatrixState); const enabledKeys = new Set(matrixStateToPermissions(state)); @@ -197,7 +199,7 @@ export const MatrixRow = ({ category, permissionLabels, permissionDescriptions } const level = getMatrixLevel(values, category); const isDanger = category === "admin"; const disabledByAdmin = !isDanger && getMatrixLevel(values, "admin") > 0; - const { levels, create: createKey } = permissionMatrix[category]; + const { levels, capabilities } = permissionMatrix[category]; const hasSingleLevel = levels.length === 1; const data = [ @@ -211,13 +213,16 @@ export const MatrixRow = ({ category, permissionLabels, permissionDescriptions } }), ]; - const createImplied = isCreateImpliedByLevel(category, level); - const createChecked = hasMatrixCreate(values, category); + const capabilityStates = capabilities.map((capability) => ({ + capability, + checked: hasMatrixCapability(values, category, capability), + implied: isCapabilityImpliedByLevel(category, capability, level), + })); const selectedKey = level === 0 ? undefined : levels[level - 1]; - // With no access level but the create capability granted, describing the row as "no access" would - // contradict the checkbox next to it, so the create permission describes the row instead. - const describedKey = selectedKey ?? (createChecked ? (createKey ?? undefined) : undefined); + // With no access level but a capability granted, describing the row as "no access" would + // contradict the checkbox next to it, so the granted capability describes the row instead. + const describedKey = selectedKey ?? capabilityStates.find(({ checked }) => checked)?.capability; const description = describedKey === undefined ? tPermissions("matrix.noneDescription") : (permissionDescriptions[describedKey] ?? ""); @@ -239,22 +244,25 @@ export const MatrixRow = ({ category, permissionLabels, permissionDescriptions } aria-label={tPermissions(`${category}.title`)} onChange={(value) => applyMatrixLevel(form, category, Number(value))} /> - {/* Fixed-width column so every level control lines up, with or without a create option. */} - - {createKey !== null && ( - - applyMatrixCreate(form, category, event.currentTarget.checked)} - /> - - )} + {/* Fixed-width column so every level control lines up, whatever capabilities a row has. */} + + + {capabilityStates.map(({ capability, checked, implied }) => ( + + applyMatrixCapability(form, capability, event.currentTarget.checked)} + /> + + ))} + diff --git a/packages/definitions/src/permissions.ts b/packages/definitions/src/permissions.ts index d3da72f2a..d8010e52b 100644 --- a/packages/definitions/src/permissions.ts +++ b/packages/definitions/src/permissions.ts @@ -121,50 +121,65 @@ export const groupPermissionKeys = objectKeys(groupPermissions).reduce((acc, key /** * 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. + * `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. * - * Together, `levels` and `create` cover every key in {@link groupPermissionKeys}, so the matrix - * is a lossless representation of a group's permissions. + * `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"], create: "board-create" }, - app: { levels: ["app-use-all", "app-modify-all", "app-full-all"], create: "app-create" }, + 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"], - create: "integration-create", + capabilities: ["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; + "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; - /** Whether the category's create/upload key is granted */ - create: boolean; + /** The category's granted capability keys, including the ones the level already implies */ + capabilities: readonly GroupPermissionKey[]; } export type PermissionMatrixState = Record; /** - * Whether the given level already grants the category's create key through permission inheritance. + * Whether the given level already grants a capability 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) { +export const isCapabilityImpliedByLevel = ( + category: PermissionMatrixCategory, + capability: GroupPermissionKey, + level: number, +) => { + if (level <= 0) { return false; } - return getPermissionsWithChildren([...permissionMatrix[category].levels.slice(0, level)]).includes(createKey); + return getPermissionsWithChildren([...permissionMatrix[category].levels.slice(0, level)]).includes(capability); }; export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): PermissionMatrixState => { @@ -177,10 +192,12 @@ export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): Per level = index + 1; } }); - const createKey = permissionMatrix[category].create; + acc[category] = { level, - create: createKey !== null && (permissionSet.has(createKey) || isCreateImpliedByLevel(category, level)), + capabilities: permissionMatrix[category].capabilities.filter( + (capability) => permissionSet.has(capability) || isCapabilityImpliedByLevel(category, capability, level), + ), }; return acc; }, {} as PermissionMatrixState); @@ -188,13 +205,11 @@ 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); - } + 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[]); }; diff --git a/packages/definitions/src/test/permissions.spec.ts b/packages/definitions/src/test/permissions.spec.ts index 419f5fd0a..1c6ee2675 100644 --- a/packages/definitions/src/test/permissions.spec.ts +++ b/packages/definitions/src/test/permissions.spec.ts @@ -7,20 +7,20 @@ import { getPermissionsWithChildren, getPermissionsWithParents, groupPermissionKeys, - isCreateImpliedByLevel, + isCapabilityImpliedByLevel, matrixStateToPermissions, permissionMatrix, permissionsToMatrixState, } from "../permissions"; const emptyState = (overrides: Partial = {}): 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 }, + 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, }); @@ -69,16 +69,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]), - ]); + 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", () => { @@ -92,70 +99,96 @@ 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, create }) => level === 0 && !create)).toBe(true); + 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, create: true }); + expect(permissionsToMatrixState(["board-create"]).board).toEqual({ level: 0, capabilities: ["board-create"] }); }); - 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("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 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); + 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("isCreateImpliedByLevel", () => { +describe("isCapabilityImpliedByLevel", () => { 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); + ["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, create: false } }))).toEqual([ + 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, create: false } }))).toEqual(["admin"]); + expect(matrixStateToPermissions(emptyState({ admin: { level: 1, capabilities: [] } }))).toEqual(["admin"]); }); - test("emits a standalone create key without any level", () => { - expect(matrixStateToPermissions(emptyState({ board: { level: 0, create: true } }))).toEqual(["board-create"]); + 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 create key of the media category", () => { - expect(matrixStateToPermissions(emptyState({ media: { level: 0, create: true } }))).toEqual(["media-upload"]); + test("emits media-upload as the capability of the media category", () => { + expect(matrixStateToPermissions(emptyState({ media: { level: 0, capabilities: ["media-upload"] } }))).toEqual([ + "media-upload", + ]); }); - test("omits a create key that the chosen level already implies", () => { - expect(matrixStateToPermissions(emptyState({ board: { level: 3, create: true } }))).toEqual([ + 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", @@ -169,31 +202,25 @@ describe("matrix helpers should round-trip stably", () => { [["integration-use-all", "integration-interact-all", "integration-full-all"]], [["admin"]], [[]], - [ - [ - "app-use-all", - "app-modify-all", - "search-engine-modify-all", - "search-engine-full-all", - "media-view-all", - "other-view-logs", - ], - ], + [["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 create key to be normalised away by the level that implies it", () => { + 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-use-all", "app-modify-all"]); + expect(roundTripped).toEqual(["app-modify-all"]); expect(getPermissionsWithChildren(roundTripped)).toContain("app-create"); }); @@ -239,8 +266,4 @@ describe("management section access", () => { expect(access.canManageAll).toBe(canManageAll); }, ); - - test("a user who can only use integrations cannot reach the management section", () => { - expect(getIntegrationManagementAccess(["integration-use-all"], false).canAccess).toBe(false); - }); }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index b67a0f4b0..1d3e73fc6 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -674,14 +674,14 @@ "empty": "No permissions selected" }, "noneDescription": "Members get no access to this area.", - "createIncluded": "Already included in “{level}”", "level": { "view": "View", "use": "Use", "interact": "Interact", "modify": "Modify", "full": "Full" - } + }, + "capabilityIncluded": "Already included in “{level}”" } }, "memberNotice": {