feat(api): Add the admin only endpoint to update the user role - #657
feat(api): Add the admin only endpoint to update the user role#657crocogab wants to merge 1 commit into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #657 +/- ##
==========================================
- Coverage 93.54% 93.53% -0.01%
==========================================
Files 59 59
Lines 3096 3108 +12
==========================================
+ Hits 2896 2907 +11
- Misses 200 201 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
fe51
left a comment
There was a problem hiding this comment.
Hi there, nice to meet you and thanks for you PR.
Nice once, clean scoping, and the test matrix is thorough. Ran the suite locally, all green.
Good calls: separate /role sub-path (no ambiguity with the password PATCH), admin excluded at
the schema level so it shows up in the OpenAPI enum, and the self-demotion guard actually works
(TokenPayload.sub is an int, so no str/int trap). Cross-org behaviour matches #633, and no
pyroclient change is needed since it doesn't wrap /users.
Three things to fix, all inline: a wrong setting name in the docstring, an endpoint branch no test
reaches, and telemetry firing before the guards.
On the token staleness: fine to ship as documented — the common case is granting rights, and
"log out / log back in" covers it. But a demotion being a no-op for up to a year deserves its own
issue rather than just a docstring.
Beyond the inline suggestions, a few tests that would be nice to have (none blocking):
- The actual effect on scopes — promote user 3,
POST /login/credsasthird_login, then
GET /login/validateand assertscopes == ["agent"]. That's what the issue is really about, and
it documents the staleness caveat in executable form. Same for the demotion direction. GET /users/{id}after the PATCH, to check persistence independently of the response body.{"role": "camera"}→ 422, and a camera-scoped token → 403. ?
| Beware that the role is baked into the access tokens that were already issued, and those are long-lived | ||
| (see `JWT_EXPIRE_MINUTES`). The new role only applies to tokens minted afterwards, so the user has to log in | ||
| again for the change to take effect. |
There was a problem hiding this comment.
User tokens use JWT_UNLIMITED (365 d), not JWT_EXPIRE_MINUTES (60, never used for user tokens).
As written, someone would lower JWT_EXPIRE_MINUTES expecting a shorter window and nothing would
happen. (Same slip in the PR description — the "one year" figure is right.)
| Beware that the role is baked into the access tokens that were already issued, and those are long-lived | |
| (see `JWT_EXPIRE_MINUTES`). The new role only applies to tokens minted afterwards, so the user has to log in | |
| again for the change to take effect. | |
| Beware that the role is baked into the access tokens that were already issued, and those last a year | |
| (`JWT_UNLIMITED`, see `login_with_creds`). The new role only applies to tokens minted afterwards, so the | |
| user has to log in again for the change to take effect. |
| telemetry_client.capture( | ||
| token_payload.sub, event="user-role", properties={"user_id": user_id, "role": payload.role} | ||
| ) | ||
| if user_id == token_payload.sub: | ||
| raise HTTPException(status.HTTP_403_FORBIDDEN, "Admins cannot change their own role : it can lead to deadlock") | ||
|
|
||
| user = cast(User, await users.get(user_id, strict=True)) | ||
| if user.role == UserRole.ADMIN: | ||
| raise HTTPException(status.HTTP_403_FORBIDDEN, "Cannot change an admin's role") |
There was a problem hiding this comment.
A 403/404 currently emits a user-role event indistinguishable from a real change. Moving it after the guards fixes that. Also folding in a wording nit ("lockout" rather than "deadlock", and the space before the colon).
| telemetry_client.capture( | |
| token_payload.sub, event="user-role", properties={"user_id": user_id, "role": payload.role} | |
| ) | |
| if user_id == token_payload.sub: | |
| raise HTTPException(status.HTTP_403_FORBIDDEN, "Admins cannot change their own role : it can lead to deadlock") | |
| user = cast(User, await users.get(user_id, strict=True)) | |
| if user.role == UserRole.ADMIN: | |
| raise HTTPException(status.HTTP_403_FORBIDDEN, "Cannot change an admin's role") | |
| if user_id == token_payload.sub: | |
| raise HTTPException(status.HTTP_403_FORBIDDEN, "Admins cannot change their own role: it would lock them out") | |
| user = cast(User, await users.get(user_id, strict=True)) | |
| if user.role == UserRole.ADMIN: | |
| raise HTTPException(status.HTTP_403_FORBIDDEN, "Cannot change an admin's role") | |
| telemetry_client.capture( | |
| token_payload.sub, event="user-role", properties={"user_id": user_id, "role": payload.role} | |
| ) |
|
|
||
|
|
||
| class UserCRUD(BaseCRUD[User, User, CredHash]): | ||
| class UserCRUD(BaseCRUD[User, User, Union[CredHash, RoleUpdate]]): |
There was a problem hiding this comment.
juste voc
Union[CredHash, RoleUpdate] has no runtime effect (BaseCRUD.update only calls model_dump) and
will need extending with every new patch schema. BaseModel would be more stable. Fine as is too.
Closes #656
Adds
PATCH /users/{user_id}/role, restricted to theadminscope, to promote/ demote a user betweenagentanduser.Roles are encoded in the access token as scopes, and
JWT_EXPIRE_MINUTESdefaults to one year. A role change therefore only applies to tokens minted afterwards, so the user has to log in again for it to take effect. This is documented in the endpoint docs.test_update_user_rolecovers 401, 403 (agent and user scopes), 403 (self-demotion), 403 (admin target), 422 (user_id=0,role=admin, empty body), 404 for nominal transitions and no modif case.