Skip to content
Merged
161 changes: 161 additions & 0 deletions docs/decisions/0003-course-authoring-flag-enforcement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
3. Course Authoring Flag Enforcement in the Admin Console
---------------------------------------------------------

Status
######

Proposed

.. note::

This is a **temporary** measure for the flag-gated rollout period. It is expected to be removed and re-evaluated once the authz system is enabled by default and `authz.enable_course_authoring` is deprecated (see `ADR 0010`_). See *Temporary nature and future work* below.

Context
#######

The authorization backend (`openedx-authz`) is rolling out the course authoring domain behind the Waffle flag `authz.enable_course_authoring`. The flag can be enabled globally (instance-wide), per organization, or per course.

The rollout is defined by the following backend decisions:

* `ADR 0010 - Course Authoring Flag`_ introduces the multi-level Waffle flag.
* `ADR 0013 - Course Authoring Automatic Migration`_ makes the flag the source of truth. When the flag changes for a scope, role assignments migrate between the legacy `CourseAccessRole` model and the new authz (Casbin) system.
* `ADR 0007 - Enforcement Mechanisms (MFEs)`_ establishes that frontends enforce authorization by querying the backend rather than deriving policy from tokens.

The Admin Console manages role assignments for two authorization domains:

* **Content libraries**, which are always enabled.
* **Course authoring**, which is gated by the feature flag.

When course authoring is disabled for a scope, the UI must not expose that domain. Specifically:

* The **Scope** filter must not list courses where authoring is disabled.
* The **Role** filter must not list course-authoring roles when the domain is disabled.
* The **Organization** filter must not hide organizations that are reachable through libraries, but it must exclude organizations that expose only disabled course authoring.
* The **assignment lists** must not display users or assignments that belong only to course authoring. Users with both library and authoring roles should display only their library roles.

Because the flag is evaluated per scope, a single instance-wide boolean is insufficient. For example, the global flag may be disabled while a specific organization or course is enabled, and vice versa. The frontend must therefore resolve enablement for the specific scope it is rendering.

Enablement must also remain independent of permission validation.

A natural implementation would infer whether course authoring is enabled from `/permissions/validate/me`. However, permission validation reflects Casbin role assignments, while enablement reflects the current Waffle flag. These sources can temporarily diverge.

As described in `ADR 0013`_, Casbin assignments exist only after migration has been executed. Automatic migration is disabled by default (`ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION = False`). Even when enabled, only organization-level and course-level changes migrate automatically; global flag changes still require manual migration commands.

Consequently, a runtime change to the global flag may not be reflected in the permission data returned by the authorization APIs. During rollout, the frontend must therefore determine whether the course authoring domain should be displayed from the flag state itself rather than from permission validation.

`Issue #340`_ and `Issue #341`_ reported that the Admin Console showed course-authoring roles and scopes even when the flag was disabled at every level. `PR #361`_ attempted to enforce the full per-scope truth table directly inside ``PermissionValidationMeView`` and other release-blocking permission endpoints, but correctness and performance across the framework could not be validated in time for the Verawood release, so the approach was reverted. The dedicated ``waffle-flag-states`` endpoint (see `ADR 0015`_) was introduced instead as an additive, isolated alternative that leaves those endpoints untouched.

To support this, the backend exposes `GET /api/authz/v1/waffle-flag-states/`::


{
"global": false,
"org_overrides": { "on": ["Demo"], "off": [] },
"course_overrides": {
"on": [],
"off": ["course-v1:testing+CT01+CT01-2024"]
}
}


The endpoint returns the global flag value together with only those organization and course overrides whose value differs from the global flag. Because overrides are separated into `on` and `off` lists, each scope's effective value can be resolved without ambiguity while preserving override precedence.

Decision
########

**1. Consume flag state through a single cached hook.**

The frontend exposes `getCourseAuthoringFlagStates` and the React Query hook `useCourseAuthoringFlagStates` to fetch the endpoint. Because waffle flag states change rarely (set by operators, not by end users), the hook is configured with a 30-minute `staleTime` and `refetchOnWindowFocus: false` to avoid unnecessary requests, and `retry: false` to surface failures immediately.

**2. Centralize enablement resolution.**

A derived hook, `useCourseAuthoringFlag`, encapsulates all resolution logic. Components consume only its public API:

* `isCourseAuthoringEnabled` — returns whether the authoring domain is enabled anywhere (globally or through any `on` override). This is the "any tier on" rule from `Issue #340`_ and `Issue #341`_ and is used for coarse domain-level gating.
* `isCourseEnabled(courseId)` — resolves enablement using precedence: **course override -> organization override -> global**.
* `isOrgAuthoringEnabled(org)` — returns `true` when the organization is explicitly enabled, has at least one course with an explicit `on` override, or inherits a global `on` value without an explicit organization `off` override.

`isCourseEnabled` and `isOrgAuthoringEnabled` implement the fuller per-scope cascade (course override → organization override → global). Applying this logic client-side is safe because it does not touch the release-blocking permission endpoints that motivated the revert of `PR #361`_.

While flag states are loading, all resolvers return `false` so course authoring remains hidden until enablement is known. If the fetch fails, all resolvers also return `false` and a single error toast with a retry action is surfaced regardless of how many components consume the hook concurrently.

**3. Apply gating only to client-owned UI elements.**

* **Role filter:** course-authoring roles require both the corresponding view permission and `isCourseAuthoringEnabled`.
* **Scope filter:** course options are filtered with `isCourseEnabled`; library scopes are always included.
* **Organization filter:** organizations are filtered with `isOrgAuthoringEnabled` only for users without library-view permissions. Users with library access—and all users while permissions or flag states are loading—see all organizations.
* **Assignment wizard:** assignable course roles require both the appropriate management permission and `isCourseAuthoringEnabled`.
* **Assignment table row actions:** the per-row view and delete actions are disabled — with a tooltip pointing users to Studio — when the row's course scope resolves to disabled via `isCourseEnabled`. Library rows are unaffected.

**4. Leave paginated assignment data to the backend.**

The role, scope, and organization filters operate on small client-owned data sets, making client-side filtering appropriate.

The assignment lists (`/assignments/` and the per-user assignment view) are server-driven and paginated. Client-side filtering would invalidate pagination, counts, and sorting. The backend therefore remains responsible for hiding authoring-only assignments. As described in `ADR 0013`_, only authoring assignments for enabled scopes are expected to exist in Casbin, so the frontend renders the backend response without additional filtering.

Implications and Assumptions
############################

Behavior depends on `ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION` (`ADR 0013`_).

**When the setting is enabled (`True`)**

* Organization- and course-level flag changes synchronously migrate role assignments between the legacy model and Casbin, keeping authorization data largely aligned with the flag.
* Global flag changes are **not** migrated automatically. The flag-state endpoint immediately reflects the new value, but Casbin data does not until migration commands are executed.
* Migration may fail or complete only partially (`ADR 0013`_), creating temporary inconsistencies between flag state and authorization data.

**When the setting is disabled (default, `False`)**

* Flag changes never migrate authorization data automatically.
* The flag-state endpoint reflects runtime configuration, while Casbin continues to reflect the most recent manual migration.
* The frontend uses the flag only to determine which UI controls are available. Paginated assignment lists continue to reflect Casbin and may therefore appear stale. The frontend cannot hide such rows without breaking pagination, but it disables their row actions (see Decision 3) so stale course assignments cannot be managed from the console.

Assumptions
***********

* `waffle-flag-states` is the authoritative runtime source of enablement.
* Permission validation and assignment lists reflect Casbin state, which may lag behind the current flag configuration.
* Content libraries are never gated by this flag.
* The frontend mirrors the backend's course-key parsing and override precedence (course -> organization -> global).

Rejected Alternatives
#####################

**Enforcing per-scope flag logic in release-blocking permission API endpoints (PR #361)**
This would have been the authoritative approach: the permission endpoints themselves would check the flag per scope and suppress disabled scopes from their responses. However, correctness and performance across the whole framework could not be validated in time for the Verawood release, per `PR #361's own comment thread`_. The per-scope logic (``is_scope_visible`` / ``has_visible_scope``) remains documented on the `PR #361`_ branch for a future cycle.

**Inferring flag state from** ``/permissions/validate/me``
Permission validation reflects Casbin role assignments, not the current Waffle flag value. As described in the Context section, these sources can temporarily diverge: Casbin data is populated by migration commands that may not run immediately (or at all when `ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION = False`). Relying on permission data to infer enablement would produce stale or incorrect UI state during and after flag changes.

Temporary nature and future work
################################

This enforcement exists only for the feature-flag rollout period. It is required because enablement can temporarily diverge from migrated authorization data.

Once the authz system is enabled by default and `authz.enable_course_authoring` is deprecated (`ADR 0010`_), enablement will be unconditional. At that point, the `waffle-flag-states` endpoint, `useCourseAuthoringFlag`, and all feature-flag-based UI gating should be removed, allowing the console to rely solely on permission validation.

Once the release-blocking permission endpoints can safely enforce per-scope flag logic (`PR #361`_), the client-side filtering in `isCourseEnabled` / `isOrgAuthoringEnabled` can also be removed in favor of authoritative server-side enforcement.

References
##########

* `ADR 0010 - Course Authoring Flag`_
* `ADR 0013 - Course Authoring Automatic Migration`_
* `ADR 0007 - Enforcement Mechanisms (MFEs)`_
* `ADR 0015 - Expose Course-Authoring Waffle Flag State via REST API`_
* `Issue #340`_ and `Issue #341`_ — admin console shows authoring roles/scopes when flag is off
* `PR #358`_ — implements the ``waffle-flag-states`` endpoint
* `PR #361`_ — reverted attempt to enforce per-scope logic in permission endpoints

.. _ADR 0010 - Course Authoring Flag: https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0010-course-authoring-flag.rst
.. _ADR 0010: https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0010-course-authoring-flag.rst
.. _ADR 0013 - Course Authoring Automatic Migration: https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0013-course-authoring-automatic-migration.rst
.. _ADR 0013: https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0013-course-authoring-automatic-migration.rst
.. _ADR 0007 - Enforcement Mechanisms (MFEs): https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0007-enforcement-mechanisms-mfe.rst
.. _ADR 0015 - Expose Course-Authoring Waffle Flag State via REST API: https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst
.. _ADR 0015: https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst
.. _Issue #340: https://github.com/openedx/openedx-authz/issues/340
.. _Issue #341: https://github.com/openedx/openedx-authz/issues/341
.. _PR #358: https://github.com/openedx/openedx-authz/pull/358
.. _PR #361: https://github.com/openedx/openedx-authz/pull/361
.. _PR #361's own comment thread: https://github.com/openedx/openedx-authz/pull/361#issuecomment-4967053225
75 changes: 74 additions & 1 deletion src/authz-module/audit-user/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { mockHttpClient, mockAppContext } from '@src/setupTest';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext';
import { useUserAccount } from '@src/data/hooks';
import { useUserAccount, useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import { useUserAssignedRoles } from '@src/authz-module/data/hooks';
import AuditUserPage from './index';

Expand Down Expand Up @@ -38,6 +38,24 @@ jest.mock('@src/data/hooks', () => ({

// Mock the useRevokeUserRoles hook
const mockRevokeUserRoles = jest.fn();
jest.mock('@src/authz-module/hooks/useViewTeamPermissions', () => ({
useViewTeamPermissions: () => ({
isCourseViewAllowed: true,
isLibraryViewAllowed: true,
isLoading: false,
}),
}));

const mockIsCourseEnabled = jest.fn(() => true);
jest.mock('@src/authz-module/hooks/useCourseAuthoringFlag', () => ({
useCourseAuthoringFlag: () => ({
isCourseAuthoringEnabled: true,
isCourseEnabled: mockIsCourseEnabled,
isOrgAuthoringEnabled: () => true,
isLoading: false,
}),
}));

jest.mock('@src/authz-module/data/hooks', () => ({
...jest.requireActual('@src/authz-module/data/hooks'),
useRevokeUserRoles: () => ({
Expand Down Expand Up @@ -97,6 +115,7 @@ const renderWithRouter = (route = '/audit/johndoe') => {
describe('AuditUserPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockIsCourseEnabled.mockReturnValue(true);
// Set up default mock behavior for useRevokeUserRoles
mockRevokeUserRoles.mockImplementation((_variables, { onSuccess }) => {
// Simulate successful deletion by default
Expand Down Expand Up @@ -553,4 +572,58 @@ describe('AuditUserPage', () => {
expect(screen.getByText('Home Page')).toBeInTheDocument();
});
});

describe('course authoring flag', () => {
const courseScope = 'course-v1:TestOrg+C101+2026';
const mockCourseAssignments = {
count: 1,
results: [
{
id: '1',
role: 'course_staff',
org: 'Test Org',
scope: courseScope,
permissionCount: 5,
},
],
next: null,
previous: null,
};

beforeEach(() => {
(useUserAccount as jest.Mock).mockReturnValue({
data: mockUser,
isLoading: false,
isError: false,
error: null,
});
(useUserAssignedRoles as jest.Mock).mockReturnValue({
data: mockCourseAssignments,
isLoading: false,
});
(useValidateUserPermissionsNonSuspense as jest.Mock).mockReturnValue({
data: [{ scope: courseScope, allowed: true }],
isLoading: false,
});
});

it('disables the delete action for a course assignment when authoring is disabled for the course', async () => {
mockIsCourseEnabled.mockReturnValue(false);

renderWithRouter();

await waitFor(() => {
expect(screen.getByRole('button', { name: /delete role action/i })).toBeDisabled();
});
expect(mockIsCourseEnabled).toHaveBeenCalledWith(courseScope);
});

it('keeps the delete action enabled for a course assignment when authoring is enabled for the course', async () => {
renderWithRouter();

await waitFor(() => {
expect(screen.getByRole('button', { name: /delete role action/i })).toBeEnabled();
});
});
});
});
17 changes: 15 additions & 2 deletions src/authz-module/audit-user/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import {
import AuthZLayout from '@src/authz-module/components/AuthZLayout';
import { useNavigate, useParams } from 'react-router-dom';
import { useUserAccount, useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import { LIBRARY_ROLE_KEYS } from '@src/authz-module/roles-permissions';
import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions';
import baseMessages from '@src/authz-module/messages';
import AddRoleButton from '@src/authz-module/components/AddRoleButton';
import {
OrgCell, RoleCell, ScopeCell, PermissionsCell, ViewAllPermissionsCell,
createActionsCell,
} from '@src/authz-module/components/TableCells';
import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings';
import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag';
import { useRevokeUserRoles, useUserAssignedRoles } from '@src/authz-module/data/hooks';
import { RoleToDelete } from '@src/types';
import { useToastManager } from '@src/components/ToastManager/ToastManagerContext';
Expand All @@ -42,9 +45,18 @@ const AuditUserPage = () => {
isLoading: isLoadingUser, data: user, isError: isErrorUser, error: errorUser,
} = useUserAccount(username);
const { querySettings, handleTableFetch } = useQuerySettings();

const { isCourseViewAllowed } = useViewTeamPermissions();
const { isCourseEnabled } = useCourseAuthoringFlag();

const effectiveQuerySettings = useMemo(() => {
if (isCourseViewAllowed || querySettings.roles) { return querySettings; }
return { ...querySettings, roles: LIBRARY_ROLE_KEYS };
}, [isCourseViewAllowed, querySettings]);

const {
isLoading: isLoadingUserAssignments, data: { results: userAssignments, count } = { results: [], count: 0 },
} = useUserAssignedRoles(username, querySettings);
} = useUserAssignedRoles(username, effectiveQuerySettings);
const [roleToDelete, setRoleToDelete] = useState<RoleToDelete | null>(null);
const [showConfirmDeletionModal, setShowConfirmDeletionModal] = useState(false);
const {
Expand Down Expand Up @@ -112,9 +124,10 @@ const AuditUserPage = () => {
Cell: createActionsCell({
onClickDeleteButton: handleShowConfirmDeletionModal,
isUserAuthenticatedPage: username === authenticatedUser?.username,
isCourseEnabled,
}),
},
], [authenticatedUser?.username, formatMessage, handleShowConfirmDeletionModal, username]);
], [authenticatedUser?.username, formatMessage, handleShowConfirmDeletionModal, username, isCourseEnabled]);

const columns = useMemo(() => [
{
Expand Down
8 changes: 8 additions & 0 deletions src/authz-module/authz-home/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerC
import AuthzHome from './index';
import messages from './messages';

jest.mock('@src/authz-module/hooks/useCourseAuthoringFlag', () => ({
useCourseAuthoringFlag: () => ({
isCourseAuthoringEnabled: true,
isCourseEnabled: () => true,
isLoading: false,
}),
}));

jest.mock('@src/authz-module/data/hooks', () => ({
useAllRoleAssignments: jest.fn(),
useOrgs: jest.fn(),
Expand Down
Loading