Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ config.parserOptions = {

config.rules = {
...config.rules,
'react/jsx-filename-extension': ['error', { extensions: ['.jsx', '.tsx'] }],
'import/extensions': ['error', 'ignorePackages', {
js: 'never',
jsx: 'never',
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ jobs:
run: npm run test
- name: Build
run: npm run build
- name: i18n_extract
run: npm run i18n_extract
- name: Coverage
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
Expand Down
148 changes: 148 additions & 0 deletions docs/how_tos/permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# How to: Query Permissions from openedx-authz

## Overview

`@openedx/frontend-authz` 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.

## Available permissions

The full list of actions and scopes supported by the `openedx-authz` service is documented here:
https://docs.openedx.org/projects/openedx-authz/en/latest/concepts/core_roles_and_permissions/index.html

Use those action strings in your `PermissionValidationQuery` values.

## 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-authz';

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-authz';
import { getConfig } from '@edx/frontend-platform';

// 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,
{ apiBaseUrl: getConfig().LMS_BASE_URL },
);

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.

> **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-authz';
import { getConfig } from '@edx/frontend-platform';
import { useWaffleFlags } from './waffleHooks'; // your MFE's waffle flag hook
import type { PermissionValidationQuery } from '@openedx/frontend-authz';

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
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-authz';
import { getConfig } from '@edx/frontend-platform';

queryClient.invalidateQueries({
queryKey: permissionsQueryKeys.validate(myQuery, getConfig().LMS_BASE_URL),
});
```
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 80 additions & 0 deletions src/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

@dcoa dcoa Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wee need to pass this as dependency @edx/frontend-platform does not exist in frontend-base word and will fail.

Something like configureAuthz function or provider. Or you need to wrap the import in a try/catch.

import { validatePermissions, PERMISSIONS_VALIDATE_PATH } from './api';

jest.mock('@edx/frontend-platform/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);
});
});
32 changes: 32 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import type {
PermissionValidationQuery,
PermissionValidationAnswer,
PermissionValidationRequestItem,
PermissionValidationResponseItem,
} from './types';

export const PERMISSIONS_VALIDATE_PATH = '/api/authz/v1/permissions/validate/me';

export const validatePermissions = async <Query extends PermissionValidationQuery>(
apiBaseUrl: string,
query: Query,
): Promise<PermissionValidationAnswer<Query>> => {
const request: PermissionValidationRequestItem[] = Object.values(query);

const response = await getAuthenticatedHttpClient().post(
`${apiBaseUrl}${PERMISSIONS_VALIDATE_PATH}`,
request,
);
const data = response.data as PermissionValidationResponseItem[];

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;
};
Loading
Loading