-
Notifications
You must be signed in to change notification settings - Fork 10
feat: implement permission validation API and hooks for authz #269
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f4976dc
2c9c885
5d9ba2d
bd39028
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| # How to: Query Permissions from openedx-authz | ||
|
|
||
| ## Overview | ||
|
|
||
| `@openedx/frontend-base` provides hooks and utilities to validate user permissions against the | ||
| `openedx-authz` service. Results are cached automatically via TanStack Query to minimize calls | ||
| to the backend. | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| Ensure your app root is wrapped with a `QueryClientProvider` from `@tanstack/react-query`. | ||
|
|
||
| --- | ||
|
|
||
| ## Core Concepts | ||
|
|
||
| ### Permission query shape | ||
|
|
||
| Permissions are expressed as a key/value map where: | ||
| - **keys** are arbitrary semantic names you choose (e.g. `canEditGrading`) | ||
| - **values** describe the `action` string and optional `scope` (resource identifier) | ||
|
|
||
| ```typescript | ||
| import type { PermissionValidationQuery } from '@openedx/frontend-base'; | ||
|
|
||
| const query: PermissionValidationQuery = { | ||
| canViewGrading: { | ||
| action: 'courses.view_grading_settings', | ||
| scope: 'course-v1:org+course+run', | ||
| }, | ||
| canEditGrading: { | ||
| action: 'courses.edit_grading_settings', | ||
| scope: 'course-v1:org+course+run', | ||
| }, | ||
| }; | ||
| ``` | ||
|
|
||
| ### Caching | ||
|
|
||
| Results are cached using TanStack Query. The cache key includes the query object and the | ||
| resolved `apiBaseUrl`, so different backends and different permission sets are cached | ||
| independently. Results are reused across components that request the same permissions within | ||
| one session. | ||
|
|
||
| --- | ||
|
|
||
| ## `usePermissions` | ||
|
|
||
| The single hook for querying permissions. Requires a `featureEnabled` boolean — always | ||
| pass the resolved waffle flag value so the caller explicitly opts in or out of authz. | ||
| Permission keys are spread at the top level — no nested `.permissions` object. | ||
|
|
||
| ```typescript | ||
| import { usePermissions } from '@openedx/frontend-base'; | ||
|
|
||
| // featureEnabled is required — always pass the resolved waffle flag boolean: | ||
| const { enableAuthz } = useWaffleFlags(resourceId); | ||
| const { isLoading, isError, isAuthzEnabled, canViewGrading, canEditGrading } = usePermissions( | ||
| { | ||
| canViewGrading: { action: 'courses.view_grading_settings', scope: resourceId }, | ||
| canEditGrading: { action: 'courses.edit_grading_settings', scope: resourceId }, | ||
| }, | ||
| enableAuthz ?? false, | ||
| ); | ||
|
|
||
| if (isLoading) { return <LoadingSpinner />; } | ||
| if (isError) { return <ErrorAlert />; } | ||
| if (!canViewGrading) { return <PermissionDeniedAlert />; } | ||
| ``` | ||
|
|
||
| When `featureEnabled` is `false`: no API call is made and all keys return `true`, | ||
| preserving the pre-authz behavior during rollout. | ||
|
|
||
| To override the backend URL (e.g. MFEs using `@edx/frontend-platform`), pass `apiBaseUrl` | ||
| in the options argument: | ||
|
|
||
| ```typescript | ||
| import { usePermissions } from '@openedx/frontend-base'; | ||
| import { getConfig } from '@edx/frontend-platform'; | ||
|
|
||
| const { enableAuthz } = useWaffleFlags(courseId); | ||
| const { isLoading, isError, canViewGrading } = usePermissions( | ||
| { canViewGrading: { action: 'courses.view_grading_settings', scope: courseId } }, | ||
| enableAuthz ?? false, | ||
| { apiBaseUrl: getConfig().LMS_BASE_URL }, | ||
| ); | ||
| ``` | ||
|
|
||
| > **Service unavailability:** if the authz API call fails, `isError` is `true` and all | ||
| > permission keys resolve to `false`. Always check `isLoading` and `isError` before | ||
| > rendering gated UI to avoid incorrectly denying access during transient failures. | ||
|
|
||
| --- | ||
|
|
||
| ## Recommended: create an MFE-specific wrapper | ||
|
|
||
| Avoid calling `usePermissions` directly in every component. Create a single MFE-level | ||
| wrapper that encapsulates the waffle flag check and base URL: | ||
|
|
||
| ```typescript | ||
| import { usePermissions } from '@openedx/frontend-base'; | ||
| import { getConfig } from '@edx/frontend-platform'; | ||
| import { useWaffleFlags } from './waffleHooks'; // your MFE's waffle flag hook | ||
| import type { PermissionValidationQuery } from '@openedx/frontend-base'; | ||
|
|
||
| export const useResourcePermissions = <Query extends PermissionValidationQuery>( | ||
| resourceId: string, | ||
| permissions: Query, | ||
| ) => { | ||
| const { enableAuthz } = useWaffleFlags(resourceId); | ||
| return usePermissions( | ||
| permissions, | ||
| enableAuthz ?? false, | ||
| { apiBaseUrl: getConfig().LMS_BASE_URL }, | ||
| ); | ||
| }; | ||
|
|
||
| export const getResourcePermissions = (resourceId: string): PermissionValidationQuery => ({ | ||
| canView: { action: 'resources.view', scope: resourceId }, | ||
| canEdit: { action: 'resources.edit', scope: resourceId }, | ||
| }); | ||
|
|
||
| // Usage in any component: | ||
| const { isLoading, canView, canEdit } = | ||
| useResourcePermissions(resourceId, getResourcePermissions(resourceId)); | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Best Practices | ||
|
|
||
| - **Define permission constants** in your MFE (`COURSE_PERMISSIONS`, etc.) rather than | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it make sense to include the constants for the existing permissions here in frontend-base?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @rodmgwgu, thanks a lot for this call! According to what I assumed, the permissions are MFE-specific. For example, in authoring MFE, we use these permissions to manage a course. I don't think they'll be needed anywhere else, and I tried to avoid coupling The thing is, I agree there is a valid case for centralizing permissions: we should do it for those that will be needed across multiple MFEs, but I'm not sure either if this will be applied to other MFEs 😅 (I think it would be possible, for example, to frontend-app-instructor-dashboard) and, in case this will be, which permissions would qualify to be added in the repo What do you think? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we will need more high level discussion around this, currently the permissions in the backend are all defined in one place, openedx-authz, so having something similar in the frontend seems to make sense. However, the plan is to extract those out of openedx-authz and instead define them on whatever module needs to implement them. So keeping it per MFE makes sense for long term. |
||
| inline strings — prevents typos and makes global renames easy. | ||
| - **Use query builder helpers** (`getGradingPermissions(courseId)`) to build the query | ||
| object — keeps permission definitions co-located with the feature they belong to. | ||
| - **Do not duplicate `{ action, scope }` pairs** within a single query — only the first | ||
| matching key is mapped in the response. | ||
| - **Keep `featureEnabled` close to the flag source** — the boolean should come directly | ||
| from your waffle flag check, not be stored in state or passed through many layers. | ||
|
|
||
| --- | ||
|
|
||
| ## Manual Cache Invalidation | ||
|
|
||
| If user roles change mid-session and you need to force a refetch: | ||
|
|
||
| ```typescript | ||
| import { permissionsQueryKeys } from '@openedx/frontend-base'; | ||
| import { getConfig } from '@edx/frontend-platform'; | ||
|
|
||
| // Default — URL comes from getSiteConfig().lmsBaseUrl (set via mergeSiteConfig): | ||
| queryClient.invalidateQueries({ | ||
| queryKey: permissionsQueryKeys.validate(myQuery), | ||
| }); | ||
|
|
||
| // Explicit URL — use when you passed apiBaseUrl in UsePermissionsOptions: | ||
| queryClient.invalidateQueries({ | ||
| queryKey: permissionsQueryKeys.validate(myQuery, getConfig().LMS_BASE_URL), | ||
| }); | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import { getAuthenticatedHttpClient } from '../auth'; | ||
| import { validatePermissions, PERMISSIONS_VALIDATE_PATH } from './api'; | ||
|
|
||
| jest.mock('../auth', () => ({ | ||
| getAuthenticatedHttpClient: jest.fn(), | ||
| })); | ||
|
|
||
| const BASE_URL = 'http://lms.example.com'; | ||
| const QUERY = { | ||
| canRead: { action: 'example.read', scope: 'lib:org:test' }, | ||
| canWrite: { action: 'example.write', scope: 'lib:org:test' }, | ||
| }; | ||
|
|
||
| describe('validatePermissions', () => { | ||
| beforeEach(() => jest.clearAllMocks()); | ||
|
|
||
| it('posts to the correct URL', async () => { | ||
| const postMock = jest.fn().mockResolvedValue({ data: [] }); | ||
| (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ post: postMock }); | ||
|
|
||
| await validatePermissions(BASE_URL, QUERY); | ||
|
|
||
| expect(postMock).toHaveBeenCalledWith( | ||
| `${BASE_URL}${PERMISSIONS_VALIDATE_PATH}`, | ||
| expect.any(Array), | ||
| ); | ||
| }); | ||
|
|
||
| it('sends all query items as an array in the request body', async () => { | ||
| const postMock = jest.fn().mockResolvedValue({ data: [] }); | ||
| (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ post: postMock }); | ||
|
|
||
| await validatePermissions(BASE_URL, QUERY); | ||
|
|
||
| const body = postMock.mock.calls[0][1]; | ||
| expect(body).toHaveLength(2); | ||
| expect(body).toEqual(expect.arrayContaining([ | ||
| { action: 'example.read', scope: 'lib:org:test' }, | ||
| { action: 'example.write', scope: 'lib:org:test' }, | ||
| ])); | ||
| }); | ||
|
|
||
| it('maps response array back to caller keys', async () => { | ||
| (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ | ||
| post: jest.fn().mockResolvedValue({ | ||
| data: [ | ||
| { action: 'example.read', scope: 'lib:org:test', allowed: true }, | ||
| { action: 'example.write', scope: 'lib:org:test', allowed: false }, | ||
| ], | ||
| }), | ||
| }); | ||
|
|
||
| const result = await validatePermissions(BASE_URL, QUERY); | ||
|
|
||
| expect(result).toEqual({ canRead: true, canWrite: false }); | ||
| }); | ||
|
|
||
| it('defaults missing keys to false', async () => { | ||
| (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ | ||
| post: jest.fn().mockResolvedValue({ data: [] }), | ||
| }); | ||
|
|
||
| const result = await validatePermissions(BASE_URL, QUERY); | ||
|
|
||
| expect(result).toEqual({ canRead: false, canWrite: false }); | ||
| }); | ||
|
|
||
| it('defaults a partially missing key to false', async () => { | ||
| (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ | ||
| post: jest.fn().mockResolvedValue({ | ||
| data: [{ action: 'example.read', scope: 'lib:org:test', allowed: true }], | ||
| }), | ||
| }); | ||
|
|
||
| const result = await validatePermissions(BASE_URL, QUERY); | ||
|
|
||
| expect(result.canRead).toBe(true); | ||
| expect(result.canWrite).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { getAuthenticatedHttpClient } from '../auth'; | ||
| import type { | ||
| PermissionValidationQuery, | ||
| PermissionValidationAnswer, | ||
| PermissionValidationRequestItem, | ||
| PermissionValidationResponseItem, | ||
| } from './types'; | ||
|
|
||
| export const PERMISSIONS_VALIDATE_PATH = '/api/authz/v1/permissions/validate/me'; | ||
|
|
||
| /** | ||
| * Validates whether the currently authenticated user holds the requested permissions | ||
| * against the openedx-authz backend. | ||
| * | ||
| * @param apiBaseUrl - Base URL of the backend running openedx-authz (e.g. getConfig().LMS_BASE_URL). | ||
| * @param query - Key/value map of permission check descriptors. | ||
| * @returns Map of the same keys to boolean allowed values. | ||
| * Any key absent from the server response resolves to false. | ||
| */ | ||
| export const validatePermissions = async <Query extends PermissionValidationQuery>( | ||
| apiBaseUrl: string, | ||
| query: Query, | ||
| ): Promise<PermissionValidationAnswer<Query>> => { | ||
| const request: PermissionValidationRequestItem[] = Object.values(query); | ||
|
|
||
| const { data }: { data: PermissionValidationResponseItem[] } | ||
| = await getAuthenticatedHttpClient().post( | ||
| `${apiBaseUrl}${PERMISSIONS_VALIDATE_PATH}`, | ||
| request, | ||
| ); | ||
|
|
||
| const result = {} as PermissionValidationAnswer<Query>; | ||
|
|
||
| for (const [key, reqItem] of Object.entries(query) as [keyof Query, PermissionValidationRequestItem][]) { | ||
| const match = data.find( | ||
| (item) => item.action === reqItem.action && item.scope === reqItem.scope, | ||
| ); | ||
| result[key] = match ? match.allowed : false; | ||
| } | ||
| return result; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks useful and generic enough, could we add it to frontend-base?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is intentionally a recommended pattern rather than a reusable hook, mainly because it references two things that are MFE-specific: the waffle flag name (
enableAuthzCourseAuthoringin Authoring MFE), and the waffle flag hook (useWaffleFlags, which in this case hits to an endpoint in the studio). I think neither of those can live infrontend-basewithout coupling the library to a specific MFE's implementationWhat do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right, although the flag is just one now (for course authoring), there could be more in the future, so keeping it as a recommendation is fine, thanks!