Skip to content
287 changes: 287 additions & 0 deletions docs/decisions/0024-api-contract-for-user-grouped-role-assignments.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
0024: API Contract for User-Grouped Role Assignments
#####################################################

Status
********

**Draft**

Context
*********

The new `Figma design`_ and product requirements for the Team Members tab in the
Admin Console change how assignments are presented: instead of listing one row
per assignment, the tab now groups assignments by user, showing one row per user
with their assignments nested underneath. The existing endpoints for gathering
user assignment data don't return all the fields this user-grouped view needs.
Before we can continue building it, we need to define a contract, either for a new
endpoint or for a backwards-compatible change to an existing one, that provides
those fields.

Following that design, the view displays a table of users. Each row shows the
username, email, a single scope the related assigned role, along with a control to
expand the row and reveal up to three assigned roles and the user's total number
of assigned roles.
Comment on lines +21 to +24

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think pointing here to the public figma design will illustrate it better, at least to the most stable version of the design

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been using this as reference: https://www.figma.com/design/xnmQJq1cTRVqNrs31R8zDM/AuthZ---v2?node-id=40000174-1081

I'm asking María de los Ángeles what would be the best link to add. Thanks for the suggestion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and added the link.


The table can be searched by username, email, or full name; sorted by username,
full name or email; filtered by organization, role, or scope; and is paginated.

Decision
**********

Extend the existing ``/api/authz/v1/users/`` endpoint, defined in ``TeamMembersAPIView``,
to include a list of assignments for each user.

This endpoint was originally created for an earlier version of the Team Members
tab that was deprioritized and never implemented in a previous phase of the RBAC
project. At that time, the view did not expose each user's assigned roles, only a
total count.

As part of this change, the existing ``assignation_count`` field is renamed to
``assignment_count``. The rest of the repository consistently uses "assignment"
(e.g. ``RoleAssignmentData``, ``get_visible_role_assignments_for_user``, the
``/api/authz/v1/users/<username>/assignments/`` endpoint), so ``assignation_count``
is an inconsistent outlier. Renaming it now keeps the new user-grouped fields
(``assignments`` and ``assignment_count``) aligned with that convention. The field
is safe to rename because the ``GET /api/authz/v1/users/`` endpoint is not called
at all by
`frontend-app-admin-console <https://github.com/openedx/frontend-app-admin-console>`_,
the only client of the AuthZ API. Its current Team Members table is sourced from
the assignment-grouped ``GET /api/authz/v1/assignments/`` endpoint, so neither the
endpoint nor the ``assignation_count`` field has any released consumer.

The embedded assignments are not paginated. Each user includes only the first n
assignments, where n defaults to 3 and can be overridden by the ``assignments_limit``
query parameter.


REST API for Team Members view
=================================

The existing ``/api/authz/v1/users/`` endpoint will be extended to return a list of
assignments per user. The number of assignments returned is capped by the
``assignments_limit`` parameter, which defaults to 3.

API Definition
--------------

GET /api/authz/v1/users/
^^^^^^^^^^^^^^^^^^^^^^^^^

Retrieve all users that have at least one role assignment (team members). Results
are filtered according to the calling user's scope-level view permissions.

Query Parameters:
"""""""""""""""""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is missing the roles filter

Suggested change
`roles`` (optional): Comma-separated list of roles to filter by (e.g. ``course_auditor,library_admin``).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I've added it, thanks!

- ``roles`` (optional): Comma-separated list of roles to filter by (e.g. ``course_auditor,library_admin``).
- ``scopes`` (optional): Comma-separated list of scopes to filter by (e.g.
``lib:Org1:LIB1``).
- ``orgs`` (optional): Comma-separated list of orgs to filter by (e.g. ``Org1,Org2``).
- ``search`` (optional): Search term to filter users by username, full name, or email.

@carlos-marquez-wgu carlos-marquez-wgu Sep 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the full name field here? Is this something visible in the UI?

edit: I see it is just the user's full name, wondering if we should allow to search by this given is not visible in the UI, it can return results where the search string isn't visible, I think it might be confusing but maybe no big deal

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not being displayed in the design, but in past discussions with Guillermo we decided to include it in the search fields.

- ``assignments_limit`` (optional): Maximum number of assignments to populate in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should set an upper limit for assignment_limit? That way, we prevent someone from requesting, for example, 1000 assignments with page_size=50, which could be quite expensive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what would be a good number? maybe 10?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think 10 is fine.

the ``assignments`` array for each user. Defaults to 3. The full total is always
reported in ``assignment_count``.
- ``sort_by`` (optional): Field to sort by. Options: ``username``, ``full_name``,
``email``. Defaults to ``username``.
- ``order`` (optional): Sort order, ``asc`` or ``desc``. Defaults to ``asc``.
- ``page`` (optional): Page number for pagination.
- ``page_size`` (optional): Number of items per page.

Example:

.. code::

GET /api/authz/v1/users/?roles=library_admin&orgs=Org1&search=john&assignments_limit=3&sort_by=username&order=asc&page=1&page_size=10

Response Body:
""""""""""""""

Format:

.. code:: ts

{
count: number
next: string | null
previous: string | null
results: Array<{
username: string
full_name: string
email: string
assignment_count: number
assignments: Array<{
is_superadmin: boolean

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we removed this while we properly support django staff / superuser. Here are the relevant issues:
#347
#345

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I wasn't aware of this, so we are no longer showing superadmins at all?

role: string
org: string
scope: string
scope_display_name: string
permission_count: number
Comment on lines +114 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to confirm if these fields in assignments are sufficient to display the information in the table correctly, especially when the scope is a Glob type (org and platform). I was thinking maybe it would be good to have a scope_type field?

cc @dcoa

}>
}>
}

The ``assignments`` array is populated with up to ``assignments_limit`` entries
(default 3), while ``assignment_count`` always reflects the user's total number of
assignments regardless of the limit.

Each assignment includes a ``scope_display_name`` field alongside the existing
``scope`` key. This is a new field relative to the current assignment-shaped
endpoints (``GET /api/authz/v1/assignments/`` and
``GET /api/authz/v1/users/<username>/assignments/``), which only return the
``scope`` key. The UI needs the human-readable name to label each assignment, so
``scope_display_name`` carries it while ``scope`` remains the stable machine
identifier.

The display name is not stored in the authorization policy store; it lives in the
platform models (``CourseOverview.display_name`` for courses and the library's
``learning_package.title`` for content libraries). Because assignments are read
from the policy store, resolving names requires reading those models. Implementers
must fetch the names in bulk (a single batched lookup per scope type per page,
keyed by scope), following the batching pattern already used for user data
(``get_user_map``) and for scope display names in ``ScopesAPIView``. Resolving the
name per assignment row would introduce N+1 queries and must be avoided.

For assignments that have no single concrete resource, such as superadmin entries
or glob scopes, ``scope_display_name`` has no meaningful value and is returned as
an empty string. If an assignment references a scope whose backing course or
library no longer exists, the name cannot be resolved and is likewise returned as
an empty string.

The example below shows the different scope kinds an assignment can reference at
the time of writing: a specific library, a specific course, an organization-level
glob (all libraries or all courses in an org), and a platform-level glob (all
libraries or all courses platform-wide). Specific scopes resolve to a
``scope_display_name``; glob scopes have no single backing resource, so
``scope_display_name`` is an empty string.

.. code:: json

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to include glob scopes in the example (org and platform)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added examples for courses and glob scopes. Thanks for the suggestion!


{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"username": "jane_doe",
"full_name": "Jane Doe",
"email": "jane_doe@example.com",
"assignment_count": 3,
"assignments": [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing this will return all types of assignments, right? Will the UI ever have a scope type filter?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, all type of assignments, the UI as I understand it only filters by specific scopes, not by type, but let me confirm this with María de los Ángeles.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed by María de los Ángeles: it only filters by specific scopes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought assignments would be paginated, not only truncated.

EDIT: I see in the consequences that this won't be immediately supported.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, not paginated, as the current UI design only shows a "preview" of the assignments, to see more, it links to the single user page which uses a different endpoint.

{
"is_superadmin": false,
"role": "library_admin",
"org": "Org1",
"scope": "lib:Org1:LIB1",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the table shows the name of the scope instead of the id

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. I added a new "scope_display_name" key and documented edge cases and implementation details.

"scope_display_name": "Intro to CS Library",
"permission_count": 11
},
{
"is_superadmin": false,
"role": "course_staff",
"org": "Org1",
"scope": "course-v1:Org1+CS101+2024",
"scope_display_name": "Introduction to Computer Science",
"permission_count": 27
},
{
"is_superadmin": false,
"role": "library_admin",
"org": "Org1",
"scope": "lib:Org1:*",
"scope_display_name": "",
"permission_count": 11
}
]
},
{
"username": "john_doe",
"full_name": "John Doe",
"email": "john_doe@example.com",
"assignment_count": 2,
"assignments": [
{
"is_superadmin": false,
"role": "course_staff",
"org": "Org2",
"scope": "course-v1:Org2+*",
"scope_display_name": "",
"permission_count": 27
},
{
"is_superadmin": false,
"role": "library_user",
"org": "*",
"scope": "lib:*",
"scope_display_name": "",
"permission_count": 4
}
]
}
]
}

Notes on the scope kinds shown above:

- ``lib:Org1:LIB1`` and ``course-v1:Org1+CS101+2024`` are specific scopes, so
their ``scope_display_name`` is resolved from the backing library/course.
- ``lib:Org1:*`` and ``course-v1:Org2+*`` are organization-level globs; ``org``
reflects the org (``Org1``, ``Org2``) and ``scope_display_name`` is empty.
- ``lib:*`` is a platform-level glob; ``org`` is ``"*"`` and
``scope_display_name`` is empty.

Possible response codes:
""""""""""""""""""""""""

- 200: Ok, includes the Response Body defined above.
- 400: Bad Request, happens when the request parameters are invalid.
- 401: Unauthorized, happens when the user is not authenticated/logged in.
- 403: Forbidden, happens when the user does not have the required permissions.

Consequences
************

- The existing /api/authz/v1/users/ endpoint will be extended to return the
additional data: a nested ``assignments`` array per user and the renamed
``assignment_count`` field.
- Each nested assignment gains a ``scope_display_name`` field, which is an addition
compared to the existing assignment-shaped endpoints that return only the
``scope`` key. Since display names are not held in the policy store, this field
requires reading the platform course/library models. Implementation must resolve
these names with batched, per-page lookups to avoid N+1 query performance issues.
- The endpoint gains a new ``roles`` query parameter that filters the returned
users by a comma-separated list of roles, supporting the role filter in the
Team Members view.
- Renaming ``assignation_count`` to ``assignment_count`` is technically a breaking
change to the response body. It is low-risk here because
frontend-app-admin-console does not call the ``GET /api/authz/v1/users/`` endpoint
at all, so no released client depends on the field. Any internal tests or
fixtures referencing ``assignation_count`` must still be updated.
- Both ``assignments`` and ``assignment_count`` reflect only the assignments the
calling user is permitted to see, so values may differ between viewers for the
same target user.
- The nested ``assignments`` array is truncated to ``assignments_limit`` and is
not paginated. A follow-up is needed if the UI requires a defined order for the
truncated entries, since no ordering is currently guaranteed within a user's
assignments.


Rejected Alternatives
*********************

- Creating a new endpoint: The /api/authz/v1/users/ endpoint already provides most
of the required logic, and it was originally created for this use case.
Extending it is the simplest and most direct solution.

- Loading each user's assignments dynamically from the frontend after the initial
/api/authz/v1/users/ response: This is suboptimal because it creates N+1 requests
for a single page, increasing load time and placing unnecessary strain on the
server.


References
**********

- `Figma design`_ for the Team Members tab in the Admin Console.

.. _Figma design: https://www.figma.com/design/xnmQJq1cTRVqNrs31R8zDM/AuthZ---v2?node-id=40000174-1081