Skip to content

Commit a435e1d

Browse files
committed
squash!: Improve permission validation hook with a clearer and safer API
1 parent 129e771 commit a435e1d

5 files changed

Lines changed: 161 additions & 49 deletions

File tree

src/authz/data/api.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,41 @@
11
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
2-
import { PermissionValidationRequest, PermissionValidationResponse } from '@src/authz/types';
2+
import {
3+
PermissionValidationAnswer,
4+
PermissionValidationQuery,
5+
PermissionValidationRequestItem,
6+
PermissionValidationResponseItem,
7+
} from '@src/authz/types';
38
import { getApiUrl } from './utils';
49

510
export const validateUserPermissions = async (
6-
validations: PermissionValidationRequest[],
7-
): Promise<PermissionValidationResponse[]> => {
8-
const { data } = await getAuthenticatedHttpClient().post(getApiUrl('/api/authz/v1/permissions/validate/me'), validations);
9-
return data;
11+
query: PermissionValidationQuery,
12+
): Promise<PermissionValidationAnswer> => {
13+
// Convert the validations query object into an array for the API request
14+
const request: PermissionValidationRequestItem[] = Object.values(query);
15+
16+
const { data }: { data: PermissionValidationResponseItem[] } = await getAuthenticatedHttpClient().post(
17+
getApiUrl('/api/authz/v1/permissions/validate/me'),
18+
request,
19+
);
20+
21+
// Convert the API response back into the expected answer format
22+
const result: PermissionValidationAnswer = {};
23+
data.forEach((item: { action: string; scope?: string; allowed: boolean }) => {
24+
const key = Object.keys(query).find(
25+
(k) => query[k].action === item.action
26+
&& query[k].scope === item.scope,
27+
);
28+
if (key) {
29+
result[key] = item.allowed;
30+
}
31+
});
32+
33+
// Fill any missing keys with false
34+
Object.keys(query).forEach((key) => {
35+
if (!(key in result)) {
36+
result[key] = false;
37+
}
38+
});
39+
40+
return result;
1041
};

src/authz/data/apiHooks.test.tsx

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { act, ReactNode } from 'react';
22
import { renderHook, waitFor } from '@testing-library/react';
33
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
5-
import { useValidateUserPermissions } from './apiHooks';
5+
import { useUserPermissions } from './apiHooks';
66

77
jest.mock('@edx/frontend-platform/auth', () => ({
88
getAuthenticatedHttpClient: jest.fn(),
@@ -18,64 +18,134 @@ const createWrapper = () => {
1818
});
1919

2020
const wrapper = ({ children }: { children: ReactNode }) => (
21-
<QueryClientProvider client={queryClient}>
22-
{children}
23-
</QueryClientProvider>
21+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
2422
);
2523

2624
return wrapper;
2725
};
2826

29-
const permissions = [
30-
{
27+
const singlePermission = {
28+
canRead: {
3129
action: 'act:read',
32-
object: 'lib:test-lib',
33-
scope: 'org:OpenedX',
30+
scope: 'lib:test-lib',
3431
},
32+
};
33+
34+
const mockValidSinglePermission = [
35+
{ action: 'act:read', scope: 'lib:test-lib', allowed: true },
36+
];
37+
38+
const mockInvalidSinglePermission = [
39+
{ action: 'act:read', scope: 'lib:test-lib', allowed: false },
40+
];
41+
42+
const mockEmptyPermissions = [
43+
// No permissions returned
3544
];
3645

37-
const mockValidPermissions = [
38-
{ action: 'act:read', object: 'lib:test-lib', allowed: true },
46+
const multiplePermissions = {
47+
canRead: {
48+
action: 'act:read',
49+
scope: 'lib:test-lib',
50+
},
51+
canWrite: {
52+
action: 'act:write',
53+
scope: 'lib:test-lib',
54+
},
55+
};
56+
57+
const mockValidMultiplePermissions = [
58+
{ action: 'act:read', scope: 'lib:test-lib', allowed: true },
59+
{ action: 'act:write', scope: 'lib:test-lib', allowed: true },
3960
];
4061

41-
const mockInvalidPermissions = [
42-
{ action: 'act:read', object: 'lib:test-lib', allowed: false },
62+
const mockInvalidMultiplePermissions = [
63+
{ action: 'act:read', scope: 'lib:test-lib', allowed: false },
64+
{ action: 'act:write', scope: 'lib:test-lib', allowed: false },
4365
];
4466

45-
describe('useValidateUserPermissions', () => {
67+
describe('useUserPermissions', () => {
4668
beforeEach(() => {
4769
jest.clearAllMocks();
4870
});
4971

50-
it('returns allowed true when permissions are valid', async () => {
72+
it('returns allowed true when permission is valid', async () => {
5173
getAuthenticatedHttpClient.mockReturnValue({
52-
post: jest.fn().mockResolvedValueOnce({ data: mockValidPermissions }),
74+
post: jest.fn().mockResolvedValueOnce({ data: mockValidSinglePermission }),
5375
});
5476

55-
const { result } = renderHook(() => useValidateUserPermissions(permissions), {
77+
const { result } = renderHook(() => useUserPermissions(singlePermission), {
5678
wrapper: createWrapper(),
5779
});
5880

5981
await waitFor(() => expect(result.current).toBeDefined());
6082
await waitFor(() => expect(result.current.data).toBeDefined());
6183

6284
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
63-
expect(result.current.data![0].allowed).toBe(true);
85+
expect(result.current.data!.canRead).toBe(true);
86+
});
87+
88+
it('returns allowed false when permission is invalid', async () => {
89+
getAuthenticatedHttpClient.mockReturnValue({
90+
post: jest.fn().mockResolvedValue({ data: mockInvalidSinglePermission }),
91+
});
92+
93+
const { result } = renderHook(() => useUserPermissions(singlePermission), {
94+
wrapper: createWrapper(),
95+
});
96+
await waitFor(() => expect(result.current).toBeDefined());
97+
await waitFor(() => expect(result.current.data).toBeDefined());
98+
99+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
100+
expect(result.current.data!.canRead).toBe(false);
101+
});
102+
103+
it('returns allowed true when multiple permissions are valid', async () => {
104+
getAuthenticatedHttpClient.mockReturnValue({
105+
post: jest.fn().mockResolvedValueOnce({ data: mockValidMultiplePermissions }),
106+
});
107+
108+
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
109+
wrapper: createWrapper(),
110+
});
111+
112+
await waitFor(() => expect(result.current).toBeDefined());
113+
await waitFor(() => expect(result.current.data).toBeDefined());
114+
115+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
116+
expect(result.current.data!.canRead).toBe(true);
117+
expect(result.current.data!.canWrite).toBe(true);
118+
});
119+
120+
it('returns allowed false when multiple permissions are invalid', async () => {
121+
getAuthenticatedHttpClient.mockReturnValue({
122+
post: jest.fn().mockResolvedValue({ data: mockInvalidMultiplePermissions }),
123+
});
124+
125+
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
126+
wrapper: createWrapper(),
127+
});
128+
await waitFor(() => expect(result.current).toBeDefined());
129+
await waitFor(() => expect(result.current.data).toBeDefined());
130+
131+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
132+
expect(result.current.data!.canRead).toBe(false);
133+
expect(result.current.data!.canWrite).toBe(false);
64134
});
65135

66-
it('returns allowed false when permissions are invalid', async () => {
136+
it('returns allowed false when the permission is not included in the server response', async () => {
67137
getAuthenticatedHttpClient.mockReturnValue({
68-
post: jest.fn().mockResolvedValue({ data: mockInvalidPermissions }),
138+
post: jest.fn().mockResolvedValue({ data: mockEmptyPermissions }),
69139
});
70140

71-
const { result } = renderHook(() => useValidateUserPermissions(permissions), {
141+
const { result } = renderHook(() => useUserPermissions(singlePermission), {
72142
wrapper: createWrapper(),
73143
});
74144
await waitFor(() => expect(result.current).toBeDefined());
75145
await waitFor(() => expect(result.current.data).toBeDefined());
76146

77147
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
78-
expect(result.current.data![0].allowed).toBe(false);
148+
expect(result.current.data!.canRead).toBe(false);
79149
});
80150

81151
it('handles error when the API call fails', async () => {
@@ -87,7 +157,7 @@ describe('useValidateUserPermissions', () => {
87157

88158
try {
89159
act(() => {
90-
renderHook(() => useValidateUserPermissions(permissions), {
160+
renderHook(() => useUserPermissions(singlePermission), {
91161
wrapper: createWrapper(),
92162
});
93163
});

src/authz/data/apiHooks.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { useQuery } from '@tanstack/react-query';
2-
import { PermissionValidationRequest, PermissionValidationResponse } from '@src/authz/types';
2+
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
33
import { validateUserPermissions } from './api';
44

55
const adminConsoleQueryKeys = {
66
all: ['authz'],
7-
permissions: (permissions: PermissionValidationRequest[]) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const,
7+
permissions: (permissions: PermissionValidationQuery) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const,
88
};
99

1010
/**
@@ -13,19 +13,23 @@ const adminConsoleQueryKeys = {
1313
* - Determine whether the current user can access certain object.
1414
* - Provide role-based rendering logic for UI components.
1515
*
16-
* @param permissions - The array of objects and actions to validate.
16+
* @param permissions - A key/value map of objects and actions to validate.
17+
* The key is an arbitrary string to identify the permission check,
18+
* and the value is an object containing the action and optional scope.
1719
*
1820
* @example
19-
* const { data } = useValidateUserPermissions([{
20-
"action": "act:read",
21-
"scope": "org:OpenedX"
22-
}]);
23-
* if (data[0].allowed) { ... }
21+
* const { isLoading, data } = useUserPermissions({
22+
* "canRead": {
23+
* "action": "act:read",
24+
* "scope": "org:OpenedX"
25+
* }
26+
* });
27+
* if (data.canRead) { ... }
2428
*
2529
*/
26-
export const useValidateUserPermissions = (
27-
permissions: PermissionValidationRequest[],
28-
) => useQuery<PermissionValidationResponse[], Error>({
30+
export const useUserPermissions = (
31+
permissions: PermissionValidationQuery,
32+
) => useQuery<PermissionValidationAnswer, Error>({
2933
queryKey: adminConsoleQueryKeys.permissions(permissions),
3034
queryFn: () => validateUserPermissions(permissions),
3135
retry: false,

src/authz/types.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
export interface PermissionValidationRequest {
1+
export interface PermissionValidationRequestItem {
22
action: string;
33
scope?: string;
44
}
55

6-
export interface PermissionValidationResponse extends PermissionValidationRequest {
6+
export interface PermissionValidationResponseItem extends PermissionValidationRequestItem {
77
allowed: boolean;
88
}
9+
10+
export interface PermissionValidationQuery {
11+
[permissionKey: string]: PermissionValidationRequestItem;
12+
}
13+
14+
export interface PermissionValidationAnswer {
15+
[permissionKey: string]: boolean;
16+
}

src/library-authoring/common/context/LibraryContext.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
useState,
88
} from 'react';
99
import { useParams } from 'react-router-dom';
10-
import { useValidateUserPermissions } from '@src/authz/data/apiHooks';
10+
import { useUserPermissions } from '@src/authz/data/apiHooks';
1111
import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz/constants';
1212
import { ContainerType } from '../../../generic/key-utils';
1313

@@ -16,10 +16,6 @@ import type { ContentLibrary, BlockTypeMetadata } from '../../data/api';
1616
import { useContentLibrary } from '../../data/apiHooks';
1717
import { useComponentPickerContext } from './ComponentPickerContext';
1818

19-
const LIBRARY_PERMISSIONS = [
20-
CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT,
21-
];
22-
2319
export interface ComponentEditorInfo {
2420
usageKey: string;
2521
blockType?:string
@@ -114,10 +110,13 @@ export const LibraryProvider = ({
114110
componentPickerMode,
115111
} = useComponentPickerContext();
116112

117-
const permissions = LIBRARY_PERMISSIONS.map(action => ({ action, scope: libraryId }));
118-
119-
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useValidateUserPermissions(permissions);
120-
const canPublish = userPermissions ? userPermissions[0]?.allowed : false;
113+
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
114+
canPublish: {
115+
action: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT,
116+
scope: libraryId,
117+
},
118+
});
119+
const canPublish = userPermissions?.canPublish || false;
121120
const readOnly = !!componentPickerMode || !libraryData?.canEditLibrary;
122121

123122
// Parse the initial collectionId and/or container ID(s) from the current URL params

0 commit comments

Comments
 (0)