diff --git a/.changeset/wild-rocks-tell.md b/.changeset/wild-rocks-tell.md new file mode 100644 index 00000000000..84d59ab9b71 --- /dev/null +++ b/.changeset/wild-rocks-tell.md @@ -0,0 +1,10 @@ +--- +"@wso2is/console": patch +"@wso2is/admin.console-settings.v1": patch +"@wso2is/admin.core.v1": patch +"@wso2is/admin.users.v1": patch +"@wso2is/identity-apps-core": patch +"@wso2is/i18n": patch +--- + +Add granular console permission scopes for create, update, and delete operations. diff --git a/apps/console/src/public/deployment.config.json b/apps/console/src/public/deployment.config.json index 8c76d153185..6d05c3591c3 100644 --- a/apps/console/src/public/deployment.config.json +++ b/apps/console/src/public/deployment.config.json @@ -908,7 +908,8 @@ "consoleSettings": { "disabledFeatures": [ "consoleSettings.invitedExternalAdmins", - "consoleSettings.privilegedUsers" + "consoleSettings.privilegedUsers", + "consoleSettings.useGranularConsolePermissions" ], "enabled": true, "featureFlags": [], diff --git a/features/admin.console-settings.v1/components/console-roles/console-roles-edit/console-role-permissions.tsx b/features/admin.console-settings.v1/components/console-roles/console-roles-edit/console-role-permissions.tsx index 27904716027..94435d41c07 100644 --- a/features/admin.console-settings.v1/components/console-roles/console-roles-edit/console-role-permissions.tsx +++ b/features/admin.console-settings.v1/components/console-roles/console-roles-edit/console-role-permissions.tsx @@ -16,6 +16,9 @@ * under the License. */ +import { UIConstants } from "@wso2is/admin.core.v1/constants/ui-constants"; +import { FeatureConfigInterface } from "@wso2is/admin.core.v1/models/config"; +import { AppState } from "@wso2is/admin.core.v1/store"; import { updateRoleDetails } from "@wso2is/admin.roles.v2/api/roles"; import { Schemas } from "@wso2is/admin.roles.v2/constants/role-constants"; import { CreateRolePermissionInterface, PatchRoleDataInterface } from "@wso2is/admin.roles.v2/models/roles"; @@ -27,17 +30,17 @@ import { RolePermissionInterface, RolesInterface } from "@wso2is/core/models"; +import { isFeatureEnabled } from "@wso2is/core/helpers"; import { addAlert } from "@wso2is/core/store"; import { EmphasizedSegment, Heading, PrimaryButton } from "@wso2is/react-components"; -import cloneDeep from "lodash-es/cloneDeep"; import isEmpty from "lodash-es/isEmpty"; import React, { FunctionComponent, ReactElement, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { Dispatch } from "redux"; import useGetAPIResourceCollections from "../../../api/use-get-api-resource-collections"; import { ConsoleRolesOnboardingConstants } from "../../../constants/console-roles-onboarding-constants"; @@ -48,6 +51,9 @@ import { APIResourceCollectionResponseInterface } from "../../../models/console-roles"; import { SelectedPermissionCategoryInterface, SelectedPermissionsInterface } from "../../../models/permissions-ui"; +import flattenFeatureConfig from "../../../utils/flatten-feature-config"; +import getEligibilityScopeNames from "../../../utils/get-eligibility-scope-names"; +import isPermissionLevelActionable from "../../../utils/is-permission-level-actionable"; import transformResourceCollectionToPermissions from "../../../utils/transform-resource-collection-to-permissions"; import CreateConsoleRoleWizardPermissionsForm from "../create-console-role-wizard/create-console-role-wizard-permissions-form"; @@ -99,6 +105,17 @@ const ConsoleRolePermissions: FunctionComponent = ( const { t } = useTranslation(); + const featureConfig: FeatureConfigInterface = useSelector((state: AppState) => state.config.ui.features); + const enabledFeatureOverridesInConsoleRolePermissions: string[] = useSelector( + (state: AppState) => state.config.ui.enabledFeatureOverridesInConsoleRolePermissions); + + /** + * Switches the permissions evaluation between the legacy and the granular mode. + */ + const useGranularConsolePermissions: boolean = isFeatureEnabled( + featureConfig?.consoleSettings, + ConsoleRolesOnboardingConstants.GRANULAR_CONSOLE_PERMISSIONS_FEATURE_KEY + ); const { data: tenantAPIResourceCollections } = useGetAPIResourceCollections( true, "type eq tenant", @@ -113,27 +130,38 @@ const ConsoleRolePermissions: FunctionComponent = ( const [ permissions, setPermissions ] = useState(undefined); + // Flatten the feature config to easily access sub features. Must match the wizard so the initial + // values computed here cover every row the wizard renders — otherwise sub-feature rows (e.g. + // `applicationAuthenticationScript`) load with no checkbox state. + const flattenedFeatureConfig: FeatureConfigInterface = useMemo( + () => flattenFeatureConfig(featureConfig), + [ featureConfig ] + ); + const filteredTenantAPIResourceCollections: APIResourceCollectionResponseInterface = useMemo(() => { if (!tenantAPIResourceCollections) { return null; } - const clonedTenantAPIResourceCollections: APIResourceCollectionResponseInterface = - cloneDeep(tenantAPIResourceCollections); const filteringAPIResourceCollectionNames: string[] = []; filteringAPIResourceCollectionNames.push( ConsoleRolesOnboardingConstants.ROLE_V1_API_RESOURCES_COLLECTION_NAME); - clonedTenantAPIResourceCollections.apiResourceCollections = - clonedTenantAPIResourceCollections?.apiResourceCollections?.filter( - (item: APIResourceCollectionInterface) => - !filteringAPIResourceCollectionNames.includes(item?.name) - ); - - return clonedTenantAPIResourceCollections; - }, [ tenantAPIResourceCollections ]); + return { + ...tenantAPIResourceCollections, + apiResourceCollections: tenantAPIResourceCollections.apiResourceCollections.filter( + (item: APIResourceCollectionInterface) => + !filteringAPIResourceCollectionNames.includes(item?.name) && + ( + enabledFeatureOverridesInConsoleRolePermissions?.includes(item?.name) + || flattenedFeatureConfig?.[item?.name]?.enabled + || flattenedFeatureConfig?.[UIConstants.CONSOLE_FEATURE_MAP[item?.name]]?.enabled + ) + ) + }; + }, [ tenantAPIResourceCollections, flattenedFeatureConfig, enabledFeatureOverridesInConsoleRolePermissions ]); const filteredOrganizationAPIResourceCollections: APIResourceCollectionResponseInterface = useMemo(() => { @@ -141,61 +169,150 @@ const ConsoleRolePermissions: FunctionComponent = ( return null; } - const clonedOrganizationAPIResourceCollections: APIResourceCollectionResponseInterface = - cloneDeep(organizationAPIResourceCollections); const filteringAPIResourceCollectionNames: string[] = []; filteringAPIResourceCollectionNames.push( ConsoleRolesOnboardingConstants.ORG_ROLE_V1_API_RESOURCES_COLLECTION_NAME); - clonedOrganizationAPIResourceCollections.apiResourceCollections = - clonedOrganizationAPIResourceCollections?.apiResourceCollections?.filter( - (item: APIResourceCollectionInterface) => - !filteringAPIResourceCollectionNames.includes(item?.name) - ); + return { + ...organizationAPIResourceCollections, + apiResourceCollections: organizationAPIResourceCollections.apiResourceCollections.filter( + (item: APIResourceCollectionInterface) => { + const itemName: string = item?.name ?? ""; + const featureNameWithoutOrgPrefix: string = itemName.startsWith( + ConsoleRolesOnboardingConstants.ORG_PREFIX) + ? itemName.substring(ConsoleRolesOnboardingConstants.ORG_PREFIX.length) + : itemName; + + return !filteringAPIResourceCollectionNames.includes(itemName) && + ( + enabledFeatureOverridesInConsoleRolePermissions?.includes(featureNameWithoutOrgPrefix) + || flattenedFeatureConfig?.[featureNameWithoutOrgPrefix]?.enabled + || flattenedFeatureConfig?.[UIConstants.CONSOLE_FEATURE_MAP[ + featureNameWithoutOrgPrefix]]?.enabled + ); + } + ) + }; + }, [ organizationAPIResourceCollections, flattenedFeatureConfig, enabledFeatureOverridesInConsoleRolePermissions ]); + + /** + * Extracts all scope names from a permission-category array. + * Returns an empty array when the category array is absent or empty. + */ + const extractScopeNames = ( + categories: APIResourceCollectionPermissionCategoryInterface[] | undefined + ): string[] => { + if (!categories || categories.length === 0) { + return []; + } + + return categories.flatMap( + (resource: APIResourceCollectionPermissionCategoryInterface) => + resource.scopes.map((scope: APIResourceCollectionPermissionScopeInterface) => scope.name) + ); + }; + + /** + * Checks whether every scope in `categories` appears in `selectedFeatures`. + * An empty (or absent) category is treated as "not selected". + * + * The per-action feature scopes (`console:_create/_update/_delete/_edit`) are dropped + * from the check in both modes — only the underlying management scopes (the `internal_*` scopes + * that actually grant the capability) decide whether a box is reported as selected. This keeps + * the loaded role's checkbox state consistent with the live form, and lights up boxes whose + * required capability is already granted through a shared scope even if the per-action feature + * scope itself is not persisted on the role. + */ + const allScopesSelected = ( + categories: APIResourceCollectionPermissionCategoryInterface[] | undefined, + selectedFeatures: string[] + ): boolean => { + const names: string[] = getEligibilityScopeNames(extractScopeNames(categories)); - return clonedOrganizationAPIResourceCollections; - }, [ organizationAPIResourceCollections ]); + return names.length > 0 && names.every((name: string) => selectedFeatures.includes(name)); + }; /** * Process the collection and return the selected permissions. * + * Legacy mode (useGranularConsolePermissions === false): + * Evaluates `read` and `write` categories. If `write` is fully covered it takes + * precedence — `read` is then marked false and only write scopes are stored. + * + * Granular mode (useGranularConsolePermissions === true): + * Evaluates `read`, `create`, `update`, and `delete` independently. + * The `write` field from the API response is ignored entirely. + * Each flag is set to `true` only when all of its scopes are present in the role. + * Any combination of flags is valid; there is no precedence between them. + * + * In both modes the eligibility check ignores the per-action feature scopes + * (`console:_create/_update/_delete/_edit`) and decides each level by the underlying + * management scopes (the `internal_*` scopes that actually grant the capability). This keeps + * boxes lit when a role holds the management scope but not the cosmetic feature scope, and + * lets levels backed by the same management scope load together. + * * @param collection - API resource collection. - * @param selectedFeatures - Selected features. - * @returns Selected permissions. + * @param selectedFeatures - Scope names already assigned to the role. + * @returns Populated SelectedPermissionCategoryInterface, or null if nothing is selected. */ const processCollection = ( collection: APIResourceCollectionInterface, selectedFeatures: string[] ): SelectedPermissionCategoryInterface | null => { - const readPermissions: string[] = collection.apiResources.read.flatMap( - (resource: APIResourceCollectionPermissionCategoryInterface) => - resource.scopes.map((scope: APIResourceCollectionPermissionScopeInterface) => scope.name) - ); - - const writePermissions: string[] = collection.apiResources.write.flatMap( - (resource: APIResourceCollectionPermissionCategoryInterface) => - resource.scopes.map((scope: APIResourceCollectionPermissionScopeInterface) => scope.name) - ); + if (useGranularConsolePermissions) { + // A create / update / delete level that carries no action scope of its own (only its + // per-action feature scope — e.g. Approvals) is not actionable: its cell is read-only in + // the form, so it must never be reported as selected here. Gate each write level on + // `isPermissionLevelActionable` before testing its scopes, otherwise — because the bucket + // is a cumulative superset of read and the feature scope is stripped by the eligibility + // check — it would falsely light up whenever read is granted. `read` is always actionable. + const hasRead: boolean = allScopesSelected(collection.apiResources.read, selectedFeatures); + const hasCreate: boolean = isPermissionLevelActionable(collection, "create") + && allScopesSelected(collection.apiResources.create, selectedFeatures); + const hasUpdate: boolean = isPermissionLevelActionable(collection, "update") + && allScopesSelected(collection.apiResources.update, selectedFeatures); + const hasDelete: boolean = isPermissionLevelActionable(collection, "delete") + && allScopesSelected(collection.apiResources.delete, selectedFeatures); + + if (!hasRead && !hasCreate && !hasUpdate && !hasDelete) { + return null; + } - const hasReadPermissions: boolean = readPermissions.every((permission: string) => - selectedFeatures.includes(permission) - ); - const hasWritePermissions: boolean = writePermissions.every((permission: string) => - selectedFeatures.includes(permission) - ); + // Collect scopes for every checked level and deduplicate. + const activeCategories: APIResourceCollectionPermissionCategoryInterface[] = [ + ...(hasRead ? (collection.apiResources.read ?? []) : []), + ...(hasCreate ? (collection.apiResources.create ?? []) : []), + ...(hasUpdate ? (collection.apiResources.update ?? []) : []), + ...(hasDelete ? (collection.apiResources.delete ?? []) : []) + ]; - if (hasReadPermissions || hasWritePermissions) { return { - permissions: transformResourceCollectionToPermissions( - collection.apiResources[hasWritePermissions ? "write" : "read"] - ), - read: hasWritePermissions ? false : hasReadPermissions, - write: hasWritePermissions + create: hasCreate, + delete: hasDelete, + permissions: transformResourceCollectionToPermissions(activeCategories), + read: hasRead, + update: hasUpdate, + write: false }; } - return null; + // Legacy mode. + const hasReadPermission: boolean = allScopesSelected(collection.apiResources.read, selectedFeatures); + const hasWritePermission: boolean = allScopesSelected(collection.apiResources.write, selectedFeatures); + + if (!hasReadPermission && !hasWritePermission) { + return null; + } + + const legacyCategories: APIResourceCollectionPermissionCategoryInterface[] = + (hasWritePermission ? collection.apiResources.write : undefined) ?? collection.apiResources.read; + + return { + permissions: transformResourceCollectionToPermissions(legacyCategories), + read: !hasWritePermission && hasReadPermission, + write: hasWritePermission + }; }; /** @@ -240,7 +357,8 @@ const ConsoleRolePermissions: FunctionComponent = ( ); return permissions; - }, [ role, filteredTenantAPIResourceCollections, filteredOrganizationAPIResourceCollections ]); + }, [ role, filteredTenantAPIResourceCollections, filteredOrganizationAPIResourceCollections, + useGranularConsolePermissions ]); /** * Update the role permissions. diff --git a/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.scss b/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.scss index 9e605849132..0fc61316449 100644 --- a/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.scss +++ b/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.scss @@ -5,11 +5,25 @@ border: 1px solid var(--oxygen-palette-divider); margin: 10px 0; + .permissions-accordion-heading { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + } + .permissions-accordion-label { - width: 40%; flex-shrink: 0; } + .permissions-count-chip { + font-weight: 500; + } + + .permissions-accordion-checkbox-placeholder { + visibility: hidden; + } + .permissions-table { .permissions-table-data-row { &:last-child td, &:last-child th { @@ -19,6 +33,9 @@ } .oxygen-accordion-summary { + padding-left: var(--oxygen-spacing-3); + padding-right: var(--oxygen-spacing-3); + .MuiAccordionSummary-content { display: flex; flex-direction: row; @@ -26,8 +43,20 @@ align-content: center; align-items: center; justify-content: flex-start; + + // MUI inflates the summary content margin when expanded, which opens up a + // large empty band between the title and the table. Keep it consistent. + &.Mui-expanded { + margin: var(--oxygen-spacing-1) 0; + } } } + + .oxygen-accordion-details { + padding-top: 0; + padding-left: var(--oxygen-spacing-3); + padding-right: var(--oxygen-spacing-3); + } } .accordion-container { @@ -37,4 +66,65 @@ } } } + + /* Granular permission mode — View / Create / Update / Delete are each their own + TableCell, so MUI's table-layout aligns header-column checkboxes with data-row + checkboxes automatically. */ + .granular-permission-column-cell { + width: 1%; /* let auto layout shrink each column to its content + padding */ + white-space: nowrap; /* keep label and checkbox on one line in the header */ + } + + // Header cell content: column label stacked above its centered select-all checkbox, + // so the checkbox lines up vertically with the data-row checkboxes below it. + .granular-permission-column-header { + display: inline-flex; + flex-direction: column; + align-items: center; + + .granular-permission-column-label { + font-weight: 500; + line-height: 1.2; + } + } + + .granular-permission-header-row { + background-color: var(--oxygen-palette-grey-100); + + th { + border-bottom: 1px solid var(--oxygen-palette-divider); + } + } + + .granular-permission-column-cell { + /* A disabled checkbox — the form is read-only, or this create / update / delete level has + no corresponding action scope — must read as an inert, non-interactive control: a faded, + muted outline with a not-allowed cursor, never a filled grey box that looks like a + selected state. (MUI disables pointer events on a disabled control, so re-enable them + just enough to show the not-allowed cursor while keeping the hover highlight suppressed.) */ + .MuiCheckbox-root.Mui-disabled { + pointer-events: auto; + cursor: not-allowed; + + &:hover { + background-color: transparent; + } + + /* Disabled + checked (e.g. a granted level shown in read-only mode): keep the checked + colour so the grant stays legible, dimmed so it still reads as non-interactive. */ + &.Mui-checked { + color: var(--oxygen-palette-primary-main); + opacity: 0.5; + } + + /* Disabled + unchecked: a plain muted outline, faded — no grey interior fill. */ + &:not(.Mui-checked) { + opacity: 0.4; + + .MuiSvgIcon-root { + color: var(--oxygen-palette-grey-500); + } + } + } + } } diff --git a/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx b/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx index f14483b13cf..d68be841223 100644 --- a/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx +++ b/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx @@ -20,6 +20,7 @@ import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; @@ -27,24 +28,20 @@ import Accordion from "@oxygen-ui/react/Accordion"; import AccordionDetails from "@oxygen-ui/react/AccordionDetails"; import AccordionSummary from "@oxygen-ui/react/AccordionSummary"; import Checkbox from "@oxygen-ui/react/Checkbox"; +import Chip from "@oxygen-ui/react/Chip"; import Paper from "@oxygen-ui/react/Paper"; import Typography from "@oxygen-ui/react/Typography"; import { ChevronDownIcon } from "@oxygen-ui/react-icons"; -import { FeatureAccessConfigInterface } from "@wso2is/access-control"; import { UIConstants } from "@wso2is/admin.core.v1/constants/ui-constants"; import { FeatureConfigInterface } from "@wso2is/admin.core.v1/models/config"; import { AppState } from "@wso2is/admin.core.v1/store"; import { useGetCurrentOrganizationType } from "@wso2is/admin.organizations.v1/hooks/use-get-organization-type"; import { CreateRolePermissionInterface } from "@wso2is/admin.roles.v2/models/roles"; +import { isFeatureEnabled } from "@wso2is/core/helpers"; import { IdentifiableComponentInterface } from "@wso2is/core/models"; import cloneDeep from "lodash-es/cloneDeep"; -import flatMap from "lodash-es/flatMap"; -import fromPairs from "lodash-es/fromPairs"; import get from "lodash-es/get"; import isEmpty from "lodash-es/isEmpty"; -import mapValues from "lodash-es/mapValues"; -import omit from "lodash-es/omit"; -import values from "lodash-es/values"; import React, { ChangeEvent, FunctionComponent, @@ -61,6 +58,7 @@ import useGetAPIResourceCollections from "../../../api/use-get-api-resource-coll import { ConsoleRolesOnboardingConstants } from "../../../constants/console-roles-onboarding-constants"; import { APIResourceCollectionInterface, + APIResourceCollectionPermissionCategoryInterface, APIResourceCollectionResponseInterface, APIResourceCollectionTypes } from "../../../models/console-roles"; @@ -69,6 +67,9 @@ import { SelectedPermissionCategoryInterface, SelectedPermissionsInterface } from "../../../models/permissions-ui"; +import flattenFeatureConfig from "../../../utils/flatten-feature-config"; +import getEligibilityScopeNames from "../../../utils/get-eligibility-scope-names"; +import isPermissionLevelActionable from "../../../utils/is-permission-level-actionable"; import transformResourceCollectionToPermissions from "../../../utils/transform-resource-collection-to-permissions"; import "./create-console-role-wizard-permissions-form.scss"; @@ -98,6 +99,95 @@ interface CreateConsoleRoleWizardPermissionsFormProps extends IdentifiableCompon const TENANT_PERMISSIONS_ACCORDION_ID: string = "tenant-permissions"; const ORGANIZATION_PERMISSIONS_ACCORDION_ID: string = "organization-permissions"; +type GranularPermissionLevel = "read" | "create" | "update" | "delete"; +type LegacyPermissionLevel = "read" | "write"; + +const GRANULAR_PERMISSION_COLUMNS: ReadonlyArray = [ + "read", + "create", + "update", + "delete" +]; + +const SCOPE_NAMESPACE_SEPARATOR: string = "::"; + +/** + * Namespaces a scope name by collection type so tenant and organization scopes stay distinct + * even when they share a backend name. The two accordions are kept separate, so the granted-scope + * set is keyed by `type::scopeName` — a scope selected under tenant never satisfies an + * organization box, and vice versa. + */ +const qualifyScope = (type: APIResourceCollectionTypes, scopeName: string): string => + `${type}${SCOPE_NAMESPACE_SEPARATOR}${scopeName}`; + +/** + * Namespaces collection metadata by type too. Tenant and organization collections can share an id, + * but their backing scopes and available levels must stay independent. + */ +const getCollectionMetadataKey = (collection: APIResourceCollectionInterface): string => + `${collection.type}${SCOPE_NAMESPACE_SEPARATOR}${collection.id}`; + +/** + * Returns the backend scope names backing a single (collection, level), or an empty array + * when that level is absent from the API response. These names are the unit of truth: a level + * checkbox is shown checked exactly when this set is fully contained in the granted scopes. + */ +const getLevelScopeNames = ( + collection: APIResourceCollectionInterface, + level: GranularPermissionLevel +): string[] => + collection?.apiResources?.[level] + ? transformResourceCollectionToPermissions(collection.apiResources[level]) + .map((scope: PermissionScopeInterface) => scope.value) + : []; + +/** + * Per-collection, per-level data derived once from the (immutable) API response and cached, so the + * render and selection paths never recompute it. + */ +interface CollectionLevelMetadata { + /** + * Levels that are actually processable (read, plus any create/update/delete that carries a real + * action scope; see `isPermissionLevelActionable`). Used to disable read-only cells and to + * exclude them from every derivation / scope-building loop. + */ + availableLevels: GranularPermissionLevel[]; + /** + * Backend scope names backing each level; a level checkbox is shown checked exactly when this + * set is fully contained in the granted scopes. + */ + scopeNamesByLevel: Record; + /** + * Scopes a level contributes *beyond* View (its backing scopes minus read's). These fall away + * when the level is unchecked; View's are excluded so unticking a write level never clears the + * row's View box. When several levels share a management scope (e.g. Branding's + * create/update/delete all resolve to `internal_branding_preference_update`), their marginal + * sets overlap, so dropping one clears the shared capability and the siblings fall away + * together instead of a sibling immediately re-ticking the box that was just unchecked. + */ + marginalScopeNamesByLevel: Record; +} + +/** + * Collects the flat set of granted scope names from a selection map. Used both to seed the + * granted-scope state from server-loaded values and to garbage-collect the working set down to + * exactly the scopes still backed by a checked box (see `applyGranularSelectionChange`). + */ +const deriveScopesFromSelection = (selected: SelectedPermissionsInterface): Set => { + const scopes: Set = new Set(); + + [ APIResourceCollectionTypes.TENANT, APIResourceCollectionTypes.ORGANIZATION ].forEach( + (type: APIResourceCollectionTypes) => { + Object.values(selected?.[type] ?? {}).forEach((entry: SelectedPermissionCategoryInterface) => { + entry?.permissions?.forEach( + (scope: PermissionScopeInterface) => scopes.add(qualifyScope(type, scope.value))); + }); + } + ); + + return scopes; +}; + /** * Text customization fields component. * @@ -129,12 +219,31 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent state.config.ui.enabledFeatureOverridesInConsoleRolePermissions); + /** + * Switches the permissions evaluation between the legacy and the granular mode. + */ + const useGranularConsolePermissions: boolean = isFeatureEnabled( + featureConfig?.consoleSettings, + ConsoleRolesOnboardingConstants.GRANULAR_CONSOLE_PERMISSIONS_FEATURE_KEY + ); + const [ expandedAccordions, setExpandedAccordions ] = useState([]); const [ selectedPermissions, setSelectedPermissions ] = useState(initialValues || { organization: {}, tenant: {} }); + /** + * The flat set of granted backend scope names — the single source of truth for the granular + * table. Every level checkbox is derived from this set: it is shown checked exactly when all + * of its backing scopes are present (see `deriveSelectedFromScopes`), regardless of whether + * the user clicked it directly or it became covered by a shared scope. The set is kept tight + * (only scopes still backed by a checked box) so it always equals what the role will store. + */ + const [ grantedScopes, setGrantedScopes ] = useState>( + deriveScopesFromSelection(initialValues || { organization: {}, tenant: {} }) + ); + const { isSubOrganization } = useGetCurrentOrganizationType(); /** @@ -146,6 +255,7 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent { - const topLevelFeatures: Record> = mapValues( - featureConfig, - (feature: FeatureAccessConfigInterface) => omit(feature, [ "subFeatures" ]) - ); - - const subLevelFeatures: Record> = fromPairs( - flatMap(values(featureConfig), (feature: FeatureAccessConfigInterface) => { - return Object.entries(feature.subFeatures || {}); - }) - ); - - return { - ...topLevelFeatures, - ...subLevelFeatures - } as FeatureConfigInterface; - - }, [ featureConfig ]); + const flattenedFeatureConfig: FeatureConfigInterface = useMemo( + () => flattenFeatureConfig(featureConfig), + [ featureConfig ] + ); const filteredTenantAPIResourceCollections: APIResourceCollectionResponseInterface = useMemo(() => { @@ -188,13 +284,14 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent @@ -216,13 +313,14 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent { @@ -245,6 +343,49 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent = useMemo(() => { + const metadataById: Map = new Map(); + const collections: APIResourceCollectionInterface[] = [ + ...(filteredTenantAPIResourceCollections?.apiResourceCollections ?? []), + ...(filteredOrganizationAPIResourceCollections?.apiResourceCollections ?? []) + ]; + + collections.forEach((collection: APIResourceCollectionInterface) => { + const scopeNamesByLevel: Record = + {} as Record; + const marginalScopeNamesByLevel: Record = + {} as Record; + const readScopeNames: Set = new Set(getLevelScopeNames(collection, "read")); + + GRANULAR_PERMISSION_COLUMNS.forEach((level: GranularPermissionLevel) => { + const names: string[] = getLevelScopeNames(collection, level); + + scopeNamesByLevel[level] = names; + marginalScopeNamesByLevel[level] = level === "read" + ? [] + : names.filter((name: string) => !readScopeNames.has(name)); + }); + + const availableLevels: GranularPermissionLevel[] = GRANULAR_PERMISSION_COLUMNS.filter( + (level: GranularPermissionLevel) => isPermissionLevelActionable(collection, level)); + + metadataById.set( + getCollectionMetadataKey(collection), + { availableLevels, marginalScopeNamesByLevel, scopeNamesByLevel } + ); + }); + + return metadataById; + }, [ filteredTenantAPIResourceCollections, filteredOrganizationAPIResourceCollections ]); + /** * Handles the accordion expand event. * @@ -259,66 +400,405 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent, type: APIResourceCollectionTypes): void => { - const _selectedPermissions: SelectedPermissionsInterface = cloneDeep(selectedPermissions); - - if (type === APIResourceCollectionTypes.TENANT) { - if (e.target.checked) { - _selectedPermissions.tenant = ( - filteredTenantAPIResourceCollections?.apiResourceCollections || [] - ).reduce( - ( - result: { - [key: string]: SelectedPermissionCategoryInterface; - }, - collection: APIResourceCollectionInterface - ) => { - result[collection.id] = { - permissions: transformResourceCollectionToPermissions(collection.apiResources.read), - read: true, - write: false - }; + const rebuildGranularPermissions = ( + collection: APIResourceCollectionInterface, + entry: SelectedPermissionCategoryInterface + ): PermissionScopeInterface[] => { + const activeCategories: APIResourceCollectionPermissionCategoryInterface[] = [ + ...(entry.read ? (collection.apiResources.read ?? []) : []), + ...(entry.create ? (collection.apiResources.create ?? []) : []), + ...(entry.update ? (collection.apiResources.update ?? []) : []), + ...(entry.delete ? (collection.apiResources.delete ?? []) : []) + ]; - return result; - }, - {} - ); - } else { - _selectedPermissions.tenant = {}; + return transformResourceCollectionToPermissions(activeCategories); + }; + + /** + * Returns the source collections array for a given accordion type. + */ + const getSourceCollections = ( + type: APIResourceCollectionTypes + ): APIResourceCollectionInterface[] => ( + type === APIResourceCollectionTypes.TENANT + ? filteredTenantAPIResourceCollections?.apiResourceCollections || [] + : filteredOrganizationAPIResourceCollections?.apiResourceCollections || [] + ); + + /** + * Derives the full checkbox state from the granted scope set. For every (collection, level), + * the level is shown checked exactly when its backing scopes are non-empty and every one of + * them is present in `grantedScopeNames` — the literal "do the granted scopes satisfy this + * box?" test. A box lights up the same way whether the user clicked it or a shared backend + * scope made it covered; the model draws no distinction between an explicit and implied check. + * + * `read` is evaluated by the same rule, so an update-without-view row (create/update/delete + * granted while the view scope is not) is shown exactly as it stands and saved as shown — the + * displayed state never claims a capability the granted scopes do not hold, and never hides + * one they do. + */ + const deriveSelectedFromScopes = (grantedScopeNames: Set): SelectedPermissionsInterface => { + const result: SelectedPermissionsInterface = { organization: {}, tenant: {} }; + + [ APIResourceCollectionTypes.TENANT, APIResourceCollectionTypes.ORGANIZATION ].forEach( + (type: APIResourceCollectionTypes) => { + getSourceCollections(type).forEach((collection: APIResourceCollectionInterface) => { + const entry: SelectedPermissionCategoryInterface = { + create: false, + delete: false, + permissions: [], + read: false, + update: false, + write: false + }; + let anyActive: boolean = false; + + getAvailableLevels(collection).forEach((level: GranularPermissionLevel) => { + // The eligibility check ignores the per-action feature scopes + // (`console:_create/_update/_delete`) so that levels backed by the same + // management scope — e.g. Branding's create/update/delete all resolving to + // `internal_branding_preference_update` — light up together: checking one searches + // the grid and selects every other box whose effective scopes are now covered. + const names: string[] = + getEligibilityScopeNames(getCollectionLevelScopeNames(collection, level)); + + if (names.length > 0 + && names.every((name: string) => grantedScopeNames.has(qualifyScope(type, name)))) { + entry[level] = true; + anyActive = true; + } + }); + + if (anyActive) { + entry.permissions = rebuildGranularPermissions(collection, entry); + result[type][collection.id] = entry; + } + }); + } + ); + + return result; + }; + + /** + * Flips a single (collection, level) box on a draft selection, enforcing the View↔write + * coupling: turning on a write level (create/update/delete) also turns View on, and turning + * View off also turns every write level off. The collection entry is created on demand when a + * box is switched on. + * + * This only sets the box flags; it never edits the scope set directly. `applyGranularSelectionChange` + * rebuilds the scopes from the post-toggle boxes and then drops the *marginal* (beyond-View) scopes + * of any box switched off — so a write level can be unchecked without disturbing the View scope it + * shares with the rest of the row, while still clearing the management scope shared with its siblings. + */ + const toggleSelectionBox = ( + draft: SelectedPermissionsInterface, + type: APIResourceCollectionTypes, + collection: APIResourceCollectionInterface, + level: GranularPermissionLevel, + checked: boolean + ): void => { + const entry: SelectedPermissionCategoryInterface = draft[type][collection.id] ?? { + create: false, + delete: false, + permissions: [], + read: false, + update: false, + write: false + }; + + if (checked) { + entry[level] = true; + + if (level === "create" || level === "update" || level === "delete") { + entry.read = true; } } else { - if (e.target.checked) { - _selectedPermissions.organization = ( - filteredOrganizationAPIResourceCollections?.apiResourceCollections || [] - ).reduce( - ( - result: { - [key: string]: SelectedPermissionCategoryInterface; - }, - collection: APIResourceCollectionInterface - ) => { - result[collection.id] = { + entry[level] = false; + + if (level === "read") { + entry.create = false; + entry.update = false; + entry.delete = false; + } + } + + draft[type][collection.id] = entry; + }; + + /** + * Builds the granted scope set from a box selection by unioning the (cumulative) backend scopes + * of every checked level, namespaced by collection type. This is the inverse direction of the + * subset test in `deriveSelectedFromScopes`: scopes flow up from the boxes the user toggled, + * and the boxes are then re-derived from those scopes (impact closure + garbage collection). + */ + const deriveScopesFromBoxes = (selected: SelectedPermissionsInterface): Set => { + const scopes: Set = new Set(); + + [ APIResourceCollectionTypes.TENANT, APIResourceCollectionTypes.ORGANIZATION ].forEach( + (type: APIResourceCollectionTypes) => { + getSourceCollections(type).forEach((collection: APIResourceCollectionInterface) => { + const entry: SelectedPermissionCategoryInterface | undefined = + selected[type]?.[collection.id]; + + if (!entry) { + return; + } + + getAvailableLevels(collection).forEach((level: GranularPermissionLevel) => { + if (entry[level]) { + getCollectionLevelScopeNames(collection, level).forEach( + (name: string) => scopes.add(qualifyScope(type, name))); + } + }); + }); + } + ); + + return scopes; + }; + + /** + * Single entry point for every granular change. It derives the current box state from the + * granted scopes (so coupling/implied flags are correct), lets the caller flip one or more + * boxes on that draft, then: + * 1. rebuilds the scope set from the post-toggle boxes (`deriveScopesFromBoxes`) — a shared + * scope survives as long as any still-checked box carries it, which keeps cross-collection + * and View scopes alive when an unrelated box is toggled; + * 2. drops the marginal scopes of every box the caller switched OFF, even when a sibling box + * still carries them — this is what lets an unchecked write level actually clear instead of + * snapping back (Branding's create/update/delete all map to the same management scope, so + * unticking one removes that shared scope and the siblings fall away with it); + * 3. re-derives the full table from the resulting scopes (`deriveSelectedFromScopes`), lighting + * up any box a shared scope now covers across collections (impact closure); + * 4. tightens the scope set down to exactly what the re-derived table backs + * (`deriveScopesFromSelection`, garbage collection) so stored scopes never drift. + * + * Step 4 reaches a fixpoint in one pass: the tightened set still contains every checked box's + * scopes, so `deriveSelectedFromScopes(tightened)` equals `nextSelected` and no box flips again. + */ + const applyGranularSelectionChange = (mutate: (draft: SelectedPermissionsInterface) => void): void => { + const draft: SelectedPermissionsInterface = deriveSelectedFromScopes(grantedScopes); + const before: SelectedPermissionsInterface = cloneDeep(draft); + + mutate(draft); + + // Collect the marginal scopes of every (collection, level) the mutation switched off, so a + // capability that several levels share is dropped rather than re-asserted by a sibling that + // is still checked (the "won't untick / looks frozen" case). + const scopesToRemove: Set = new Set(); + + [ APIResourceCollectionTypes.TENANT, APIResourceCollectionTypes.ORGANIZATION ].forEach( + (type: APIResourceCollectionTypes) => { + getSourceCollections(type).forEach((collection: APIResourceCollectionInterface) => { + getAvailableLevels(collection).forEach((level: GranularPermissionLevel) => { + const wasOn: boolean = !!get(before[type], [ collection.id, level ]); + const isOn: boolean = !!get(draft[type], [ collection.id, level ]); + + if (wasOn && !isOn) { + getCollectionLevelMarginalScopeNames(collection, level).forEach( + (name: string) => scopesToRemove.add(qualifyScope(type, name))); + } + }); + }); + } + ); + + const intendedScopes: Set = deriveScopesFromBoxes(draft); + + scopesToRemove.forEach((scope: string) => intendedScopes.delete(scope)); + + const nextSelected: SelectedPermissionsInterface = deriveSelectedFromScopes(intendedScopes); + const tightenedScopes: Set = deriveScopesFromSelection(nextSelected); + + setGrantedScopes(tightenedScopes); + setSelectedPermissions(nextSelected); + processPermissionsChange(nextSelected); + }; + + /** + * The backend scope names backing a legacy (collection, level) — an empty array when the level + * is absent from the API response. Used by the legacy impact-closure path; the granular path + * has its own cached lookup. + */ + const getLegacyLevelScopeNames = ( + collection: APIResourceCollectionInterface, + level: LegacyPermissionLevel + ): string[] => + collection?.apiResources?.[level] + ? transformResourceCollectionToPermissions(collection.apiResources[level]) + .map((scope: PermissionScopeInterface) => scope.value) + : []; + + /** + * Legacy counterpart of {@link deriveSelectedFromScopes}: for every collection, checks whether + * write's (or, failing that, read's) eligibility scopes are all present in `grantedScopeNames`, + * and lights up the row accordingly. Write wins when both are covered — same precedence as the + * old `handlePermissionLevelChange` used to enforce directly. + * + * Because the eligibility check strips the per-action feature scopes, two collections backed by + * the same underlying management scope light up together: selecting one row's read (or write) + * now marks every other row whose eligibility scopes the change made covered. + */ + const deriveLegacySelectedFromScopes = (grantedScopeNames: Set): SelectedPermissionsInterface => { + const result: SelectedPermissionsInterface = { organization: {}, tenant: {} }; + + [ APIResourceCollectionTypes.TENANT, APIResourceCollectionTypes.ORGANIZATION ].forEach( + (type: APIResourceCollectionTypes) => { + getSourceCollections(type).forEach((collection: APIResourceCollectionInterface) => { + const readEligibility: string[] = + getEligibilityScopeNames(getLegacyLevelScopeNames(collection, "read")); + const writeEligibility: string[] = + getEligibilityScopeNames(getLegacyLevelScopeNames(collection, "write")); + + const readCovered: boolean = readEligibility.length > 0 + && readEligibility.every( + (name: string) => grantedScopeNames.has(qualifyScope(type, name))); + const writeCovered: boolean = writeEligibility.length > 0 + && writeEligibility.every( + (name: string) => grantedScopeNames.has(qualifyScope(type, name))); + + if (writeCovered) { + result[type][collection.id] = { + permissions: transformResourceCollectionToPermissions( + collection.apiResources.write ?? collection.apiResources.read + ), + read: false, + write: true + }; + } else if (readCovered) { + result[type][collection.id] = { permissions: transformResourceCollectionToPermissions(collection.apiResources.read), read: true, write: false }; + } + }); + } + ); - return result; - }, - {} - ); - } else { - _selectedPermissions.organization = {}; + return result; + }; + + /** + * Legacy counterpart of {@link applyGranularSelectionChange}. Derives the current row state from + * the granted scopes, lets the caller flip one or more rows on that draft, then: + * 1. rebuilds the scope set from the post-toggle boxes — shared scopes stay alive as long as + * any still-checked row carries them, so unrelated rows are undisturbed; + * 2. force-removes the marginal scopes of any row switched off (or downgraded from write→read), + * so a management scope shared across rows is dropped rather than kept alive by a sibling + * that is still checked; + * 3. re-derives the full table from the resulting scopes, lighting up every row a shared scope + * now covers (this is what makes "one row selects other relevant rows too" work); + * 4. tightens the stored scope set down to exactly what the re-derived table backs, so persisted + * scopes never drift from what the UI shows. + */ + const applyLegacySelectionChange = (mutate: (draft: SelectedPermissionsInterface) => void): void => { + const draft: SelectedPermissionsInterface = deriveLegacySelectedFromScopes(grantedScopes); + const before: SelectedPermissionsInterface = cloneDeep(draft); + + mutate(draft); + + // Collect scopes to force-remove for rows that were unchecked or downgraded from write→read. + const scopesToRemove: Set = new Set(); + + [ APIResourceCollectionTypes.TENANT, APIResourceCollectionTypes.ORGANIZATION ].forEach( + (type: APIResourceCollectionTypes) => { + getSourceCollections(type).forEach((collection: APIResourceCollectionInterface) => { + const wasEntry: SelectedPermissionCategoryInterface | undefined = + before[type][collection.id]; + const nextEntry: SelectedPermissionCategoryInterface | undefined = + draft[type][collection.id]; + + const wasRead: boolean = !!wasEntry?.read; + const wasWrite: boolean = !!wasEntry?.write; + const isRead: boolean = !!nextEntry?.read; + const isWrite: boolean = !!nextEntry?.write; + + // Downgraded from write to read (or unchecked) — drop write-only scopes. + if (wasWrite && !isWrite) { + const readNames: Set = + new Set(getLegacyLevelScopeNames(collection, "read")); + const marginalWriteNames: string[] = + getLegacyLevelScopeNames(collection, "write") + .filter((name: string) => !readNames.has(name)); + + marginalWriteNames.forEach( + (name: string) => scopesToRemove.add(qualifyScope(type, name))); + } + + // Row unchecked entirely — drop read scopes too. + if ((wasRead || wasWrite) && !isRead && !isWrite) { + getLegacyLevelScopeNames(collection, "read").forEach( + (name: string) => scopesToRemove.add(qualifyScope(type, name))); + } + }); } - } + ); + + // Rebuild the granted scope set from the post-toggle boxes. + const intendedScopes: Set = new Set(); + + [ APIResourceCollectionTypes.TENANT, APIResourceCollectionTypes.ORGANIZATION ].forEach( + (type: APIResourceCollectionTypes) => { + getSourceCollections(type).forEach((collection: APIResourceCollectionInterface) => { + const entry: SelectedPermissionCategoryInterface | undefined = draft[type][collection.id]; + + if (!entry) { + return; + } + + const level: LegacyPermissionLevel | null = + entry.write ? "write" : entry.read ? "read" : null; - setSelectedPermissions(_selectedPermissions); - processPermissionsChange(_selectedPermissions); + if (level === null) { + return; + } + + getLegacyLevelScopeNames(collection, level).forEach( + (name: string) => intendedScopes.add(qualifyScope(type, name))); + }); + } + ); + + scopesToRemove.forEach((scope: string) => intendedScopes.delete(scope)); + + const nextSelected: SelectedPermissionsInterface = deriveLegacySelectedFromScopes(intendedScopes); + const tightenedScopes: Set = deriveScopesFromSelection(nextSelected); + + setGrantedScopes(tightenedScopes); + setSelectedPermissions(nextSelected); + processPermissionsChange(nextSelected); + }; + + /** + * Handles the accordion-level select-all checkbox change — flips every row on (as read) or off. + * Uses the same code path as the row-level handlers so the impact closure runs uniformly. + * + * Legacy mode only; the accordion select-all checkbox is not rendered in granular mode. + */ + const handleSelectAll = (e: ChangeEvent, type: APIResourceCollectionTypes): void => { + const checked: boolean = e.target.checked; + + applyLegacySelectionChange((draft: SelectedPermissionsInterface) => { + if (checked) { + getSourceCollections(type).forEach((collection: APIResourceCollectionInterface) => { + draft[type][collection.id] = { + permissions: [], + read: true, + write: false + }; + }); + } else { + draft[type] = {}; + } + }); }; /** @@ -327,7 +807,36 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent { - const uniquePermissionsSet: Set = new Set(); + if (!useGranularConsolePermissions) { + const uniquePermissionsSet: Set = new Set(); + + Object.keys(permissions).forEach((key: string) => { + const typePermissions: SelectedPermissionsInterface = permissions[key]; + + Object.keys(typePermissions).forEach((id: string) => { + const resource: SelectedPermissionCategoryInterface = typePermissions[id]; + + if (resource.permissions && resource.permissions.length > 0) { + resource.permissions.forEach((permission: PermissionScopeInterface) => { + uniquePermissionsSet.add(JSON.stringify(permission)); + }); + } + }); + }); + + const flattenedPermissions: CreateRolePermissionInterface[] = Array.from( + uniquePermissionsSet + ).map((permissionString: string) => JSON.parse(permissionString)); + + onPermissionsChange(flattenedPermissions); + + return; + } + + // Permission objects are `{ value }`, so deduplicate by value directly — avoids a + // JSON.stringify/parse round-trip over every scope on each change. + const uniquePermissionsByValue: Map = + new Map(); Object.keys(permissions).forEach((key: string) => { const typePermissions: SelectedPermissionsInterface = permissions[key]; @@ -335,57 +844,62 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent { const resource: SelectedPermissionCategoryInterface = typePermissions[id]; - if (resource.permissions && resource.permissions.length > 0) { - resource.permissions.forEach((permission: PermissionScopeInterface) => { - uniquePermissionsSet.add(JSON.stringify(permission)); - }); - } + resource.permissions?.forEach((permission: PermissionScopeInterface) => { + uniquePermissionsByValue.set(permission.value, { value: permission.value }); + }); }); }); - const flattenedPermissions: CreateRolePermissionInterface[] = Array.from( - uniquePermissionsSet - ).map((permissionString: string) => JSON.parse(permissionString)); - - onPermissionsChange(flattenedPermissions); + onPermissionsChange(Array.from(uniquePermissionsByValue.values())); }; /** - * Handles the select checkbox change event. + * Single-entry point every legacy row change funnels through — the row checkbox, the read/write + * toggle, and the accordion select-all all end up here. Keeping one code path is what makes the + * impact closure (defined in {@link applyLegacySelectionChange}) fire uniformly: whichever + * control the user clicks, related rows whose eligibility scopes the change covered light up + * together, and rows unchecked entirely fall away in lockstep. * - * @param e - Change event. - * @param collection - Selected API resource collection. - * @param type - Selected API resource collection type. + * `read: false, write: false` deletes the row's entry (equivalent to unchecking it). Otherwise + * the row is stored with the requested flags and the closure re-derives every other row. + */ + const setLegacyRowLevel = ( + collection: APIResourceCollectionInterface, + type: APIResourceCollectionTypes, + level: { read: boolean; write: boolean } + ): void => { + applyLegacySelectionChange((draft: SelectedPermissionsInterface) => { + if (!level.read && !level.write) { + delete draft[type][collection.id]; + + return; + } + + draft[type][collection.id] = { + permissions: [], + read: level.read, + write: level.write + }; + }); + }; + + /** + * Row-level checkbox change (legacy mode). */ const handleSelect = ( e: ChangeEvent, collection: APIResourceCollectionInterface, type: APIResourceCollectionTypes ): void => { - const { id, apiResources } = collection; - const _selectedPermissions: SelectedPermissionsInterface = cloneDeep(selectedPermissions); - - if (e.target.checked) { - _selectedPermissions[type][id] = { - permissions: transformResourceCollectionToPermissions(apiResources.read), - read: true, - write: false - }; - } else { - delete _selectedPermissions[type][id]; - } - - setSelectedPermissions(_selectedPermissions); - processPermissionsChange(_selectedPermissions); + setLegacyRowLevel(collection, type, { + read: e.target.checked, + write: false + }); }; /** - * Handles the permission level change event. - * - * @param _ - Mouse event. - * @param collection - Selected API resource collection. - * @param value - Selected permission level. - * @param type - Selected API resource collection type. + * Read/write ToggleButtonGroup change (legacy mode). Write takes precedence over read — the two + * flags are mutually exclusive on the persisted entry, mirroring the exclusive toggle in the UI. */ const handlePermissionLevelChange = ( _: MouseEvent, @@ -393,17 +907,390 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent { - const { id, apiResources } = collection; - const _selectedPermissions: SelectedPermissionsInterface = cloneDeep(selectedPermissions); - - _selectedPermissions[type][id] = { - permissions: transformResourceCollectionToPermissions(apiResources[value]), + setLegacyRowLevel(collection, type, { read: value === "read", write: value === "write" + }); + }; + + /** + * Handles an individual granular-permission checkbox toggle (Create / Update / Delete + * or View/read) by adding or removing that level's backing scopes from the granted set. + * The checkbox state is then re-derived from the scopes, so the box (and any other box the + * change covers or uncovers) reflects exactly what is granted. + * + * Only called when `useGranularConsolePermissions` is true. + * + * @param e - Change event. + * @param collection - The collection whose permission level is changing. + * @param level - The permission level key being toggled ("read" | "create" | "update" | "delete"). + * @param type - Tenant or organisation. + */ + const handleGranularPermissionLevelChange = ( + e: ChangeEvent, + collection: APIResourceCollectionInterface, + level: GranularPermissionLevel, + type: APIResourceCollectionTypes + ): void => { + applyGranularSelectionChange((draft: SelectedPermissionsInterface) => { + toggleSelectionBox(draft, type, collection, level, e.target.checked); + }); + }; + + /** + * Derives the column-level select-all state for a granular permission field — + * checked when every collection that supports the field has it active, indeterminate + * when only some do, unchecked when none do. Collections with no backing scopes for + * the field are ignored, since they can never carry it. + */ + const computeColumnSelectAllState = ( + type: APIResourceCollectionTypes, + field: GranularPermissionLevel + ): { checked: boolean; indeterminate: boolean } => { + const applicableCollections: APIResourceCollectionInterface[] = getSourceCollections(type).filter( + (collection: APIResourceCollectionInterface) => getAvailableLevels(collection).includes(field) + ); + + if (applicableCollections.length === 0) { + return { checked: false, indeterminate: false }; + } + + const activeCount: number = applicableCollections.filter((collection: APIResourceCollectionInterface) => + !!get(selectedPermissions[type], [ collection.id, field ]) + ).length; + + return { + checked: activeCount === applicableCollections.length, + indeterminate: activeCount > 0 && activeCount < applicableCollections.length + }; + }; + + /** + * Whether the row-level checkbox for a single collection should appear checked — + * tracks whether the collection has any entry. + * + * Legacy mode only; the row checkbox is not rendered in granular mode (the View + * column checkbox tracks `read` directly there). + */ + const isCollectionRowSelected = ( + type: APIResourceCollectionTypes, + collectionId: string + ): boolean => Object.keys(selectedPermissions[type]).includes(collectionId); + + /** + * The accordion-summary select-all checkbox state — checked when every collection has + * an entry. + * + * Legacy mode only; the accordion select-all checkbox is not rendered in granular mode. + */ + const computeAccordionSelectAllState = ( + type: APIResourceCollectionTypes + ): { checked: boolean; indeterminate: boolean } => { + const sourceCollections: APIResourceCollectionInterface[] = getSourceCollections(type); + const selectedCount: number = Object.keys(selectedPermissions[type]).length; + + return { + checked: sourceCollections.length > 0 && selectedCount === sourceCollections.length, + indeterminate: selectedCount > 0 && selectedCount < sourceCollections.length }; + }; + + /** + * Toggles a single granular permission field across every collection of the given + * type. Unlike `handleSelectAll` (the row-level select-all that flips entire rows), + * this only flips one column. + * + * For each collection: if the field becomes true the entry is created if missing + * (with all other granular fields preserved or defaulted to false). If the field + * becomes false and the entry ends up with every level unchecked, the entry is + * removed entirely — same convention as `handleGranularPermissionLevelChange`. + * + * Only called in granular mode. + */ + const handleColumnSelectAll = ( + e: ChangeEvent, + field: GranularPermissionLevel, + type: APIResourceCollectionTypes + ): void => { + const checked: boolean = e.target.checked; + + applyGranularSelectionChange((draft: SelectedPermissionsInterface) => { + getSourceCollections(type).forEach((collection: APIResourceCollectionInterface) => { + if (!getAvailableLevels(collection).includes(field)) { + return; + } + + toggleSelectionBox(draft, type, collection, field, checked); + }); + }); + }; - setSelectedPermissions(_selectedPermissions); - processPermissionsChange(_selectedPermissions); + /** + * Returns the granular levels that are actually processable for a collection. `read` is always + * present; create / update / delete only when the level carries a corresponding action scope — + * i.e. it grants a real management scope beyond read, not merely its per-action feature scope + * (see `isPermissionLevelActionable`). A level that is not actionable is treated as read-only: + * its cell renders disabled and it is excluded from every derivation / scope-building path that + * iterates these levels, so it is never processed. + * + * Backed by `collectionLevelMetadataById`, so this is an O(1) lookup rather than a recomputation + * on every call (it runs once per collection × column on each render). + */ + const getAvailableLevels = ( + collection: APIResourceCollectionInterface + ): GranularPermissionLevel[] => + collectionLevelMetadataById.get(getCollectionMetadataKey(collection))?.availableLevels ?? [ "read" ]; + + /** + * The backend scope names backing a single (collection, level) — an O(1) lookup into the cached + * per-collection metadata. Empty when the level is absent from the API response. + */ + const getCollectionLevelScopeNames = ( + collection: APIResourceCollectionInterface, + level: GranularPermissionLevel + ): string[] => + collectionLevelMetadataById.get(getCollectionMetadataKey(collection))?.scopeNamesByLevel[level] ?? []; + + /** + * The scopes a level contributes beyond View (its marginal capability) — an O(1) lookup into the + * cached per-collection metadata. See `CollectionLevelMetadata`. + */ + const getCollectionLevelMarginalScopeNames = ( + collection: APIResourceCollectionInterface, + level: GranularPermissionLevel + ): string[] => + collectionLevelMetadataById.get(getCollectionMetadataKey(collection))?.marginalScopeNamesByLevel[level] ?? []; + + /** + * Derives the row-level select-all state for a single collection — checked when every + * available level is active, indeterminate when only some are, unchecked otherwise. + */ + const computeRowSelectAllState = ( + type: APIResourceCollectionTypes, + collection: APIResourceCollectionInterface + ): { checked: boolean; indeterminate: boolean } => { + const entry: SelectedPermissionCategoryInterface | undefined = + selectedPermissions[type][collection.id]; + + if (!entry) { + return { checked: false, indeterminate: false }; + } + + const availableLevels: GranularPermissionLevel[] = getAvailableLevels(collection); + const activeCount: number = availableLevels.filter( + (level: GranularPermissionLevel) => !!entry[level] + ).length; + + return { + checked: activeCount === availableLevels.length, + indeterminate: activeCount > 0 && activeCount < availableLevels.length + }; + }; + + /** + * Row-level select-all handler — flips every available level of a single collection on + * or off. Unchecking clears all levels, which drops the entry entirely. + * + * Only called in granular mode. + */ + const handleRowSelectAll = ( + e: ChangeEvent, + collection: APIResourceCollectionInterface, + type: APIResourceCollectionTypes + ): void => { + const checked: boolean = e.target.checked; + + applyGranularSelectionChange((draft: SelectedPermissionsInterface) => { + getAvailableLevels(collection).forEach((level: GranularPermissionLevel) => { + toggleSelectionBox(draft, type, collection, level, checked); + }); + }); + }; + + /** + * Renders the leading row-level select-all checkbox for a collection (granular mode). + */ + const renderGranularRowSelectAll = ( + collection: APIResourceCollectionInterface, + type: APIResourceCollectionTypes + ): ReactElement => { + const { checked, indeterminate } = computeRowSelectAllState(type, collection); + + return ( + ) => + handleRowSelectAll(e, collection, type) + } + inputProps={ { + "aria-label": t("consoleSettings:roles.permissionLevels.selectAll", { + label: collection.displayName + }) + } } + /> + ); + }; + + /** + * Renders the column-header row with a select-all checkbox for each permission level. + * Every column (including View) keeps its select-all enabled regardless of the row + * state — View can always be selected/cleared across all rows. + */ + const renderGranularHeader = (type: APIResourceCollectionTypes): ReactElement => { + return ( + + + + + { GRANULAR_PERMISSION_COLUMNS.map((field: GranularPermissionLevel) => { + const { checked, indeterminate } = computeColumnSelectAllState(type, field); + const label: string = t(`consoleSettings:roles.permissionLevels.${field}`); + + return ( + + + + { label } + + ) => + handleColumnSelectAll(e, field, type) + } + inputProps={ { + "aria-label": t("consoleSettings:roles.permissionLevels.selectAll", { + label + }) + } } + /> + + + ); + }) } + + + ); + }; + + /** + * Renders a single granular-permission data cell (one column) for a collection row. + * + * Each checkbox reflects its real underlying state — checked exactly when the granted scopes + * satisfy that level. Selecting create/update/delete lights up View on its own, because each + * write level's scope definition already contains the row's view scope; clearing View still + * clears the write levels, so a directly-edited row is never write-without-view. + * A write-without-view row can still appear when a shared backend scope makes a write level + * effectively granted while that collection's view scope is not — that capability genuinely + * exists, so it is shown and saved as-is rather than hidden, never claiming more or fewer scopes + * than the role holds. + * + * Levels that are not actionable for the collection — create / update / delete that carry no + * action scope of their own, only their per-action feature scope (e.g. Approvals, which has no + * ``/``/`` management scope) — are disabled, since toggling them would + * grant nothing real. They are excluded from `getAvailableLevels`, so they never appear checked + * and are never processed. + * + * Only used in granular mode. + */ + const renderGranularCell = ( + collection: APIResourceCollectionInterface, + field: GranularPermissionLevel, + type: APIResourceCollectionTypes + ): ReactElement => { + const isLevelAvailable: boolean = getAvailableLevels(collection).includes(field); + + return ( + + ) => { + handleGranularPermissionLevelChange(e, collection, field, type); + } } + inputProps={ { + "aria-label": t("consoleSettings:roles.permissionLevels.selectCollection", { + collection: collection.displayName, + label: t(`consoleSettings:roles.permissionLevels.${field}`) + }) + } } + /> + + ); + }; + + /** + * Renders the legacy-mode permission cell for a collection row — an exclusive + * read / write toggle in a single right-aligned cell. + * + * Not used in granular mode. + */ + const renderLegacyCell = ( + collection: APIResourceCollectionInterface, + type: APIResourceCollectionTypes + ): ReactElement => { + const hasEntry: boolean = Object.keys(selectedPermissions[type]).includes(collection.id); + const value: string | null = hasEntry + ? (get(selectedPermissions[type], collection.id)?.write ? "write" : "read") + : null; + + return ( + + , selectedValue: string) => { + // If no value is selected and exclusive is true the value is null. + // Guard against the null value to prevent submitting an empty selection. + if (!selectedValue) { + return; + } + + handlePermissionLevelChange(e, collection, selectedValue, type); + } } + aria-label={ t("consoleSettings:roles.permissionLevels.toggleGroupAriaLabel") } + size="small" + > + + { t("consoleSettings:roles.permissionLevels.view") } + + + { t("consoleSettings:roles.permissionLevels.edit") } + + + + ); }; return ( @@ -421,101 +1308,111 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent - ) => { - handleSelectAll(e, APIResourceCollectionTypes.TENANT); - } } - inputProps={ { - "aria-label": "Select all tenant permissions" - } } - /> - - { t("consoleSettings:roles.add.tenantPermissions.label") } - - - { filteredTenantAPIResourceCollections?.apiResourceCollections?.length } Permissions - + { !useGranularConsolePermissions && ( + ) => { + handleSelectAll(e, APIResourceCollectionTypes.TENANT); + } } + inputProps={ { + "aria-label": + t("consoleSettings:roles.permissionLevels.selectAllTenant") + } } + /> + ) } +
+ + { t("consoleSettings:roles.add.tenantPermissions.label") } + + { typeof filteredTenantAPIResourceCollections?.apiResourceCollections?.length + === "number" && ( + + ) } +
- +
+ { useGranularConsolePermissions && + renderGranularHeader(APIResourceCollectionTypes.TENANT) } { filteredTenantAPIResourceCollections?.apiResourceCollections?.map( (collection: APIResourceCollectionInterface) => ( - ) => + handleSelect( + e, + collection, + APIResourceCollectionTypes.TENANT + ) + } + inputProps={ { + "aria-label": + t("consoleSettings:roles." + + "permissionLevels." + + "selectRowPermission", { + collection: collection.displayName + }) + } } + /> ) } - onChange={ (e: ChangeEvent) => - handleSelect( - e, - collection, - APIResourceCollectionTypes.TENANT - ) - } - inputProps={ { - "aria-label": - `Select ${collection.displayName} permission` - } } - /> { collection.displayName } - - , value: string) => { - // If no value is selected and exclusive is true - // the value is null. Purpose of this if block is - // to prevent the submit in the case of null value. - if (!value) { - return; - } - - handlePermissionLevelChange( - e, - collection, - value, - APIResourceCollectionTypes.TENANT - ); - } } - aria-label="text alignment" - size="small" - > - - { t("consoleSettings:roles.permissionLevels.view") } - - - { t("consoleSettings:roles.permissionLevels.edit") } - - - + { useGranularConsolePermissions + ? ( + GRANULAR_PERMISSION_COLUMNS.map( + (field: GranularPermissionLevel) => + renderGranularCell( + collection, + field, + APIResourceCollectionTypes.TENANT + ) + ) + ) + : renderLegacyCell( + collection, + APIResourceCollectionTypes.TENANT + ) + } ) ) } @@ -538,98 +1435,111 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent - ) => { - handleSelectAll(e, APIResourceCollectionTypes.ORGANIZATION); - } } - inputProps={ { - "aria-label": "Select all organization permissions" - } } - /> - - { t("consoleSettings:roles.add.organizationPermissions.label") } - - - { filteredOrganizationAPIResourceCollections?.apiResourceCollections?.length } Permissions - + { !useGranularConsolePermissions && ( + ) => { + handleSelectAll(e, APIResourceCollectionTypes.ORGANIZATION); + } } + inputProps={ { + "aria-label": + t("consoleSettings:roles.permissionLevels.selectAllOrganization") + } } + /> + ) } +
+ + { t("consoleSettings:roles.add.organizationPermissions.label") } + + { typeof filteredOrganizationAPIResourceCollections?.apiResourceCollections?.length + === "number" && ( + + ) } +
+ { useGranularConsolePermissions && + renderGranularHeader(APIResourceCollectionTypes.ORGANIZATION) } { filteredOrganizationAPIResourceCollections?.apiResourceCollections?.map( (collection: APIResourceCollectionInterface) => ( - ) => - handleSelect( - e, - collection, - APIResourceCollectionTypes.ORGANIZATION - ) - } - inputProps={ { - "aria-label": `Select ${collection.displayName} permission` - } } - /> + { useGranularConsolePermissions + ? renderGranularRowSelectAll( + collection, + APIResourceCollectionTypes.ORGANIZATION + ) + : ( + ) => + handleSelect( + e, + collection, + APIResourceCollectionTypes.ORGANIZATION + ) + } + inputProps={ { + "aria-label": + t("consoleSettings:roles." + + "permissionLevels." + + "selectRowPermission", { + collection: collection.displayName + }) + } } + /> + ) } { collection.displayName } - - , value: string) => { - handlePermissionLevelChange( - e, - collection, - value, - APIResourceCollectionTypes.ORGANIZATION - ); - } } - aria-label="text alignment" - size="small" - > - - { t("consoleSettings:roles.permissionLevels.view") } - - - { t("consoleSettings:roles.permissionLevels.edit") } - - - + { useGranularConsolePermissions + ? ( + GRANULAR_PERMISSION_COLUMNS.map( + (field: GranularPermissionLevel) => + renderGranularCell( + collection, + field, + APIResourceCollectionTypes.ORGANIZATION + ) + ) + ) + : renderLegacyCell( + collection, + APIResourceCollectionTypes.ORGANIZATION + ) + } ) ) } diff --git a/features/admin.console-settings.v1/constants/console-roles-onboarding-constants.ts b/features/admin.console-settings.v1/constants/console-roles-onboarding-constants.ts index e5742eac888..ea57d4168e0 100644 --- a/features/admin.console-settings.v1/constants/console-roles-onboarding-constants.ts +++ b/features/admin.console-settings.v1/constants/console-roles-onboarding-constants.ts @@ -35,4 +35,6 @@ export class ConsoleRolesOnboardingConstants { public static readonly ORG_ROLE_API_RESOURCES_COLLECTION_NAME: string = "org_roles"; public static readonly ADMINISTRATOR: string = "Administrator"; public static readonly ORG_PREFIX: string = "org_"; + public static readonly GRANULAR_CONSOLE_PERMISSIONS_FEATURE_KEY: string = + "consoleSettings.useGranularConsolePermissions"; } diff --git a/features/admin.console-settings.v1/models/console-roles.ts b/features/admin.console-settings.v1/models/console-roles.ts index 3585a32dc96..695bc068d59 100644 --- a/features/admin.console-settings.v1/models/console-roles.ts +++ b/features/admin.console-settings.v1/models/console-roles.ts @@ -62,16 +62,31 @@ export interface APIResourceCollectionInterface { /** * Represents the details of an API resource collection. + * + * The `write` field is present in older API versions and is ignored by the granular-permissions + * code path. New API responses replace it with `create`, `update`, and `delete`. */ interface APIResourceCollectionDetailsInterface { /** * Set of read scopes. */ - read: APIResourceCollectionPermissionCategoryInterface[], + read: APIResourceCollectionPermissionCategoryInterface[]; + /** + * Set of write scopes. Legacy field to support backward compatibility. + */ + write?: APIResourceCollectionPermissionCategoryInterface[]; + /** + * Set of create scopes. + */ + create?: APIResourceCollectionPermissionCategoryInterface[]; + /** + * Set of update scopes. + */ + update?: APIResourceCollectionPermissionCategoryInterface[]; /** - * Set of write scopes. + * Set of delete scopes. */ - write: APIResourceCollectionPermissionCategoryInterface[], + delete?: APIResourceCollectionPermissionCategoryInterface[]; } /** diff --git a/features/admin.console-settings.v1/models/permissions-ui.ts b/features/admin.console-settings.v1/models/permissions-ui.ts index ade851fe43f..40d60420292 100644 --- a/features/admin.console-settings.v1/models/permissions-ui.ts +++ b/features/admin.console-settings.v1/models/permissions-ui.ts @@ -47,18 +47,34 @@ export interface SelectedPermissionsInterface { /** * Interface to represent selected permission category. + * + * In the legacy (non-granular) mode only `read`, `write`, and `permissions` are populated. + * In the granular mode `write` is always `false` and the individual `create`, `update`, + * and `delete` flags are used instead. */ export interface SelectedPermissionCategoryInterface { /** - * Indicates if the permission is read only. + * Indicates that the read permission level is selected. */ read: boolean; /** - * Indicates if the permission is write only. + * Indicates that the write permission level is selected. */ write: boolean; /** - * Indicates if the permission is delete only. + * Indicates that the create permission level is selected. + */ + create?: boolean; + /** + * Indicates that the update permission level is selected. + */ + update?: boolean; + /** + * Indicates that the delete permission level is selected. + */ + delete?: boolean; + /** + * Flat list of permission scope values that are currently active for this collection. */ permissions: PermissionScopeInterface[]; } diff --git a/features/admin.console-settings.v1/utils/flatten-feature-config.ts b/features/admin.console-settings.v1/utils/flatten-feature-config.ts new file mode 100644 index 00000000000..4859568f657 --- /dev/null +++ b/features/admin.console-settings.v1/utils/flatten-feature-config.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FeatureAccessConfigInterface } from "@wso2is/access-control"; +import { FeatureConfigInterface } from "@wso2is/admin.core.v1/models/config"; +import flatMap from "lodash-es/flatMap"; +import fromPairs from "lodash-es/fromPairs"; +import mapValues from "lodash-es/mapValues"; +import omit from "lodash-es/omit"; +import values from "lodash-es/values"; + +/** + * Lifts every `subFeatures.*` entry to the top level so a collection whose name matches a sub-feature + * (e.g. `applicationAuthenticationScript`, nested under `applications.subFeatures`) can be looked up + * by name in a single `featureConfig[name]` access. + * + * @param featureConfig - Full feature config from the Redux store. + * @returns A shallow map keyed by feature and sub-feature name. + */ +const flattenFeatureConfig = (featureConfig: FeatureConfigInterface): FeatureConfigInterface => { + const topLevelFeatures: Record> = mapValues( + featureConfig, + (feature: FeatureAccessConfigInterface) => omit(feature, [ "subFeatures" ]) + ); + + const subLevelFeatures: Record> = fromPairs( + flatMap(values(featureConfig), (feature: FeatureAccessConfigInterface) => { + return Object.entries(feature.subFeatures || {}); + }) + ); + + return { + ...topLevelFeatures, + ...subLevelFeatures + } as FeatureConfigInterface; +}; + +export default flattenFeatureConfig; diff --git a/features/admin.console-settings.v1/utils/get-eligibility-scope-names.ts b/features/admin.console-settings.v1/utils/get-eligibility-scope-names.ts new file mode 100644 index 00000000000..629861f6c5b --- /dev/null +++ b/features/admin.console-settings.v1/utils/get-eligibility-scope-names.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const FEATURE_SCOPE_PREFIX: string = "console:"; +const PER_ACTION_FEATURE_SCOPE_SUFFIXES: ReadonlyArray = [ "_create", "_update", "_delete", "_edit", "_view" ]; + +/** + * Whether a scope is a per-action Console feature scope (see {@link PER_ACTION_FEATURE_SCOPE_SUFFIXES}). + */ +const isPerActionFeatureScope = (scopeName: string): boolean => + scopeName.startsWith(FEATURE_SCOPE_PREFIX) && + PER_ACTION_FEATURE_SCOPE_SUFFIXES.some((suffix: string) => scopeName.endsWith(suffix)); + +/** + * Reduces a level's backing scope names to the set that should decide whether that level is shown + * checked — every scope except the per-action feature scopes. A level is then considered granted + * when all of these "eligibility" scopes are present, so two levels that resolve to the same + * management scopes (differing only by their per-action feature scope) select and clear together. + * + * @param scopeNames - The full set of backing scope names for a permission level. + * @returns The scope names to use for the granted/eligible check. + */ +const getEligibilityScopeNames = (scopeNames: string[]): string[] => + scopeNames.filter((name: string) => !isPerActionFeatureScope(name)); + +export default getEligibilityScopeNames; diff --git a/features/admin.console-settings.v1/utils/is-permission-level-actionable.ts b/features/admin.console-settings.v1/utils/is-permission-level-actionable.ts new file mode 100644 index 00000000000..5808faf45ea --- /dev/null +++ b/features/admin.console-settings.v1/utils/is-permission-level-actionable.ts @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import getEligibilityScopeNames from "./get-eligibility-scope-names"; +import { + APIResourceCollectionInterface, + APIResourceCollectionPermissionCategoryInterface, + APIResourceCollectionPermissionScopeInterface +} from "../models/console-roles"; + +/** + * The granular permission levels a collection can expose. `read` is always actionable; the rest are + * gated by {@link isPermissionLevelActionable}. + */ +type GranularPermissionLevel = "read" | "create" | "update" | "delete"; + +/** + * Flattens a collection's permission-category array into its backing scope names. + */ +const extractScopeNames = ( + categories: APIResourceCollectionPermissionCategoryInterface[] | undefined +): string[] => + (categories ?? []).flatMap( + (category: APIResourceCollectionPermissionCategoryInterface) => + (category?.scopes ?? []).map( + (scope: APIResourceCollectionPermissionScopeInterface) => scope.name) + ); + +/** + * Whether a create / update / delete level genuinely grants something beyond read — i.e. it has a + * corresponding *action* scope. + * + * The meta API returns each level's bucket as a cumulative superset of `read`, so a create / update + * / delete bucket is never empty just because its action carries no real capability: it still echoes + * every read scope plus, at minimum, its own per-action **feature** scope + * (`console:_create` / `_update` / `_delete`). That feature scope is persisted by the role + * but the backend `ConsoleRoleListener` expands it to a management (`internal_*`) scope only when the + * collection actually defines a `` / `` / `` action — otherwise it resolves + * to nothing. Such a cell looks interactive but granting it changes no real capability. + * + * A level is therefore "actionable" only when its scopes *beyond read*, after dropping the per-action + * feature scopes (see {@link getEligibilityScopeNames}), still contain at least one scope — that + * remaining scope is the action's real management scope. `read` is always actionable. Cells that are + * not actionable are rendered read-only and excluded from every selection / scope-derivation path, so + * they are never processed. + * + * Only meaningful in the granular console-permission model. + * + * @param collection - The API resource collection. + * @param level - The permission level to test. + * @returns `true` when the level grants a real capability beyond read. + */ +const isPermissionLevelActionable = ( + collection: APIResourceCollectionInterface, + level: GranularPermissionLevel +): boolean => { + if (level === "read") { + return true; + } + + const readScopeNames: Set = new Set( + extractScopeNames(collection?.apiResources?.read)); + const marginalScopeNames: string[] = extractScopeNames(collection?.apiResources?.[level]) + .filter((name: string) => !readScopeNames.has(name)); + + return getEligibilityScopeNames(marginalScopeNames).length > 0; +}; + +export default isPermissionLevelActionable; diff --git a/features/admin.core.v1/models/config.ts b/features/admin.core.v1/models/config.ts index 902a85a7390..debae9ce687 100644 --- a/features/admin.core.v1/models/config.ts +++ b/features/admin.core.v1/models/config.ts @@ -140,6 +140,11 @@ export interface FeatureConfigInterface { * Certificates configurations feature. */ certificates?: FeatureAccessConfigInterface; + /** + * Console settings feature. Currently the only consumer is the console-roles permissions UI, + * which reads the granular-console-permissions flag from its `disabledFeatures` list. + */ + consoleSettings?: FeatureAccessConfigInterface; /** * Copilot AI assistant feature. */ diff --git a/features/admin.users.v1/components/user-groups-edit.tsx b/features/admin.users.v1/components/user-groups-edit.tsx index 83c7f32ca35..d76138385c0 100644 --- a/features/admin.users.v1/components/user-groups-edit.tsx +++ b/features/admin.users.v1/components/user-groups-edit.tsx @@ -16,6 +16,7 @@ * under the License. */ +import { FeatureAccessConfigInterface, useRequiredScopes } from "@wso2is/access-control"; import { updateResources } from "@wso2is/admin.core.v1/api/bulk-operations"; import { AppState } from "@wso2is/admin.core.v1/store"; import { userstoresConfig } from "@wso2is/admin.extensions.v1/configs/userstores"; @@ -86,6 +87,19 @@ export const UserGroupsList: FunctionComponent = ( const primaryUserStoreDomainName: string = useSelector((state: AppState) => state?.config?.ui?.primaryUserStoreDomainName); + const groupsFeatureConfig: FeatureAccessConfigInterface = useSelector( + (state: AppState) => state?.config?.ui?.features?.groups); + + const groupUpdateScopes: string[] = groupsFeatureConfig?.scopes?.update ?? []; + const hasGroupsUpdatePermission: boolean = useRequiredScopes(groupUpdateScopes); + + /** + * Group memberships are updated through the SCIM2 Groups PATCH API, which requires + * group update permission. Hence, the section is read only if the user lacks that + * permission, in addition to the read only state passed down by the parent. + */ + const isGroupsReadOnly: boolean = isReadOnly || !hasGroupsUpdatePermission; + const { t } = useTranslation(); const dispatch: Dispatch = useDispatch(); @@ -458,7 +472,7 @@ export const UserGroupsList: FunctionComponent = ( handleOpenAddNewGroupModal={ handleOpenAddNewGroupModal } handleUserUpdate={ handleUserUpdate } isLoading={ isLoading } - isReadOnly={ isReadOnly } + isReadOnly={ isGroupsReadOnly } user={ user } /> { addNewGroupModal() } diff --git a/features/admin.users.v1/components/user-profile.tsx b/features/admin.users.v1/components/user-profile.tsx index cddb07a7460..dbc8ad23dd4 100644 --- a/features/admin.users.v1/components/user-profile.tsx +++ b/features/admin.users.v1/components/user-profile.tsx @@ -195,6 +195,9 @@ export const UserProfile: FunctionComponent = ( const hasUsersUpdatePermissions: boolean = useRequiredScopes( featureConfig?.users?.scopes?.update ); + const hasUsersDeletePermissions: boolean = useRequiredScopes( + featureConfig?.users?.scopes?.delete + ); const roleAssignmentsConfig: FeatureAccessConfigInterface = useSelector( (state: AppState) => state?.config?.ui?.features?.roleAssignments); @@ -992,7 +995,7 @@ export const UserProfile: FunctionComponent = ( }; const resolveDangerActions = (): ReactElement => { - if (!hasUsersUpdatePermissions) { + if (!hasUsersUpdatePermissions && !hasUsersDeletePermissions) { return null; } @@ -1010,9 +1013,12 @@ export const UserProfile: FunctionComponent = ( ) && ( !isCurrentUserAdmin || !isUserCurrentLoggedInUser + ) && ( + hasUsersDeletePermissions + || hasUsersUpdatePermissions ) ? ( - = ( } { !allowDeleteOnly && configSettings?.accountDisable === "true" && ( - + + + ) } { !allowDeleteOnly && ( - + + + ) } { @@ -1122,49 +1132,53 @@ export const UserProfile: FunctionComponent = ( adminUserType === AdminAccountTypes.INTERNAL && associationType !== UserManagementConstants.GUEST_ADMIN_ASSOCIATION_TYPE && ( + + { + setShowAdminRevokeConfirmationModal(true); + setDeletingUser(user); + } } + /> + + ) + } + { !isUserCurrentLoggedInUser && + getUserNameWithoutDomain(user.userName) !== adminUsername && ( + { - setShowAdminRevokeConfirmationModal(true); + setShowDeleteConfirmationModal(true); setDeletingUser(user); } } + isButtonDisabled={ + adminUserType === AdminAccountTypes.INTERNAL && + isReadOnlyUserStore } + buttonDisableHint={ t("user:editUser.dangerZoneGroup." + + "deleteUserZone.buttonDisableHint") } /> - ) - } - { !isUserCurrentLoggedInUser && - getUserNameWithoutDomain(user.userName) !== adminUsername && ( - { - setShowDeleteConfirmationModal(true); - setDeletingUser(user); - } } - isButtonDisabled={ - adminUserType === AdminAccountTypes.INTERNAL && - isReadOnlyUserStore } - buttonDisableHint={ t("user:editUser.dangerZoneGroup." + - "deleteUserZone.buttonDisableHint") } - /> + ) } ) } - + ) : null } ); diff --git a/identity-apps-core/components/org.wso2.identity.apps.common/src/main/java/org/wso2/identity/apps/common/listner/ConsoleRoleListener.java b/identity-apps-core/components/org.wso2.identity.apps.common/src/main/java/org/wso2/identity/apps/common/listner/ConsoleRoleListener.java index 54143b6ff73..6b97396f778 100644 --- a/identity-apps-core/components/org.wso2.identity.apps.common/src/main/java/org/wso2/identity/apps/common/listner/ConsoleRoleListener.java +++ b/identity-apps-core/components/org.wso2.identity.apps.common/src/main/java/org/wso2/identity/apps/common/listner/ConsoleRoleListener.java @@ -50,7 +50,10 @@ import java.util.Set; import java.util.stream.Collectors; +import static org.wso2.carbon.identity.api.resource.collection.mgt.constant.APIResourceCollectionManagementConstants.APIResourceCollectionConfigBuilderConstants.CREATE_FEATURE_SCOPE_SUFFIX; +import static org.wso2.carbon.identity.api.resource.collection.mgt.constant.APIResourceCollectionManagementConstants.APIResourceCollectionConfigBuilderConstants.DELETE_FEATURE_SCOPE_SUFFIX; import static org.wso2.carbon.identity.api.resource.collection.mgt.constant.APIResourceCollectionManagementConstants.APIResourceCollectionConfigBuilderConstants.EDIT_FEATURE_SCOPE_SUFFIX; +import static org.wso2.carbon.identity.api.resource.collection.mgt.constant.APIResourceCollectionManagementConstants.APIResourceCollectionConfigBuilderConstants.UPDATE_FEATURE_SCOPE_SUFFIX; import static org.wso2.carbon.identity.api.resource.collection.mgt.constant.APIResourceCollectionManagementConstants.APIResourceCollectionConfigBuilderConstants.VIEW_FEATURE_SCOPE_SUFFIX; import static org.wso2.carbon.identity.role.v2.mgt.core.RoleConstants.ADMINISTRATOR; import static org.wso2.carbon.identity.role.v2.mgt.core.RoleConstants.CONSOLE_APP_AUDIENCE_NAME; @@ -67,6 +70,8 @@ public class ConsoleRoleListener extends AbstractRoleManagementListener { private static final String EVERYONE = "everyone"; private static final String REQUIRE_FEATURE_PERMISSIONS = "SCIM2.ConsoleRoles.RequireFeaturePermissions"; private static final String IS_CUSTOM_CONSOLE_ROLES_ENABLED = "SCIM2.ConsoleRoles.EnableCustomRoles"; + private static final String USE_GRANULAR_CONSOLE_PERMISSIONS_CONFIG = + "ConsoleSettings.UseGranularConsolePermissions"; @Override public int getDefaultOrderId() { @@ -241,9 +246,9 @@ private List getSystemRolePermissions(String roleName, String tenant } }); + // Fetch all system permissions. + Map systemPermissionsMap = getSystemPermissionsMap(tenantDomain); if (!addedPermissionNames.isEmpty()) { - // Fetch all system permissions. - Map systemPermissionsMap = getSystemPermissionsMap(tenantDomain); addedPermissionNames.forEach(permissionName -> { Permission permission = systemPermissionsMap.get(permissionName); if (permission != null) { @@ -252,12 +257,17 @@ private List getSystemRolePermissions(String roleName, String tenant }); } + resolveWriteFeatureScopes(resolvedRolePermissions, getAPIResourceCollections(tenantDomain), + new ArrayList<>(systemPermissionsMap.values())); return resolvedRolePermissions; } /** - * This method resolves the new permissions for the console roles. In this method, we resolve two type of console - * roles. 1. Console roles created after 7.0.0. 2. Console roles created in 7.0.0. + * This method resolves the new permissions for the console roles. In this method, we resolve 3 type of console + * roles. + * 1. Console roles created after 7.0.0. + * 2. Console roles created in 7.0.0. + * 3. Console roles with granular permissions (create, update, delete) added instead of edit permission. * * @param rolePermissions List of permissions of the role. * @param tenantDomain Tenant domain. @@ -269,13 +279,16 @@ private List getUpgradedPermissions(List rolePermissions // Fetch all system permissions. Map systemPermissionsMap = getSystemPermissionsMap(tenantDomain); + List apiResourceCollections = getAPIResourceCollections(tenantDomain); List consoleFeaturePermissions = getConsoleFeaturePermissions(rolePermissions); Set addedPermissionNames = new HashSet<>(); + List upgradedPermissions; if (!consoleFeaturePermissions.isEmpty()) { // This is where we handle the new console roles (console roles created after 7.0.0) permissions. // We check whether the role has the view feature scope or edit feature scope. If the role has the // view feature scope, then we add all the read scopes. If the role has the edit feature scope, then we - // add all the write scopes. + // add all the write scopes. If granular permissions are enabled, we also resolve create/update/delete + // feature scopes the same way. // Fetch console feature scopes and its mapped permissions. Map> consoleFeaturePermissionsMap = getConsoleFeaturePermissionsMap(tenantDomain); List resolvedRolePermissions = new ArrayList<>(); @@ -293,12 +306,11 @@ private List getUpgradedPermissions(List rolePermissions } }); } - return resolvedRolePermissions; + upgradedPermissions = resolvedRolePermissions; } else { // This is where we handle the initial console roles (console roles created in 7.0.0) permissions. // Here we assume these role only contains legacy feature scope not the new feature scopes. // Fetch all system scopes to resolve permission details from permission name. - List apiResourceCollections = getAPIResourceCollections(tenantDomain); Set resolvedRolePermissions = new HashSet<>(new ArrayList<>(rolePermissions)); List consolePermissions = getConsolePermissions(rolePermissions); consolePermissions.forEach(permission -> { @@ -326,8 +338,11 @@ private List getUpgradedPermissions(List rolePermissions } }); }); - return new ArrayList<>(resolvedRolePermissions); + upgradedPermissions = new ArrayList<>(resolvedRolePermissions); } + resolveWriteFeatureScopes(upgradedPermissions, apiResourceCollections, + new ArrayList<>(systemPermissionsMap.values())); + return upgradedPermissions; } private void populateSystemConsoleRolesPermissions(List permissions, List roleIds, @@ -440,6 +455,7 @@ private Map> getConsoleFeaturePermissionsMap(String tenantDo Map> featurePermissions = new HashMap<>(); List apiResources = getAPIResourceCollections(tenantDomain); Map scopeToResourceMap = new HashMap<>(); + boolean granular = isGranularConsolePermissionsEnabled(); for (APIResourceCollection resource : apiResources) { if (resource.getViewFeatureScope() != null) { scopeToResourceMap.put(resource.getViewFeatureScope(), resource); @@ -447,6 +463,17 @@ private Map> getConsoleFeaturePermissionsMap(String tenantDo if (resource.getEditFeatureScope() != null) { scopeToResourceMap.put(resource.getEditFeatureScope(), resource); } + if (granular) { + if (resource.getCreateFeatureScope() != null) { + scopeToResourceMap.put(resource.getCreateFeatureScope(), resource); + } + if (resource.getUpdateFeatureScope() != null) { + scopeToResourceMap.put(resource.getUpdateFeatureScope(), resource); + } + if (resource.getDeleteFeatureScope() != null) { + scopeToResourceMap.put(resource.getDeleteFeatureScope(), resource); + } + } } // Resolve permissions for each feature scope recursively. @@ -462,6 +489,24 @@ private Map> getConsoleFeaturePermissionsMap(String tenantDo apiResource.getEditFeatureScope(), scopeToResourceMap, new HashSet<>()); featurePermissions.put(apiResource.getEditFeatureScope(), editPermissions); } + + if (granular) { + if (apiResource.getCreateFeatureScope() != null) { + Set createPermissions = resolveScopes(apiResource.getCreateScopes(), + apiResource.getCreateFeatureScope(), scopeToResourceMap, new HashSet<>()); + featurePermissions.put(apiResource.getCreateFeatureScope(), createPermissions); + } + if (apiResource.getUpdateFeatureScope() != null) { + Set updatePermissions = resolveScopes(apiResource.getUpdateScopes(), + apiResource.getUpdateFeatureScope(), scopeToResourceMap, new HashSet<>()); + featurePermissions.put(apiResource.getUpdateFeatureScope(), updatePermissions); + } + if (apiResource.getDeleteFeatureScope() != null) { + Set deletePermissions = resolveScopes(apiResource.getDeleteScopes(), + apiResource.getDeleteFeatureScope(), scopeToResourceMap, new HashSet<>()); + featurePermissions.put(apiResource.getDeleteFeatureScope(), deletePermissions); + } + } } return featurePermissions; @@ -484,7 +529,10 @@ private Set resolveScopes(List scopes, String currentFeatureScop // Check if this is a console feature scope if (scope.startsWith(CONSOLE_SCOPE_PREFIX) && - (scope.endsWith(VIEW_FEATURE_SCOPE_SUFFIX) || scope.endsWith(EDIT_FEATURE_SCOPE_SUFFIX))) { + (scope.endsWith(VIEW_FEATURE_SCOPE_SUFFIX) || scope.endsWith(EDIT_FEATURE_SCOPE_SUFFIX) || + scope.endsWith(CREATE_FEATURE_SCOPE_SUFFIX) || + scope.endsWith(UPDATE_FEATURE_SCOPE_SUFFIX) || + scope.endsWith(DELETE_FEATURE_SCOPE_SUFFIX))) { APIResourceCollection nestedResource = scopeToResourceMap.get(scope); if (nestedResource != null) { @@ -494,6 +542,12 @@ private Set resolveScopes(List scopes, String currentFeatureScop nestedScopes = nestedResource.getReadScopes(); } else if (scope.equals(nestedResource.getEditFeatureScope())) { nestedScopes = nestedResource.getWriteScopes(); + } else if (scope.equals(nestedResource.getCreateFeatureScope())) { + nestedScopes = nestedResource.getCreateScopes(); + } else if (scope.equals(nestedResource.getUpdateFeatureScope())) { + nestedScopes = nestedResource.getUpdateScopes(); + } else if (scope.equals(nestedResource.getDeleteFeatureScope())) { + nestedScopes = nestedResource.getDeleteScopes(); } else { nestedScopes = new ArrayList<>(); } @@ -520,7 +574,10 @@ private List getConsoleFeaturePermissions(List rolePermi permission.getName() != null && (permission.getName().startsWith(CONSOLE_SCOPE_PREFIX) || permission.getName().startsWith(CONSOLE_ORG_SCOPE_PREFIX)) && (permission.getName().endsWith(VIEW_FEATURE_SCOPE_SUFFIX) || - permission.getName().endsWith(EDIT_FEATURE_SCOPE_SUFFIX))) + permission.getName().endsWith(EDIT_FEATURE_SCOPE_SUFFIX) || + permission.getName().endsWith(CREATE_FEATURE_SCOPE_SUFFIX) || + permission.getName().endsWith(UPDATE_FEATURE_SCOPE_SUFFIX) || + permission.getName().endsWith(DELETE_FEATURE_SCOPE_SUFFIX))) .collect(Collectors.toList()); } @@ -536,7 +593,10 @@ private List getConsolePermissions(List rolePermissions) permission.getName() != null && (permission.getName().startsWith(CONSOLE_SCOPE_PREFIX) || permission.getName().startsWith(CONSOLE_ORG_SCOPE_PREFIX)) && !(permission.getName().endsWith(VIEW_FEATURE_SCOPE_SUFFIX) || - permission.getName().endsWith(EDIT_FEATURE_SCOPE_SUFFIX))) + permission.getName().endsWith(EDIT_FEATURE_SCOPE_SUFFIX) || + permission.getName().endsWith(CREATE_FEATURE_SCOPE_SUFFIX) || + permission.getName().endsWith(UPDATE_FEATURE_SCOPE_SUFFIX) || + permission.getName().endsWith(DELETE_FEATURE_SCOPE_SUFFIX))) .collect(Collectors.toList()); } @@ -604,4 +664,94 @@ private boolean isFeaturePermissionsRequired() { } return Boolean.parseBoolean(isFeaturePermissionsRequiredValue); } + + /** + * Check whether the granular console permission model (create/update/delete feature scopes) is enabled. + * + * @return True if granular console permissions are enabled. + */ + private boolean isGranularConsolePermissionsEnabled() { + + return Boolean.parseBoolean(IdentityUtil.getProperty(USE_GRANULAR_CONSOLE_PERMISSIONS_CONFIG)); + } + + /** + * Supports backward compatibility between the legacy write model and the new granular permission model, + * so that a role resolves correctly regardless of which model it was created with: + * - The edit (write) feature scope is equivalent to having the create, update and delete feature scopes. + * So if the role has the edit feature scope and granular permissions are enabled, the create, update and + * delete feature scopes are added. + * - If `create, update and delete` are all present, the equivalent `edit` (write) feature scope is added. + * + * Only the feature scopes are added here; the internal scopes corresponding to these feature scopes are already + * resolved earlier in {@link #getUpgradedPermissions}. + * + * @param resolvedRolePermissions Resolved role permissions to be updated in place. + * @param apiResourceCollections API resource collections. + * @param systemPermissions System permissions used to resolve permission details from permission names. + */ + private void resolveWriteFeatureScopes(List resolvedRolePermissions, + List apiResourceCollections, + List systemPermissions) { + + Set resolvedPermissionNames = resolvedRolePermissions.stream().map(Permission::getName) + .collect(Collectors.toCollection(HashSet::new)); + boolean granular = isGranularConsolePermissionsEnabled(); + apiResourceCollections.forEach(apiResourceCollection -> { + String editFeatureScope = apiResourceCollection.getEditFeatureScope(); + String createFeatureScope = apiResourceCollection.getCreateFeatureScope(); + String updateFeatureScope = apiResourceCollection.getUpdateFeatureScope(); + String deleteFeatureScope = apiResourceCollection.getDeleteFeatureScope(); + + boolean hasEdit = editFeatureScope != null && resolvedPermissionNames.contains(editFeatureScope); + boolean hasCreate = createFeatureScope != null && resolvedPermissionNames.contains(createFeatureScope); + boolean hasUpdate = updateFeatureScope != null && resolvedPermissionNames.contains(updateFeatureScope); + boolean hasDelete = deleteFeatureScope != null && resolvedPermissionNames.contains(deleteFeatureScope); + + // The edit feature scope is equivalent to having the create, update and delete feature scopes. + if (hasEdit && granular) { + if (!hasCreate) { + addResolvedScope(createFeatureScope, systemPermissions, resolvedRolePermissions, + resolvedPermissionNames); + } + if (!hasUpdate) { + addResolvedScope(updateFeatureScope, systemPermissions, resolvedRolePermissions, + resolvedPermissionNames); + } + if (!hasDelete) { + addResolvedScope(deleteFeatureScope, systemPermissions, resolvedRolePermissions, + resolvedPermissionNames); + } + } + // If the role has all the granular write feature scopes, it is equivalent to the edit feature scope. + if (hasCreate && hasUpdate && hasDelete && !hasEdit) { + addResolvedScope(editFeatureScope, systemPermissions, resolvedRolePermissions, + resolvedPermissionNames); + } + }); + } + + /** + * Resolve the given scope name against the system permissions and add it to the resolved role permissions if it is + * not already present. + * + * @param scope Scope name to resolve and add. + * @param systemPermissions System permissions used to resolve permission details from permission names. + * @param resolvedRolePermissions Resolved role permissions to be updated in place. + * @param resolvedPermissionNames Names of the already resolved permissions, used to avoid duplicates. + */ + private void addResolvedScope(String scope, List systemPermissions, + List resolvedRolePermissions, Set resolvedPermissionNames) { + + if (scope == null || resolvedPermissionNames.contains(scope)) { + return; + } + systemPermissions.stream() + .filter(systemPermission -> systemPermission.getName().equals(scope)) + .findFirst() + .ifPresent(systemPermission -> { + resolvedRolePermissions.add(systemPermission); + resolvedPermissionNames.add(scope); + }); + } } diff --git a/identity-apps-core/components/org.wso2.identity.apps.common/src/test/java/org/wso2/identity/apps/common/listener/ConsoleRoleListenerTest.java b/identity-apps-core/components/org.wso2.identity.apps.common/src/test/java/org/wso2/identity/apps/common/listener/ConsoleRoleListenerTest.java new file mode 100644 index 00000000000..2e7e26d7965 --- /dev/null +++ b/identity-apps-core/components/org.wso2.identity.apps.common/src/test/java/org/wso2/identity/apps/common/listener/ConsoleRoleListenerTest.java @@ -0,0 +1,484 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.wso2.identity.apps.common.listener; + +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.api.resource.collection.mgt.APIResourceCollectionManager; +import org.wso2.carbon.identity.api.resource.collection.mgt.model.APIResourceCollection; +import org.wso2.carbon.identity.api.resource.collection.mgt.model.APIResourceCollectionSearchResult; +import org.wso2.carbon.identity.api.resource.mgt.APIResourceManager; +import org.wso2.carbon.identity.application.common.model.Scope; +import org.wso2.carbon.identity.core.util.IdentityUtil; +import org.wso2.carbon.identity.role.v2.mgt.core.RoleManagementService; +import org.wso2.carbon.identity.role.v2.mgt.core.model.Permission; +import org.wso2.carbon.identity.role.v2.mgt.core.model.Role; +import org.wso2.identity.apps.common.internal.AppsCommonDataHolder; +import org.wso2.identity.apps.common.listner.ConsoleRoleListener; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; +import static org.wso2.carbon.identity.role.v2.mgt.core.RoleConstants.ADMINISTRATOR; +import static org.wso2.carbon.identity.role.v2.mgt.core.RoleConstants.CONSOLE_APP_AUDIENCE_NAME; + +/** + * Test class for {@link ConsoleRoleListener}. + *

+ * Covers the public {@code postGetRole} entry point (which funnels through the private + * {@code getUpgradedPermissions}) for both the new-roles branch and the legacy (7.0.0) branch, plus direct + * reflection-based tests for the {@code resolveWriteFeatureScopes} / {@code addResolvedScope} consolidation helpers + * that keep the edit (write) feature scope and the granular create/update/delete feature scopes consistent. + */ +public class ConsoleRoleListenerTest { + + private static final String TENANT_DOMAIN = "carbon.super"; + + // Feature scope markers for the "users" collection. + private static final String USERS_VIEW = "console:users_view"; + private static final String USERS_EDIT = "console:users_edit"; + private static final String USERS_CREATE = "console:users_create"; + private static final String USERS_UPDATE = "console:users_update"; + private static final String USERS_DELETE = "console:users_delete"; + + // Internal API scopes resolved from the feature markers. + private static final String INTERNAL_USER_VIEW = "internal_user_view"; + private static final String INTERNAL_USER_LIST = "internal_user_list"; + private static final String INTERNAL_USER_CREATE = "internal_user_create"; + private static final String INTERNAL_USER_UPDATE = "internal_user_update"; + private static final String INTERNAL_USER_DELETE = "internal_user_delete"; + + // Write is the combination of the create, update and delete internal scopes. + private static final List INTERNAL_USER_WRITE_SCOPES = Arrays.asList(INTERNAL_USER_CREATE, + INTERNAL_USER_UPDATE, INTERNAL_USER_DELETE); + + // Legacy (7.0.0) console scopes that don't carry the feature-scope suffixes. + private static final String LEGACY_VIEW = "console:userMgt:view"; + private static final String LEGACY_WRITE = "console:userMgt:create"; + + private ConsoleRoleListener consoleRoleListener; + private AutoCloseable closeable; + + private MockedStatic appsCommonDataHolder; + private MockedStatic identityUtil; + + @Mock + private APIResourceManager apiResourceManager; + @Mock + private APIResourceCollectionManager apiResourceCollectionManager; + + @BeforeMethod + public void setUp() { + + consoleRoleListener = new ConsoleRoleListener(); + appsCommonDataHolder = mockStatic(AppsCommonDataHolder.class); + identityUtil = mockStatic(IdentityUtil.class); + identityUtil.when(() -> IdentityUtil.getProperty(anyString())).thenReturn("true"); + closeable = MockitoAnnotations.openMocks(this); + } + + @AfterMethod + public void tearDown() throws Exception { + + identityUtil.close(); + appsCommonDataHolder.close(); + closeable.close(); + } + + /** + * Switch the granular console permission flag off for an off-mode test. + */ + private void disableGranularConsolePermissions() { + + identityUtil.when(() -> IdentityUtil.getProperty(anyString())).thenReturn("false"); + } + + @Test + public void testGetDefaultOrderId() { + + assertEquals(consoleRoleListener.getDefaultOrderId(), 87); + } + + @Test + public void testIsEnable() { + + assertTrue(consoleRoleListener.isEnable()); + } + + @Test + public void testPostGetRoleSkipsAdministratorRole() throws Exception { + + Role role = buildRole(ADMINISTRATOR, CONSOLE_APP_AUDIENCE_NAME, Collections.singletonList(perm(USERS_VIEW))); + consoleRoleListener.postGetRole(role, "roleId", TENANT_DOMAIN); + + // Administrator already has all permissions; the listener must not touch them. + assertEquals(names(role.getPermissions()), new HashSet<>(Collections.singletonList(USERS_VIEW))); + } + + @Test + public void testPostGetRoleSkipsNonConsoleAudience() throws Exception { + + Role role = buildRole("editor", "Some Business App", Collections.singletonList(perm(USERS_VIEW))); + consoleRoleListener.postGetRole(role, "roleId", TENANT_DOMAIN); + + assertEquals(names(role.getPermissions()), new HashSet<>(Collections.singletonList(USERS_VIEW))); + } + + @Test + public void testPostGetRoleNewRoleViewFeatureScopeResolvesReadScopes() throws Exception { + + mockDataHolder(allSystemScopes(), Collections.singletonList(usersCollection())); + Role role = buildRole("viewer", CONSOLE_APP_AUDIENCE_NAME, Collections.singletonList(perm(USERS_VIEW))); + + consoleRoleListener.postGetRole(role, "roleId", TENANT_DOMAIN); + + assertEquals(names(role.getPermissions()), new HashSet<>(Arrays.asList(INTERNAL_USER_VIEW, + INTERNAL_USER_LIST))); + } + + @Test + public void testPostGetRoleNewRoleEditFeatureScopeResolvesWriteScopes() throws Exception { + + mockDataHolder(allSystemScopes(), Collections.singletonList(usersCollection())); + Role role = buildRole("editor", CONSOLE_APP_AUDIENCE_NAME, Collections.singletonList(perm(USERS_EDIT))); + + consoleRoleListener.postGetRole(role, "roleId", TENANT_DOMAIN); + + Set resolved = names(role.getPermissions()); + assertTrue(resolved.containsAll(Arrays.asList(INTERNAL_USER_CREATE, INTERNAL_USER_UPDATE, + INTERNAL_USER_DELETE)), "Edit feature scope must resolve to all write scopes."); + } + + @Test + public void testPostGetRoleNewRoleGranularScopesResolved() throws Exception { + + mockDataHolder(allSystemScopes(), Collections.singletonList(usersCollection())); + Role role = buildRole("granular", CONSOLE_APP_AUDIENCE_NAME, + Arrays.asList(perm(USERS_CREATE), perm(USERS_UPDATE), perm(USERS_DELETE))); + + consoleRoleListener.postGetRole(role, "roleId", TENANT_DOMAIN); + + Set resolved = names(role.getPermissions()); + assertTrue(resolved.containsAll(Arrays.asList(INTERNAL_USER_CREATE, INTERNAL_USER_UPDATE, + INTERNAL_USER_DELETE)), "Granular feature scopes must resolve to their internal scopes."); + } + + @Test + public void testPostGetRoleGranularDisabledIgnoresGranularScopes() throws Exception { + + // Granular off + a role carrying only granular create/update/delete feature scopes: the granular scopes are + // inert. They are neither resolved to their internal scopes nor derived into the edit feature scope. + disableGranularConsolePermissions(); + mockDataHolder(allSystemScopes(), Collections.singletonList(collectionWithEditInWriteScopes())); + Role role = buildRole("granular", CONSOLE_APP_AUDIENCE_NAME, + Arrays.asList(perm(USERS_CREATE), perm(USERS_UPDATE), perm(USERS_DELETE))); + + consoleRoleListener.postGetRole(role, "roleId", TENANT_DOMAIN); + + Set resolved = names(role.getPermissions()); + assertFalse(resolved.contains(USERS_EDIT), + "Granular feature scopes must not be derived into the edit feature scope when granular is off."); + assertFalse(resolved.contains(INTERNAL_USER_CREATE) || resolved.contains(INTERNAL_USER_UPDATE) + || resolved.contains(INTERNAL_USER_DELETE), "Granular feature scopes must not be resolved when off."); + assertFalse(resolved.contains(USERS_CREATE) || resolved.contains(USERS_UPDATE) + || resolved.contains(USERS_DELETE), "Granular feature scopes must not be returned when off."); + } + + @Test + public void testPostGetRoleGranularDisabledDoesNotDeriveGranularFromEdit() throws Exception { + + // Granular off + an edit role: the edit feature scope is returned, but it must NOT expand into the granular + // create/update/delete feature scopes (forward derivation is skipped). + disableGranularConsolePermissions(); + mockDataHolder(allSystemScopes(), Collections.singletonList(collectionWithEditInWriteScopes())); + Role role = buildRole("editor", CONSOLE_APP_AUDIENCE_NAME, Collections.singletonList(perm(USERS_EDIT))); + + consoleRoleListener.postGetRole(role, "roleId", TENANT_DOMAIN); + + Set resolved = names(role.getPermissions()); + assertTrue(resolved.contains(USERS_EDIT), "The edit feature scope must be returned when off."); + assertFalse(resolved.contains(USERS_CREATE) || resolved.contains(USERS_UPDATE) + || resolved.contains(USERS_DELETE), "Granular feature scopes must not be derived from edit when off."); + } + + @Test + public void testPostGetRoleLegacyRoleForwardConsolidationAddsEditFeatureScope() throws Exception { + + /* Legacy role holding read + legacy-write scopes. The collection's writeScopes carry the create/update/delete + feature markers but NOT the edit marker, so the consolidation must add the edit (write) feature scope. */ + APIResourceCollection collection = legacyCollection( + withInternalWriteScopes(USERS_CREATE, USERS_UPDATE, USERS_DELETE)); + mockDataHolder(allSystemScopes(), Collections.singletonList(collection)); + Role role = buildRole("legacy", CONSOLE_APP_AUDIENCE_NAME, + Arrays.asList(perm(LEGACY_VIEW), perm(LEGACY_WRITE))); + + consoleRoleListener.postGetRole(role, "roleId", TENANT_DOMAIN); + + Set resolved = names(role.getPermissions()); + assertTrue(resolved.contains(USERS_EDIT), + "All granular write feature scopes present must add the edit feature scope."); + } + + @Test + public void testPostGetRoleLegacyRoleReverseConsolidationAddsGranularFeatureScopes() throws Exception { + + /* The collection's writeScopes carry the edit marker but NOT the create/update/delete markers, so the + consolidation must add the granular create/update/delete feature scopes. */ + APIResourceCollection collection = legacyCollection(withInternalWriteScopes(USERS_EDIT)); + mockDataHolder(allSystemScopes(), Collections.singletonList(collection)); + Role role = buildRole("legacy", CONSOLE_APP_AUDIENCE_NAME, + Arrays.asList(perm(LEGACY_VIEW), perm(LEGACY_WRITE))); + + consoleRoleListener.postGetRole(role, "roleId", TENANT_DOMAIN); + + Set resolved = names(role.getPermissions()); + assertTrue(resolved.containsAll(Arrays.asList(USERS_CREATE, USERS_UPDATE, USERS_DELETE)), + "Edit feature scope present must add the granular create/update/delete feature scopes."); + } + + @Test + public void testResolveWriteFeatureScopesForwardAddsEdit() throws Exception { + + List resolved = new ArrayList<>(Arrays.asList(perm(USERS_CREATE), perm(USERS_UPDATE), + perm(USERS_DELETE))); + invokeResolveWriteFeatureScopes(resolved, Collections.singletonList(usersCollection()), + permissions(allScopeNames())); + + assertTrue(names(resolved).contains(USERS_EDIT)); + } + + @Test + public void testResolveWriteFeatureScopesReverseAddsGranular() throws Exception { + + List resolved = new ArrayList<>(Collections.singletonList(perm(USERS_EDIT))); + invokeResolveWriteFeatureScopes(resolved, Collections.singletonList(usersCollection()), + permissions(allScopeNames())); + + assertTrue(names(resolved).containsAll(Arrays.asList(USERS_CREATE, USERS_UPDATE, USERS_DELETE))); + } + + @Test + public void testResolveWriteFeatureScopesNoChangeWhenAllPresent() throws Exception { + + List resolved = new ArrayList<>(Arrays.asList(perm(USERS_EDIT), perm(USERS_CREATE), + perm(USERS_UPDATE), perm(USERS_DELETE))); + invokeResolveWriteFeatureScopes(resolved, Collections.singletonList(usersCollection()), + permissions(allScopeNames())); + + assertEquals(resolved.size(), 4, "Nothing should be added when all feature scopes are already present."); + } + + @Test + public void testResolveWriteFeatureScopesNoChangeWhenGranularIncomplete() throws Exception { + + // Missing the delete feature scope, so the forward consolidation must NOT add the edit feature scope. + List resolved = new ArrayList<>(Arrays.asList(perm(USERS_CREATE), perm(USERS_UPDATE))); + invokeResolveWriteFeatureScopes(resolved, Collections.singletonList(usersCollection()), + permissions(allScopeNames())); + + assertFalse(names(resolved).contains(USERS_EDIT)); + assertEquals(resolved.size(), 2); + } + + @Test + public void testAddResolvedScopeResolvesAndAdds() throws Exception { + + List resolved = new ArrayList<>(); + Set resolvedNames = new HashSet<>(); + invokeAddResolvedScope(USERS_EDIT, permissions(allScopeNames()), resolved, resolvedNames); + + assertEquals(names(resolved), new HashSet<>(Collections.singletonList(USERS_EDIT))); + assertTrue(resolvedNames.contains(USERS_EDIT)); + } + + @Test + public void testAddResolvedScopeNullScopeIsNoOp() throws Exception { + + List resolved = new ArrayList<>(); + Set resolvedNames = new HashSet<>(); + invokeAddResolvedScope(null, permissions(allScopeNames()), resolved, resolvedNames); + + assertTrue(resolved.isEmpty()); + } + + @Test + public void testAddResolvedScopeDuplicateIsNoOp() throws Exception { + + List resolved = new ArrayList<>(Collections.singletonList(perm(USERS_EDIT))); + Set resolvedNames = new HashSet<>(Collections.singletonList(USERS_EDIT)); + invokeAddResolvedScope(USERS_EDIT, permissions(allScopeNames()), resolved, resolvedNames); + + assertEquals(resolved.size(), 1, "An already-resolved scope must not be added twice."); + } + + @Test + public void testAddResolvedScopeUnknownScopeNotAdded() throws Exception { + + List resolved = new ArrayList<>(); + Set resolvedNames = new HashSet<>(); + invokeAddResolvedScope("console:unknown_edit", permissions(allScopeNames()), resolved, resolvedNames); + + assertTrue(resolved.isEmpty(), "A scope absent from system permissions must not be added."); + } + + private void mockDataHolder(List systemScopes, List collections) throws Exception { + + AppsCommonDataHolder dataHolder = mock(AppsCommonDataHolder.class); + appsCommonDataHolder.when(AppsCommonDataHolder::getInstance).thenReturn(dataHolder); + when(dataHolder.getAPIResourceManager()).thenReturn(apiResourceManager); + when(dataHolder.getApiResourceCollectionManager()).thenReturn(apiResourceCollectionManager); + RoleManagementService roleManagementService = mock(RoleManagementService.class); + when(dataHolder.getRoleManagementServiceV2()).thenReturn(roleManagementService); + when(roleManagementService.getSystemRoles()).thenReturn(Collections.emptySet()); + when(apiResourceManager.getSystemAPIScopes(anyString())).thenReturn(systemScopes); + APIResourceCollectionSearchResult searchResult = new APIResourceCollectionSearchResult(collections); + when(apiResourceCollectionManager.getAPIResourceCollections(anyString(), anyList(), anyString())) + .thenReturn(searchResult); + } + + private static Role buildRole(String name, String audienceName, List permissions) { + + Role role = new Role(); + role.setName(name); + role.setAudienceName(audienceName); + role.setPermissions(new ArrayList<>(permissions)); + return role; + } + + private APIResourceCollection usersCollection() { + + APIResourceCollection collection = new APIResourceCollection(); + collection.setViewFeatureScope(USERS_VIEW); + collection.setEditFeatureScope(USERS_EDIT); + collection.setCreateFeatureScope(USERS_CREATE); + collection.setUpdateFeatureScope(USERS_UPDATE); + collection.setDeleteFeatureScope(USERS_DELETE); + collection.setReadScopes(Arrays.asList(INTERNAL_USER_VIEW, INTERNAL_USER_LIST)); + collection.setWriteScopes(Arrays.asList(INTERNAL_USER_CREATE, INTERNAL_USER_UPDATE, INTERNAL_USER_DELETE)); + collection.setCreateScopes(Collections.singletonList(INTERNAL_USER_CREATE)); + collection.setUpdateScopes(Collections.singletonList(INTERNAL_USER_UPDATE)); + collection.setDeleteScopes(Collections.singletonList(INTERNAL_USER_DELETE)); + return collection; + } + + /** + * A users collection whose write scopes also carry the edit feature scope, mirroring the runtime config builder + * which places the edit feature scope in the write scope set. Used by the off-mode tests so resolving the edit + * feature scope yields the edit marker in the response. + */ + private APIResourceCollection collectionWithEditInWriteScopes() { + + APIResourceCollection collection = usersCollection(); + collection.setWriteScopes(Arrays.asList(USERS_EDIT, INTERNAL_USER_CREATE, INTERNAL_USER_UPDATE, + INTERNAL_USER_DELETE)); + return collection; + } + + /** + * A collection shaped like a 7.0.0 (legacy) collection: read matching is done against a legacy console scope and + * a legacy write marker, while the supplied {@code writeScopes} drive what feature markers get resolved. + * + * @param writeScopes Write scopes the collection exposes (used to seed the resolved set before consolidation). + */ + private APIResourceCollection legacyCollection(List writeScopes) { + + APIResourceCollection collection = usersCollection(); + collection.setReadScopes(Arrays.asList(LEGACY_VIEW, INTERNAL_USER_VIEW)); + collection.setLegacyReadScopes(Collections.singletonList(LEGACY_VIEW)); + collection.setLegacyWriteScopes(Collections.singletonList(LEGACY_WRITE)); + collection.setWriteScopes(writeScopes); + return collection; + } + + private static List allScopeNames() { + + return Arrays.asList(INTERNAL_USER_VIEW, INTERNAL_USER_LIST, INTERNAL_USER_CREATE, INTERNAL_USER_UPDATE, + INTERNAL_USER_DELETE, USERS_VIEW, USERS_EDIT, USERS_CREATE, USERS_UPDATE, USERS_DELETE); + } + + /** + * Build a write-scope list out of the given feature markers plus the internal write scopes (create, update, + * delete), since "write" is the combination of those three internal scopes. + */ + private static List withInternalWriteScopes(String... featureMarkers) { + + List scopes = new ArrayList<>(Arrays.asList(featureMarkers)); + scopes.addAll(INTERNAL_USER_WRITE_SCOPES); + return scopes; + } + + private static List allSystemScopes() { + + return allScopeNames().stream().map(name -> new Scope(name, name, name, name)) + .collect(Collectors.toList()); + } + + private static List permissions(Collection names) { + + return names.stream().map(ConsoleRoleListenerTest::perm).collect(Collectors.toList()); + } + + private static Permission perm(String name) { + + return new Permission(name, name, name); + } + + private static Set names(List permissions) { + + return permissions.stream().map(Permission::getName).collect(Collectors.toSet()); + } + + private void invokeResolveWriteFeatureScopes(List resolvedRolePermissions, + List collections, + List systemPermissions) throws Exception { + + Method method = ConsoleRoleListener.class.getDeclaredMethod("resolveWriteFeatureScopes", List.class, + List.class, List.class); + method.setAccessible(true); + method.invoke(consoleRoleListener, resolvedRolePermissions, collections, systemPermissions); + } + + private void invokeAddResolvedScope(String scope, List systemPermissions, + List resolvedRolePermissions, Set resolvedPermissionNames) + throws Exception { + + Method method = ConsoleRoleListener.class.getDeclaredMethod("addResolvedScope", String.class, List.class, + List.class, Set.class); + method.setAccessible(true); + method.invoke(consoleRoleListener, scope, systemPermissions, resolvedRolePermissions, resolvedPermissionNames); + } +} diff --git a/identity-apps-core/components/org.wso2.identity.apps.common/src/test/resources/testng.xml b/identity-apps-core/components/org.wso2.identity.apps.common/src/test/resources/testng.xml index ebd691e5c44..55729490843 100644 --- a/identity-apps-core/components/org.wso2.identity.apps.common/src/test/resources/testng.xml +++ b/identity-apps-core/components/org.wso2.identity.apps.common/src/test/resources/testng.xml @@ -21,6 +21,7 @@ + diff --git a/identity-apps-core/pom.xml b/identity-apps-core/pom.xml index 3f8f43cbf95..f2509c51b7b 100644 --- a/identity-apps-core/pom.xml +++ b/identity-apps-core/pom.xml @@ -804,7 +804,7 @@ 3.0.0 5.1.5 1.0.8 - 7.10.68 + 7.11.160 [5.0.0, 8.0.0) 1.0.77 diff --git a/modules/i18n/src/models/namespaces/console-settings-ns.ts b/modules/i18n/src/models/namespaces/console-settings-ns.ts index acdf70e4fef..f92411bc322 100644 --- a/modules/i18n/src/models/namespaces/console-settings-ns.ts +++ b/modules/i18n/src/models/namespaces/console-settings-ns.ts @@ -53,14 +53,32 @@ export interface ConsoleSettingsNS { organizationPermissions: { label: string; }; + permissionsCount: string; tenantPermissions: { label: string; }; }; tabLabel: string; permissionLevels: { - edit: string; view: string; + edit: string; + selectAll: string; + selectAllTenant: string; + selectAllOrganization: string; + selectCollection: string; + selectRowPermission: string; + read: string; + create: string; + update: string; + delete: string; + viewRequiredWarning: string; + viewLockedRow: string; + viewSelectAllLocked: string; + toggleGroupAriaLabel: string; + toggleViewAriaLabel: string; + toggleEditAriaLabel: string; + tenantTableAriaLabel: string; + organizationTableAriaLabel: string; }; }; enterpriseLogin: { diff --git a/modules/i18n/src/translations/en-US/portals/console-settings.ts b/modules/i18n/src/translations/en-US/portals/console-settings.ts index 7dcf8ed22e4..25bce7be9ab 100644 --- a/modules/i18n/src/translations/en-US/portals/console-settings.ts +++ b/modules/i18n/src/translations/en-US/portals/console-settings.ts @@ -61,14 +61,32 @@ export const consoleSettings: ConsoleSettingsNS = { organizationPermissions: { label: "Organization Permissions" }, + permissionsCount: "Permissions: {{count}}", tenantPermissions: { label: "Root Organization Permissions" } }, tabLabel: "Roles", permissionLevels: { + view: "View", edit: "Edit", - view: "View" + selectAll: "Select all {{label}} permissions", + selectAllTenant: "Select all tenant permissions", + selectAllOrganization: "Select all organization permissions", + selectCollection: "Select {{collection}} {{label}} permission", + selectRowPermission: "Select {{collection}} permission", + read: "View", + create: "Create", + update: "Update", + delete: "Delete", + viewRequiredWarning: "View is required when other permissions are active.", + viewLockedRow: "View can't be turned off while other permissions are active.", + viewSelectAllLocked: "Some rows still have other permissions active. Remove them first.", + toggleGroupAriaLabel: "Permission level", + toggleViewAriaLabel: "View", + toggleEditAriaLabel: "Edit", + tenantTableAriaLabel: "Tenant permissions table", + organizationTableAriaLabel: "Organization permissions table" } }, enterpriseLogin: {