Skip to content

[US-14.2 / OBT-247] Derived user role + platform-admin role management endpoint - #101

Open
levigtri wants to merge 6 commits into
mainfrom
levigft/obt-247-us-142-derived-user-role-membermanagerplatform_admin-role
Open

[US-14.2 / OBT-247] Derived user role + platform-admin role management endpoint#101
levigtri wants to merge 6 commits into
mainfrom
levigft/obt-247-us-142-derived-user-role-membermanagerplatform_admin-role

Conversation

@levigtri

@levigtri levigtri commented Jul 10, 2026

Copy link
Copy Markdown
Member

[US-14.2 / OBT-247] Derived user role + platform-admin role management endpoint

Summary

This PR exposes a derived platform role for every user returned by the users API and adds a dedicated endpoint for platform admins to manage that role. The role is not stored anywhere new — it is derived on read: a user is a platform_admin when users.is_platform_admin is set, a manager when they hold manager access on at least one project in project_user_access, and a member otherwise. Because the role is fully derived from existing columns, no Alembic migration is needed.

The new PUT /api/users/{user_id}/role endpoint lets a platform admin promote or demote any other user. Promotion to manager can grant manager access to specific projects (upserting project_user_access rows without duplicates), demotion to member downgrades all of the user's manager accesses, and a lockout guard prevents an admin from changing their own role. The manager-id lookup is done with a single distinct query so listing endpoints stay free of N+1 queries.

Changes

  1. role field on the user DTOapp/models/user.py: UserListResponse gains role: Literal["member", "manager", "platform_admin"] (via the UserRole alias) and the new UserRoleUpdate request model (role + optional project_ids).
  2. Manager-id helperapp/services/user/get_manager_user_ids.py: returns the set of user ids holding manager access on at least one project in a single SELECT DISTINCT user_id FROM project_user_access WHERE role = 'manager' query, optionally filtered by a list of user ids. This keeps GET /api/users at one extra query for the whole page instead of one per user.
  3. DTO builder with role derivationapp/services/user/build_user_list_response.py: builds UserListResponse from the ORM User plus an is_manager flag, applying the precedence platform_admin > manager > member.
  4. Role management serviceapp/services/user/set_user_role.py: validates the target user (NotFoundError), blocks self-change by the acting admin (AuthorizationError), and applies the role: platform_admin sets the flag and leaves project access untouched; manager clears the flag and upserts manager access for the given project_ids (each project validated, existing rows upgraded in place, no duplicate rows) or requires the user to already manage a project when project_ids is absent (ValidationError); member clears the flag and downgrades only that user's manager rows to member. Returns the updated UserListResponse with the derived role.
  5. Router wiringapp/api/users.py: GET /api/users, GET /api/users/search, GET /api/users/{id} and PATCH /api/users/{id} now compose the manager-id helper with the DTO builder to populate role; new PUT /api/users/{user_id}/role endpoint guarded by require_platform_admin. Services keep returning ORM users, so existing callers (app/api/auth.py) are unaffected; the router does no direct data access.
  6. Service exportsapp/services/user/__init__.py: exposes get_manager_user_ids, build_user_list_response and set_user_role.
  7. Test factorytests/baker.py: make_project_user_access accepts a keyword-only role (default "member").
  8. Role derivation teststests/test_user_service.py: member without access, member with non-manager access, manager with manager access, platform_admin precedence over manager, and a mixed list deriving all three roles.
  9. Role management teststests/test_set_user_role.py: promotion to platform_admin (access untouched), promotion to manager with project_ids (new row created and existing member row upgraded, no duplicates), manager without project_ids for a user who already manages (ok) and for one who doesn't (ValidationError), demotion to member (other users' rows and non-manager rows intact), missing user/project (NotFoundError), and self-change (AuthorizationError).

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactor (no functional changes)

Testing

uv run --group dev python -m pytest tests/test_user_service.py tests/test_set_user_role.py
uv run --group dev python -m pytest
uv run ruff check .
uv run ruff format --check .

Manual verification of the new endpoint:

# Promote a user to platform admin
curl -X PUT http://localhost:8000/api/users/<user_id>/role \
  -H "Authorization: Bearer <admin_token>" \
  -H "Content-Type: application/json" \
  -d '{"role": "platform_admin"}'

# Promote a user to manager of specific projects
curl -X PUT http://localhost:8000/api/users/<user_id>/role \
  -H "Authorization: Bearer <admin_token>" \
  -H "Content-Type: application/json" \
  -d '{"role": "manager", "project_ids": ["<project_id_1>", "<project_id_2>"]}'

# Demote a user to member (all manager accesses become member)
curl -X PUT http://localhost:8000/api/users/<user_id>/role \
  -H "Authorization: Bearer <admin_token>" \
  -H "Content-Type: application/json" \
  -d '{"role": "member"}'

# Derived role visible in listings
curl http://localhost:8000/api/users -H "Authorization: Bearer <admin_token>"

Summary by CodeRabbit

  • New Features

    • Added role management for platform administrators, managers, and members.
    • Added support for assigning managers to specific projects.
    • User listings and details now display each user’s effective role.
    • Added safeguards for invalid projects and self-role changes.
  • Tests

    • Added coverage for role promotion, demotion, project assignments, validation, and role display.

levigtri added 4 commits July 10, 2026 00:56
The existing services in app/services/user carry no docstrings; the three
new helpers introduced for the derived role feature should follow suit.
…romotion

set_user_role deduplicates project_ids before upserting access rows;
without coverage a regression would surface as an IntegrityError on the
(project_id, user_id) unique constraint.
@levigtri levigtri self-assigned this Jul 15, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

User responses now expose derived roles based on platform-admin status and manager project access. A protected role-update endpoint and service support platform-admin promotion, manager project assignment, demotion, validation, and persistence.

Changes

User role management

Layer / File(s) Summary
Role contracts and derived response construction
app/models/user.py, app/services/user/build_user_list_response.py, app/services/user/get_manager_user_ids.py, app/services/user/__init__.py, tests/test_user_service.py
Defines UserRole and UserRoleUpdate, derives manager IDs from project access, builds role-aware responses, exports the new helpers, and tests member, manager, and platform-admin role derivation.
Role assignment and project access updates
app/services/user/set_user_role.py, tests/baker.py, tests/test_set_user_role.py
Adds role mutation logic, manager-access upserts, manager demotion, validation, self-change protection, and end-to-end database coverage.
API role endpoint and manager-aware user endpoints
app/api/users.py
Updates list, search, retrieval, and user-update responses to include derived manager roles, and adds the platform-admin-only role update endpoint.

Sequence Diagram(s)

sequenceDiagram
  participant PlatformAdmin
  participant UsersAPI
  participant UserService
  participant ProjectUserAccess
  PlatformAdmin->>UsersAPI: PUT /{user_id}/role
  UsersAPI->>UserService: set_user_role(role, project_ids)
  UserService->>ProjectUserAccess: update manager or member access
  UserService-->>UsersAPI: updated UserListResponse
  UsersAPI-->>PlatformAdmin: role-aware user response
Loading

Suggested reviewers: joaocarvoli

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: derived user roles and a new platform-admin role management endpoint.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch levigft/obt-247-us-142-derived-user-role-membermanagerplatform_admin-role

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (2)
app/services/user/build_user_list_response.py-5-5 (1)

5-5: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a concise docstring for this public service function.

Proposed fix
 def build_user_list_response(user: User, *, is_manager: bool) -> UserListResponse:
+    """Build a user response with its derived platform role."""
     role: UserRole = (

As per coding guidelines, public service functions require “concise docstrings on public service functions.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/user/build_user_list_response.py` at line 5, Add a concise
docstring to the public build_user_list_response function describing that it
builds and returns a user list response, while preserving its existing
parameters and behavior.

Source: Coding guidelines

app/services/user/set_user_role.py-13-19 (1)

13-19: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the required concise docstrings to the new public service API. Both newly introduced public service functions omit the required documentation.

  • app/services/user/set_user_role.py#L13-L19: add a concise docstring describing role mutation, validation, and the returned role-aware response.
  • app/services/user/get_manager_user_ids.py#L7-L14: add a concise docstring describing manager-ID derivation and optional user filtering.

As per coding guidelines, “Keep service functions function-oriented and composable, with concise docstrings on public service functions.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/user/set_user_role.py` around lines 13 - 19, Add concise
docstrings to the public functions set_user_role in
app/services/user/set_user_role.py (lines 13-19) and get_manager_user_ids in
app/services/user/get_manager_user_ids.py (lines 7-14); describe role mutation,
validation, and the role-aware response for set_user_role, and manager-ID
derivation with optional user filtering for get_manager_user_ids.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Other comments:
In `@app/services/user/build_user_list_response.py`:
- Line 5: Add a concise docstring to the public build_user_list_response
function describing that it builds and returns a user list response, while
preserving its existing parameters and behavior.

In `@app/services/user/set_user_role.py`:
- Around line 13-19: Add concise docstrings to the public functions
set_user_role in app/services/user/set_user_role.py (lines 13-19) and
get_manager_user_ids in app/services/user/get_manager_user_ids.py (lines 7-14);
describe role mutation, validation, and the role-aware response for
set_user_role, and manager-ID derivation with optional user filtering for
get_manager_user_ids.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Pro Plus

Run ID: fcc9a2bb-31af-4740-aa4f-126964757daa

📥 Commits

Reviewing files that changed from the base of the PR and between d669a56 and 17ee690.

📒 Files selected for processing (9)
  • app/api/users.py
  • app/models/user.py
  • app/services/user/__init__.py
  • app/services/user/build_user_list_response.py
  • app/services/user/get_manager_user_ids.py
  • app/services/user/set_user_role.py
  • tests/baker.py
  • tests/test_set_user_role.py
  • tests/test_user_service.py

@levigtri

Copy link
Copy Markdown
Member Author

@coderabbitai — decisions on this review:

  1. Docstrings on build_user_list_response, set_user_role, get_manager_user_ids:
    Won't do. Docstrings are intentionally omitted in this codebase — they were deliberately dropped earlier in this very PR (commit "drop function docstrings to match service conventions"), so re-adding them would reverse that.

  2. Skipped test (the "@claude" mention):
    No test in this PR is skipped. The two skipped the suite reports are pre-existing project_health concurrency tests that require a real Postgres backend (aiosqlite can't honour FOR UPDATE); they're unrelated to this US. The "claude" item is the on-demand @claude review CI job, which is skipped by design — it is not a pytest test.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@levigtri Thanks for the clarification on both points — makes sense.

  1. Understood on the docstrings — noted that this is an intentional convention in the codebase, not an oversight.
  2. Good to know the project_health skips are Postgres-only concurrency tests and the @claude mention refers to the on-demand CI job rather than a pytest test. That clears up my concern.

I won't flag either of these again.


✏️ Learnings added
Learnt from: levigtri
URL: https://github.com/shemaobt/tripod-api/pull/101

Timestamp: 2026-07-16T01:58:37.140Z
Learning: In the shemaobt/tripod-api repository, function-level docstrings are intentionally omitted from service functions (e.g., under app/services/) as a deliberate convention — a commit titled "drop function docstrings to match service conventions" removed them. Do not flag missing docstrings on service functions as a review comment.

Learnt from: levigtri
URL: https://github.com/shemaobt/tripod-api/pull/101

Timestamp: 2026-07-16T01:58:37.140Z
Learning: In the shemaobt/tripod-api repository, the test suite has pre-existing `project_health` concurrency tests that are skipped when run against aiosqlite because it cannot honor `FOR UPDATE` locking; these require a real Postgres backend and are unrelated to unrelated feature PRs. Additionally, "claude" in CI refers to an on-demand `claude` review CI job (not a pytest test), which is skipped by design.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@levigtri
levigtri requested a review from joaocarvoli July 16, 2026 02:05

@joaocarvoli joaocarvoli 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.

some comments

Comment thread app/api/users.py
@@ -53,7 +56,24 @@ async def update_user(
is_platform_admin=payload.is_platform_admin,

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.

Just make sure here: the new PUT /{user_id}/role refuses a self role change, but this PATCH still accepts is_platform_admin with no guard, so the admin can demote himself anyway by this route. The lockout is really effective then? Could you explore that @levigtri — if touching the PATCH is out of scope here that's ok, but then maybe the guard is only giving a false sense of protection.

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.

2 participants