Skip to content

feat(api): Add the admin only endpoint to update the user role - #657

Open
crocogab wants to merge 1 commit into
pyronear:mainfrom
crocogab:feat/update-user-role
Open

feat(api): Add the admin only endpoint to update the user role#657
crocogab wants to merge 1 commit into
pyronear:mainfrom
crocogab:feat/update-user-role

Conversation

@crocogab

Copy link
Copy Markdown

Closes #656

Adds PATCH /users/{user_id}/role, restricted to the admin scope, to promote/ demote a user between agent and user.

Roles are encoded in the access token as scopes, and JWT_EXPIRE_MINUTES defaults 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_role covers 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.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 93.53%. Comparing base (f5ee92e) to head (3e0993f).

Files with missing lines Patch % Lines
src/app/api/api_v1/endpoints/users.py 90.00% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fe51 fe51 left a comment

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.

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/creds as third_login, then
    GET /login/validate and assert scopes == ["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. ?

Comment on lines +98 to +100
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.

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.

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.)

Suggested change
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.

Comment on lines +102 to +110
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")

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.

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).

Suggested change
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}
)

Comment thread src/app/crud/crud_user.py


class UserCRUD(BaseCRUD[User, User, CredHash]):
class UserCRUD(BaseCRUD[User, User, Union[CredHash, RoleUpdate]]):

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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an admin-only endpoint to update a user's role.

2 participants