feat: Implement querying openedx-authz for publish permissions (#2685)

This commit is contained in:
Rodrigo Mendez
2025-12-04 13:40:32 -06:00
committed by GitHub
parent b76b003e01
commit 0796e897ae
10 changed files with 310 additions and 4 deletions

16
src/authz/constants.ts Normal file
View File

@@ -0,0 +1,16 @@
export const CONTENT_LIBRARY_PERMISSIONS = {
DELETE_LIBRARY: 'content_libraries.delete_library',
MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags',
VIEW_LIBRARY: 'content_libraries.view_library',
EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content',
PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content',
REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content',
CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection',
EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection',
DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection',
MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
};

41
src/authz/data/api.ts Normal file
View File

@@ -0,0 +1,41 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
PermissionValidationAnswer,
PermissionValidationQuery,
PermissionValidationRequestItem,
PermissionValidationResponseItem,
} from '@src/authz/types';
import { getApiUrl } from './utils';
export const validateUserPermissions = async (
query: PermissionValidationQuery,
): Promise<PermissionValidationAnswer> => {
// Convert the validations query object into an array for the API request
const request: PermissionValidationRequestItem[] = Object.values(query);
const { data }: { data: PermissionValidationResponseItem[] } = await getAuthenticatedHttpClient().post(
getApiUrl('/api/authz/v1/permissions/validate/me'),
request,
);
// Convert the API response back into the expected answer format
const result: PermissionValidationAnswer = {};
data.forEach((item: { action: string; scope?: string; allowed: boolean }) => {
const key = Object.keys(query).find(
(k) => query[k].action === item.action
&& query[k].scope === item.scope,
);
if (key) {
result[key] = item.allowed;
}
});
// Fill any missing keys with false
Object.keys(query).forEach((key) => {
if (!(key in result)) {
result[key] = false;
}
});
return result;
};

View File

@@ -0,0 +1,168 @@
import { act, ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useUserPermissions } from './apiHooks';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return wrapper;
};
const singlePermission = {
canRead: {
action: 'example.read',
scope: 'lib:example-org:test-lib',
},
};
const mockValidSinglePermission = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
];
const mockInvalidSinglePermission = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
];
const mockEmptyPermissions = [
// No permissions returned
];
const multiplePermissions = {
canRead: {
action: 'example.read',
scope: 'lib:example-org:test-lib',
},
canWrite: {
action: 'example.write',
scope: 'lib:example-org:test-lib',
},
};
const mockValidMultiplePermissions = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: true },
];
const mockInvalidMultiplePermissions = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: false },
];
describe('useUserPermissions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns allowed true when permission is valid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValueOnce({ data: mockValidSinglePermission }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(true);
});
it('returns allowed false when permission is invalid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockInvalidSinglePermission }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
});
it('returns allowed true when multiple permissions are valid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValueOnce({ data: mockValidMultiplePermissions }),
});
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(true);
expect(result.current.data!.canWrite).toBe(true);
});
it('returns allowed false when multiple permissions are invalid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockInvalidMultiplePermissions }),
});
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
expect(result.current.data!.canWrite).toBe(false);
});
it('returns allowed false when the permission is not included in the server response', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockEmptyPermissions }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
});
it('handles error when the API call fails', async () => {
const mockError = new Error('API Error');
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockRejectedValue(new Error('API Error')),
});
try {
act(() => {
renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
});
} catch (error) {
expect(error).toEqual(mockError); // Check for the expected error
}
});
});

View File

@@ -0,0 +1,36 @@
import { useQuery } from '@tanstack/react-query';
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
import { validateUserPermissions } from './api';
const adminConsoleQueryKeys = {
all: ['authz'],
permissions: (permissions: PermissionValidationQuery) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const,
};
/**
* React Query hook to validate if the current user has permissions over a certain object in the instance.
* It helps to:
* - Determine whether the current user can access certain object.
* - Provide role-based rendering logic for UI components.
*
* @param permissions - A key/value map of objects and actions to validate.
* The key is an arbitrary string to identify the permission check,
* and the value is an object containing the action and optional scope.
*
* @example
* const { isLoading, data } = useUserPermissions({
* canRead: {
* action: "content_libraries.view_library",
* scope: "lib:OpenedX:CSPROB"
* }
* });
* if (data.canRead) { ... }
*
*/
export const useUserPermissions = (
permissions: PermissionValidationQuery,
) => useQuery<PermissionValidationAnswer, Error>({
queryKey: adminConsoleQueryKeys.permissions(permissions),
queryFn: () => validateUserPermissions(permissions),
retry: false,
});

4
src/authz/data/utils.ts Normal file
View File

@@ -0,0 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`;
export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`;

16
src/authz/types.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface PermissionValidationRequestItem {
action: string;
scope?: string;
}
export interface PermissionValidationResponseItem extends PermissionValidationRequestItem {
allowed: boolean;
}
export interface PermissionValidationQuery {
[permissionKey: string]: PermissionValidationRequestItem;
}
export interface PermissionValidationAnswer {
[permissionKey: string]: boolean;
}

View File

@@ -7,6 +7,8 @@ import {
useState,
} from 'react';
import { useParams } from 'react-router-dom';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz/constants';
import { ContainerType } from '../../../generic/key-utils';
import type { ComponentPicker } from '../../component-picker';
@@ -25,6 +27,7 @@ export type LibraryContextData = {
libraryId: string;
libraryData?: ContentLibrary;
readOnly: boolean;
canPublish: boolean;
isLoadingLibraryData: boolean;
/** The ID of the current collection/container, on the sidebar OR page */
collectionId: string | undefined;
@@ -107,6 +110,13 @@ export const LibraryProvider = ({
componentPickerMode,
} = useComponentPickerContext();
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
canPublish: {
action: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT,
scope: libraryId,
},
});
const canPublish = userPermissions?.canPublish || false;
const readOnly = !!componentPickerMode || !libraryData?.canEditLibrary;
// Parse the initial collectionId and/or container ID(s) from the current URL params
@@ -131,7 +141,8 @@ export const LibraryProvider = ({
containerId,
setContainerId,
readOnly,
isLoadingLibraryData,
canPublish,
isLoadingLibraryData: isLoadingLibraryData || isLoadingUserPermissions,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,
@@ -154,7 +165,9 @@ export const LibraryProvider = ({
containerId,
setContainerId,
readOnly,
canPublish,
isLoadingLibraryData,
isLoadingUserPermissions,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,

View File

@@ -8,6 +8,7 @@ import {
waitFor,
initializeMocks,
} from '@src/testUtils';
import { validateUserPermissions } from '@src/authz/data/api';
import { mockContentLibrary } from '../data/api.mocks';
import { getCommitLibraryChangesUrl } from '../data/api';
import { LibraryProvider } from '../common/context/LibraryContext';
@@ -33,6 +34,7 @@ const render = (libraryId: string = mockLibraryId) => baseRender(<LibraryInfo />
let axiosMock: MockAdapter;
let mockShowToast: (message: string) => void;
let validateUserPermissionsMock: jest.SpiedFunction<typeof validateUserPermissions>;
mockContentLibrary.applyMock();
@@ -41,6 +43,9 @@ describe('<LibraryInfo />', () => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
validateUserPermissionsMock = mocks.validateUserPermissionsMock;
validateUserPermissionsMock.mockResolvedValue({ canPublish: true });
});
afterEach(() => {

View File

@@ -12,7 +12,7 @@ import messages from './messages';
const LibraryPublishStatus = () => {
const intl = useIntl();
const { libraryData, readOnly } = useLibraryContext();
const { libraryData, readOnly, canPublish } = useLibraryContext();
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const commitLibraryChanges = useCommitLibraryChanges();
@@ -51,10 +51,10 @@ const LibraryPublishStatus = () => {
<>
<StatusWidget
{...libraryData}
onCommit={!readOnly ? commit : undefined}
onCommit={!readOnly && canPublish ? commit : undefined}
onCommitStatus={commitLibraryChanges.status}
onCommitLabel={intl.formatMessage(messages.publishLibraryButtonLabel)}
onRevert={!readOnly ? openConfirmModal : undefined}
onRevert={!readOnly && canPublish ? openConfirmModal : undefined}
/>
<DeleteModal
isOpen={isConfirmModalOpen}

View File

@@ -23,6 +23,7 @@ import {
Routes,
} from 'react-router-dom';
import * as authzApi from '@src/authz/data/api';
import { ToastContext, type ToastContextData } from './generic/toast-context';
import initializeReduxStore, { type DeprecatedReduxState } from './store';
import { getApiWaffleFlagsUrl } from './data/api';
@@ -31,6 +32,7 @@ import { getApiWaffleFlagsUrl } from './data/api';
let reduxStore: Store;
let queryClient: QueryClient;
let axiosMock: MockAdapter;
let validateUserPermissionsMock: jest.SpiedFunction<typeof authzApi.validateUserPermissions>;
/** To use this: `const { mockShowToast } = initializeMocks()` and `expect(mockShowToast).toHaveBeenCalled()` */
let mockToastContext: ToastContextData = {
@@ -192,12 +194,17 @@ export function initializeMocks({ user = defaultUser, initialState = undefined }
// Clear the call counts etc. of all mocks. This doesn't remove the mock's effects; just clears their history.
jest.clearAllMocks();
// Mock user permissions to avoid breaking tests that monitor axios calls
// If needed, override the mockResolvedValue in your test
validateUserPermissionsMock = jest.spyOn(authzApi, 'validateUserPermissions').mockResolvedValue({});
return {
reduxStore,
axiosMock,
mockShowToast: mockToastContext.showToast,
mockToastAction: mockToastContext.toastAction,
queryClient,
validateUserPermissionsMock,
};
}