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:

GET /api/authz/v1/roles/?scope_type=course
Response Body:#
{
    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:

{
    "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#