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": {