Skip to content

[US-16.1 / OBT-252] Keep platform admins out of project access - #104

Open
levigtri wants to merge 5 commits into
mainfrom
levigft/obt-252-us-161-keep-platform-admins-out-of-project-access-not-listed
Open

[US-16.1 / OBT-252] Keep platform admins out of project access#104
levigtri wants to merge 5 commits into
mainfrom
levigft/obt-252-us-161-keep-platform-admins-out-of-project-access-not-listed

Conversation

@levigtri

@levigtri levigtri commented Jul 13, 2026

Copy link
Copy Markdown
Member

[US-16.1 / OBT-252] Keep platform admins out of project access

Summary

Platform Admin accounts must never appear in the per-project access surface. They already manage every project implicitly — every authorization guard (assert_project_access, assert_can_grant_access, list_projects_for_user, require_platform_admin) short-circuits on the users.is_platform_admin boolean — so a platform admin does not need, and must not hold, a project_user_access row. Today they leak in two ways: the project-creation flow writes the creator in as manager (and creation is admin-driven), and the member listing/grant paths never look at is_platform_admin. This change makes the API the source of truth for the rule: admins can't be added to a project, can't be given a project role, and don't show up in a project's member list. Because admins keep full access through the boolean, nothing about their capabilities changes. No schema change → no Alembic migration.

Enforcement lives at the project_user_access write choke point (grant_user_access), so it also covers the oral-collector invite-acceptance path, plus the direct role-update and listing services.

Scope note (branched from main). The team_size counter (count_project_team_sizes) and the global role-management endpoint (set_user_role / PUT /users/{id}/role) are not on main yet — they live in still-open review branches. Their platform-admin coherence (exclude admins from team_size; drop memberships when a user is promoted to platform_admin) is intentionally out of scope here and should be folded in when those branches land.

Changes

  1. Reject adding a platform admin — app/services/project/grant_user_access.py
    • Loads the target user (via get_user_by_id, so an unknown user_id now raises NotFoundError → 404) and raises ValidationError (400) when the target is a platform admin, before any row is written. This is the single write path for project_user_access, so the guard also covers oral-collector invite acceptance.
  2. Reject giving a platform admin a project role — app/services/project/update_user_access_role.py
    • Raises ValidationError (400) when the target user is a platform admin, before the access-row lookup.
  3. Don't grant membership to an admin creator — app/services/project/create_project.py
    • When creator_user_id is set, the creator is granted manager access only if they are not a platform admin. A non-admin creator (e.g. a manager whose creation request was approved) still receives manager; an admin-created project has no admin member.
  4. Exclude admins from the member listing — app/services/project/list_project_user_access.py
    • Adds User.is_platform_admin.is_(False) to the join so any admin with a legacy access row is hidden from the project's member list.
  5. Tests — tests/test_platform_admin_project_access.py (new)
    • Covers: grant rejects a platform admin (400); grant of an unknown user (404); role-update rejects a platform admin (400); listing excludes admins; and an admin creator gets no membership row.

Type of Change

  • New feature (non-breaking change which adds functionality)
  • Bug fix (platform admins no longer leak into project access)
  • Breaking change
  • Refactor / chore

Testing

  1. uv run ruff check . and uv run ruff format --check . pass on the changed files.
  2. uv run --group dev python -m pytest tests/test_platform_admin_project_access.py — 5 passed (grant/role-update rejection, unknown-user 404, list exclusion, admin-creator no-membership).
  3. Regression: uv run --group dev python -m pytest tests/test_project_service.py tests/test_oc_invite_service.py — existing project-access and invite-acceptance behavior unchanged (non-admin grants, idempotency, listing, revoke, invite accept).

Summary by CodeRabbit

  • Bug Fixes

    • Platform administrators are no longer automatically granted project membership when creating projects.
    • Prevented platform administrators from being granted project access or receiving project roles.
    • Project access listings now exclude platform administrators.
    • Added clearer validation for invalid or unknown user IDs when managing project access.
  • Tests

    • Added new test coverage to verify the above platform-administrator restrictions for grant, role updates, listing, and project creation.

levigtri added 2 commits July 13, 2026 15:24
Platform admins already manage every project implicitly (all authorization
guards short-circuit on is_platform_admin), so they must not hold a
project_user_access row. Enforce this at the write choke point and the
listing:

- grant_user_access: load the target user and reject a platform admin with
  ValidationError (400); unknown user now raises NotFoundError (404). This
  also covers the oral-collector invite-acceptance path.
- update_user_access_role: reject a platform-admin target with
  ValidationError (400).
- create_project: grant the creator manager access only when the creator is
  not a platform admin, so admin-created projects have no admin member.
- list_project_user_access: exclude platform admins from a project's members.

No schema change.
Grant/role-update rejection (400), unknown-user 404, listing exclusion, and
admin-creator getting no membership row.
@linear-code

linear-code Bot commented Jul 13, 2026

Copy link
Copy Markdown

OBT-252

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Pro Plus

Run ID: 2b372f1c-0cc8-4cfe-940f-8372881ff237

📥 Commits

Reviewing files that changed from the base of the PR and between 4871942 and acd29e6.

📒 Files selected for processing (1)
  • tests/test_platform_admin_project_access.py

📝 Walkthrough

Walkthrough

Project access services now prevent platform admins from receiving project memberships or roles, exclude them from access listings, and avoid creating creator memberships for admin-created projects. New async tests cover these behaviors and unknown-user validation.

Changes

Platform admin project access

Layer / File(s) Summary
Access grant and role validation
app/services/project/grant_user_access.py, app/services/project/update_user_access_role.py
User lookup is performed before access changes; platform admins are rejected with ValidationError, while unknown users produce NotFoundError.
Project creation and access listing
app/services/project/create_project.py, app/services/project/list_project_user_access.py
Project creators receive manager access only when they are not platform admins, and access listings filter out platform admins.
Platform admin access coverage
tests/test_platform_admin_project_access.py
Tests cover rejected grants and roles, unknown users, filtered listings, and project creation by platform admins.
🚥 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 clearly matches the main change: excluding platform admins from project access.
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-252-us-161-keep-platform-admins-out-of-project-access-not-listed

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.

🧹 Nitpick comments (2)
tests/test_platform_admin_project_access.py (1)

34-41: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add unknown-user coverage for update_user_access_role.

Only grant_user_access has a test for the unknown-user/NotFoundError path (lines 23-31). update_user_access_role resolves the user via the same get_user_by_id helper, so it should get a symmetric test.

Suggested test addition
`@pytest.mark.asyncio`
async def test_update_user_access_role_rejects_unknown_user(db_session) -> None:
    lang = await make_language(db_session, code="kos")
    project = await make_project(db_session, language_id=lang.id)

    with pytest.raises(NotFoundError, match=r"User .* not found"):
        await project_service.update_user_access_role(
            db_session, project.id, "00000000-0000-0000-0000-000000000000", "manager"
        )
🤖 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 `@tests/test_platform_admin_project_access.py` around lines 34 - 41, Add a
symmetric async test for project_service.update_user_access_role covering an
unknown user ID: create the language and project, call the method with a
nonexistent UUID, and assert NotFoundError with the existing “User .* not found”
message pattern.
app/services/project/grant_user_access.py (1)

15-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate platform-admin rejection logic across two services.

Both grant_user_access and update_user_access_role fetch the target user and raise ValidationError when is_platform_admin is true, differing only in message text. Extracting a shared helper avoids the two copies drifting apart as this policy evolves.

  • app/services/project/grant_user_access.py#L15-L19: replace the inline check with a call to a shared helper (e.g., assert_not_platform_admin(target, context="added to a project")).
  • app/services/project/update_user_access_role.py#L15-L19: replace the inline check with the same shared helper (e.g., assert_not_platform_admin(target, context="receive a project role")).
Suggested shared helper
# e.g. app/services/user/guard_not_platform_admin.py
from app.core.exceptions import ValidationError
from app.db.models.auth import User


def guard_not_platform_admin(user: User, action: str) -> None:
    if user.is_platform_admin:
        raise ValidationError(
            f"Platform admins cannot {action}; they already manage every project."
        )
 # grant_user_access.py
-    target = await get_user_by_id(db, user_id)
-    if target.is_platform_admin:
-        raise ValidationError(
-            "Platform admins cannot be added to a project; they already manage every project."
-        )
+    target = await get_user_by_id(db, user_id)
+    guard_not_platform_admin(target, "be added to a project")
 # update_user_access_role.py
-    target = await get_user_by_id(db, user_id)
-    if target.is_platform_admin:
-        raise ValidationError(
-            "Platform admins cannot receive a project role; they already manage every project."
-        )
+    target = await get_user_by_id(db, user_id)
+    guard_not_platform_admin(target, "receive a project role")
🤖 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/project/grant_user_access.py` around lines 15 - 19, Extract the
duplicated platform-admin validation into a shared helper, such as
guard_not_platform_admin, preserving the existing ValidationError behavior while
accepting an action/context for the message. In
app/services/project/grant_user_access.py lines 15-19, replace the inline check
with the helper using the project-addition context; apply the same replacement
in app/services/project/update_user_access_role.py lines 15-19 using the
project-role context.
🤖 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.

Nitpick comments:
In `@app/services/project/grant_user_access.py`:
- Around line 15-19: Extract the duplicated platform-admin validation into a
shared helper, such as guard_not_platform_admin, preserving the existing
ValidationError behavior while accepting an action/context for the message. In
app/services/project/grant_user_access.py lines 15-19, replace the inline check
with the helper using the project-addition context; apply the same replacement
in app/services/project/update_user_access_role.py lines 15-19 using the
project-role context.

In `@tests/test_platform_admin_project_access.py`:
- Around line 34-41: Add a symmetric async test for
project_service.update_user_access_role covering an unknown user ID: create the
language and project, call the method with a nonexistent UUID, and assert
NotFoundError with the existing “User .* not found” message pattern.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Pro Plus

Run ID: b5fcf480-835b-4533-8a41-7fa19278c20d

📥 Commits

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

📒 Files selected for processing (5)
  • app/services/project/create_project.py
  • app/services/project/grant_user_access.py
  • app/services/project/list_project_user_access.py
  • app/services/project/update_user_access_role.py
  • tests/test_platform_admin_project_access.py

@levigtri levigtri self-assigned this Jul 15, 2026
levigtri and others added 2 commits July 15, 2026 16:58
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@levigtri

Copy link
Copy Markdown
Member Author

@coderabbitai — decisions on this review:

  1. grant_user_access.py / update_user_access_role.py (extract a shared guard_not_platform_admin helper):
    Won't do (nitpick). The duplication is ~2 lines and the two sites raise different messages ("cannot be added to a project" vs "cannot receive a project role"); a one-function-per-file helper for that adds more indirection than it removes.

  2. tests/test_platform_admin_project_access.py (symmetric test for update_user_access_role with an unknown user):
    Done. Added test_update_user_access_role_rejects_unknown_user asserting NotFoundError ("User .* not found"), mirroring the existing grant-path 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 detailed follow-up.

  1. Understood on the helper extraction — agreed that with only ~2 lines of duplication and distinct error messages, a shared guard_not_platform_admin helper would add more indirection than value. No action needed.

  2. Great, test_update_user_access_role_rejects_unknown_user closes the gap nicely and keeps coverage symmetric with the grant-path test.

@levigtri
levigtri requested a review from joaocarvoli July 16, 2026 01:45

@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

.where(ProjectUserAccess.project_id == project_id)
.where(
ProjectUserAccess.project_id == project_id,
User.is_platform_admin.is_(False),

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.

Your scope note says the team_size counter is not on main yet, but get_member_counts (oral_collector/project_member_counts.py) is there and counts the project_user_access rows raw, and it feeds member_count on GET /oral-collector/projects. So a legacy admin row stays inside the count while this line hides it from the list — the OC project card says 3 members and the members page shows 2. Could you verify that? Maybe it is another counter that you meant.

role: str = "member",
) -> ProjectUserAccess:
target = await get_user_by_id(db, user_id)
if target.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 about the invite path: create_invite never looks at is_platform_admin, so an admin can still be invited, and now accept_invite raises this 400 before setting the status — the invite stays pending forever and only decline works. The body says the guard covers that path, but it covers it by failing. Is that correct? @levigtri

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