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
160 changes: 160 additions & 0 deletions docs/how_tos/permissions.md
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>(
Copy link
Copy Markdown

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?

Copy link
Copy Markdown
Author

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 (enableAuthzCourseAuthoring in 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 in frontend-base without coupling the library to a specific MFE's implementation

What do you think?

Copy link
Copy Markdown

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!

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Author

@bra-i-am bra-i-am May 26, 2026

Choose a reason for hiding this comment

The 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 frontend-base to specific MFE permissions

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?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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),
});
```
80 changes: 80 additions & 0 deletions runtime/authz/api.test.ts
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);
});
});
41 changes: 41 additions & 0 deletions runtime/authz/api.ts
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;
};
Loading