feat: Implement querying openedx-authz for publish permissions (#2685)
This commit is contained in:
16
src/authz/constants.ts
Normal file
16
src/authz/constants.ts
Normal 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
41
src/authz/data/api.ts
Normal 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;
|
||||
};
|
||||
168
src/authz/data/apiHooks.test.tsx
Normal file
168
src/authz/data/apiHooks.test.tsx
Normal 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
|
||||
}
|
||||
});
|
||||
});
|
||||
36
src/authz/data/apiHooks.ts
Normal file
36
src/authz/data/apiHooks.ts
Normal 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
4
src/authz/data/utils.ts
Normal 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
16
src/authz/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user