Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/authz-module/constants.test.ts
Original file line number Diff line number Diff line change
@@ -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}`;

Expand Down Expand Up @@ -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"');
});
});
33 changes: 22 additions & 11 deletions src/authz-module/constants.ts
Original file line number Diff line number Diff line change
@@ -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(() => ({
Expand Down Expand Up @@ -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];
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });

Expand All @@ -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 } });

Expand All @@ -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 } });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand All @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,40 @@ describe('useScopePermissions', () => {
});

describe('hasPlatformPermission', () => {
it('is always false (pending backend support)', () => {
it('is true when the platform-wide entry (index 0) is allowed', () => {
mockUseValidateUserPermissions.mockReturnValue({
data: [{ allowed: true }, { allowed: false }],
});

const { result } = renderHook(() => useScopePermissions({
contextType: 'course',
orderedOrgs: ['MIT'],
}));

expect(result.current.hasPlatformPermission).toBe(true);
});

it('is false when the platform-wide entry is not allowed', () => {
mockUseValidateUserPermissions.mockReturnValue({
data: [{ allowed: false }, { allowed: true }],
});

const { result } = renderHook(() => useScopePermissions({
contextType: 'course',
orderedOrgs: ['MIT'],
}));

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', () => {
Expand All @@ -37,8 +63,9 @@ describe('useScopePermissions', () => {
});

it('maps allowed responses by org slug index for course context', () => {
// Index 0 is the platform-wide entry; org entries follow.
mockUseValidateUserPermissions.mockReturnValue({
data: [{ allowed: true }, { allowed: false }],
data: [{ allowed: false }, { allowed: true }, { allowed: false }],
});

const { result } = renderHook(() => useScopePermissions({
Expand All @@ -50,8 +77,9 @@ describe('useScopePermissions', () => {
});

it('maps allowed responses by org slug index for library context', () => {
// Index 0 is the platform-wide entry; org entries follow.
mockUseValidateUserPermissions.mockReturnValue({
data: [{ allowed: false }, { allowed: true }],
data: [{ allowed: false }, { allowed: false }, { allowed: true }],
});

const { result } = renderHook(() => useScopePermissions({
Expand Down Expand Up @@ -86,37 +114,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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -17,32 +18,36 @@ 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
// Validate the platform-wide aggregate (course-v1:* / lib:*) together with each
// org-level aggregate in a single request. The platform-wide scope is always at
// index 0; the per-org scopes follow in `orderedOrgs` order.
// Note: Using glob patterns (*:org:*)
const orgPermissionRequests = useMemo(() => {
if (!orderedOrgs.length || !contextType) { return []; }
const permissionRequests = useMemo(() => {
if (!contextType) { return []; }
const action = contextType === 'course'
? CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM
: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM;
return orderedOrgs.map((org) => ({
action,
scope: getOrgAggregateScopeKey(contextType, org),
}));
return [
{ action, scope: getPlatformAggregateScopeKey(contextType as ContextType) },
...orderedOrgs.map((org) => ({
action,
scope: getOrgAggregateScopeKey(contextType as ContextType, org),
})),
];
}, [orderedOrgs, contextType]);

const { data: orgPerms } = useValidateUserPermissions(orgPermissionRequests);
const { data: perms } = useValidateUserPermissions(permissionRequests);

const hasPlatformPermission = !!contextType && (perms?.[0]?.allowed ?? false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is perms?.[0] safe enough? How do we always ensure this order?


// Build a map of `org: has_permission`
// Build a map of `org: has_permission`. Offset by 1 to skip the platform-wide entry.
const orgHasPermission = useMemo(() => {
const map: Record<string, boolean> = {};
orderedOrgs.forEach((org, idx) => {
map[org] = orgPerms?.[idx]?.allowed ?? false;
map[org] = perms?.[idx + 1]?.allowed ?? false;
});
return map;
}, [orderedOrgs, orgPerms]);
}, [orderedOrgs, perms]);

return { hasPlatformPermission, orgHasPermission };
};
Expand Down