Files
Metamorph/backend/app/services/users.py

205 lines
6.7 KiB
Python
Raw Permalink Normal View History

feat(m3): RBAC — atomic perms, groups, users, admin SPA pages Permission catalogue (services/permissions_seed.py) - 31 atomic codes across 10 families: user.*, group.*, invitation.*, test_template.*, scenario_template.*, mission.* (incl. mission.write_red_fields + mission.write_blue_fields), detection_level.{read,update}, setting.{read,update}, mitre.sync. - Default bindings: admin = all 31; redteam = 8 (catalogue read + mission. {read,create,update,archive,write_red_fields} + detection_level.read); blueteam = 5 (catalogue read + mission.{read,write_blue_fields} + detection_level.read). - Seed runs at boot AND after /setup so a freshly truncated DB (via /diag/reset) gets the bindings back via the bootstrap path. Idempotent + additive (never removes a perm from a system group). Users admin (services/users.py + api/users.py) - list (q + is_active filter + pagination), get, patch (display_name / locale / is_active with tri-state sentinel for clear-vs-unset), soft-delete, set groups. - Last-admin protection on update (deactivate), delete, and group-strip (refusing to remove the admin group from the last active admin). Groups admin (services/groups.py + api/groups.py) - Full CRUD with system-group protection (no rename, no delete on admin/redteam/blueteam). - PUT /groups/{id}/permissions sets the perm list. - Admin system group's perm set is locked to the full catalogue (SystemGroupProtected → 409) — preserves the bypass invariant even if a future refactor moves to perm-based checks. Permissions read-only (api/permissions.py) - GET /permissions returns the catalogue (admin or group.read holders). /diag/reset extension - After truncate + token mint, the limiter is also reset (limiter.reset()) so the Playwright suite doesn't hit 10/min budgets across spec files. Guarded by limiter.enabled to no-op in APP_ENV=test. Rate-limit scope (core/rate_limit.py) - enabled = APP_ENV in ("prod", "staging"). A staging deployment serves humans, so it gets the limits too. Dev/test stay unthrottled for Playwright ergonomics. Spec §6 NF-security is an operator-facing requirement. Frontend chrome - components/RequireAdmin.tsx + ui/Modal.tsx (reusable centered dialog with accessible name + Escape + backdrop-click). - Layout.tsx shows Admin nav links only when is_admin === true. Server remains the arbiter — non-admins hitting /admin/* get redirected to /. Frontend pages - pages/AdminUsersPage.tsx, AdminGroupsPage.tsx, AdminInvitationsPage.tsx with edit modals using TanStack Query mutations + multi-select for perms grouped by family + copy-once invitation URL display. - lib/admin.ts: shared types + query keys + groupPermsByFamily helper. - lib/api.ts: apiPatch / apiPut / apiDelete added. Playwright config (e2e/playwright.config.ts) - workers: 1 + fullyParallel: false: spec files share the live Postgres, so concurrent /diag/reset calls clobber each other. Intra-file order preserved via test.describe.configure({ mode: 'serial' }). Testing - backend/tests/test_rbac.py: 15 integration tests (39 backend total — 1 health + 8 schema + 15 auth + 15 RBAC). - e2e/tests/m3-rbac.spec.ts: 8 Playwright tests covering DoD §10 #2/#3 (28 e2e total — 8 M0 + 4 M1 + 8 M2 + 8 M3). - tasks/testing-m3.md. DoD: make test-api → 39 passed, make e2e → 28 passed. Spec-reviewer pass applied (admin perm invariant + staging rate-limit scope). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:17:07 +02:00
"""Admin-side user management: list, get, update, soft-delete, assign groups.
Self-service updates (locale, password, display_name) live in
`services.auth` this module is for admin operations on other users.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterable
from sqlalchemy import func, or_, select
from app.db.session import session_scope
from app.models.auth import Group, User, UserGroup
from app.services.bootstrap import ADMIN_GROUP_NAME
class UserNotFound(Exception):
pass
class LastAdminProtected(Exception):
"""Refusing to strip admin from the last active admin."""
class SystemGroupProtected(Exception):
"""Refusing to delete or rename a built-in system group."""
@dataclass(frozen=True)
class UserView:
id: uuid.UUID
email: str
display_name: str | None
locale: str
is_active: bool
deleted_at: datetime | None
created_at: datetime
updated_at: datetime
groups: list[tuple[uuid.UUID, str]]
def _to_view(u: User) -> UserView:
return UserView(
id=u.id,
email=u.email,
display_name=u.display_name,
locale=u.locale,
is_active=u.is_active,
deleted_at=u.deleted_at,
created_at=u.created_at,
updated_at=u.updated_at,
groups=[(g.id, g.name) for g in u.groups if g.deleted_at is None],
)
def list_users(
*,
q: str | None = None,
is_active: bool | None = None,
include_deleted: bool = False,
limit: int = 50,
offset: int = 0,
) -> tuple[list[UserView], int]:
"""Return (rows, total_count) with case-insensitive search on email + display_name."""
with session_scope() as s:
stmt = select(User)
count_stmt = select(func.count()).select_from(User)
if not include_deleted:
stmt = stmt.where(User.deleted_at.is_(None))
count_stmt = count_stmt.where(User.deleted_at.is_(None))
if is_active is not None:
stmt = stmt.where(User.is_active.is_(is_active))
count_stmt = count_stmt.where(User.is_active.is_(is_active))
if q:
like = f"%{q.lower()}%"
stmt = stmt.where(
or_(func.lower(User.email).like(like), func.lower(User.display_name).like(like))
)
count_stmt = count_stmt.where(
or_(func.lower(User.email).like(like), func.lower(User.display_name).like(like))
)
stmt = stmt.order_by(User.email.asc()).limit(limit).offset(offset)
rows = s.scalars(stmt).all()
total = int(s.scalar(count_stmt) or 0)
views = [_to_view(u) for u in rows]
return views, total
def get_user(user_id: uuid.UUID, *, include_deleted: bool = False) -> UserView:
with session_scope() as s:
u = s.get(User, user_id)
if u is None or (u.deleted_at is not None and not include_deleted):
raise UserNotFound()
return _to_view(u)
def update_user(
user_id: uuid.UUID,
*,
display_name: str | None | object = ...,
locale: str | None = None,
is_active: bool | None = None,
) -> UserView:
"""Partial update. Pass display_name=None to clear; omit to leave unchanged."""
with session_scope() as s:
u = s.get(User, user_id)
if u is None or u.deleted_at is not None:
raise UserNotFound()
if display_name is not ...:
if display_name in (None, ""):
u.display_name = None
else:
u.display_name = display_name.strip() or None
if locale is not None:
u.locale = locale
if is_active is not None:
# If deactivating the last active admin, refuse.
if not is_active and _is_last_active_admin(s, u):
raise LastAdminProtected("cannot deactivate the last active admin")
u.is_active = is_active
return _to_view(u)
def soft_delete_user(user_id: uuid.UUID) -> None:
with session_scope() as s:
u = s.get(User, user_id)
if u is None or u.deleted_at is not None:
raise UserNotFound()
if _is_last_active_admin(s, u):
raise LastAdminProtected("cannot delete the last active admin")
u.deleted_at = datetime.now(tz=timezone.utc)
u.is_active = False
def set_user_groups(user_id: uuid.UUID, group_ids: Iterable[uuid.UUID]) -> UserView:
"""Replace the user's group memberships with the given set."""
desired = set(group_ids)
with session_scope() as s:
u = s.get(User, user_id)
if u is None or u.deleted_at is not None:
raise UserNotFound()
# Resolve admin group id once.
admin_group_id = s.scalar(
select(Group.id).where(Group.name == ADMIN_GROUP_NAME, Group.is_system.is_(True))
)
is_currently_admin = admin_group_id in {g.id for g in u.groups}
will_be_admin = admin_group_id in desired
if is_currently_admin and not will_be_admin and _is_last_active_admin(s, u):
raise LastAdminProtected("cannot remove admin from the last active admin")
# Refuse silently for unknown groups: validate first.
if desired:
known = set(
s.scalars(
select(Group.id).where(Group.id.in_(desired), Group.deleted_at.is_(None))
).all()
)
unknown = desired - known
if unknown:
raise ValueError(f"unknown groups: {sorted(map(str, unknown))}")
current = {g.id for g in u.groups}
to_add = desired - current
to_remove = current - desired
for gid in to_remove:
row = s.get(UserGroup, (u.id, gid))
if row is not None:
s.delete(row)
for gid in to_add:
s.add(UserGroup(user_id=u.id, group_id=gid))
s.flush()
s.refresh(u)
return _to_view(u)
def _is_last_active_admin(s, user: User) -> bool:
"""True when `user` is currently in the admin system group and removing/blocking
them would leave the platform with zero active admins."""
admin_group_id = s.scalar(
select(Group.id).where(Group.name == ADMIN_GROUP_NAME, Group.is_system.is_(True))
)
if admin_group_id is None:
return False
if admin_group_id not in {g.id for g in user.groups}:
return False
other_admins = s.scalar(
select(func.count())
.select_from(User)
.join(UserGroup, UserGroup.user_id == User.id)
.where(
UserGroup.group_id == admin_group_id,
User.id != user.id,
User.deleted_at.is_(None),
User.is_active.is_(True),
)
)
return int(other_admins or 0) == 0