hataa 0032579017
feat(authz): admin role-assignment API for configured custom roles (#4063 gap 2) (#6382)
* feat(authz): admin role-assignment API for configured custom roles (#4063 gap 2)

The RBAC provider accepts arbitrary configured role names (guest, ...),
but nothing could assign them to real users: User.system_role was pinned
to Literal["admin", "user"] at the API layer while the DB column stayed
a plain String(16) for exactly this future, and zhfeng's #3462 audit
listed the missing assignment surface as gap 2 — custom roles were
declarable in YAML but unreachable for authenticated web users.

- User/UserResponse.system_role widens to a non-empty string; whether a
  role is *assignable* is the admin API's contract, not the model's.
- RbacAuthorizationProvider exposes known_roles; the Gateway's new
  assignable_role_names() unions the built-ins with the provider's
  configured role names (custom providers: built-ins only).
- New admin-only router /api/v1/admin/users: GET lists users with their
  role; PATCH assigns — role validated against assignable names (an
  unknown role would silently fall through to default_role on every
  decision), 404 for missing users, and the last remaining admin cannot
  be demoted (bootstrap lockout protection). Enforcement is unchanged:
  the assignment only changes which role the principal carries; the
  @require_permission route guards and runtime provider decisions remain
  the single enforcement points.
- SQLite repository gains list_users (ordered oldest-first).

Tests: router guard/422/404/409/no-op/happy-path, last-admin invariant
(both directions), assignable names union, principal carries the
configured role; the two old Literal-rejection contract tests updated to
the new contract (custom role representable; empty string still
rejected).

* fix(auth): widen the frontend user schema for custom roles (review-lessons catch)

The role-assignment API's review-lessons sweep found the one consumer
class the implementation had not enumerated: the FRONTEND parses /me
with a strict userSchema whose system_role was z.enum(["admin",
"user"]) — a user assigned a provider-configured custom role (guest)
would fail userSchema.parse in server.ts and break the auth flow before
rendering anything.

- types.ts: system_role widens to z.string().min(1), kept in lockstep
  with the backend model (min_length=1). All existing consumers compare
  === "admin", so any other value is simply non-admin — no behavior
  change for admin/user.
- server.test.ts: contract tests — custom role parses verbatim; empty
  string still rejected.
- config.example.yaml: the authorization comment now says custom roles
  become reachable via PATCH /api/v1/admin/users/{id} and that a
  declared-but-unassigned role grants nothing.

* fix(auth): commit the admin_users router, drop the stale type-ignore, document the surface

Review catch on #6382: the router module was never staged — app.py and
the tests import it, so the pushed branch could not boot (ImportError in
create_app). The local tree kept it as an untracked file, which is why
every check passed before the push.

- Actually commit app/gateway/routers/admin_users.py.
- _row_to_user: drop the now-stale '# type: ignore[arg-type]' on
  system_role — the model field is a plain string now (keep the comma).
- README: the authorization paragraph now mentions the admin
  role-assignment surface (GET/PATCH /api/v1/admin/users, admin-only,
  last-admin demotion protected, declared-but-unassigned grants nothing).
- server.test.ts import-order lint fix from the frontend sweep.

* fix(auth): field-scoped role writes close the demotion-undo race

Review round 2 on #6382 — three findings, one design:

- [P1] update_user copied system_role from the caller's account snapshot,
  so a password change that read the user as admin could restore a role
  concurrently revoked by the demotion route (stale-snapshot write-back).
  Account writes are now field-scoped against each other: update_user is
  the credential/profile writer (never touches the role, mirrors the
  row's current role back), update_system_role is the only role writer
  (single-column, never touches credentials or token_version).
- The last-admin lockout check moved inside the role write's serialized
  transaction: count-then-write across two sessions let two concurrent
  demotions of the only two admins both pass. Same idiom as
  create_first_admin — BEGIN IMMEDIATE on SQLite, pg_advisory_xact_lock
  on a distinct key on Postgres, raise on unknown dialects.
- UserNotFoundError from the racing-delete window maps to the route's
  404 instead of a 500.

Tests 11->12: real-repo regression covering the P1 interleaving
(demotion lands -> stale credential snapshot writes -> role stays
revoked, credentials applied), field isolation both directions, and the
last-admin invariant raised from inside the write with the row intact.
Mutation-verified: restoring the role write-back in update_user fails
it.

* style(auth): format the server auth tests

* fix(tests): migrate the personal-MCP demote revocation to update_system_role

The role-write field-scoping (66e2cec23) made update_user a
credential/profile-only writer, so the demote branch of
test_revocation_blocks_discovery... silently no-oped and CI failed on
all four shards (assert 2 == 1 — the role never changed).

- The demote now goes through update_system_role, the sole role writer.
- The fixture gains a second admin: the revocation test demotes its sole
  synthetic admin, which the new last-admin lockout guard correctly
  blocks — a second admin keeps the scenario legal without weakening
  either invariant.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-07 14:59:16 +08:00

96 lines
4.0 KiB
Python

"""Admin user-management surface: list users and assign system roles.
RFC #4063 / issue #3462 gap 2: the RBAC provider accepts arbitrary configured
role names (``guest``, ...), but no surface could assign them to real users —
``User.system_role`` was pinned to admin/user at the API layer while the DB
column stayed a plain string for exactly this future. This router is that
surface: admin-only (§12 Q6 — management surfaces stay admin-gated), role
names validated against the provider's configured roles, and the last
remaining admin cannot be demoted (bootstrap lockout protection).
Enforcement is unchanged: the assignment only changes which role the
principal carries; the Gateway's ``@require_permission`` route guards and the
runtime provider decisions remain the single enforcement points.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from app.gateway.auth.repositories.base import LastAdminRemainsError, UserNotFoundError
from app.gateway.authz import assignable_role_names
from app.gateway.deps import get_user_repository, require_admin_user
if TYPE_CHECKING:
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from app.gateway.auth.models import UserResponse
router = APIRouter(prefix="/api/v1/admin/users", tags=["admin-users"])
class UserRoleUpdate(BaseModel):
"""Assignment payload: a built-in or provider-configured role name."""
system_role: str = Field(..., min_length=1, max_length=16, description="Built-in (admin/user) or a role configured in the authorization provider's roles mapping")
def _to_response(user) -> UserResponse:
return UserResponse(
id=str(user.id),
email=str(user.email),
system_role=str(user.system_role),
needs_setup=bool(getattr(user, "needs_setup", False)),
oauth_provider=getattr(user, "oauth_provider", None),
)
@router.get("")
async def list_users(request: Request, repository: SQLiteUserRepository = Depends(get_user_repository)) -> list[UserResponse]:
"""List all users with their system role (admin-only)."""
await require_admin_user(request, detail="admin user listing requires an admin credential")
users = await repository.list_users()
return [_to_response(user) for user in users]
@router.patch("/{user_id}")
async def update_user_role(
request: Request,
user_id: UUID,
update: UserRoleUpdate,
repository: SQLiteUserRepository = Depends(get_user_repository),
) -> UserResponse:
"""Assign a user's system role (admin-only).
The role must be a built-in ("admin"/"user") or declared in the
authorization provider's roles configuration — an unknown name would make
the user's principal fall through to ``default_role`` on every decision,
which is never what an operator intended. Demoting the last remaining
admin is rejected so a deployment cannot lock itself out of management.
"""
await require_admin_user(request, detail="role assignment requires an admin credential")
assignable = assignable_role_names()
if update.system_role not in assignable:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"unknown role '{update.system_role}'; assignable roles: {sorted(assignable)}",
)
try:
# Single serialized column write: the last-admin count and the write
# share one transaction (two concurrent demotions cannot both pass),
# and credential writers can never race this — see
# SQLiteUserRepository.update_system_role / update_user.
user = await repository.update_system_role(str(user_id), update.system_role)
except UserNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from None
except LastAdminRemainsError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="cannot demote the last remaining admin") from None
return _to_response(user)