From 7d6b65b725774db4aa608a224eb50645abc4205b Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 30 Sep 2026 17:50:12 -0500 Subject: [PATCH 01/11] docs: add ADR 0028 API contract for the role and permission catalog Define the response of GET /api/authz/v1/roles/ queried by scope_type, returning paginated roles with the categories and permissions catalogs needed by the Admin Console roles and permissions matrix. Co-Authored-By: Claude Sonnet 5.5 --- .../0028-api-contract-for-role-catalog.rst | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 docs/decisions/0028-api-contract-for-role-catalog.rst diff --git a/docs/decisions/0028-api-contract-for-role-catalog.rst b/docs/decisions/0028-api-contract-for-role-catalog.rst new file mode 100644 index 00000000..7cc503ff --- /dev/null +++ b/docs/decisions/0028-api-contract-for-role-catalog.rst @@ -0,0 +1,319 @@ +0028: API Contract for the Role and Permission Catalog +###################################################### + +Status +****** + +**Draft** + +Context +******* + +`ADR 0021`_ decided that ``GET /api/authz/v1/roles/`` must return the authorization +definitions stored in the authz model (display names, descriptions, categories, icons and +definition kind) so clients no longer keep their own copy of them. It left the exact +response shape open. + +The Roles and Permissions tab of the Admin Console (`frontend-app-admin-console`_) +renders a matrix for one scope type at a time (Courses or Libraries): + +* the columns are the roles, each with a name and a description; +* the rows are permissions grouped by category; a category has an icon, a label and a + description shown in a tooltip, and a permission has an icon and a label; +* each cell says whether the role grants the permission. + +Today the frontend hardcodes all of this (``course/constants.ts`` and +``library/constants.ts``) and builds the matrix in ``buildPermissionMatrixByResource``. +The matrix needs every permission of the scope type, including the ones a role does not +grant, so a list of roles that only carries the permissions each one grants is not +enough. This ADR defines a response that lets the client build the matrix directly. + +Decision +******** + +Extend the existing ``GET /api/authz/v1/roles/`` endpoint, served by ``RoleListView``, to +return a catalog for a scope type. A single call returns the categories, the permissions +and the roles that exist for the scope type, without any knowledge hardcoded in the client. +No new endpoints are added. + +Query by scope type +=================== + +The endpoint is queried by ``scope_type`` instead of ``scope``, like ``ScopesAPIView`` +(``GET /api/authz/v1/scopes/``) and with the same accepted values, ``course`` and +``library`` (``ScopesTypeField``). The catalog describes what a scope type offers, not a +particular course or library, so a concrete scope is not needed. + +* ``scope_type`` is required. Unlike ``/scopes/``, a request without it is invalid (400) + because one matrix cannot mix course and library roles. +* The scope type is mapped to its scope namespace (``course`` to ``course-v1``, ``library`` + to ``lib``). +* The ``scope`` query parameter is removed. The Admin Console does not call + ``GET /api/authz/v1/roles/`` (it only uses ``/roles/users/``), so no released client + depends on the old shape. + +Authorization +============= + +The permission needed depends on the requested ``scope_type``, so a user cannot read the +roles of a scope type whose team they cannot view: + +* ``scope_type=course`` requires ``courses.view_course_team`` (``COURSES_VIEW_COURSE_TEAM``). +* ``scope_type=library`` requires ``content_libraries.view_library_team`` + (``VIEW_LIBRARY_TEAM``). + +The check is not tied to one scope, so the user must hold the permission in at least one +scope of any kind (a specific course or library, or an org or platform glob), as +``AnyScopePermission`` does. Superusers and staff always pass. + +The existing classes cannot express this. ``DynamicScopePermission`` needs a concrete +``scope`` in the request, which no longer exists. ``AnyScopePermission`` accepts any of the +permissions declared by ``@authz_permissions``, which is how ``ScopesAPIView`` lets a user +with only the course permission also query libraries. The implementation therefore adds a +permission class that, like ``AnyScopePermission``, looks for the permission in any scope +with ``get_scopes_for_user_and_permission``, but it reads ``scope_type`` from the request +and only requires the permission mapped to that type. An invalid or missing ``scope_type`` +is rejected as a 400 by the serializer, not by the permission class. + +Role user count +=============== + +``user_count`` is kept, but it is no longer calculated for a specific scope. It is now +calculated by scope type: the number of users assigned to the role in the requested scope +type. + +Data source +=========== + +The endpoint reads from the authz schema models (``AuthzRoleDefinition``, +``AuthzRolePermission``, ``AuthzPermissionDefinition`` and ``AuthzPermissionCategory``), +the same data the Casbin policy is rendered from. It does not keep a parallel copy and does +not return raw Casbin rows. + +* ``permissions`` contains the permissions whose supported scopes include the namespace. +* ``roles`` contains the non-``hidden`` roles (`ADR 0023`_) with at least one grant in the + namespace. Each role's ``permissions`` lists the identifiers of the grants in that + namespace. +* ``categories`` contains only the categories used by those permissions. Categories are + global and a schema may define one without permissions, or only with permissions of + another scope type, so the rest are left out. +* Categories, permissions and roles are returned in a stable order (by identifier). + Explicit display ordering is a follow-up. + +One normalized shape +==================== + +Permission metadata is sent once in the top-level ``permissions`` list, and a role only +references permissions by identifier. This avoids repeating the metadata of a permission +for every role that grants it, and it contains the permissions a role does not grant, which +the matrix requires. To build a cell, the client checks whether the row's permission ``id`` +is in the role's ``permissions``. + +Every permission has a ``category`` with the id of one category. The authz schema requires +it, so it is never ``null``. A category that no permission of the requested scope type uses +is not returned, even if it exists in the schema. + +Definition kind +=============== + +Every role includes ``definition_kind``, with one of the values ``static`` or +``user_defined``. Only static roles are stored today, so the value is always ``static`` +until user-defined roles exist. The field is reserved now so clients do not need to change +later. Detailed source information (distribution, module, schema path, see `ADR 0025`_) is +not exposed. + +Localization +============ + +``display_name`` and ``description`` of roles, permissions and categories are returned in +the language of the request, following `ADR 0020`_ and Django's normal fallback rules. +Identifiers (``role``, ``id``, ``namespace``, ``name``) and ``icon`` names are never +translated. Since the body depends on the request language, the response must vary on +``Accept-Language``. + +Icons are Paragon icon names (validated by the schema, see `ADR 0026`_). The client maps +the name to its component; the API only returns the name, or ``null``. + +Pagination +========== + +The endpoint stays paginated with the existing ``AuthZAPIViewPagination`` and the ``page`` +and ``page_size`` parameters. The pagination applies to the roles, which are the +``results``. The ``categories`` and ``permissions`` catalogs are not paginated: they are +bounded by what the schemas of one scope type declare, and every page carries the complete +catalogs so any page can be rendered on its own. A client that needs the whole matrix in one +request asks for a ``page_size`` large enough to hold every role. + +REST API +======== + +GET /api/authz/v1/roles/ +------------------------ + +Retrieve the roles, permissions and categories available for a scope type. + +Query Parameters: +^^^^^^^^^^^^^^^^^ + +- ``scope_type`` (required): Scope type to query. Either ``course`` or ``library``. +- ``page`` (optional): Page number for pagination of the roles. +- ``page_size`` (optional): Number of roles per page. + +Example: + +.. code:: + + GET /api/authz/v1/roles/?scope_type=course + +Response Body: +^^^^^^^^^^^^^^ + +.. code:: ts + + { + count: number + next: string | null + previous: string | null + scope_type: "course" | "library" + categories: Array<{ + id: string + display_name: string + description: string + icon: string | null + }> + permissions: Array<{ + id: string // complete id, e.g. "courses.view_course" + namespace: string + name: string + display_name: string + description: string + icon: string | null + category: string // id of an entry of "categories" + }> + results: Array<{ // roles, paginated + role: string + display_name: string + description: string + icon: string | null + definition_kind: "static" | "user_defined" + permissions: string[] // ids of entries of "permissions" + user_count: number + }> + } + +Example: + +.. code:: json + + { + "count": 2, + "next": null, + "previous": null, + "scope_type": "course", + "categories": [ + { + "id": "course_access_content", + "display_name": "Course access & content", + "description": "Open the course and work with its content.", + "icon": "BookOpen" + } + ], + "permissions": [ + { + "id": "courses.view_course", + "namespace": "courses", + "name": "view_course", + "display_name": "View course", + "description": "View the course and its content in Studio.", + "icon": "RemoveRedEye", + "category": "course_access_content" + }, + { + "id": "courses.create_course", + "namespace": "courses", + "name": "create_course", + "display_name": "Create course", + "description": "Create new courses.", + "icon": "Plus", + "category": "course_access_content" + } + ], + "results": [ + { + "role": "course_staff", + "display_name": "Course Staff", + "description": "Can edit and publish course content.", + "icon": null, + "definition_kind": "static", + "permissions": ["courses.view_course"], + "user_count": 8 + }, + { + "role": "course_auditor", + "display_name": "Course Auditor", + "description": "Can view the course.", + "icon": null, + "definition_kind": "static", + "permissions": ["courses.view_course"], + "user_count": 3 + } + ] + } + +Possible response codes: +^^^^^^^^^^^^^^^^^^^^^^^^ + +- 200: Ok, includes the Response Body defined above. +- 400: Bad Request, ``scope_type`` is missing or not one of the supported values. +- 401: Unauthorized, the user is not authenticated. +- 403: Forbidden, the user lacks ``courses.view_course_team`` (``scope_type=course``) or + ``content_libraries.view_library_team`` (``scope_type=library``) in any scope. + +Consequences +************ + +* The Roles and Permissions tab can render its matrix from one request and drop + ``course/constants.ts`` and ``library/constants.ts``. Roles and permissions contributed by + other applications show up without a frontend release. +* This is a breaking change to ``GET /api/authz/v1/roles/``: ``scope`` becomes + ``scope_type``, ``user_count`` now counts across the scope type instead of one scope, and + the response adds the ``categories`` and ``permissions`` catalogs next to the paginated + roles. It is low risk because no released client calls the endpoint. Tests + and docs that reference the old shape must be updated, and the deviation from the + compatibility promise of `ADR 0021`_ is intentional. +* Implementations must load definitions with a constant number of queries, not one query + per role or permission. +* Every page repeats the ``categories`` and ``permissions`` catalogs, which is a small + cost for a pageable roles list. +* A new permission class is needed for the per-scope-type authorization. +* ``definition_kind`` is reserved for user-defined roles. Their storage, the translation of + their names and any source detail remain out of scope. +* A role present in the Casbin policy but with no stored definition cannot be described. It + is returned with its identifier as ``display_name`` and empty metadata instead of being + omitted, so enforcement and listing stay consistent. + +Rejected Alternatives +********************* + +Separate permission and category endpoints +========================================== + +The Admin Console would need several requests and would have to join the data itself. + +References +********** + +* `ADR 0020`_ +* `ADR 0021`_ +* `ADR 0023`_ +* `ADR 0024`_ +* `ADR 0025`_ +* `ADR 0026`_ + +.. _frontend-app-admin-console: https://github.com/openedx/frontend-app-admin-console +.. _ADR 0020: 0020-authorization-schema-internationalization.rst +.. _ADR 0021: 0021-authorization-definition-api.rst +.. _ADR 0023: 0023-extend-static-roles.rst +.. _ADR 0024: 0024-api-contract-for-user-grouped-role-assignments.rst +.. _ADR 0025: 0025-authorization-schema-source-tracking.rst +.. _ADR 0026: 0026-paragon-icon-list-maintenance.rst From 766cbd5872bcb9b7aa2afa36726f07840dcaa043 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 2 Oct 2026 10:39:48 -0500 Subject: [PATCH 02/11] docs: note scope registry as future work in ADR 0028 Record that scope types could later be resolved from scope_registry so plugins can contribute new ones, while course and library stay explicit for now. Co-Authored-By: Claude Sonnet 5.5 --- .../0028-api-contract-for-role-catalog.rst | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/decisions/0028-api-contract-for-role-catalog.rst b/docs/decisions/0028-api-contract-for-role-catalog.rst index 7cc503ff..3add4f18 100644 --- a/docs/decisions/0028-api-contract-for-role-catalog.rst +++ b/docs/decisions/0028-api-contract-for-role-catalog.rst @@ -75,6 +75,30 @@ with ``get_scopes_for_user_and_permission``, but it reads ``scope_type`` from th and only requires the permission mapped to that type. An invalid or missing ``scope_type`` is rejected as a 400 by the serializer, not by the permission class. +Extensibility to new scope types (future work) +============================================== + +For now the two supported scope types, ``course`` and ``library``, and their mapping to a +scope namespace and view-team permission stay explicit, as described above. This is kept in +one helper so it is not duplicated in the view. + +A possible future improvement is to stop hardcoding the scope types. ``ScopeMeta`` already +registers every ``ScopeData`` subclass in ``scope_registry``, keyed by its ``NAMESPACE`` +(``course-v1``, ``lib``), so the endpoint could resolve the requested scope type from that +registry. Any plugin that registers a scope class would then contribute a new scope type +without changing authz: + +* The accepted ``scope_type`` values and their namespace would come from ``scope_registry`` + instead of a fixed enum. +* The catalog would not change in shape. It is built from the authz schemas, so a new scope + type appears as soon as a schema declares permissions and roles for its namespace. +* The permission required to read the catalog would be declared by the scope class. A scope + type that does not declare one would be rejected rather than left open. + +Open point for that work: ``/scopes/`` exposes the short names ``course`` and ``library`` +(``ScopesTypeField``), not the namespaces, so using ``NAMESPACE`` as the key would need an +alias or a change in the accepted values. + Role user count =============== From ca3fbc8d33589d2f78f28c483e23e4e29e017ad3 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 2 Oct 2026 10:46:04 -0500 Subject: [PATCH 03/11] docs: accept a list of scope types in ADR 0028 Replace scope_type with a comma-separated scope_types parameter so the catalog can later serve roles with multiple scope types. A role is returned if it has grants in any requested type, and the user needs the view-team permission of each one. Co-Authored-By: Claude Sonnet 5.5 --- .../0028-api-contract-for-role-catalog.rst | 93 +++++++++++-------- 1 file changed, 54 insertions(+), 39 deletions(-) diff --git a/docs/decisions/0028-api-contract-for-role-catalog.rst b/docs/decisions/0028-api-contract-for-role-catalog.rst index 3add4f18..29e78814 100644 --- a/docs/decisions/0028-api-contract-for-role-catalog.rst +++ b/docs/decisions/0028-api-contract-for-role-catalog.rst @@ -32,22 +32,31 @@ Decision ******** Extend the existing ``GET /api/authz/v1/roles/`` endpoint, served by ``RoleListView``, to -return a catalog for a scope type. A single call returns the categories, the permissions -and the roles that exist for the scope type, without any knowledge hardcoded in the client. -No new endpoints are added. +return a catalog for one or more scope types. A single call returns the categories, the +permissions and the roles that exist for the requested scope types, without any knowledge +hardcoded in the client. No new endpoints are added. Query by scope type =================== -The endpoint is queried by ``scope_type`` instead of ``scope``, like ``ScopesAPIView`` -(``GET /api/authz/v1/scopes/``) and with the same accepted values, ``course`` and -``library`` (``ScopesTypeField``). The catalog describes what a scope type offers, not a +The endpoint is queried by ``scope_types`` instead of ``scope``. It is a comma-separated +list (for example ``?scope_types=course,library``) whose values are the ones accepted by +``ScopesAPIView`` (``GET /api/authz/v1/scopes/``) for ``scope_type``, ``course`` and +``library`` (``ScopesTypeField``). The catalog describes what scope types offer, not a particular course or library, so a concrete scope is not needed. -* ``scope_type`` is required. Unlike ``/scopes/``, a request without it is invalid (400) - because one matrix cannot mix course and library roles. -* The scope type is mapped to its scope namespace (``course`` to ``course-v1``, ``library`` +A list is accepted, instead of a single ``scope_type``, so that listing the roles +dynamically for assignments can ask for several scope types at once when roles with +multiple scope types are supported. Such roles do not exist yet, so nothing specific to +them is implemented now. + +* ``scope_types`` is required and must have at least one value. Unlike ``/scopes/``, a + request without it is invalid (400). Empty and unknown values are also invalid (400). +* Each scope type is mapped to its scope namespace (``course`` to ``course-v1``, ``library`` to ``lib``). +* A role is returned if it has grants in **any** of the requested scope types (OR). +* The Roles and Permissions tab sends a single value, because one matrix cannot mix course + and library roles. * The ``scope`` query parameter is removed. The Admin Console does not call ``GET /api/authz/v1/roles/`` (it only uses ``/roles/users/``), so no released client depends on the old shape. @@ -55,25 +64,25 @@ particular course or library, so a concrete scope is not needed. Authorization ============= -The permission needed depends on the requested ``scope_type``, so a user cannot read the -roles of a scope type whose team they cannot view: +The permission needed depends on the requested ``scope_types``, so a user cannot read the +roles of a scope type whose team they cannot view. The user must hold the permission of +**each** requested scope type: -* ``scope_type=course`` requires ``courses.view_course_team`` (``COURSES_VIEW_COURSE_TEAM``). -* ``scope_type=library`` requires ``content_libraries.view_library_team`` - (``VIEW_LIBRARY_TEAM``). +* ``course`` requires ``courses.view_course_team`` (``COURSES_VIEW_COURSE_TEAM``). +* ``library`` requires ``content_libraries.view_library_team`` (``VIEW_LIBRARY_TEAM``). -The check is not tied to one scope, so the user must hold the permission in at least one -scope of any kind (a specific course or library, or an org or platform glob), as -``AnyScopePermission`` does. Superusers and staff always pass. +The check is not tied to one scope, so for each requested scope type the user must hold its +permission in at least one scope of any kind (a specific course or library, or an org or +platform glob), as ``AnyScopePermission`` does. Superusers and staff always pass. The existing classes cannot express this. ``DynamicScopePermission`` needs a concrete ``scope`` in the request, which no longer exists. ``AnyScopePermission`` accepts any of the permissions declared by ``@authz_permissions``, which is how ``ScopesAPIView`` lets a user with only the course permission also query libraries. The implementation therefore adds a permission class that, like ``AnyScopePermission``, looks for the permission in any scope -with ``get_scopes_for_user_and_permission``, but it reads ``scope_type`` from the request -and only requires the permission mapped to that type. An invalid or missing ``scope_type`` -is rejected as a 400 by the serializer, not by the permission class. +with ``get_scopes_for_user_and_permission``, but it reads ``scope_types`` from the request +and requires the permission mapped to each requested type. An invalid or missing +``scope_types`` is rejected as a 400 by the serializer, not by the permission class. Extensibility to new scope types (future work) ============================================== @@ -88,7 +97,7 @@ registers every ``ScopeData`` subclass in ``scope_registry``, keyed by its ``NAM registry. Any plugin that registers a scope class would then contribute a new scope type without changing authz: -* The accepted ``scope_type`` values and their namespace would come from ``scope_registry`` +* The accepted ``scope_types`` values and their namespace would come from ``scope_registry`` instead of a fixed enum. * The catalog would not change in shape. It is built from the authz schemas, so a new scope type appears as soon as a schema declares permissions and roles for its namespace. @@ -103,8 +112,8 @@ Role user count =============== ``user_count`` is kept, but it is no longer calculated for a specific scope. It is now -calculated by scope type: the number of users assigned to the role in the requested scope -type. +calculated by scope type: the number of users assigned to the role across the requested +scope types. Data source =========== @@ -114,10 +123,11 @@ The endpoint reads from the authz schema models (``AuthzRoleDefinition``, the same data the Casbin policy is rendered from. It does not keep a parallel copy and does not return raw Casbin rows. -* ``permissions`` contains the permissions whose supported scopes include the namespace. -* ``roles`` contains the non-``hidden`` roles (`ADR 0023`_) with at least one grant in the - namespace. Each role's ``permissions`` lists the identifiers of the grants in that - namespace. +* ``permissions`` contains the permissions whose supported scopes include any of the + requested namespaces (the union across the requested scope types). +* ``roles`` contains the non-``hidden`` roles (`ADR 0023`_) with at least one grant in any + of the requested namespaces. Each role's ``permissions`` lists the identifiers of the + grants in those namespaces. * ``categories`` contains only the categories used by those permissions. Categories are global and a schema may define one without permissions, or only with permissions of another scope type, so the rest are left out. @@ -134,7 +144,7 @@ the matrix requires. To build a cell, the client checks whether the row's permis is in the role's ``permissions``. Every permission has a ``category`` with the id of one category. The authz schema requires -it, so it is never ``null``. A category that no permission of the requested scope type uses +it, so it is never ``null``. A category that no permission of the requested scope types uses is not returned, even if it exists in the schema. Definition kind @@ -164,7 +174,7 @@ Pagination The endpoint stays paginated with the existing ``AuthZAPIViewPagination`` and the ``page`` and ``page_size`` parameters. The pagination applies to the roles, which are the ``results``. The ``categories`` and ``permissions`` catalogs are not paginated: they are -bounded by what the schemas of one scope type declare, and every page carries the complete +bounded by what the schemas of the requested scope types declare, and every page carries the complete catalogs so any page can be rendered on its own. A client that needs the whole matrix in one request asks for a ``page_size`` large enough to hold every role. @@ -174,12 +184,13 @@ REST API GET /api/authz/v1/roles/ ------------------------ -Retrieve the roles, permissions and categories available for a scope type. +Retrieve the roles, permissions and categories available for one or more scope types. Query Parameters: ^^^^^^^^^^^^^^^^^ -- ``scope_type`` (required): Scope type to query. Either ``course`` or ``library``. +- ``scope_types`` (required): Comma-separated list of scope types to query, with at least + one value. Each one is ``course`` or ``library``. - ``page`` (optional): Page number for pagination of the roles. - ``page_size`` (optional): Number of roles per page. @@ -187,7 +198,7 @@ Example: .. code:: - GET /api/authz/v1/roles/?scope_type=course + GET /api/authz/v1/roles/?scope_types=course Response Body: ^^^^^^^^^^^^^^ @@ -198,7 +209,7 @@ Response Body: count: number next: string | null previous: string | null - scope_type: "course" | "library" + scope_types: Array<"course" | "library"> categories: Array<{ id: string display_name: string @@ -233,7 +244,7 @@ Example: "count": 2, "next": null, "previous": null, - "scope_type": "course", + "scope_types": ["course"], "categories": [ { "id": "course_access_content", @@ -288,10 +299,12 @@ Possible response codes: ^^^^^^^^^^^^^^^^^^^^^^^^ - 200: Ok, includes the Response Body defined above. -- 400: Bad Request, ``scope_type`` is missing or not one of the supported values. +- 400: Bad Request, ``scope_types`` is missing, has an empty value or has a value that is + not supported. - 401: Unauthorized, the user is not authenticated. -- 403: Forbidden, the user lacks ``courses.view_course_team`` (``scope_type=course``) or - ``content_libraries.view_library_team`` (``scope_type=library``) in any scope. +- 403: Forbidden, the user lacks ``courses.view_course_team`` (``course``) or + ``content_libraries.view_library_team`` (``library``) in any scope, for any of the + requested scope types. Consequences ************ @@ -300,7 +313,8 @@ Consequences ``course/constants.ts`` and ``library/constants.ts``. Roles and permissions contributed by other applications show up without a frontend release. * This is a breaking change to ``GET /api/authz/v1/roles/``: ``scope`` becomes - ``scope_type``, ``user_count`` now counts across the scope type instead of one scope, and + ``scope_types``, ``user_count`` now counts across the requested scope types instead of one + scope, and the response adds the ``categories`` and ``permissions`` catalogs next to the paginated roles. It is low risk because no released client calls the endpoint. Tests and docs that reference the old shape must be updated, and the deviation from the @@ -309,7 +323,8 @@ Consequences per role or permission. * Every page repeats the ``categories`` and ``permissions`` catalogs, which is a small cost for a pageable roles list. -* A new permission class is needed for the per-scope-type authorization. +* A new permission class is needed for the per-scope-type authorization, which requires the + permission of each requested scope type. * ``definition_kind`` is reserved for user-defined roles. Their storage, the translation of their names and any source detail remain out of scope. * A role present in the Casbin policy but with no stored definition cannot be described. It From 6220a76173d21e64e0e356175f868466a461e362 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 2 Oct 2026 10:49:56 -0500 Subject: [PATCH 04/11] docs: omit roles without a stored definition in ADR 0028 Roles that exist only in the Casbin policy are no longer listed, so the catalog reads only from the database and filtering and pagination stay in a single query. Co-Authored-By: Claude Sonnet 5.5 --- docs/decisions/0028-api-contract-for-role-catalog.rst | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/decisions/0028-api-contract-for-role-catalog.rst b/docs/decisions/0028-api-contract-for-role-catalog.rst index 29e78814..06fbf983 100644 --- a/docs/decisions/0028-api-contract-for-role-catalog.rst +++ b/docs/decisions/0028-api-contract-for-role-catalog.rst @@ -131,6 +131,9 @@ not return raw Casbin rows. * ``categories`` contains only the categories used by those permissions. Categories are global and a schema may define one without permissions, or only with permissions of another scope type, so the rest are left out. +* Only roles with a stored definition are listed. A role that exists in the Casbin policy + but has no stored definition is omitted, so the endpoint reads only from the database and + filtering and pagination are done in a single query. * Categories, permissions and roles are returned in a stable order (by identifier). Explicit display ordering is a follow-up. @@ -327,9 +330,10 @@ Consequences permission of each requested scope type. * ``definition_kind`` is reserved for user-defined roles. Their storage, the translation of their names and any source detail remain out of scope. -* A role present in the Casbin policy but with no stored definition cannot be described. It - is returned with its identifier as ``display_name`` and empty metadata instead of being - omitted, so enforcement and listing stay consistent. +* A role present in the Casbin policy but with no stored definition is not listed, because + it cannot be described. Listing it would require querying Casbin in addition to the + database, which complicates filtering and pagination. Such a role still works for + enforcement; it just does not appear in the catalog. Rejected Alternatives ********************* From 733d1feebfac9b697b4e419880a11c097165c0f7 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 6 Oct 2026 09:21:14 -0500 Subject: [PATCH 05/11] docs: make ADR 0028 context independent of the permissions matrix Frame the API as the source of truth for the descriptive information of the authz model instead of around the current matrix UI. The same data also feeds the permissions sub-table and the wizard role list, so generalize the remaining matrix-specific wording. Co-Authored-By: Claude Sonnet 5.5 --- .../0028-api-contract-for-role-catalog.rst | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/docs/decisions/0028-api-contract-for-role-catalog.rst b/docs/decisions/0028-api-contract-for-role-catalog.rst index 06fbf983..7c8844ed 100644 --- a/docs/decisions/0028-api-contract-for-role-catalog.rst +++ b/docs/decisions/0028-api-contract-for-role-catalog.rst @@ -14,19 +14,25 @@ definitions stored in the authz model (display names, descriptions, categories, definition kind) so clients no longer keep their own copy of them. It left the exact response shape open. -The Roles and Permissions tab of the Admin Console (`frontend-app-admin-console`_) -renders a matrix for one scope type at a time (Courses or Libraries): - -* the columns are the roles, each with a name and a description; -* the rows are permissions grouped by category; a category has an icon, a label and a - description shown in a tooltip, and a permission has an icon and a label; -* each cell says whether the role grants the permission. - -Today the frontend hardcodes all of this (``course/constants.ts`` and -``library/constants.ts``) and builds the matrix in ``buildPermissionMatrixByResource``. -The matrix needs every permission of the scope type, including the ones a role does not -grant, so a list of roles that only carries the permissions each one grants is not -enough. This ADR defines a response that lets the client build the matrix directly. +The authz model is the source of truth for what roles and permissions exist, but the +descriptive information about them (display names, descriptions, categories and icons) is +not exposed by the API. Clients such as the Admin Console (`frontend-app-admin-console`_) +keep their own hardcoded copy of it (``course/constants.ts`` and ``library/constants.ts``) +and use it in several places: the Roles and Permissions matrix, the permissions sub-table +and the role list of the assignment wizard. + +Clients need more than the permissions each role grants. They need: + +* every role of a scope type, each with a name and a description; +* every permission of the scope type, including the ones a role does not grant, grouped by + category; a category has an icon, a label and a description, and a permission has an + icon and a label; +* which permissions each role grants. + +This ADR makes the API the source of truth for the descriptive information of the authz +model. It defines a response that returns it together with the role-permission +relationships, so clients can build any view from it, whatever the way they choose to +present it, without keeping their own copy. Decision ******** @@ -55,8 +61,8 @@ them is implemented now. * Each scope type is mapped to its scope namespace (``course`` to ``course-v1``, ``library`` to ``lib``). * A role is returned if it has grants in **any** of the requested scope types (OR). -* The Roles and Permissions tab sends a single value, because one matrix cannot mix course - and library roles. +* A client that presents the roles of one scope type at a time, like the Roles and + Permissions tab, sends a single value. * The ``scope`` query parameter is removed. The Admin Console does not call ``GET /api/authz/v1/roles/`` (it only uses ``/roles/users/``), so no released client depends on the old shape. @@ -143,8 +149,8 @@ One normalized shape Permission metadata is sent once in the top-level ``permissions`` list, and a role only references permissions by identifier. This avoids repeating the metadata of a permission for every role that grants it, and it contains the permissions a role does not grant, which -the matrix requires. To build a cell, the client checks whether the row's permission ``id`` -is in the role's ``permissions``. +clients need to show what a role lacks. To know whether a role grants a permission, the +client checks whether the permission ``id`` is in the role's ``permissions``. Every permission has a ``category`` with the id of one category. The authz schema requires it, so it is never ``null``. A category that no permission of the requested scope types uses @@ -177,9 +183,9 @@ Pagination The endpoint stays paginated with the existing ``AuthZAPIViewPagination`` and the ``page`` and ``page_size`` parameters. The pagination applies to the roles, which are the ``results``. The ``categories`` and ``permissions`` catalogs are not paginated: they are -bounded by what the schemas of the requested scope types declare, and every page carries the complete -catalogs so any page can be rendered on its own. A client that needs the whole matrix in one -request asks for a ``page_size`` large enough to hold every role. +bounded by what the schemas of the requested scope types declare, and every page carries +the complete catalogs so any page can be rendered on its own. A client that needs every +role in one request asks for a ``page_size`` large enough to hold every role. REST API ======== @@ -312,9 +318,10 @@ Possible response codes: Consequences ************ -* The Roles and Permissions tab can render its matrix from one request and drop - ``course/constants.ts`` and ``library/constants.ts``. Roles and permissions contributed by - other applications show up without a frontend release. +* Clients such as the Admin Console can build the Roles and Permissions tab, the permissions + sub-table and the wizard role list from one request and drop ``course/constants.ts`` and + ``library/constants.ts``. Roles and permissions contributed by other applications show up + without a frontend release. * This is a breaking change to ``GET /api/authz/v1/roles/``: ``scope`` becomes ``scope_types``, ``user_count`` now counts across the requested scope types instead of one scope, and From 45b3db74eab432bf358da167b4a4f29280653964 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 6 Oct 2026 12:43:52 -0500 Subject: [PATCH 06/11] docs: return scopes in the role catalog response in ADR 0028 Rename the response scope_types to scopes using backend namespaces (course-v1, lib) and add scopes to each permission. Co-Authored-By: Claude Sonnet 5.5 --- .../0028-api-contract-for-role-catalog.rst | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/decisions/0028-api-contract-for-role-catalog.rst b/docs/decisions/0028-api-contract-for-role-catalog.rst index 7c8844ed..28ad4bd7 100644 --- a/docs/decisions/0028-api-contract-for-role-catalog.rst +++ b/docs/decisions/0028-api-contract-for-role-catalog.rst @@ -63,6 +63,9 @@ them is implemented now. * A role is returned if it has grants in **any** of the requested scope types (OR). * A client that presents the roles of one scope type at a time, like the Roles and Permissions tab, sends a single value. +* The query parameter keeps the short names (``course``, ``library``), but the response + returns the requested scopes with the namespaces used by the backend (``course-v1``, + ``lib``) in ``scopes``, see `Scopes in the response`_. * The ``scope`` query parameter is removed. The Admin Console does not call ``GET /api/authz/v1/roles/`` (it only uses ``/roles/users/``), so no released client depends on the old shape. @@ -130,7 +133,8 @@ the same data the Casbin policy is rendered from. It does not keep a parallel co not return raw Casbin rows. * ``permissions`` contains the permissions whose supported scopes include any of the - requested namespaces (the union across the requested scope types). + requested namespaces (the union across the requested scope types). Each permission + returns all its supported scopes in ``scopes``, not only the requested ones. * ``roles`` contains the non-``hidden`` roles (`ADR 0023`_) with at least one grant in any of the requested namespaces. Each role's ``permissions`` lists the identifiers of the grants in those namespaces. @@ -143,6 +147,18 @@ not return raw Casbin rows. * Categories, permissions and roles are returned in a stable order (by identifier). Explicit display ordering is a follow-up. +Scopes in the response +====================== + +The top-level ``scopes`` field replaces ``scope_types``. It lists the scopes requested in +``scope_types``, in the format the backend uses: the scope namespace (``course-v1`` for +``course``, ``lib`` for ``library``). + +Every permission also returns ``scopes``, the namespaces of the scopes it supports (for +example ``["course-v1"]``). It lists all the scopes the permission supports, even those not +requested, so a client can tell which scope types a permission applies to when it queries +several at once. + One normalized shape ==================== @@ -218,7 +234,7 @@ Response Body: count: number next: string | null previous: string | null - scope_types: Array<"course" | "library"> + scopes: Array<"course-v1" | "lib"> // requested scopes categories: Array<{ id: string display_name: string @@ -233,6 +249,7 @@ Response Body: description: string icon: string | null category: string // id of an entry of "categories" + scopes: Array<"course-v1" | "lib"> // all the scopes it supports }> results: Array<{ // roles, paginated role: string @@ -253,7 +270,7 @@ Example: "count": 2, "next": null, "previous": null, - "scope_types": ["course"], + "scopes": ["course-v1"], "categories": [ { "id": "course_access_content", @@ -270,7 +287,8 @@ Example: "display_name": "View course", "description": "View the course and its content in Studio.", "icon": "RemoveRedEye", - "category": "course_access_content" + "category": "course_access_content", + "scopes": ["course-v1"] }, { "id": "courses.create_course", @@ -279,7 +297,8 @@ Example: "display_name": "Create course", "description": "Create new courses.", "icon": "Plus", - "category": "course_access_content" + "category": "course_access_content", + "scopes": ["course-v1"] } ], "results": [ @@ -322,10 +341,11 @@ Consequences sub-table and the wizard role list from one request and drop ``course/constants.ts`` and ``library/constants.ts``. Roles and permissions contributed by other applications show up without a frontend release. -* This is a breaking change to ``GET /api/authz/v1/roles/``: ``scope`` becomes - ``scope_types``, ``user_count`` now counts across the requested scope types instead of one - scope, and - the response adds the ``categories`` and ``permissions`` catalogs next to the paginated +* This is a breaking change to ``GET /api/authz/v1/roles/``: the ``scope`` query parameter + becomes ``scope_types``, the response returns the requested scopes in ``scopes`` (with + backend namespaces such as ``course-v1`` and ``lib``), ``user_count`` now counts across + the requested scope types instead of one scope, and the response adds the ``categories`` + and ``permissions`` catalogs (each permission with its ``scopes``) next to the paginated roles. It is low risk because no released client calls the endpoint. Tests and docs that reference the old shape must be updated, and the deviation from the compatibility promise of `ADR 0021`_ is intentional. From fc1b409edcc71dd7a85cb69ae251908da2455254 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 6 Oct 2026 13:10:22 -0500 Subject: [PATCH 07/11] docs: rename permission category to category_id in ADR 0028 Match the field name used by the authz schema. Co-Authored-By: Claude Sonnet 5.5 --- docs/decisions/0028-api-contract-for-role-catalog.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/decisions/0028-api-contract-for-role-catalog.rst b/docs/decisions/0028-api-contract-for-role-catalog.rst index 28ad4bd7..ca87e056 100644 --- a/docs/decisions/0028-api-contract-for-role-catalog.rst +++ b/docs/decisions/0028-api-contract-for-role-catalog.rst @@ -168,7 +168,7 @@ for every role that grants it, and it contains the permissions a role does not g clients need to show what a role lacks. To know whether a role grants a permission, the client checks whether the permission ``id`` is in the role's ``permissions``. -Every permission has a ``category`` with the id of one category. The authz schema requires +Every permission has a ``category_id`` with the id of one category. The authz schema requires it, so it is never ``null``. A category that no permission of the requested scope types uses is not returned, even if it exists in the schema. @@ -248,7 +248,7 @@ Response Body: display_name: string description: string icon: string | null - category: string // id of an entry of "categories" + category_id: string // id of an entry of "categories" scopes: Array<"course-v1" | "lib"> // all the scopes it supports }> results: Array<{ // roles, paginated @@ -287,7 +287,7 @@ Example: "display_name": "View course", "description": "View the course and its content in Studio.", "icon": "RemoveRedEye", - "category": "course_access_content", + "category_id": "course_access_content", "scopes": ["course-v1"] }, { @@ -297,7 +297,7 @@ Example: "display_name": "Create course", "description": "Create new courses.", "icon": "Plus", - "category": "course_access_content", + "category_id": "course_access_content", "scopes": ["course-v1"] } ], From 8d80e835d0c5dc898cf89888f66df4807cd74f97 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 7 Oct 2026 17:28:22 -0500 Subject: [PATCH 08/11] docs: reuse shared permission logic for the role catalog in ADR 0028 Describe extracting the logic shared with AnyScopePermission instead of duplicating it in the permission of the role catalog endpoint. Co-Authored-By: Claude Sonnet 5.5 --- .../0028-api-contract-for-role-catalog.rst | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/decisions/0028-api-contract-for-role-catalog.rst b/docs/decisions/0028-api-contract-for-role-catalog.rst index ca87e056..6e9074fa 100644 --- a/docs/decisions/0028-api-contract-for-role-catalog.rst +++ b/docs/decisions/0028-api-contract-for-role-catalog.rst @@ -84,14 +84,23 @@ The check is not tied to one scope, so for each requested scope type the user mu permission in at least one scope of any kind (a specific course or library, or an org or platform glob), as ``AnyScopePermission`` does. Superusers and staff always pass. -The existing classes cannot express this. ``DynamicScopePermission`` needs a concrete -``scope`` in the request, which no longer exists. ``AnyScopePermission`` accepts any of the -permissions declared by ``@authz_permissions``, which is how ``ScopesAPIView`` lets a user -with only the course permission also query libraries. The implementation therefore adds a -permission class that, like ``AnyScopePermission``, looks for the permission in any scope -with ``get_scopes_for_user_and_permission``, but it reads ``scope_types`` from the request -and requires the permission mapped to each requested type. An invalid or missing -``scope_types`` is rejected as a 400 by the serializer, not by the permission class. +The existing classes cannot be used as they are. ``DynamicScopePermission`` needs a concrete +``scope`` in the request, which no longer exists. ``AnyScopePermission`` already looks for the +permission in any scope, but it takes the permissions from ``@authz_permissions`` and requires +only one of them, which is how ``ScopesAPIView`` lets a user with only the course permission +also query libraries. + +Instead of duplicating that logic, the common part is extracted and reused by +``AnyScopePermission`` and by the permission of this endpoint: + +* The common part is the superuser and staff bypass and the check that a user has a + permission in at least one scope of any kind. +* ``AnyScopePermission`` keeps its behavior. +* The permission of this endpoint reads ``scope_types`` from the request, maps each requested + scope type to its view-team permission and requires all of them. + +A missing or invalid ``scope_types`` is not decided by the permission, so the serializer +rejects it as a 400. Extensibility to new scope types (future work) ============================================== From 493865067d729942bfecbadcd0899931281b29e8 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Thu, 8 Oct 2026 10:28:45 -0500 Subject: [PATCH 09/11] docs: use scope namespaces as the scope type naming in ADR 0028 Co-Authored-By: Claude Sonnet 5.5 --- .../0028-api-contract-for-role-catalog.rst | 115 +++++++++++------- 1 file changed, 68 insertions(+), 47 deletions(-) diff --git a/docs/decisions/0028-api-contract-for-role-catalog.rst b/docs/decisions/0028-api-contract-for-role-catalog.rst index 6e9074fa..7840bc58 100644 --- a/docs/decisions/0028-api-contract-for-role-catalog.rst +++ b/docs/decisions/0028-api-contract-for-role-catalog.rst @@ -46,11 +46,17 @@ Query by scope type =================== The endpoint is queried by ``scope_types`` instead of ``scope``. It is a comma-separated -list (for example ``?scope_types=course,library``) whose values are the ones accepted by -``ScopesAPIView`` (``GET /api/authz/v1/scopes/``) for ``scope_type``, ``course`` and -``library`` (``ScopesTypeField``). The catalog describes what scope types offer, not a +list (for example ``?scope_types=course-v1,lib``) whose values are the ``NAMESPACE`` of the +scope classes, ``course-v1`` and ``lib``, the same prefixes the backend uses in scope keys +(``course-v1:...``, ``lib:...``). The catalog describes what scope types offer, not a particular course or library, so a concrete scope is not needed. +Scope types have a single naming, ``course-v1`` and ``lib``, in the request and in the +response. ``ScopesTypeField`` (also used by ``GET /api/authz/v1/scopes/``) derives its +accepted values from the ``NAMESPACE`` of the scope classes, so they have a single source of +truth and a scope type registered by a plugin follows the same naming without an alias. See +`Scope type naming`_ for how ``/scopes/`` is aligned. + A list is accepted, instead of a single ``scope_type``, so that listing the roles dynamically for assignments can ask for several scope types at once when roles with multiple scope types are supported. Such roles do not exist yet, so nothing specific to @@ -58,14 +64,13 @@ them is implemented now. * ``scope_types`` is required and must have at least one value. Unlike ``/scopes/``, a request without it is invalid (400). Empty and unknown values are also invalid (400). -* Each scope type is mapped to its scope namespace (``course`` to ``course-v1``, ``library`` - to ``lib``). +* Only ``course-v1`` and ``lib`` are accepted. The short names ``course`` and ``library`` + are not valid in this endpoint (400), since it is new and no client depends on them. * A role is returned if it has grants in **any** of the requested scope types (OR). * A client that presents the roles of one scope type at a time, like the Roles and Permissions tab, sends a single value. -* The query parameter keeps the short names (``course``, ``library``), but the response - returns the requested scopes with the namespaces used by the backend (``course-v1``, - ``lib``) in ``scopes``, see `Scopes in the response`_. +* The response returns the requested scope types with the same values in ``scope_types``, + see `Scope types in the response`_. * The ``scope`` query parameter is removed. The Admin Console does not call ``GET /api/authz/v1/roles/`` (it only uses ``/roles/users/``), so no released client depends on the old shape. @@ -77,8 +82,8 @@ The permission needed depends on the requested ``scope_types``, so a user cannot roles of a scope type whose team they cannot view. The user must hold the permission of **each** requested scope type: -* ``course`` requires ``courses.view_course_team`` (``COURSES_VIEW_COURSE_TEAM``). -* ``library`` requires ``content_libraries.view_library_team`` (``VIEW_LIBRARY_TEAM``). +* ``course-v1`` requires ``courses.view_course_team`` (``COURSES_VIEW_COURSE_TEAM``). +* ``lib`` requires ``content_libraries.view_library_team`` (``VIEW_LIBRARY_TEAM``). The check is not tied to one scope, so for each requested scope type the user must hold its permission in at least one scope of any kind (a specific course or library, or an org or @@ -105,27 +110,23 @@ rejects it as a 400. Extensibility to new scope types (future work) ============================================== -For now the two supported scope types, ``course`` and ``library``, and their mapping to a -scope namespace and view-team permission stay explicit, as described above. This is kept in -one helper so it is not duplicated in the view. +For now the two supported scope types, ``course-v1`` and ``lib``, and their mapping to a +view-team permission stay explicit, as described above. This is kept in one helper so it is +not duplicated in the view. A possible future improvement is to stop hardcoding the scope types. ``ScopeMeta`` already registers every ``ScopeData`` subclass in ``scope_registry``, keyed by its ``NAMESPACE`` -(``course-v1``, ``lib``), so the endpoint could resolve the requested scope type from that -registry. Any plugin that registers a scope class would then contribute a new scope type -without changing authz: +(``course-v1``, ``lib``), which are already the values of ``scope_types``, so the endpoint +could resolve the requested scope type from that registry. Any plugin that registers a scope +class would then contribute a new scope type without changing authz and without an alias: -* The accepted ``scope_types`` values and their namespace would come from ``scope_registry`` - instead of a fixed enum. +* The accepted ``scope_types`` values would come from ``scope_registry`` instead of a fixed + enum. * The catalog would not change in shape. It is built from the authz schemas, so a new scope type appears as soon as a schema declares permissions and roles for its namespace. * The permission required to read the catalog would be declared by the scope class. A scope type that does not declare one would be rejected rather than left open. -Open point for that work: ``/scopes/`` exposes the short names ``course`` and ``library`` -(``ScopesTypeField``), not the namespaces, so using ``NAMESPACE`` as the key would need an -alias or a change in the accepted values. - Role user count =============== @@ -141,12 +142,12 @@ The endpoint reads from the authz schema models (``AuthzRoleDefinition``, the same data the Casbin policy is rendered from. It does not keep a parallel copy and does not return raw Casbin rows. -* ``permissions`` contains the permissions whose supported scopes include any of the - requested namespaces (the union across the requested scope types). Each permission - returns all its supported scopes in ``scopes``, not only the requested ones. +* ``permissions`` contains the permissions whose supported scope types include any of the + requested ones (the union across the requested scope types). Each permission returns all + its supported scope types in ``scope_types``, not only the requested ones. * ``roles`` contains the non-``hidden`` roles (`ADR 0023`_) with at least one grant in any - of the requested namespaces. Each role's ``permissions`` lists the identifiers of the - grants in those namespaces. + of the requested scope types. Each role's ``permissions`` lists the identifiers of the + grants in those scope types. * ``categories`` contains only the categories used by those permissions. Categories are global and a schema may define one without permissions, or only with permissions of another scope type, so the rest are left out. @@ -156,15 +157,31 @@ not return raw Casbin rows. * Categories, permissions and roles are returned in a stable order (by identifier). Explicit display ordering is a follow-up. -Scopes in the response -====================== +Scope type naming +================= + +Scope types are named after the ``NAMESPACE`` of the scope classes (``course-v1`` and +``lib``) everywhere, instead of the short names ``course`` and ``library`` that +``GET /api/authz/v1/scopes/`` accepts today in its ``scope_type`` query parameter. It is the +only place that uses the short names, so aligning it removes the two names for the same +thing. + +* ``ScopesTypeField`` accepts ``course-v1`` and ``lib``, derived from the ``NAMESPACE`` of + the scope classes. +* To avoid breaking the Admin Console, ``/scopes/`` keeps accepting ``course`` and + ``library`` as deprecated aliases of ``course-v1`` and ``lib``. The aliases are only + accepted in the request of ``/scopes/``; ``/roles/`` does not accept them. +* The aliases are removed once the frontend migrates. A ticket in + ``frontend-app-admin-console`` tracks updating it to the new values. + +Scope types in the response +=========================== -The top-level ``scopes`` field replaces ``scope_types``. It lists the scopes requested in -``scope_types``, in the format the backend uses: the scope namespace (``course-v1`` for -``course``, ``lib`` for ``library``). +The top-level ``scope_types`` field lists the scope types requested in ``scope_types``, +with the same values (``course-v1``, ``lib``). -Every permission also returns ``scopes``, the namespaces of the scopes it supports (for -example ``["course-v1"]``). It lists all the scopes the permission supports, even those not +Every permission also returns ``scope_types``, the scope types it supports (for example +``["course-v1"]``). It lists all the scope types the permission supports, even those not requested, so a client can tell which scope types a permission applies to when it queries several at once. @@ -224,7 +241,7 @@ Query Parameters: ^^^^^^^^^^^^^^^^^ - ``scope_types`` (required): Comma-separated list of scope types to query, with at least - one value. Each one is ``course`` or ``library``. + one value. Each one is ``course-v1`` or ``lib``. - ``page`` (optional): Page number for pagination of the roles. - ``page_size`` (optional): Number of roles per page. @@ -232,7 +249,7 @@ Example: .. code:: - GET /api/authz/v1/roles/?scope_types=course + GET /api/authz/v1/roles/?scope_types=course-v1 Response Body: ^^^^^^^^^^^^^^ @@ -243,7 +260,7 @@ Response Body: count: number next: string | null previous: string | null - scopes: Array<"course-v1" | "lib"> // requested scopes + scope_types: Array<"course-v1" | "lib"> // requested scope types categories: Array<{ id: string display_name: string @@ -258,7 +275,7 @@ Response Body: description: string icon: string | null category_id: string // id of an entry of "categories" - scopes: Array<"course-v1" | "lib"> // all the scopes it supports + scope_types: Array<"course-v1" | "lib"> // all the scope types it supports }> results: Array<{ // roles, paginated role: string @@ -279,7 +296,7 @@ Example: "count": 2, "next": null, "previous": null, - "scopes": ["course-v1"], + "scope_types": ["course-v1"], "categories": [ { "id": "course_access_content", @@ -297,7 +314,7 @@ Example: "description": "View the course and its content in Studio.", "icon": "RemoveRedEye", "category_id": "course_access_content", - "scopes": ["course-v1"] + "scope_types": ["course-v1"] }, { "id": "courses.create_course", @@ -307,7 +324,7 @@ Example: "description": "Create new courses.", "icon": "Plus", "category_id": "course_access_content", - "scopes": ["course-v1"] + "scope_types": ["course-v1"] } ], "results": [ @@ -339,8 +356,8 @@ Possible response codes: - 400: Bad Request, ``scope_types`` is missing, has an empty value or has a value that is not supported. - 401: Unauthorized, the user is not authenticated. -- 403: Forbidden, the user lacks ``courses.view_course_team`` (``course``) or - ``content_libraries.view_library_team`` (``library``) in any scope, for any of the +- 403: Forbidden, the user lacks ``courses.view_course_team`` (``course-v1``) or + ``content_libraries.view_library_team`` (``lib``) in any scope, for any of the requested scope types. Consequences @@ -351,13 +368,17 @@ Consequences ``library/constants.ts``. Roles and permissions contributed by other applications show up without a frontend release. * This is a breaking change to ``GET /api/authz/v1/roles/``: the ``scope`` query parameter - becomes ``scope_types``, the response returns the requested scopes in ``scopes`` (with - backend namespaces such as ``course-v1`` and ``lib``), ``user_count`` now counts across + becomes ``scope_types``, the response returns the requested scope types in + ``scope_types`` (``course-v1`` and ``lib``), ``user_count`` now counts across the requested scope types instead of one scope, and the response adds the ``categories`` - and ``permissions`` catalogs (each permission with its ``scopes``) next to the paginated - roles. It is low risk because no released client calls the endpoint. Tests + and ``permissions`` catalogs (each permission with its ``scope_types``) next to the + paginated roles. It is low risk because no released client calls the endpoint. Tests and docs that reference the old shape must be updated, and the deviation from the compatibility promise of `ADR 0021`_ is intentional. +* ``GET /api/authz/v1/scopes/`` changes the values of its ``scope_type`` query parameter + from ``course``/``library`` to ``course-v1``/``lib``. To avoid a breaking change, the old + values are still accepted as deprecated aliases until the Admin Console migrates, which is + tracked in a ticket in ``frontend-app-admin-console``. * Implementations must load definitions with a constant number of queries, not one query per role or permission. * Every page repeats the ``categories`` and ``permissions`` catalogs, which is a small From 8ccb1375894ed5a6956c5ca3c7eb138cf577443a Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Thu, 8 Oct 2026 10:35:03 -0500 Subject: [PATCH 10/11] docs: remove user_count from the role catalog in ADR 0028 Co-Authored-By: Claude Sonnet 5.5 --- .../0028-api-contract-for-role-catalog.rst | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/docs/decisions/0028-api-contract-for-role-catalog.rst b/docs/decisions/0028-api-contract-for-role-catalog.rst index 7840bc58..ad08fe3f 100644 --- a/docs/decisions/0028-api-contract-for-role-catalog.rst +++ b/docs/decisions/0028-api-contract-for-role-catalog.rst @@ -130,9 +130,10 @@ class would then contribute a new scope type without changing authz and without Role user count =============== -``user_count`` is kept, but it is no longer calculated for a specific scope. It is now -calculated by scope type: the number of users assigned to the role across the requested -scope types. +``user_count`` is removed from the response. The Admin Console does not read it (it does +not call ``GET /api/authz/v1/roles/`` yet) and it is not expected to display it, so it is +not worth calculating a count that has no consumer. It can be added back in a later ADR if +a real use appears. Data source =========== @@ -284,7 +285,6 @@ Response Body: icon: string | null definition_kind: "static" | "user_defined" permissions: string[] // ids of entries of "permissions" - user_count: number }> } @@ -334,8 +334,7 @@ Example: "description": "Can edit and publish course content.", "icon": null, "definition_kind": "static", - "permissions": ["courses.view_course"], - "user_count": 8 + "permissions": ["courses.view_course"] }, { "role": "course_auditor", @@ -343,8 +342,7 @@ Example: "description": "Can view the course.", "icon": null, "definition_kind": "static", - "permissions": ["courses.view_course"], - "user_count": 3 + "permissions": ["courses.view_course"] } ] } @@ -369,12 +367,12 @@ Consequences without a frontend release. * This is a breaking change to ``GET /api/authz/v1/roles/``: the ``scope`` query parameter becomes ``scope_types``, the response returns the requested scope types in - ``scope_types`` (``course-v1`` and ``lib``), ``user_count`` now counts across - the requested scope types instead of one scope, and the response adds the ``categories`` - and ``permissions`` catalogs (each permission with its ``scope_types``) next to the - paginated roles. It is low risk because no released client calls the endpoint. Tests - and docs that reference the old shape must be updated, and the deviation from the - compatibility promise of `ADR 0021`_ is intentional. + ``scope_types`` (``course-v1`` and ``lib``), ``user_count`` is removed because no + client uses it, and the response adds the ``categories`` and ``permissions`` catalogs + (each permission with its ``scope_types``) next to the paginated roles. It is low risk + because no released client calls the endpoint. Tests and docs that reference the old + shape must be updated, and the deviation from the compatibility promise of `ADR 0021`_ + is intentional. * ``GET /api/authz/v1/scopes/`` changes the values of its ``scope_type`` query parameter from ``course``/``library`` to ``course-v1``/``lib``. To avoid a breaking change, the old values are still accepted as deprecated aliases until the Admin Console migrates, which is From 39c70439f0ed5163b53cc96cc994f50079b0b0a6 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Thu, 8 Oct 2026 11:09:55 -0500 Subject: [PATCH 11/11] docs: simplify permission fields and localization in ADR 0028 Co-Authored-By: Claude Sonnet 5.5 --- docs/decisions/0028-api-contract-for-role-catalog.rst | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/docs/decisions/0028-api-contract-for-role-catalog.rst b/docs/decisions/0028-api-contract-for-role-catalog.rst index ad08fe3f..eae6e97d 100644 --- a/docs/decisions/0028-api-contract-for-role-catalog.rst +++ b/docs/decisions/0028-api-contract-for-role-catalog.rst @@ -213,9 +213,7 @@ Localization ``display_name`` and ``description`` of roles, permissions and categories are returned in the language of the request, following `ADR 0020`_ and Django's normal fallback rules. -Identifiers (``role``, ``id``, ``namespace``, ``name``) and ``icon`` names are never -translated. Since the body depends on the request language, the response must vary on -``Accept-Language``. +Identifiers (``role``, ``id``) and ``icon`` names are never translated. Icons are Paragon icon names (validated by the schema, see `ADR 0026`_). The client maps the name to its component; the API only returns the name, or ``null``. @@ -270,8 +268,6 @@ Response Body: }> permissions: Array<{ id: string // complete id, e.g. "courses.view_course" - namespace: string - name: string display_name: string description: string icon: string | null @@ -308,8 +304,6 @@ Example: "permissions": [ { "id": "courses.view_course", - "namespace": "courses", - "name": "view_course", "display_name": "View course", "description": "View the course and its content in Studio.", "icon": "RemoveRedEye", @@ -318,8 +312,6 @@ Example: }, { "id": "courses.create_course", - "namespace": "courses", - "name": "create_course", "display_name": "Create course", "description": "Create new courses.", "icon": "Plus",