Skip to content

Commit f4976dc

Browse files
committed
feat: implement permission validation API and hooks for authz
1 parent ccb276f commit f4976dc

8 files changed

Lines changed: 505 additions & 0 deletions

File tree

docs/how_tos/permissions.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# How to: Query Permissions from openedx-authz
2+
3+
## Overview
4+
5+
`@openedx/frontend-base` provides hooks and utilities to validate user permissions against the
6+
`openedx-authz` service. Results are cached automatically via TanStack Query to minimize calls
7+
to the backend.
8+
9+
## Prerequisites
10+
11+
Ensure your app root is wrapped with a `QueryClientProvider` from `@tanstack/react-query`.
12+
13+
---
14+
15+
## Core Concepts
16+
17+
### Permission query shape
18+
19+
Permissions are expressed as a key/value map where:
20+
- **keys** are arbitrary semantic names you choose (e.g. `canEditGrading`)
21+
- **values** describe the `action` string and optional `scope` (resource identifier)
22+
23+
```typescript
24+
import type { PermissionValidationQuery } from '@openedx/frontend-base';
25+
26+
const query: PermissionValidationQuery = {
27+
canViewGrading: {
28+
action: 'courses.view_grading_settings',
29+
scope: 'course-v1:org+course+run',
30+
},
31+
canEditGrading: {
32+
action: 'courses.edit_grading_settings',
33+
scope: 'course-v1:org+course+run',
34+
},
35+
};
36+
```
37+
38+
### Caching
39+
40+
Results are cached using TanStack Query. The cache key includes the query object and the
41+
resolved `apiBaseUrl`, so different backends and different permission sets are cached
42+
independently. Results are reused across components that request the same permissions within
43+
one session.
44+
45+
---
46+
47+
## `usePermissions`
48+
49+
The single hook for querying permissions. Requires a `featureEnabled` boolean — always
50+
pass the resolved waffle flag value so the caller explicitly opts in or out of authz.
51+
Permission keys are spread at the top level — no nested `.permissions` object.
52+
53+
```typescript
54+
import { usePermissions } from '@openedx/frontend-base';
55+
import { getConfig } from '@edx/frontend-platform';
56+
57+
// featureEnabled is required — always pass the resolved waffle flag boolean:
58+
const { enableAuthz } = useWaffleFlags(resourceId);
59+
const { isLoading, isAuthzEnabled, canViewGrading, canEditGrading } = usePermissions(
60+
{
61+
canViewGrading: { action: 'courses.view_grading_settings', scope: resourceId },
62+
canEditGrading: { action: 'courses.edit_grading_settings', scope: resourceId },
63+
},
64+
enableAuthz ?? false,
65+
);
66+
67+
// Override the backend URL (e.g. MFEs using @edx/frontend-platform):
68+
const { isLoading, canViewGrading } = usePermissions(
69+
{ canViewGrading: { action: 'courses.view_grading_settings', scope: courseId } },
70+
enableAuthz ?? false,
71+
{ apiBaseUrl: getConfig().LMS_BASE_URL },
72+
);
73+
74+
if (!canViewGrading) { return <PermissionDeniedAlert />; }
75+
```
76+
77+
When `featureEnabled` is `false`: no API call is made and all keys return `true`,
78+
preserving the pre-authz behavior during rollout.
79+
80+
---
81+
82+
## Recommended: create an MFE-specific wrapper
83+
84+
Avoid calling `usePermissions` directly in every component. Create a single MFE-level
85+
wrapper that encapsulates the waffle flag check and base URL:
86+
87+
```typescript
88+
import { usePermissions } from '@openedx/frontend-base';
89+
import { getConfig } from '@edx/frontend-platform';
90+
import { useWaffleFlags } from './waffleHooks'; // your MFE's waffle flag hook
91+
import type { PermissionValidationQuery } from '@openedx/frontend-base';
92+
93+
export const useResourcePermissions = <Query extends PermissionValidationQuery>(
94+
resourceId: string,
95+
permissions: Query,
96+
) => {
97+
const { enableAuthz } = useWaffleFlags(resourceId);
98+
return usePermissions(
99+
permissions,
100+
enableAuthz ?? false,
101+
{ apiBaseUrl: getConfig().LMS_BASE_URL },
102+
);
103+
};
104+
105+
export const getResourcePermissions = (resourceId: string): PermissionValidationQuery => ({
106+
canView: { action: 'resources.view', scope: resourceId },
107+
canEdit: { action: 'resources.edit', scope: resourceId },
108+
});
109+
110+
// Usage in any component:
111+
const { isLoading, canView, canEdit } =
112+
useResourcePermissions(resourceId, getResourcePermissions(resourceId));
113+
```
114+
115+
---
116+
117+
## Best Practices
118+
119+
- **Define permission constants** in your MFE (`COURSE_PERMISSIONS`, etc.) rather than
120+
inline strings — prevents typos and makes global renames easy.
121+
- **Use query builder helpers** (`getGradingPermissions(courseId)`) to build the query
122+
object — keeps permission definitions co-located with the feature they belong to.
123+
- **Do not duplicate `{ action, scope }` pairs** within a single query — only the first
124+
matching key is mapped in the response.
125+
- **Keep `featureEnabled` close to the flag source** — the boolean should come directly
126+
from your waffle flag check, not be stored in state or passed through many layers.
127+
128+
---
129+
130+
## Manual Cache Invalidation
131+
132+
If user roles change mid-session and you need to force a refetch:
133+
134+
```typescript
135+
import { permissionsQueryKeys } from '@openedx/frontend-base';
136+
import { getConfig } from '@edx/frontend-platform';
137+
138+
// Default — URL comes from getSiteConfig().lmsBaseUrl (set via mergeSiteConfig):
139+
queryClient.invalidateQueries({
140+
queryKey: permissionsQueryKeys.validate(myQuery),
141+
});
142+
143+
// Explicit URL — use when you passed apiBaseUrl in UsePermissionsOptions:
144+
queryClient.invalidateQueries({
145+
queryKey: permissionsQueryKeys.validate(myQuery, getConfig().LMS_BASE_URL),
146+
});
147+
```

runtime/authz/api.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { getAuthenticatedHttpClient } from '../auth';
2+
import { validatePermissions, PERMISSIONS_VALIDATE_PATH } from './api';
3+
4+
jest.mock('../auth', () => ({
5+
getAuthenticatedHttpClient: jest.fn(),
6+
}));
7+
8+
const BASE_URL = 'http://lms.example.com';
9+
const QUERY = {
10+
canRead: { action: 'example.read', scope: 'lib:org:test' },
11+
canWrite: { action: 'example.write', scope: 'lib:org:test' },
12+
};
13+
14+
describe('validatePermissions', () => {
15+
beforeEach(() => jest.clearAllMocks());
16+
17+
it('posts to the correct URL', async () => {
18+
const postMock = jest.fn().mockResolvedValue({ data: [] });
19+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ post: postMock });
20+
21+
await validatePermissions(BASE_URL, QUERY);
22+
23+
expect(postMock).toHaveBeenCalledWith(
24+
`${BASE_URL}${PERMISSIONS_VALIDATE_PATH}`,
25+
expect.any(Array),
26+
);
27+
});
28+
29+
it('sends all query items as an array in the request body', async () => {
30+
const postMock = jest.fn().mockResolvedValue({ data: [] });
31+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ post: postMock });
32+
33+
await validatePermissions(BASE_URL, QUERY);
34+
35+
const body = postMock.mock.calls[0][1];
36+
expect(body).toHaveLength(2);
37+
expect(body).toEqual(expect.arrayContaining([
38+
{ action: 'example.read', scope: 'lib:org:test' },
39+
{ action: 'example.write', scope: 'lib:org:test' },
40+
]));
41+
});
42+
43+
it('maps response array back to caller keys', async () => {
44+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
45+
post: jest.fn().mockResolvedValue({
46+
data: [
47+
{ action: 'example.read', scope: 'lib:org:test', allowed: true },
48+
{ action: 'example.write', scope: 'lib:org:test', allowed: false },
49+
],
50+
}),
51+
});
52+
53+
const result = await validatePermissions(BASE_URL, QUERY);
54+
55+
expect(result).toEqual({ canRead: true, canWrite: false });
56+
});
57+
58+
it('defaults missing keys to false', async () => {
59+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
60+
post: jest.fn().mockResolvedValue({ data: [] }),
61+
});
62+
63+
const result = await validatePermissions(BASE_URL, QUERY);
64+
65+
expect(result).toEqual({ canRead: false, canWrite: false });
66+
});
67+
68+
it('defaults a partially missing key to false', async () => {
69+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
70+
post: jest.fn().mockResolvedValue({
71+
data: [{ action: 'example.read', scope: 'lib:org:test', allowed: true }],
72+
}),
73+
});
74+
75+
const result = await validatePermissions(BASE_URL, QUERY);
76+
77+
expect(result.canRead).toBe(true);
78+
expect(result.canWrite).toBe(false);
79+
});
80+
});

runtime/authz/api.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { getAuthenticatedHttpClient } from '../auth';
2+
import type {
3+
PermissionValidationQuery,
4+
PermissionValidationAnswer,
5+
PermissionValidationRequestItem,
6+
PermissionValidationResponseItem,
7+
} from './types';
8+
9+
export const PERMISSIONS_VALIDATE_PATH = '/api/authz/v1/permissions/validate/me';
10+
11+
/**
12+
* Validates whether the currently authenticated user holds the requested permissions
13+
* against the openedx-authz backend.
14+
*
15+
* @param apiBaseUrl - Base URL of the backend running openedx-authz (e.g. getConfig().LMS_BASE_URL).
16+
* @param query - Key/value map of permission check descriptors.
17+
* @returns Map of the same keys to boolean allowed values.
18+
* Any key absent from the server response resolves to false.
19+
*
20+
* Known limitation: if two entries in the query share identical { action, scope },
21+
* only the first matching key is mapped. Do not duplicate { action, scope } pairs.
22+
*/
23+
export const validatePermissions = async <Query extends PermissionValidationQuery>(
24+
apiBaseUrl: string,
25+
query: Query,
26+
): Promise<PermissionValidationAnswer<Query>> => {
27+
const request: PermissionValidationRequestItem[] = Object.values(query);
28+
29+
const { data }: { data: PermissionValidationResponseItem[] }
30+
= await getAuthenticatedHttpClient().post(
31+
`${apiBaseUrl}${PERMISSIONS_VALIDATE_PATH}`,
32+
request,
33+
);
34+
35+
const result = {} as PermissionValidationAnswer<Query>;
36+
37+
data.forEach((item) => {
38+
const key = Object.keys(query).find(
39+
(k) => query[k].action === item.action && query[k].scope === item.scope,
40+
) as keyof Query | undefined;
41+
if (key !== undefined) {
42+
result[key] = item.allowed;
43+
}
44+
});
45+
46+
// Default any key absent from the server response to false
47+
(Object.keys(query) as (keyof Query)[]).forEach((key) => {
48+
if (!(key in result)) {
49+
result[key] = false;
50+
}
51+
});
52+
53+
return result;
54+
};

runtime/authz/hooks.test.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import React from 'react';
2+
import { renderHook, waitFor } from '@testing-library/react';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4+
import { getAuthenticatedHttpClient } from '../auth';
5+
import { usePermissions, permissionsQueryKeys } from './hooks';
6+
7+
jest.mock('../auth', () => ({
8+
getAuthenticatedHttpClient: jest.fn(),
9+
}));
10+
11+
const BASE_URL = 'http://lms.example.com';
12+
const QUERY = {
13+
canView: { action: 'courses.view_grading_settings', scope: 'course-v1:org+course+run' },
14+
canEdit: { action: 'courses.edit_grading_settings', scope: 'course-v1:org+course+run' },
15+
};
16+
17+
const createWrapper = () => {
18+
const queryClient = new QueryClient({
19+
defaultOptions: { queries: { retry: false } },
20+
});
21+
return ({ children }: { children: React.ReactNode }) => (
22+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
23+
);
24+
};
25+
26+
describe('usePermissions', () => {
27+
beforeEach(() => jest.clearAllMocks());
28+
29+
it('returns actual server values when featureEnabled is true', async () => {
30+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
31+
post: jest.fn().mockResolvedValue({
32+
data: [
33+
{ action: 'courses.view_grading_settings', scope: 'course-v1:org+course+run', allowed: true },
34+
{ action: 'courses.edit_grading_settings', scope: 'course-v1:org+course+run', allowed: false },
35+
],
36+
}),
37+
});
38+
39+
const { result } = renderHook(
40+
() => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
41+
{ wrapper: createWrapper() },
42+
);
43+
await waitFor(() => expect(result.current.isLoading).toBe(false));
44+
45+
expect(result.current.canView).toBe(true);
46+
expect(result.current.canEdit).toBe(false);
47+
expect(result.current.isAuthzEnabled).toBe(true);
48+
});
49+
50+
it('returns all keys as true and makes no API call when featureEnabled is false', () => {
51+
const postMock = jest.fn();
52+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ post: postMock });
53+
54+
const { result } = renderHook(
55+
() => usePermissions(QUERY, false, { apiBaseUrl: BASE_URL }),
56+
{ wrapper: createWrapper() },
57+
);
58+
59+
expect(postMock).not.toHaveBeenCalled();
60+
expect(result.current.canView).toBe(true);
61+
expect(result.current.canEdit).toBe(true);
62+
expect(result.current.isLoading).toBe(false);
63+
expect(result.current.isAuthzEnabled).toBe(false);
64+
});
65+
66+
it('defaults absent server keys to false when featureEnabled is true', async () => {
67+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
68+
post: jest.fn().mockResolvedValue({ data: [] }),
69+
});
70+
71+
const { result } = renderHook(
72+
() => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
73+
{ wrapper: createWrapper() },
74+
);
75+
await waitFor(() => expect(result.current.isLoading).toBe(false));
76+
77+
expect(result.current.canView).toBe(false);
78+
expect(result.current.canEdit).toBe(false);
79+
});
80+
81+
it('spreads permission keys at the top level — no nested .permissions object', async () => {
82+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
83+
post: jest.fn().mockResolvedValue({
84+
data: [
85+
{ action: 'courses.view_grading_settings', scope: 'course-v1:org+course+run', allowed: true },
86+
],
87+
}),
88+
});
89+
90+
const { result } = renderHook(
91+
() => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
92+
{ wrapper: createWrapper() },
93+
);
94+
await waitFor(() => expect(result.current.isLoading).toBe(false));
95+
96+
expect('canView' in result.current).toBe(true);
97+
expect('permissions' in result.current).toBe(false);
98+
});
99+
100+
it('scopes cache by apiBaseUrl — different base URLs produce distinct query keys', () => {
101+
const keyA = permissionsQueryKeys.validate(QUERY, 'http://lms-a.example.com');
102+
const keyB = permissionsQueryKeys.validate(QUERY, 'http://lms-b.example.com');
103+
expect(keyA).not.toEqual(keyB);
104+
});
105+
});

0 commit comments

Comments
 (0)