Skip to content
Draft
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>(
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-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