diff --git a/src/authz-module/constants.test.ts b/src/authz-module/constants.test.ts index 8e5eace3..60b4f9d0 100644 --- a/src/authz-module/constants.test.ts +++ b/src/authz-module/constants.test.ts @@ -1,4 +1,7 @@ -import { buildWizardPath, getOrgAggregateScopeKey, ROUTES } from './constants'; +import { + buildWizardPath, getOrgAggregateScopeKey, getPlatformAggregateScopeKey, ROUTES, +} from './constants'; +import type { ContextType } from './constants'; const BASE = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`; @@ -43,6 +46,20 @@ describe('getOrgAggregateScopeKey', () => { }); it('throws for an unknown contextType', () => { - expect(() => getOrgAggregateScopeKey('unknown', 'MIT')).toThrow('Unknown contextType: "unknown"'); + expect(() => getOrgAggregateScopeKey('unknown' as ContextType, 'MIT')).toThrow('Unknown contextType: "unknown"'); + }); +}); + +describe('getPlatformAggregateScopeKey', () => { + it('returns the platform-wide course wildcard scope for course context', () => { + expect(getPlatformAggregateScopeKey('course')).toBe('course-v1:*'); + }); + + it('returns the platform-wide library wildcard scope for library context', () => { + expect(getPlatformAggregateScopeKey('library')).toBe('lib:*'); + }); + + it('throws for an unknown contextType', () => { + expect(() => getPlatformAggregateScopeKey('unknown' as ContextType)).toThrow('Unknown contextType: "unknown"'); }); }); diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index f26940cb..c344f9aa 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,14 +1,33 @@ +// Resource Type Definitions +export const CONTEXT_TYPES = { + LIBRARY: 'library', + COURSE: 'course', +} as const; + +export type ContextType = typeof CONTEXT_TYPES[keyof typeof CONTEXT_TYPES]; + const ORG_AGGREGATE_SCOPE_BUILDERS = { - course: (orgSlug: string) => `course-v1:${orgSlug}+*`, - library: (orgSlug: string) => `lib:${orgSlug}:*`, + [CONTEXT_TYPES.COURSE]: (orgSlug: string) => `course-v1:${orgSlug}+*`, + [CONTEXT_TYPES.LIBRARY]: (orgSlug: string) => `lib:${orgSlug}:*`, }; -export const getOrgAggregateScopeKey = (contextType: string, orgSlug: string): string => { +export const getOrgAggregateScopeKey = (contextType: ContextType, orgSlug: string): string => { const builder = ORG_AGGREGATE_SCOPE_BUILDERS[contextType]; if (!builder) { throw new Error(`Unknown contextType: "${contextType}"`); } return builder(orgSlug); }; +const PLATFORM_AGGREGATE_SCOPE_KEYS = { + [CONTEXT_TYPES.COURSE]: 'course-v1:*', + [CONTEXT_TYPES.LIBRARY]: 'lib:*', +}; + +export const getPlatformAggregateScopeKey = (contextType: ContextType): string => { + const scope = PLATFORM_AGGREGATE_SCOPE_KEYS[contextType]; + if (!scope) { throw new Error(`Unknown contextType: "${contextType}"`); } + return scope; +}; + export const DEFAULT_TOAST_DELAY = 5000; export const RETRY_TOAST_DELAY = 120_000; // 2 minutes export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ @@ -65,11 +84,3 @@ export const TABLE_DEFAULT_PAGE_SIZE = 10; export const DEFAULT_FILTER_PAGE_SIZE = 5; export const ADMIN_ROLES = ['course_admin', 'library_admin']; - -// Resource Type Definitions -export const CONTEXT_TYPES = { - LIBRARY: 'library', - COURSE: 'course', -} as const; - -export type ResourceType = typeof CONTEXT_TYPES[keyof typeof CONTEXT_TYPES]; diff --git a/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx index 8684e31e..1d8c7c7c 100644 --- a/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx +++ b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx @@ -160,7 +160,7 @@ describe('DefineApplicationScopeStep', () => { it('does not render platform aggregate (disabled pending backend support)', () => { (useScopes as jest.Mock).mockReturnValue(makeScopesHook()); renderComponent({ selectedRole: 'library_admin' }); - expect(screen.queryByText('All libraries in Platform')).not.toBeInTheDocument(); + expect(screen.queryByText('All libraries on the platform')).not.toBeInTheDocument(); }); it('renders scopes grouped by org in OrgSection', () => { @@ -318,17 +318,17 @@ describe('DefineApplicationScopeStep', () => { it('does not show platform aggregate (disabled pending backend support)', () => { (useScopes as jest.Mock).mockReturnValue(makeScopesHook()); renderComponent({ selectedRole: 'library_admin' }); - expect(screen.queryByText('All libraries in Platform')).not.toBeInTheDocument(); + expect(screen.queryByText('All libraries on the platform')).not.toBeInTheDocument(); }); it('does not show platform aggregate when selectedRole is null', () => { renderComponent({ selectedRole: null }); - expect(screen.queryByText('All libraries in Platform')).not.toBeInTheDocument(); + expect(screen.queryByText('All libraries on the platform')).not.toBeInTheDocument(); }); - it('does not show "All courses in Platform" (disabled pending backend support)', () => { + it('does not show "All courses on the platform" (disabled pending backend support)', () => { renderComponent({ selectedRole: 'course_admin' }); - expect(screen.queryByText('All courses in Platform')).not.toBeInTheDocument(); + expect(screen.queryByText('All courses on the platform')).not.toBeInTheDocument(); }); }); diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.test.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.test.ts index ff9fa445..aad09619 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.test.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.test.ts @@ -228,7 +228,7 @@ describe('useScopeListData', () => { }); describe('Platform aggregate scope item', () => { - it('returns null platformAggregateScopeItem for library context (disabled pending backend support)', () => { + it('returns null platformAggregateScopeItem when the user lacks platform permission', () => { mockUseScopes.mockReturnValue(makeScopesHook()); mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); @@ -241,7 +241,8 @@ describe('useScopeListData', () => { expect(result.current.platformAggregateScopeItem).toBeNull(); }); - it('returns null platformAggregateScopeItem for course context (disabled pending backend support)', () => { + it('emits the platform-wide course scope (course-v1:*) when permission is granted', () => { + mockUseScopePermissions.mockReturnValue({ hasPlatformPermission: true, orgHasPermission: {} }); mockUseScopes.mockReturnValue(makeScopesHook()); mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); @@ -251,10 +252,31 @@ describe('useScopeListData', () => { orgs: [], }), { wrapper }); - expect(result.current.platformAggregateScopeItem).toBeNull(); + expect(result.current.platformAggregateScopeItem).toMatchObject({ + externalKey: 'course-v1:*', + org: null, + }); + }); + + it('emits the platform-wide library scope (lib:*) when permission is granted', () => { + mockUseScopePermissions.mockReturnValue({ hasPlatformPermission: true, orgHasPermission: {} }); + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.platformAggregateScopeItem).toMatchObject({ + externalKey: 'lib:*', + org: null, + }); }); it('returns null platformAggregateScopeItem when contextType is undefined', () => { + mockUseScopePermissions.mockReturnValue({ hasPlatformPermission: true, orgHasPermission: {} }); mockUseScopes.mockReturnValue(makeScopesHook()); mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts index 3c192f84..3740ed0d 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts @@ -3,7 +3,8 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Scope } from '@src/types'; import { useOrgs, useScopes } from '@src/authz-module/data/hooks'; import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; -import { getOrgAggregateScopeKey } from '@src/authz-module/constants'; +import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants'; +import type { ContextType } from '@src/authz-module/constants'; import messages from '../messages'; import useScopePermissions from './useScopePermissions'; @@ -83,7 +84,7 @@ const useScopeListData = ({ contextType, search, orgs }: UseScopeListDataParams) const platformAggregateScopeItem: Scope | null = (contextType && hasPlatformPermission) ? { - externalKey: '*', + externalKey: getPlatformAggregateScopeKey(contextType as ContextType), displayName: platformAggregateLabel, description: aggregateDescription, org: null, @@ -109,7 +110,7 @@ const useScopeListData = ({ contextType, search, orgs }: UseScopeListDataParams) .map((orgSlug) => [ orgSlug, { - externalKey: getOrgAggregateScopeKey(contextType, orgSlug), + externalKey: getOrgAggregateScopeKey(contextType as ContextType, orgSlug), displayName: orgAggregateLabel, description: aggregateDescription, org: { id: '0', name: orgSlug, shortName: orgSlug }, diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts index 30eb05a6..6d848a2d 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts @@ -16,7 +16,30 @@ describe('useScopePermissions', () => { }); describe('hasPlatformPermission', () => { - it('is always false (pending backend support)', () => { + it('is true when the platform-wide scope is allowed', () => { + mockUseValidateUserPermissions.mockReturnValue({ + data: [ + { scope: 'course-v1:*', allowed: true }, + { scope: 'course-v1:MIT+*', allowed: false }, + ], + }); + + const { result } = renderHook(() => useScopePermissions({ + contextType: 'course', + orderedOrgs: ['MIT'], + })); + + expect(result.current.hasPlatformPermission).toBe(true); + }); + + it('is false when the platform-wide scope is not allowed', () => { + mockUseValidateUserPermissions.mockReturnValue({ + data: [ + { scope: 'course-v1:*', allowed: false }, + { scope: 'course-v1:MIT+*', allowed: true }, + ], + }); + const { result } = renderHook(() => useScopePermissions({ contextType: 'course', orderedOrgs: ['MIT'], @@ -24,6 +47,15 @@ describe('useScopePermissions', () => { expect(result.current.hasPlatformPermission).toBe(false); }); + + it('is false when contextType is undefined', () => { + const { result } = renderHook(() => useScopePermissions({ + contextType: undefined, + orderedOrgs: ['MIT'], + })); + + expect(result.current.hasPlatformPermission).toBe(false); + }); }); describe('orgHasPermission', () => { @@ -36,9 +68,13 @@ describe('useScopePermissions', () => { expect(result.current.orgHasPermission).toEqual({}); }); - it('maps allowed responses by org slug index for course context', () => { + it('maps allowed responses by org slug for course context', () => { mockUseValidateUserPermissions.mockReturnValue({ - data: [{ allowed: true }, { allowed: false }], + data: [ + { scope: 'course-v1:*', allowed: false }, + { scope: 'course-v1:MIT+*', allowed: true }, + { scope: 'course-v1:HarvardX+*', allowed: false }, + ], }); const { result } = renderHook(() => useScopePermissions({ @@ -49,9 +85,13 @@ describe('useScopePermissions', () => { expect(result.current.orgHasPermission).toEqual({ MIT: true, HarvardX: false }); }); - it('maps allowed responses by org slug index for library context', () => { + it('maps allowed responses by org slug for library context', () => { mockUseValidateUserPermissions.mockReturnValue({ - data: [{ allowed: false }, { allowed: true }], + data: [ + { scope: 'lib:*', allowed: false }, + { scope: 'lib:MIT:*', allowed: false }, + { scope: 'lib:HarvardX:*', allowed: true }, + ], }); const { result } = renderHook(() => useScopePermissions({ @@ -73,7 +113,7 @@ describe('useScopePermissions', () => { expect(result.current.orgHasPermission).toEqual({ MIT: false }); }); - it('defaults to false when orgPerms data is undefined', () => { + it('defaults to false when the response data is undefined', () => { mockUseValidateUserPermissions.mockReturnValue({ data: undefined }); const { result } = renderHook(() => useScopePermissions({ @@ -86,37 +126,41 @@ describe('useScopePermissions', () => { }); describe('permission request construction', () => { - it('uses MANAGE_COURSE_TEAM action with course-v1 scope for course context', () => { + it('uses MANAGE_COURSE_TEAM action with the platform-wide and per-org course scopes', () => { renderHook(() => useScopePermissions({ contextType: 'course', orderedOrgs: ['MIT', 'HarvardX'], })); expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([ + { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope: 'course-v1:*' }, { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope: 'course-v1:MIT+*' }, { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope: 'course-v1:HarvardX+*' }, ]); }); - it('uses MANAGE_LIBRARY_TEAM action with lib scope for library context', () => { + it('uses MANAGE_LIBRARY_TEAM action with the platform-wide and per-org lib scopes', () => { renderHook(() => useScopePermissions({ contextType: 'library', orderedOrgs: ['MIT', 'HarvardX'], })); expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([ + { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, scope: 'lib:*' }, { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, scope: 'lib:MIT:*' }, { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, scope: 'lib:HarvardX:*' }, ]); }); - it('passes empty array to useValidateUserPermissions when orderedOrgs is empty', () => { + it('validates only the platform-wide scope when orderedOrgs is empty', () => { renderHook(() => useScopePermissions({ contextType: 'course', orderedOrgs: [], })); - expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([]); + expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([ + { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope: 'course-v1:*' }, + ]); }); it('passes empty array when contextType is undefined', () => { diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts index a92122ec..e79487bc 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useValidateUserPermissions } from '@src/data/hooks'; -import { getOrgAggregateScopeKey } from '@src/authz-module/constants'; +import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants'; +import type { ContextType } from '@src/authz-module/constants'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions'; interface UseScopePermissionsParams { @@ -17,32 +18,55 @@ const useScopePermissions = ({ contextType, orderedOrgs, }: UseScopePermissionsParams): UseScopePermissionsResult => { - // TODO: compute hasPlatformPermission once the backend supports validating platform-wide permissions. - const hasPlatformPermission = false; - - // Validate per-organization permissions for org-level aggregate options - // Note: Using glob patterns (*:org:*) - const orgPermissionRequests = useMemo(() => { - if (!orderedOrgs.length || !contextType) { return []; } - const action = contextType === 'course' + // Validate the platform-wide aggregate (course-v1:* / lib:*) and one org-level + // aggregate (course-v1:Org+* / lib:Org:*) per org in a single request; `action` + // is the same for every scope. + const typedContext = contextType as ContextType; + + // 1. Build the API request payload + const permissionRequests = useMemo(() => { + if (!typedContext) { return []; } + + const action = typedContext === 'course' ? CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM : CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM; - return orderedOrgs.map((org) => ({ + + const platformRequest = { action, scope: getPlatformAggregateScopeKey(typedContext) }; + const orgRequests = orderedOrgs.map((org) => ({ action, - scope: getOrgAggregateScopeKey(contextType, org), + scope: getOrgAggregateScopeKey(typedContext, org), })); - }, [orderedOrgs, contextType]); - const { data: orgPerms } = useValidateUserPermissions(orgPermissionRequests); + return [platformRequest, ...orgRequests]; + }, [orderedOrgs, typedContext]); - // Build a map of `org: has_permission` - const orgHasPermission = useMemo(() => { - const map: Record = {}; - orderedOrgs.forEach((org, idx) => { - map[org] = orgPerms?.[idx]?.allowed ?? false; + const { data: perms } = useValidateUserPermissions(permissionRequests); + + // 2. Create a lightweight index for fast lookups + // Indexed by the `scope` the API echoes back on each result. + const allowedByScope = useMemo(() => { + const byScope: Record = Object.create(null); + perms?.forEach(({ scope, allowed }) => { + if (scope !== undefined) { byScope[scope] = allowed; } }); - return map; - }, [orderedOrgs, orgPerms]); + return byScope; + }, [perms]); + + // 3. Extract platform-wide permission + const hasPlatformPermission = !!typedContext + && (allowedByScope[getPlatformAggregateScopeKey(typedContext)] ?? false); + + // 4. Map permissions back to the requested Orgs + const orgHasPermission = useMemo(() => { + const result: Record = {}; + if (typedContext) { + orderedOrgs.forEach((org) => { + const scope = getOrgAggregateScopeKey(typedContext, org); + result[org] = allowedByScope[scope] ?? false; + }); + } + return result; + }, [orderedOrgs, typedContext, allowedByScope]); return { hasPlatformPermission, orgHasPermission }; }; diff --git a/src/authz-module/role-assignation-wizard/messages.ts b/src/authz-module/role-assignation-wizard/messages.ts index 4af243ff..47694635 100644 --- a/src/authz-module/role-assignation-wizard/messages.ts +++ b/src/authz-module/role-assignation-wizard/messages.ts @@ -205,12 +205,12 @@ const messages = defineMessages({ }, 'wizard.step2.scope.aggregate.platform.label.course': { id: 'wizard.step2.scope.aggregate.platform.label.course', - defaultMessage: 'All courses in Platform', + defaultMessage: 'All courses on the platform', description: 'Display name for the platform-wide aggregate scope item when context type is course', }, 'wizard.step2.scope.aggregate.platform.label.library': { id: 'wizard.step2.scope.aggregate.platform.label.library', - defaultMessage: 'All libraries in Platform', + defaultMessage: 'All libraries on the platform', description: 'Display name for the platform-wide aggregate scope item when context type is library', },