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>
This commit is contained in:
Knacky
2026-05-11 06:17:07 +02:00
parent 700b563297
commit bb23bf3928
15 changed files with 2634 additions and 0 deletions

169
backend/app/api/groups.py Normal file
View File

@@ -0,0 +1,169 @@
"""Admin endpoints for groups + their permission bindings."""
from __future__ import annotations
import logging
import uuid
from flask import Blueprint, jsonify, request
from pydantic import BaseModel, Field, ValidationError
from app.core.auth_decorators import require_auth, require_perm
from app.services import groups as groups_svc
bp = Blueprint("groups", __name__, url_prefix="/groups")
log = logging.getLogger("metamorph.api.groups")
def _serialize(g: groups_svc.GroupView) -> dict:
return {
"id": str(g.id),
"name": g.name,
"description": g.description,
"is_system": g.is_system,
"members_count": g.members_count,
"permissions": g.permissions,
"created_at": g.created_at.isoformat(),
"updated_at": g.updated_at.isoformat(),
}
class CreateGroupPayload(BaseModel):
name: str = Field(min_length=1, max_length=80)
description: str | None = Field(default=None, max_length=2000)
model_config = {"extra": "forbid"}
class UpdateGroupPayload(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=80)
description: str | None = Field(default=None, max_length=2000)
model_config = {"extra": "forbid"}
class SetPermissionsPayload(BaseModel):
codes: list[str] = Field(default_factory=list)
model_config = {"extra": "forbid"}
def _parse_uuid_or_400(raw: str):
try:
return uuid.UUID(raw)
except ValueError:
return None
@bp.get("")
@require_auth
@require_perm("group.read")
def list_groups():
rows = groups_svc.list_groups()
return jsonify({"items": [_serialize(g) for g in rows], "total": len(rows)})
@bp.get("/<group_id>")
@require_auth
@require_perm("group.read")
def get_group(group_id: str):
gid = _parse_uuid_or_400(group_id)
if gid is None:
return jsonify({"error": "invalid_id"}), 400
try:
g = groups_svc.get_group(gid)
except groups_svc.GroupNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize(g))
@bp.post("")
@require_auth
@require_perm("group.create")
def create_group():
try:
payload = CreateGroupPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
g = groups_svc.create_group(name=payload.name, description=payload.description)
except groups_svc.GroupNameConflict as e:
return jsonify({"error": "name_conflict", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info("metamorph.group.created", extra={"group_id": str(g.id), "group_name": g.name})
return jsonify(_serialize(g)), 201
@bp.patch("/<group_id>")
@require_auth
@require_perm("group.update")
def update_group(group_id: str):
gid = _parse_uuid_or_400(group_id)
if gid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateGroupPayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
description_unset = "description" not in raw
try:
g = groups_svc.update_group(
gid,
name=payload.name,
description=... if description_unset else payload.description,
)
except groups_svc.GroupNotFound:
return jsonify({"error": "not_found"}), 404
except groups_svc.SystemGroupProtected as e:
return jsonify({"error": "system_group_protected", "message": str(e)}), 409
except groups_svc.GroupNameConflict as e:
return jsonify({"error": "name_conflict", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info("metamorph.group.updated", extra={"group_id": str(gid), "fields": sorted(raw.keys())})
return jsonify(_serialize(g))
@bp.delete("/<group_id>")
@require_auth
@require_perm("group.delete")
def soft_delete(group_id: str):
gid = _parse_uuid_or_400(group_id)
if gid is None:
return jsonify({"error": "invalid_id"}), 400
try:
groups_svc.soft_delete_group(gid)
except groups_svc.GroupNotFound:
return jsonify({"error": "not_found"}), 404
except groups_svc.SystemGroupProtected as e:
return jsonify({"error": "system_group_protected", "message": str(e)}), 409
log.info("metamorph.group.soft_deleted", extra={"group_id": str(gid)})
return jsonify({"ok": True})
@bp.put("/<group_id>/permissions")
@require_auth
@require_perm("group.update")
def set_permissions(group_id: str):
gid = _parse_uuid_or_400(group_id)
if gid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = SetPermissionsPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
g = groups_svc.set_group_permissions(gid, payload.codes)
except groups_svc.GroupNotFound:
return jsonify({"error": "not_found"}), 404
except groups_svc.SystemGroupProtected as e:
return jsonify({"error": "system_group_protected", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.group.permissions_set",
extra={"group_id": str(gid), "count": len(payload.codes)},
)
return jsonify(_serialize(g))

View File

@@ -0,0 +1,17 @@
"""Read-only catalogue of platform permission codes."""
from __future__ import annotations
from flask import Blueprint, jsonify
from app.core.auth_decorators import require_auth, require_perm
from app.services import groups as groups_svc
bp = Blueprint("permissions", __name__, url_prefix="/permissions")
@bp.get("")
@require_auth
@require_perm("group.read")
def list_permissions():
return jsonify({"items": groups_svc.list_permissions()})

185
backend/app/api/users.py Normal file
View File

@@ -0,0 +1,185 @@
"""Admin endpoints for user management.
Note: self-service updates (own display name, locale, password) belong to
`/auth/*`; this blueprint is admin-only.
"""
from __future__ import annotations
import logging
import uuid
from flask import Blueprint, jsonify, request
from pydantic import BaseModel, Field, ValidationError
from app.core.auth_decorators import require_auth, require_perm
from app.services import users as users_svc
bp = Blueprint("users", __name__, url_prefix="/users")
log = logging.getLogger("metamorph.api.users")
def _serialize(u: users_svc.UserView) -> dict:
return {
"id": str(u.id),
"email": u.email,
"display_name": u.display_name,
"locale": u.locale,
"is_active": u.is_active,
"deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
"created_at": u.created_at.isoformat(),
"updated_at": u.updated_at.isoformat(),
"groups": [{"id": str(gid), "name": name} for gid, name in u.groups],
}
class UpdateUserPayload(BaseModel):
# display_name: omitted = no change, null = clear, str = set.
# Tri-state encoded with a `default-unset` sentinel via model_extra.
display_name: str | None = None
locale: str | None = Field(default=None, pattern=r"^[a-z]{2}$")
is_active: bool | None = None
model_config = {"extra": "forbid"}
class SetGroupsPayload(BaseModel):
group_ids: list[uuid.UUID]
model_config = {"extra": "forbid"}
def _parse_uuid_or_400(raw: str):
try:
return uuid.UUID(raw)
except ValueError:
return None
@bp.get("")
@require_auth
@require_perm("user.read")
def list_users():
q = request.args.get("q") or None
is_active_raw = request.args.get("is_active")
is_active: bool | None
if is_active_raw is None:
is_active = None
elif is_active_raw.lower() in ("true", "1", "yes"):
is_active = True
elif is_active_raw.lower() in ("false", "0", "no"):
is_active = False
else:
return jsonify({"error": "invalid_is_active"}), 400
try:
limit = int(request.args.get("limit", "50"))
offset = int(request.args.get("offset", "0"))
except ValueError:
return jsonify({"error": "invalid_pagination"}), 400
limit = max(1, min(limit, 200))
offset = max(0, offset)
rows, total = users_svc.list_users(q=q, is_active=is_active, limit=limit, offset=offset)
return jsonify(
{
"items": [_serialize(u) for u in rows],
"total": total,
"limit": limit,
"offset": offset,
}
)
@bp.get("/<user_id>")
@require_auth
@require_perm("user.read")
def get_user(user_id: str):
uid = _parse_uuid_or_400(user_id)
if uid is None:
return jsonify({"error": "invalid_id"}), 400
try:
u = users_svc.get_user(uid)
except users_svc.UserNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize(u))
@bp.patch("/<user_id>")
@require_auth
@require_perm("user.update")
def update_user(user_id: str):
uid = _parse_uuid_or_400(user_id)
if uid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateUserPayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
# Distinguish "key absent" (no change) from "key=null" (clear) for display_name.
display_name_unset = "display_name" not in raw
try:
u = users_svc.update_user(
uid,
display_name=... if display_name_unset else payload.display_name,
locale=payload.locale,
is_active=payload.is_active,
)
except users_svc.UserNotFound:
return jsonify({"error": "not_found"}), 404
except users_svc.LastAdminProtected as e:
return jsonify({"error": "last_admin_protected", "message": str(e)}), 409
log.info(
"metamorph.user.updated",
extra={
"user_id": str(uid),
"fields": sorted(raw.keys()),
},
)
return jsonify(_serialize(u))
@bp.delete("/<user_id>")
@require_auth
@require_perm("user.delete")
def soft_delete(user_id: str):
uid = _parse_uuid_or_400(user_id)
if uid is None:
return jsonify({"error": "invalid_id"}), 400
try:
users_svc.soft_delete_user(uid)
except users_svc.UserNotFound:
return jsonify({"error": "not_found"}), 404
except users_svc.LastAdminProtected as e:
return jsonify({"error": "last_admin_protected", "message": str(e)}), 409
log.info("metamorph.user.soft_deleted", extra={"user_id": str(uid)})
return jsonify({"ok": True})
@bp.put("/<user_id>/groups")
@require_auth
@require_perm("user.update")
def set_groups(user_id: str):
uid = _parse_uuid_or_400(user_id)
if uid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = SetGroupsPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
u = users_svc.set_user_groups(uid, payload.group_ids)
except users_svc.UserNotFound:
return jsonify({"error": "not_found"}), 404
except users_svc.LastAdminProtected as e:
return jsonify({"error": "last_admin_protected", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.user.groups_set",
extra={"user_id": str(uid), "groups": [str(g) for g in payload.group_ids]},
)
return jsonify(_serialize(u))

View File

@@ -0,0 +1,210 @@
"""Admin-side group management: CRUD + permission bindings.
System groups (`is_system=True`: admin, redteam, blueteam) cannot be renamed
or deleted, but their permission bindings are seeded on boot and editable
afterwards (e.g. an admin can broaden `redteam`).
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import func, select
from app.db.session import session_scope
from app.models.auth import Group, GroupPermission, Permission, UserGroup
from app.services.bootstrap import ADMIN_GROUP_NAME
class GroupNotFound(Exception):
pass
class GroupNameConflict(Exception):
pass
class SystemGroupProtected(Exception):
"""Refusing to delete or rename a built-in system group."""
@dataclass(frozen=True)
class GroupView:
id: uuid.UUID
name: str
description: str | None
is_system: bool
deleted_at: datetime | None
members_count: int
permissions: list[str]
created_at: datetime
updated_at: datetime
def _to_view(g: Group, members_count: int) -> GroupView:
return GroupView(
id=g.id,
name=g.name,
description=g.description,
is_system=g.is_system,
deleted_at=g.deleted_at,
members_count=members_count,
permissions=sorted(p.code for p in g.permissions),
created_at=g.created_at,
updated_at=g.updated_at,
)
def _members_counts(s, group_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not group_ids:
return {}
from app.models.auth import User as _U # local to avoid model cycles
rows = s.execute(
select(UserGroup.group_id, func.count(UserGroup.user_id))
.join(_U, _U.id == UserGroup.user_id)
.where(UserGroup.group_id.in_(group_ids), _U.deleted_at.is_(None))
.group_by(UserGroup.group_id)
).all()
return {gid: int(cnt) for gid, cnt in rows}
def list_groups(*, include_deleted: bool = False) -> list[GroupView]:
with session_scope() as s:
stmt = select(Group).order_by(Group.is_system.desc(), Group.name.asc())
if not include_deleted:
stmt = stmt.where(Group.deleted_at.is_(None))
rows = s.scalars(stmt).all()
counts = _members_counts(s, [g.id for g in rows])
return [_to_view(g, counts.get(g.id, 0)) for g in rows]
def get_group(group_id: uuid.UUID) -> GroupView:
with session_scope() as s:
g = s.get(Group, group_id)
if g is None or g.deleted_at is not None:
raise GroupNotFound()
counts = _members_counts(s, [g.id])
return _to_view(g, counts.get(g.id, 0))
def create_group(*, name: str, description: str | None) -> GroupView:
name_norm = name.strip()
if not name_norm:
raise ValueError("name is required")
with session_scope() as s:
existing = s.scalar(
select(Group).where(Group.name == name_norm, Group.deleted_at.is_(None))
)
if existing is not None:
raise GroupNameConflict(f"group name {name_norm!r} already in use")
g = Group(name=name_norm, description=(description or "").strip() or None, is_system=False)
s.add(g)
s.flush()
return _to_view(g, 0)
def update_group(
group_id: uuid.UUID,
*,
name: str | None = None,
description: str | None | object = ...,
) -> GroupView:
with session_scope() as s:
g = s.get(Group, group_id)
if g is None or g.deleted_at is not None:
raise GroupNotFound()
if name is not None:
name_norm = name.strip()
if not name_norm:
raise ValueError("name cannot be empty")
if g.is_system and name_norm != g.name:
raise SystemGroupProtected("system groups cannot be renamed")
if name_norm != g.name:
clash = s.scalar(
select(Group).where(
Group.name == name_norm,
Group.deleted_at.is_(None),
Group.id != g.id,
)
)
if clash is not None:
raise GroupNameConflict(f"group name {name_norm!r} already in use")
g.name = name_norm
if description is not ...:
if description in (None, ""):
g.description = None
else:
g.description = description.strip() or None
counts = _members_counts(s, [g.id])
return _to_view(g, counts.get(g.id, 0))
def soft_delete_group(group_id: uuid.UUID) -> None:
with session_scope() as s:
g = s.get(Group, group_id)
if g is None or g.deleted_at is not None:
raise GroupNotFound()
if g.is_system:
raise SystemGroupProtected("system groups cannot be deleted")
g.deleted_at = datetime.now(tz=timezone.utc)
def set_group_permissions(group_id: uuid.UUID, codes: list[str]) -> GroupView:
"""Replace the group's permission set with the given codes (validated)."""
desired_codes = set(codes)
with session_scope() as s:
g = s.get(Group, group_id)
if g is None or g.deleted_at is not None:
raise GroupNotFound()
# Preserve the invariant "the system `admin` group has every perm." The
# decorator's admin bypass relies on `is_admin` (group membership), not
# on the perm set, so a stripped admin group would still grant access —
# but the listing would look misleading and a future refactor could
# reasonably switch the bypass to a perm-based check.
if g.is_system and g.name == ADMIN_GROUP_NAME:
all_codes = {p.code for p in s.scalars(select(Permission)).all()}
if desired_codes != all_codes:
raise SystemGroupProtected(
"the admin group must keep every permission"
)
if desired_codes:
perms = s.scalars(select(Permission).where(Permission.code.in_(desired_codes))).all()
known = {p.code for p in perms}
unknown = desired_codes - known
if unknown:
raise ValueError(f"unknown permission codes: {sorted(unknown)}")
else:
perms = []
current = {p.code: p for p in g.permissions}
to_remove = set(current) - desired_codes
to_add = desired_codes - set(current)
for code in to_remove:
row = s.get(GroupPermission, (g.id, current[code].id))
if row is not None:
s.delete(row)
for p in perms:
if p.code in to_add:
s.add(GroupPermission(group_id=g.id, permission_id=p.id))
s.flush()
s.refresh(g)
counts = _members_counts(s, [g.id])
return _to_view(g, counts.get(g.id, 0))
def list_permissions() -> list[dict]:
"""Return the catalogue of all permissions known to the platform."""
with session_scope() as s:
rows = s.scalars(select(Permission).order_by(Permission.code.asc())).all()
return [
{"id": str(p.id), "code": p.code, "description": p.description}
for p in rows
]

View File

@@ -0,0 +1,179 @@
"""Atomic permission catalogue + seed for the 3 default system groups.
Permissions follow the `<entity>.<action>` convention. They are the ground truth
checked by `@require_perm`; admins bypass everything (cf. `auth_decorators.py`).
This module is the single place that lists every permission code shipped with
the platform. To add a new perm in a future milestone:
1. Add an entry to `PERMISSION_CATALOGUE`.
2. Decide which system group(s) should get it by default — edit
`_default_redteam_perms()` / `_default_blueteam_perms()` if relevant
(admin always gets everything, so no edit needed there).
3. The next boot picks it up; existing groups are *upgraded* (perms added),
never downgraded (we never remove perms from a system group, even if you
trim the catalogue — that would be a destructive op disguised as a seed).
The seed is idempotent and safe to call on every boot.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from sqlalchemy import select
from app.db.session import session_scope
from app.models.auth import Group, GroupPermission, Permission
from app.services.bootstrap import (
ADMIN_GROUP_NAME,
BLUETEAM_GROUP_NAME,
REDTEAM_GROUP_NAME,
)
log = logging.getLogger("metamorph.permissions")
@dataclass(frozen=True)
class PermissionDef:
code: str
description: str
# === Catalogue ================================================================
#
# Order is presentation-only; the seed is idempotent. Grouped by family to keep
# diffs reviewable and to mirror the admin UI grouping in M3.6.
#
PERMISSION_CATALOGUE: tuple[PermissionDef, ...] = (
# users
PermissionDef("user.read", "View users."),
PermissionDef("user.create", "Create users (typically via invitation)."),
PermissionDef("user.update", "Update user metadata (display name, locale, active flag)."),
PermissionDef("user.delete", "Soft-delete a user."),
# groups
PermissionDef("group.read", "View groups and their permissions."),
PermissionDef("group.create", "Create a custom group."),
PermissionDef("group.update", "Edit a custom group (name, description, permissions, members)."),
PermissionDef("group.delete", "Soft-delete a custom group."),
# invitations
PermissionDef("invitation.read", "View pending invitations."),
PermissionDef("invitation.create", "Issue a new invitation URL."),
PermissionDef("invitation.revoke", "Revoke an unconsumed invitation."),
# test templates
PermissionDef("test_template.read", "View the test-template catalogue."),
PermissionDef("test_template.create", "Create a test template."),
PermissionDef("test_template.update", "Edit a test template."),
PermissionDef("test_template.delete", "Soft-delete a test template."),
# scenario templates
PermissionDef("scenario_template.read", "View the scenario-template catalogue."),
PermissionDef("scenario_template.create", "Create a scenario template."),
PermissionDef("scenario_template.update", "Edit a scenario template (and its ordered tests)."),
PermissionDef("scenario_template.delete", "Soft-delete a scenario template."),
# missions
PermissionDef("mission.read", "View missions (server still filters by membership for non-admin)."),
PermissionDef("mission.create", "Create a mission."),
PermissionDef("mission.update", "Edit mission metadata, scenarios, members."),
PermissionDef("mission.archive", "Move a mission to status=archived."),
PermissionDef("mission.delete", "Soft-delete a mission."),
PermissionDef("mission.write_red_fields", "Write red-side fields on a mission test."),
PermissionDef("mission.write_blue_fields", "Write blue-side fields and upload evidence."),
# detection levels + platform settings + MITRE sync
PermissionDef("detection_level.read", "View the detection-level taxonomy."),
PermissionDef("detection_level.update", "Edit the detection-level taxonomy."),
PermissionDef("setting.read", "Read platform settings."),
PermissionDef("setting.update", "Update platform settings."),
PermissionDef("mitre.sync", "Trigger a MITRE ATT&CK Enterprise re-sync."),
)
def _default_redteam_perms() -> frozenset[str]:
return frozenset(
{
# catalogue read-only
"test_template.read",
"scenario_template.read",
# MITRE/detection refs
"detection_level.read",
# missions: full lifecycle on red side
"mission.read",
"mission.create",
"mission.update",
"mission.archive",
"mission.write_red_fields",
}
)
def _default_blueteam_perms() -> frozenset[str]:
return frozenset(
{
"test_template.read",
"scenario_template.read",
"detection_level.read",
"mission.read",
"mission.write_blue_fields",
}
)
def _all_perm_codes() -> frozenset[str]:
return frozenset(p.code for p in PERMISSION_CATALOGUE)
def seed_permissions() -> dict[str, int]:
"""Insert any missing permissions. Returns counts: `created`, `total`."""
created = 0
with session_scope() as s:
existing_codes = set(s.scalars(select(Permission.code)).all())
for p in PERMISSION_CATALOGUE:
if p.code in existing_codes:
continue
s.add(Permission(code=p.code, description=p.description))
created += 1
return {"created": created, "total": len(PERMISSION_CATALOGUE)}
def _assign_perms_to_group(group_name: str, codes: frozenset[str]) -> int:
"""Attach the named perms to the given system group. Returns added count.
We never *remove* perms from a system group here — the seed is additive.
Admins/operators who want to revoke must do so explicitly via the UI/API.
"""
if not codes:
return 0
added = 0
with session_scope() as s:
group = s.scalar(select(Group).where(Group.name == group_name, Group.is_system.is_(True)))
if group is None:
raise RuntimeError(f"system group {group_name!r} missing — call ensure_system_groups() first")
existing_codes = {p.code for p in group.permissions}
perms = s.scalars(select(Permission).where(Permission.code.in_(codes))).all()
for p in perms:
if p.code in existing_codes:
continue
s.add(GroupPermission(group_id=group.id, permission_id=p.id))
added += 1
return added
def seed_default_group_permissions() -> dict[str, int]:
"""Bind the catalogue to the 3 default groups. Idempotent + additive."""
counts: dict[str, int] = {}
counts[ADMIN_GROUP_NAME] = _assign_perms_to_group(ADMIN_GROUP_NAME, _all_perm_codes())
counts[REDTEAM_GROUP_NAME] = _assign_perms_to_group(REDTEAM_GROUP_NAME, _default_redteam_perms())
counts[BLUETEAM_GROUP_NAME] = _assign_perms_to_group(BLUETEAM_GROUP_NAME, _default_blueteam_perms())
return counts
def seed_all() -> dict[str, dict[str, int]]:
"""One-shot helper: catalogue + default group bindings."""
perms = seed_permissions()
bindings = seed_default_group_permissions()
log.info(
"metamorph.permissions.seeded",
extra={"perms_created": perms["created"], "perms_total": perms["total"], "bindings": bindings},
)
return {"permissions": perms, "bindings": bindings}

View File

@@ -0,0 +1,204 @@
"""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

344
backend/tests/test_rbac.py Normal file
View File

@@ -0,0 +1,344 @@
"""Integration tests for M3: permission seed + users/groups/permissions APIs.
Exercises the Flask test client against a live Postgres. The DB is wiped at
module load so test ordering inside the module matters (see `pytest.shared_*`).
"""
from __future__ import annotations
import secrets
import pytest
from sqlalchemy import text
from app.core.install_token import regenerate_install_token
from app.main import create_app
from app.services.permissions_seed import PERMISSION_CATALOGUE
def _truncate_all(engine):
"""Wipe data plus permissions table. CASCADE handles dependent rows."""
with engine.begin() as conn:
conn.execute(
text(
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
"user_groups, group_permissions, permissions, settings, groups "
"RESTART IDENTITY CASCADE"
)
)
@pytest.fixture(scope="module")
def app(db_engine_or_skip):
_truncate_all(db_engine_or_skip)
flask_app = create_app() # triggers bootstrap → seed_all()
flask_app.config.update(TESTING=True)
return flask_app
@pytest.fixture()
def client(app):
return app.test_client()
def _unique_email(prefix: str) -> str:
return f"{prefix}-{secrets.token_hex(4)}@metamorph.local"
def _login(client, email: str, password: str) -> str:
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert r.status_code == 200, r.get_data(as_text=True)
return r.get_json()["access_token"]
# -- M3.1 — Permissions seeded at boot -----------------------------------------
def test_permissions_catalogue_seeded(client):
"""Catalogue table has every code from PERMISSION_CATALOGUE."""
# We need an admin to call /permissions — bootstrap one via /setup.
token = regenerate_install_token()
email = _unique_email("admin")
r = client.post(
"/api/v1/setup",
json={
"install_token": token,
"email": email,
"password": "AdminPass1234!",
"display_name": "Admin",
},
)
assert r.status_code == 201, r.get_data(as_text=True)
pytest.shared_admin = {"email": email, "password": "AdminPass1234!", "user_id": r.get_json()["user_id"]} # type: ignore[attr-defined]
access = _login(client, email, "AdminPass1234!")
perms = client.get(
"/api/v1/permissions", headers={"Authorization": f"Bearer {access}"}
).get_json()
codes = {p["code"] for p in perms["items"]}
expected = {p.code for p in PERMISSION_CATALOGUE}
assert expected.issubset(codes)
def test_admin_group_has_every_permission(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
groups = client.get(
"/api/v1/groups", headers={"Authorization": f"Bearer {access}"}
).get_json()
admin_group = next(g for g in groups["items"] if g["name"] == "admin")
assert set(admin_group["permissions"]) == {p.code for p in PERMISSION_CATALOGUE}
assert admin_group["is_system"] is True
def test_redteam_group_has_red_perms_but_not_blue_write(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
groups = client.get(
"/api/v1/groups", headers={"Authorization": f"Bearer {access}"}
).get_json()
redteam = next(g for g in groups["items"] if g["name"] == "redteam")
assert "mission.write_red_fields" in redteam["permissions"]
assert "mission.write_blue_fields" not in redteam["permissions"]
assert "mission.create" in redteam["permissions"]
def test_blueteam_group_has_blue_perm_but_not_red_write(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
groups = client.get(
"/api/v1/groups", headers={"Authorization": f"Bearer {access}"}
).get_json()
blueteam = next(g for g in groups["items"] if g["name"] == "blueteam")
assert "mission.write_blue_fields" in blueteam["permissions"]
assert "mission.write_red_fields" not in blueteam["permissions"]
assert "mission.create" not in blueteam["permissions"]
# -- M3.2 — Users CRUD ---------------------------------------------------------
def _invite_user(client, admin_access: str, email: str, password: str, group_ids: list[str] | None = None) -> str:
"""Create + accept an invitation, return the new user's id."""
create = client.post(
"/api/v1/invitations",
headers={"Authorization": f"Bearer {admin_access}"},
json={"email_hint": email, "group_ids": group_ids or []},
)
assert create.status_code == 201, create.get_data(as_text=True)
token = create.get_json()["token"]
accept = client.post(
f"/api/v1/invitations/accept/{token}",
json={"email": email, "password": password, "display_name": email.split("@")[0]},
)
assert accept.status_code == 201, accept.get_data(as_text=True)
return accept.get_json()["user_id"]
def test_admin_lists_users(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
r = client.get("/api/v1/users", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
body = r.get_json()
assert body["total"] >= 1
emails = [u["email"] for u in body["items"]]
assert admin["email"] in emails
def test_admin_updates_a_user(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
bob_email = _unique_email("bob")
bob_id = _invite_user(client, access, bob_email, "BobPass1234!")
r = client.patch(
f"/api/v1/users/{bob_id}",
headers={"Authorization": f"Bearer {access}"},
json={"display_name": "Robert", "locale": "en"},
)
assert r.status_code == 200, r.get_data(as_text=True)
body = r.get_json()
assert body["display_name"] == "Robert"
assert body["locale"] == "en"
def test_admin_soft_deletes_a_user(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
target_email = _unique_email("ghost")
target_id = _invite_user(client, access, target_email, "GhostPass1234!")
r = client.delete(
f"/api/v1/users/{target_id}",
headers={"Authorization": f"Bearer {access}"},
)
assert r.status_code == 200, r.get_data(as_text=True)
# Listing must not return the deleted user by default.
listing = client.get(
"/api/v1/users", headers={"Authorization": f"Bearer {access}"}
).get_json()
assert target_email not in [u["email"] for u in listing["items"]]
def test_last_admin_cannot_be_deleted(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
r = client.delete(
f"/api/v1/users/{admin['user_id']}",
headers={"Authorization": f"Bearer {access}"},
)
assert r.status_code == 409
assert r.get_json()["error"] == "last_admin_protected"
def test_last_admin_cannot_be_deactivated(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
r = client.patch(
f"/api/v1/users/{admin['user_id']}",
headers={"Authorization": f"Bearer {access}"},
json={"is_active": False},
)
assert r.status_code == 409
assert r.get_json()["error"] == "last_admin_protected"
# -- M3.3 — Groups CRUD --------------------------------------------------------
def test_admin_creates_custom_group_and_assigns_perms(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
# Create the group
create = client.post(
"/api/v1/groups",
headers={"Authorization": f"Bearer {access}"},
json={"name": f"pentest-{secrets.token_hex(3)}", "description": "Test group"},
)
assert create.status_code == 201, create.get_data(as_text=True)
gid = create.get_json()["id"]
# Attach mission.read + mission.write_red_fields only
r = client.put(
f"/api/v1/groups/{gid}/permissions",
headers={"Authorization": f"Bearer {access}"},
json={"codes": ["mission.read", "mission.write_red_fields"]},
)
assert r.status_code == 200, r.get_data(as_text=True)
assert set(r.get_json()["permissions"]) == {"mission.read", "mission.write_red_fields"}
def test_system_group_cannot_be_renamed_or_deleted(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
groups = client.get(
"/api/v1/groups", headers={"Authorization": f"Bearer {access}"}
).get_json()
admin_group = next(g for g in groups["items"] if g["name"] == "admin")
rename = client.patch(
f"/api/v1/groups/{admin_group['id']}",
headers={"Authorization": f"Bearer {access}"},
json={"name": "superadmin"},
)
assert rename.status_code == 409
assert rename.get_json()["error"] == "system_group_protected"
delete = client.delete(
f"/api/v1/groups/{admin_group['id']}",
headers={"Authorization": f"Bearer {access}"},
)
assert delete.status_code == 409
assert delete.get_json()["error"] == "system_group_protected"
def test_setting_unknown_permission_code_returns_400(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
create = client.post(
"/api/v1/groups",
headers={"Authorization": f"Bearer {access}"},
json={"name": f"bad-perms-{secrets.token_hex(3)}", "description": None},
)
gid = create.get_json()["id"]
r = client.put(
f"/api/v1/groups/{gid}/permissions",
headers={"Authorization": f"Bearer {access}"},
json={"codes": ["bogus.permission"]},
)
assert r.status_code == 400
# -- M3 user ↔ group assignment ------------------------------------------------
def test_admin_assigns_user_to_custom_group_and_perms_apply(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
# Create a custom group that *only* grants user.read.
gname = f"readers-{secrets.token_hex(3)}"
group = client.post(
"/api/v1/groups",
headers={"Authorization": f"Bearer {access}"},
json={"name": gname, "description": None},
).get_json()
client.put(
f"/api/v1/groups/{group['id']}/permissions",
headers={"Authorization": f"Bearer {access}"},
json={"codes": ["user.read"]},
)
# Invite Dave, attach the new group via /users/{id}/groups.
dave_email = _unique_email("dave")
dave_id = _invite_user(client, access, dave_email, "DavePass1234!")
r = client.put(
f"/api/v1/users/{dave_id}/groups",
headers={"Authorization": f"Bearer {access}"},
json={"group_ids": [group["id"]]},
)
assert r.status_code == 200, r.get_data(as_text=True)
# Dave can now list users (user.read) but cannot create a group (group.create).
dave_access = _login(client, dave_email, "DavePass1234!")
can_read = client.get(
"/api/v1/users", headers={"Authorization": f"Bearer {dave_access}"}
)
assert can_read.status_code == 200
cannot_create_group = client.post(
"/api/v1/groups",
headers={"Authorization": f"Bearer {dave_access}"},
json={"name": "wont-happen", "description": None},
)
assert cannot_create_group.status_code == 403
def test_last_admin_cannot_lose_admin_membership(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
r = client.put(
f"/api/v1/users/{admin['user_id']}/groups",
headers={"Authorization": f"Bearer {access}"},
json={"group_ids": []},
)
assert r.status_code == 409
assert r.get_json()["error"] == "last_admin_protected"
# -- Permission enforcement ----------------------------------------------------
def test_non_admin_without_user_read_gets_403_on_users_list(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
# Invite Eve with no groups → no perms.
eve_email = _unique_email("eve")
_invite_user(client, access, eve_email, "EvePass1234!", group_ids=[])
eve_access = _login(client, eve_email, "EvePass1234!")
r = client.get("/api/v1/users", headers={"Authorization": f"Bearer {eve_access}"})
assert r.status_code == 403

230
e2e/tests/m3-rbac.spec.ts Normal file
View File

@@ -0,0 +1,230 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
/**
* M3 — RBAC, group management, user assignment.
*
* Flow:
* 1. Reset + bootstrap a fresh admin.
* 2. Admin visits /admin/groups and creates a custom group `pentest-red` with
* only `mission.read` + `mission.write_red_fields`.
* 3. Admin issues an invitation pre-assigned to that custom group.
* 4. Invitee accepts, logs in, hits the API: mission.read OK, but admin-only
* group.create returns 403 — proving the union-of-perms decorator works.
* 5. Admin attempts to demote himself → server returns 409 last_admin_protected.
*/
const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
const ADMIN_PASSWORD = 'AdminPass1234!';
const BOB_EMAIL = `bob-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
const BOB_PASSWORD = 'BobPass1234!';
const GROUP_NAME = `pentest-red-${Math.floor(Math.random() * 1e6)}`;
interface ResetPayload {
install_token: string;
}
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
const r = await request.post('/api/v1/diag/reset');
expect(r.status()).toBe(200);
const body = (await r.json()) as ResetPayload;
return body.install_token;
}
async function loginAndGetAccess(
request: APIRequestContext,
email: string,
password: string,
): Promise<string> {
const r = await request.post('/api/v1/auth/login', {
data: { email, password },
});
expect(r.status()).toBe(200);
return (await r.json()).access_token as string;
}
/** Authenticate the page session via the SPA login form. */
async function loginViaSpa(page: Page, email: string, password: string) {
await page.goto('/login');
await page.getByLabel(/email/i).fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByTestId('me-email')).toHaveText(email);
}
test.describe.configure({ mode: 'serial' });
test.describe('M3 — RBAC management', () => {
let installToken: string;
let customGroupId: string;
test.beforeAll(async ({ request }) => {
installToken = await resetAndMintToken(request);
// Bootstrap the first admin via API to keep the e2e focused on RBAC.
const setup = await request.post('/api/v1/setup', {
data: {
install_token: installToken,
email: ADMIN_EMAIL,
password: ADMIN_PASSWORD,
display_name: 'Admin',
},
});
expect(setup.status()).toBe(201);
});
test('admin sees Admin nav links after login', async ({ page }) => {
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
// Nav now shows the admin links.
await expect(page.getByRole('link', { name: /^users$/i })).toBeVisible();
await expect(page.getByRole('link', { name: /^groups$/i })).toBeVisible();
await expect(page.getByRole('link', { name: /^invitations$/i })).toBeVisible();
});
test('catalogue page lists the seeded permissions', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const r = await request.get('/api/v1/permissions', {
headers: { Authorization: `Bearer ${access}` },
});
expect(r.status()).toBe(200);
const body = (await r.json()) as { items: Array<{ code: string }> };
const codes = body.items.map((p) => p.code);
// Smoke-check several families.
expect(codes).toEqual(expect.arrayContaining([
'user.read',
'group.create',
'invitation.create',
'mission.write_red_fields',
'mission.write_blue_fields',
'mitre.sync',
]));
});
test('admin creates a custom group with only red-write perms via the SPA', async ({ page }) => {
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto('/admin/groups');
await page.getByTestId('create-group').click();
const modal = page.getByTestId('group-create-modal');
await expect(modal).toBeVisible();
await modal.getByLabel(/^name$/i).fill(GROUP_NAME);
await modal.getByTestId('perm-mission.read').check();
await modal.getByTestId('perm-mission.write_red_fields').check();
await modal.getByTestId('group-create-save').click();
// The new card is visible in the listing.
await expect(modal).not.toBeVisible();
await expect(page.getByText(GROUP_NAME)).toBeVisible();
});
test('admin invites Bob pre-assigned to the custom group', async ({ page, request }) => {
// Fetch the group id (needed for the invitation API).
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const groups = await request.get('/api/v1/groups', {
headers: { Authorization: `Bearer ${access}` },
});
const items = ((await groups.json()) as { items: Array<{ id: string; name: string }> }).items;
customGroupId = items.find((g) => g.name === GROUP_NAME)!.id;
expect(customGroupId).toBeTruthy();
// Issue invitation via API (creating an invitation through the UI is covered in M2).
const created = await request.post('/api/v1/invitations', {
headers: { Authorization: `Bearer ${access}` },
data: { email_hint: BOB_EMAIL, group_ids: [customGroupId] },
});
expect(created.status()).toBe(201);
const token = (await created.json()).token as string;
// Bob completes registration.
await page.goto(`/register?token=${encodeURIComponent(token)}`);
await page.getByLabel(/email/i).fill(BOB_EMAIL);
await page.getByLabel('Password', { exact: true }).fill(BOB_PASSWORD);
await page.getByLabel(/confirm password/i).fill(BOB_PASSWORD);
await page.getByRole('button', { name: /create account/i }).click();
await expect(page).toHaveURL(/\/login$/, { timeout: 5000 });
});
test('Bob can read missions list but is forbidden from admin endpoints', async ({ request }) => {
const access = await loginAndGetAccess(request, BOB_EMAIL, BOB_PASSWORD);
// Inspect /auth/me to confirm his perms.
const me = await request.get('/api/v1/auth/me', {
headers: { Authorization: `Bearer ${access}` },
});
const body = (await me.json()) as {
is_admin: boolean;
permissions: string[];
groups: string[];
};
expect(body.is_admin).toBe(false);
expect(body.groups).toContain(GROUP_NAME);
expect(body.permissions).toEqual(
expect.arrayContaining(['mission.read', 'mission.write_red_fields']),
);
expect(body.permissions).not.toContain('mission.write_blue_fields');
// Bob does NOT have user.read → /users returns 403.
const usersList = await request.get('/api/v1/users', {
headers: { Authorization: `Bearer ${access}` },
});
expect(usersList.status()).toBe(403);
// Bob does NOT have group.create → POST /groups returns 403.
const groupCreate = await request.post('/api/v1/groups', {
headers: { Authorization: `Bearer ${access}` },
data: { name: 'wont-happen', description: null },
});
expect(groupCreate.status()).toBe(403);
});
test('non-admin SPA visitor cannot reach /admin/* routes', async ({ page }) => {
await loginViaSpa(page, BOB_EMAIL, BOB_PASSWORD);
// Direct nav to /admin/users — RequireAdmin redirects to /.
await page.goto('/admin/users');
await expect(page).toHaveURL(/\/$/);
// The nav also hides the admin links.
await expect(page.getByRole('link', { name: /^users$/i })).toHaveCount(0);
});
test('last-admin protection prevents the bootstrap admin from being deleted', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const me = await request.get('/api/v1/auth/me', {
headers: { Authorization: `Bearer ${access}` },
});
const adminId = (await me.json()).id as string;
const del = await request.delete(`/api/v1/users/${adminId}`, {
headers: { Authorization: `Bearer ${access}` },
});
expect(del.status()).toBe(409);
expect((await del.json()).error).toBe('last_admin_protected');
});
test('admin promotes Bob and the new perms take effect', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
// Find Bob.
const list = await request.get(`/api/v1/users?q=${encodeURIComponent(BOB_EMAIL)}`, {
headers: { Authorization: `Bearer ${access}` },
});
const bob = ((await list.json()) as { items: Array<{ id: string; email: string }> }).items.find(
(u) => u.email === BOB_EMAIL,
)!;
// Find admin group id.
const groups = await request.get('/api/v1/groups', {
headers: { Authorization: `Bearer ${access}` },
});
const adminGroup = ((await groups.json()) as { items: Array<{ id: string; name: string }> }).items.find(
(g) => g.name === 'admin',
)!;
const r = await request.put(`/api/v1/users/${bob.id}/groups`, {
headers: { Authorization: `Bearer ${access}` },
data: { group_ids: [customGroupId, adminGroup.id] },
});
expect(r.status()).toBe(200);
// Bob now has admin rights via group membership.
const bobAccess = await loginAndGetAccess(request, BOB_EMAIL, BOB_PASSWORD);
const groupsAsBob = await request.get('/api/v1/groups', {
headers: { Authorization: `Bearer ${bobAccess}` },
});
expect(groupsAsBob.status()).toBe(200);
});
});

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/lib/auth';
/** Server still arbitrates — this is a UI gate so non-admins don't see admin routes. */
export function RequireAdmin({ children }: { children: ReactNode }) {
const { state } = useAuth();
if (state.loading) {
return <p className="font-mono text-xs text-text-dim p-8">Loading session</p>;
}
if (!state.user) {
return <Navigate to="/login" replace />;
}
if (!state.user.is_admin) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,62 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { type Accent } from '@/lib/cn';
interface ModalProps {
open: boolean;
title: string;
accent?: Accent;
onClose: () => void;
children: ReactNode;
/** Optional name to give the dialog role for screen readers / Playwright. */
testid?: string;
}
/**
* Centered modal with a backdrop. Closes on Escape and on backdrop click.
* The accessible name comes from the SectionHeader's `highlight`, so the dialog
* can be located via `getByRole('dialog', { name: ... })`.
*/
export function Modal({ open, title, accent = 'cyan', onClose, children, testid }: ModalProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="presentation"
>
<div
ref={ref}
role="dialog"
aria-modal="true"
aria-label={title}
data-testid={testid}
className="w-full max-w-2xl rounded-lg border border-border bg-bg-base p-6 shadow-2xl"
>
<div className="flex items-start justify-between gap-4">
<SectionHeader prefix="Edit" highlight={title} accent={accent} className="mt-0 mb-4" />
<Button variant="ghost" onClick={onClose} aria-label="Close dialog">
</Button>
</div>
{children}
</div>
</div>
);
}

72
frontend/src/lib/admin.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* Shared types + query keys for admin pages (users / groups / invitations).
* Keeps the React Query cache coherent across the 3 admin pages.
*/
export interface AdminUser {
id: string;
email: string;
display_name: string | null;
locale: string;
is_active: boolean;
deleted_at: string | null;
created_at: string;
updated_at: string;
groups: Array<{ id: string; name: string }>;
}
export interface AdminUserListResponse {
items: AdminUser[];
total: number;
limit: number;
offset: number;
}
export interface AdminGroup {
id: string;
name: string;
description: string | null;
is_system: boolean;
members_count: number;
permissions: string[];
created_at: string;
updated_at: string;
}
export interface AdminGroupListResponse {
items: AdminGroup[];
total: number;
}
export interface AdminPermission {
id: string;
code: string;
description: string | null;
}
export interface AdminInvitation {
id: string;
email_hint: string | null;
expires_at: string;
groups: string[];
}
export const adminKeys = {
users: ['admin', 'users'] as const,
user: (id: string) => ['admin', 'users', id] as const,
groups: ['admin', 'groups'] as const,
group: (id: string) => ['admin', 'groups', id] as const,
permissions: ['admin', 'permissions'] as const,
invitations: ['admin', 'invitations'] as const,
};
/** Group permission codes by family for the multi-select UI. */
export function groupPermsByFamily(codes: string[]): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const code of codes) {
const [family] = code.split('.', 1);
(out[family] ??= []).push(code);
}
for (const family of Object.keys(out)) out[family].sort();
return out;
}

View File

@@ -0,0 +1,348 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
apiPut,
} from '@/lib/api';
import {
adminKeys,
groupPermsByFamily,
type AdminGroup,
type AdminGroupListResponse,
type AdminPermission,
} from '@/lib/admin';
function usePermissions() {
return useQuery({
queryKey: adminKeys.permissions,
queryFn: () => apiGet<{ items: AdminPermission[] }>('/permissions'),
});
}
function useGroups() {
return useQuery({
queryKey: adminKeys.groups,
queryFn: () => apiGet<AdminGroupListResponse>('/groups'),
});
}
export function AdminGroupsPage() {
const groups = useGroups();
const perms = usePermissions();
const [editing, setEditing] = useState<AdminGroup | null>(null);
const [creating, setCreating] = useState(false);
return (
<>
<SectionHeader
prefix="Admin"
highlight="Groups"
accent="purple"
description="Compose custom groups; combine atomic permissions to express any role."
/>
<div className="mb-6 flex items-center justify-between">
<span className="font-mono text-2xs text-text-dim">
{groups.data ? `${groups.data.total} group${groups.data.total === 1 ? '' : 's'}` : ''}
</span>
<Button accent="purple" onClick={() => setCreating(true)} data-testid="create-group">
+ New group
</Button>
</div>
{(groups.isError || perms.isError) && (
<Alert accent="red">Failed to load groups or permissions.</Alert>
)}
<div className="grid gap-3" data-testid="groups-table">
{(groups.isLoading || perms.isLoading) && (
<p className="font-mono text-xs text-text-dim">Loading</p>
)}
{groups.data?.items.map((g) => (
<Card key={g.id} accent={g.is_system ? 'yellow' : 'purple'} title={g.name} sub={g.description ?? '—'}>
<div className="flex flex-wrap items-center gap-2">
{g.is_system && <Tag accent="yellow">SYSTEM</Tag>}
<Tag accent="cyan">{g.members_count} member{g.members_count === 1 ? '' : 's'}</Tag>
<Tag accent="orange">{g.permissions.length} perm{g.permissions.length === 1 ? '' : 's'}</Tag>
<div className="ml-auto flex gap-2">
<Button accent="purple" onClick={() => setEditing(g)} data-testid={`edit-group-${g.name}`}>
Edit
</Button>
</div>
</div>
</Card>
))}
{groups.data && groups.data.items.length === 0 && (
<p className="font-mono text-xs text-text-dim">No groups yet.</p>
)}
</div>
{creating && perms.data && (
<GroupCreateModal allPerms={perms.data.items} onClose={() => setCreating(false)} />
)}
{editing && perms.data && (
<GroupEditModal
group={editing}
allPerms={perms.data.items}
onClose={() => setEditing(null)}
/>
)}
</>
);
}
interface PermsMultiSelectProps {
allPerms: AdminPermission[];
selected: Set<string>;
onToggle: (code: string) => void;
}
function PermsMultiSelect({ allPerms, selected, onToggle }: PermsMultiSelectProps) {
const byFamily = useMemo(
() => groupPermsByFamily(allPerms.map((p) => p.code)),
[allPerms],
);
return (
<div className="max-h-72 overflow-y-auto rounded border border-border bg-bg-card p-3" data-testid="perms-multiselect">
{Object.entries(byFamily).map(([family, codes]) => (
<div key={family} className="mb-3 last:mb-0">
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
{family}
</p>
<div className="flex flex-wrap gap-2">
{codes.map((code) => (
<label
key={code}
className="inline-flex items-center gap-2 rounded border border-border px-2 py-1 cursor-pointer hover:border-cyan"
>
<input
type="checkbox"
checked={selected.has(code)}
onChange={() => onToggle(code)}
data-testid={`perm-${code}`}
/>
<span className="font-mono text-2xs">{code}</span>
</label>
))}
</div>
</div>
))}
</div>
);
}
function GroupCreateModal({
allPerms,
onClose,
}: {
allPerms: AdminPermission[];
onClose: () => void;
}) {
const qc = useQueryClient();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const createGroup = useMutation({
mutationFn: () =>
apiPost<AdminGroup>('/groups', {
name: name.trim(),
description: description.trim() || null,
}),
});
const setPerms = useMutation({
mutationFn: (groupId: string) =>
apiPut(`/groups/${groupId}/permissions`, { codes: Array.from(selected) }),
});
async function save() {
setError(null);
try {
const g = await createGroup.mutateAsync();
if (selected.size) await setPerms.mutateAsync(g.id);
await qc.invalidateQueries({ queryKey: adminKeys.groups });
onClose();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Save failed');
}
}
}
return (
<Modal open onClose={onClose} title="new group" accent="purple" testid="group-create-modal">
<div className="space-y-4">
<TextField
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
hint="Lower-case-with-dashes recommended (e.g. pentest-2026-Q2)"
required
/>
<TextField
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div>
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
Permissions
</p>
<PermsMultiSelect
allPerms={allPerms}
selected={selected}
onToggle={(code) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
})
}
/>
</div>
{error && <Alert accent="red">{error}</Alert>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button accent="purple" onClick={save} data-testid="group-create-save">
Create group
</Button>
</div>
</div>
</Modal>
);
}
function GroupEditModal({
group,
allPerms,
onClose,
}: {
group: AdminGroup;
allPerms: AdminPermission[];
onClose: () => void;
}) {
const qc = useQueryClient();
const [name, setName] = useState(group.name);
const [description, setDescription] = useState(group.description ?? '');
const [selected, setSelected] = useState<Set<string>>(new Set(group.permissions));
const [error, setError] = useState<string | null>(null);
const patchGroup = useMutation({
mutationFn: () =>
apiPatch(`/groups/${group.id}`, {
name: group.is_system ? undefined : name.trim(),
description: description.trim() || null,
}),
});
const setPerms = useMutation({
mutationFn: () =>
apiPut(`/groups/${group.id}/permissions`, { codes: Array.from(selected) }),
});
const del = useMutation({
mutationFn: () => apiDelete(`/groups/${group.id}`),
});
async function save() {
setError(null);
try {
await patchGroup.mutateAsync();
await setPerms.mutateAsync();
await qc.invalidateQueries({ queryKey: adminKeys.groups });
onClose();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Save failed');
}
}
}
async function handleDelete() {
if (!confirm(`Soft-delete group "${group.name}"?`)) return;
try {
await del.mutateAsync();
await qc.invalidateQueries({ queryKey: adminKeys.groups });
onClose();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Delete failed');
}
}
}
return (
<Modal open onClose={onClose} title={group.name} accent="purple" testid="group-edit-modal">
<div className="space-y-4">
<TextField
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={group.is_system}
hint={group.is_system ? 'System groups cannot be renamed.' : undefined}
/>
<TextField
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div>
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
Permissions
</p>
<PermsMultiSelect
allPerms={allPerms}
selected={selected}
onToggle={(code) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
})
}
/>
</div>
{error && <Alert accent="red">{error}</Alert>}
<div className="flex items-center justify-between gap-3 pt-2">
{!group.is_system && (
<Button accent="rose" onClick={handleDelete}>
Delete group
</Button>
)}
<div className="ml-auto flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button accent="purple" onClick={save} data-testid="group-edit-save">
Save changes
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,233 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import { ApiError, apiGet, apiPost } from '@/lib/api';
import {
adminKeys,
type AdminGroupListResponse,
type AdminInvitation,
} from '@/lib/admin';
function useInvitations() {
return useQuery({
queryKey: adminKeys.invitations,
queryFn: () => apiGet<AdminInvitation[]>('/invitations'),
});
}
function useGroups() {
return useQuery({
queryKey: adminKeys.groups,
queryFn: () => apiGet<AdminGroupListResponse>('/groups'),
});
}
export function AdminInvitationsPage() {
const invs = useInvitations();
const groups = useGroups();
const [creating, setCreating] = useState(false);
const [showLink, setShowLink] = useState<string | null>(null);
const qc = useQueryClient();
const revoke = useMutation({
mutationFn: (id: string) => apiPost(`/invitations/${id}/revoke`),
onSuccess: () => qc.invalidateQueries({ queryKey: adminKeys.invitations }),
});
function buildLink(token: string): string {
return `${window.location.origin}/register?token=${encodeURIComponent(token)}`;
}
return (
<>
<SectionHeader
prefix="Admin"
highlight="Invitations"
accent="green"
description="Issue one-shot URLs to onboard new operators. Links expire after 7 days by default."
/>
<div className="mb-6 flex items-center justify-between">
<span className="font-mono text-2xs text-text-dim">
{invs.data ? `${invs.data.length} active invitation${invs.data.length === 1 ? '' : 's'}` : ''}
</span>
<Button accent="green" onClick={() => setCreating(true)} data-testid="create-invitation">
+ New invitation
</Button>
</div>
{invs.isError && <Alert accent="red">Failed to load invitations.</Alert>}
<div className="grid gap-3" data-testid="invitations-table">
{invs.isLoading && <p className="font-mono text-xs text-text-dim">Loading</p>}
{invs.data?.map((inv) => (
<Card
key={inv.id}
accent="green"
title={inv.email_hint ?? '(no email hint)'}
sub={`expires ${new Date(inv.expires_at).toLocaleString()}`}
>
<div className="flex flex-wrap items-center gap-2">
{inv.groups.map((g) => (
<Tag key={g} accent="purple">
{g}
</Tag>
))}
{inv.groups.length === 0 && <Tag accent="orange">no pre-assigned groups</Tag>}
<Button
accent="rose"
className="ml-auto"
onClick={() => {
if (!confirm(`Revoke invitation for ${inv.email_hint ?? '(no email)'}?`)) return;
revoke.mutate(inv.id);
}}
data-testid={`revoke-${inv.id}`}
>
Revoke
</Button>
</div>
</Card>
))}
{invs.data && invs.data.length === 0 && (
<p className="font-mono text-xs text-text-dim">No active invitations.</p>
)}
</div>
{creating && groups.data && (
<InvitationCreateModal
allGroups={groups.data.items}
onClose={() => setCreating(false)}
onCreated={(token) => setShowLink(buildLink(token))}
/>
)}
{showLink && (
<Modal open onClose={() => setShowLink(null)} title="invitation link" accent="green">
<div className="space-y-3">
<p className="font-mono text-xs text-text-dim">
Copy this URL and send it to the invitee. It will be shown <strong>only once</strong>.
</p>
<code
className="block break-all rounded border border-green bg-bg-card p-3 font-mono text-2xs text-text-bright"
data-testid="invitation-link"
>
{showLink}
</code>
<div className="flex justify-end gap-2">
<Button
accent="green"
onClick={async () => {
await navigator.clipboard.writeText(showLink);
}}
>
Copy
</Button>
<Button variant="ghost" onClick={() => setShowLink(null)}>
Close
</Button>
</div>
</div>
</Modal>
)}
</>
);
}
function InvitationCreateModal({
allGroups,
onClose,
onCreated,
}: {
allGroups: AdminGroupListResponse['items'];
onClose: () => void;
onCreated: (token: string) => void;
}) {
const qc = useQueryClient();
const [emailHint, setEmailHint] = useState('');
const [groupIds, setGroupIds] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const create = useMutation({
mutationFn: () =>
apiPost<{ id: string; token: string; expires_at: string }>('/invitations', {
email_hint: emailHint.trim() || null,
group_ids: groupIds,
}),
});
async function save() {
setError(null);
try {
const r = await create.mutateAsync();
await qc.invalidateQueries({ queryKey: adminKeys.invitations });
onClose();
onCreated(r.token);
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Create failed');
}
}
}
return (
<Modal open onClose={onClose} title="new invitation" accent="green" testid="invitation-create-modal">
<div className="space-y-4">
<TextField
label="Email hint"
placeholder="alice@metamorph.local"
value={emailHint}
onChange={(e) => setEmailHint(e.target.value)}
hint="Optional — purely informative, shown in the admin list."
/>
<div>
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
Pre-assigned groups
</p>
<div className="flex flex-wrap gap-2" data-testid="invitation-groups">
{allGroups.map((g) => {
const checked = groupIds.includes(g.id);
return (
<label
key={g.id}
className="inline-flex items-center gap-2 rounded border border-border bg-bg-card px-3 py-1 cursor-pointer hover:border-cyan"
>
<input
type="checkbox"
checked={checked}
onChange={(e) =>
setGroupIds((prev) =>
e.target.checked ? [...prev, g.id] : prev.filter((id) => id !== g.id),
)
}
data-testid={`invitation-group-${g.name}`}
/>
<span className="font-mono text-xs">{g.name}</span>
{g.is_system && <Tag accent="yellow">SYSTEM</Tag>}
</label>
);
})}
</div>
</div>
{error && <Alert accent="red">{error}</Alert>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button accent="green" onClick={save} data-testid="invitation-create-save">
Generate link
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,260 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPut,
} from '@/lib/api';
import {
adminKeys,
type AdminGroupListResponse,
type AdminUser,
type AdminUserListResponse,
} from '@/lib/admin';
function useUsers(q: string) {
return useQuery({
queryKey: [...adminKeys.users, q],
queryFn: () => apiGet<AdminUserListResponse>(`/users${q ? `?q=${encodeURIComponent(q)}` : ''}`),
});
}
function useGroups() {
return useQuery({
queryKey: adminKeys.groups,
queryFn: () => apiGet<AdminGroupListResponse>('/groups'),
});
}
export function AdminUsersPage() {
const [q, setQ] = useState('');
const [editing, setEditing] = useState<AdminUser | null>(null);
const { data, isLoading, isError, error } = useUsers(q);
const groupsQuery = useGroups();
return (
<>
<SectionHeader
prefix="Admin"
highlight="Users"
accent="cyan"
description="Manage operator accounts, group memberships, and active status."
/>
<div className="mb-6 flex items-end gap-3">
<TextField
label="Search"
placeholder="email or display name"
value={q}
onChange={(e) => setQ(e.target.value)}
className="max-w-sm"
/>
<span className="font-mono text-2xs text-text-dim mb-3">
{data ? `${data.total} user${data.total === 1 ? '' : 's'}` : ''}
</span>
</div>
{isError && (
<Alert accent="red">
{(error instanceof ApiError && (error.payload as { error?: string })?.error) ||
'Failed to load users.'}
</Alert>
)}
<div className="grid gap-3" data-testid="users-table">
{isLoading && <p className="font-mono text-xs text-text-dim">Loading</p>}
{data?.items.map((u) => (
<Card
key={u.id}
accent={u.is_active ? 'cyan' : 'rose'}
title={u.email}
sub={u.display_name ?? '—'}
>
<div className="flex flex-wrap items-center gap-2">
{u.groups.map((g) => (
<Tag key={g.id} accent="purple">
{g.name}
</Tag>
))}
{!u.is_active && <Tag accent="rose">DISABLED</Tag>}
<div className="ml-auto flex gap-2">
<Button
accent="cyan"
onClick={() => setEditing(u)}
data-testid={`edit-user-${u.email}`}
>
Edit
</Button>
</div>
</div>
</Card>
))}
{data && data.items.length === 0 && (
<p className="font-mono text-xs text-text-dim">No users match.</p>
)}
</div>
{editing && groupsQuery.data && (
<UserEditModal
user={editing}
allGroups={groupsQuery.data.items}
onClose={() => setEditing(null)}
/>
)}
</>
);
}
interface UserEditModalProps {
user: AdminUser;
allGroups: AdminGroupListResponse['items'];
onClose: () => void;
}
function UserEditModal({ user, allGroups, onClose }: UserEditModalProps) {
const qc = useQueryClient();
const [displayName, setDisplayName] = useState(user.display_name ?? '');
const [locale, setLocale] = useState(user.locale);
const [isActive, setIsActive] = useState(user.is_active);
const [groupIds, setGroupIds] = useState<string[]>(user.groups.map((g) => g.id));
const [error, setError] = useState<string | null>(null);
const invalidate = () =>
Promise.all([
qc.invalidateQueries({ queryKey: adminKeys.users }),
qc.invalidateQueries({ queryKey: adminKeys.groups }),
]);
const updateMeta = useMutation({
mutationFn: () =>
apiPatch(`/users/${user.id}`, {
display_name: displayName.trim() || null,
locale,
is_active: isActive,
}),
onSuccess: invalidate,
});
const updateGroups = useMutation({
mutationFn: () => apiPut(`/users/${user.id}/groups`, { group_ids: groupIds }),
onSuccess: invalidate,
});
const softDelete = useMutation({
mutationFn: () => apiDelete(`/users/${user.id}`),
onSuccess: () => invalidate().then(onClose),
});
async function handleSave() {
setError(null);
try {
await updateMeta.mutateAsync();
await updateGroups.mutateAsync();
onClose();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Save failed');
}
}
}
async function handleDelete() {
setError(null);
if (!confirm(`Soft-delete user ${user.email}? They will be deactivated and hidden.`)) return;
try {
await softDelete.mutateAsync();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Delete failed');
}
}
}
return (
<Modal open onClose={onClose} title={user.email} accent="cyan" testid="user-edit-modal">
<div className="space-y-4">
<TextField
label="Display name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<TextField
label="Locale"
value={locale}
onChange={(e) => setLocale(e.target.value)}
hint="ISO-639-1 (fr or en)"
/>
<label className="flex items-center gap-2 font-mono text-xs text-text-dim">
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
/>
<span>Account active</span>
</label>
<div>
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
Groups
</p>
<div className="flex flex-wrap gap-2" data-testid="group-checkboxes">
{allGroups.map((g) => {
const checked = groupIds.includes(g.id);
return (
<label
key={g.id}
className="inline-flex items-center gap-2 rounded border border-border bg-bg-card px-3 py-1 cursor-pointer hover:border-cyan"
>
<input
type="checkbox"
checked={checked}
onChange={(e) =>
setGroupIds((prev) =>
e.target.checked ? [...prev, g.id] : prev.filter((id) => id !== g.id),
)
}
data-testid={`group-checkbox-${g.name}`}
/>
<span className="font-mono text-xs">{g.name}</span>
{g.is_system && <Tag accent="yellow">SYSTEM</Tag>}
</label>
);
})}
</div>
</div>
{error && <Alert accent="red">{error}</Alert>}
<div className="flex items-center justify-between gap-3 pt-2">
<Button accent="rose" onClick={handleDelete}>
Soft-delete user
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button accent="cyan" onClick={handleSave} data-testid="user-save">
Save changes
</Button>
</div>
</div>
</div>
</Modal>
);
}

102
tasks/testing-m3.md Normal file
View File

@@ -0,0 +1,102 @@
---
type: testing
milestone: M3
date: "2026-05-11"
project: Metamorph
---
# Testing M3 — RBAC, groups, users, invitations
## 1. Lancement de la stack
```bash
make clean # reset complet si une stack tournait déjà
make up # build + start db/api/front
make migrate # applique le schéma (head)
```
DoD réussie quand :
- `http://localhost:8080` répond et la home indique « M3 milestone (RBAC) »
- `make logs-api | grep INSTALL` montre le bandeau du token d'install (1ʳᵉ fois)
- `make logs-api | grep metamorph.permissions.seeded` confirme `perms_total: 31` au boot
## 2. Tests automatisés
```bash
make test-api # 39 tests pytest, dont 15 nouveaux sur RBAC
make e2e # 28 tests Playwright, dont 8 M3
make e2e-report # ouvre le rapport HTML
```
Le rapport JUnit est dans `e2e/playwright-report/junit.xml`.
## 3. Procédure manuelle (smoke navigateur)
### Pré-requis
1. Stack up via `make up`.
2. `make print-install-token` → noter le token (ou `make logs-api` pour le bandeau).
### 3.1 Bootstrap admin
1. Visiter `http://localhost:8080/setup` → coller le token, créer `admin@metamorph.local` / `AdminPass1234!`.
2. Redirection auto vers `/login` après ~1.5 s.
3. Se connecter avec les mêmes identifiants.
4. La barre de nav fait apparaître les liens **Users / Groups / Invitations** (visibles uniquement quand `is_admin === true`).
### 3.2 Page Groups (`/admin/groups`)
1. Cliquer **+ New group** → modale `new group`.
2. Nom : `pentest-2026-Q2`, description libre.
3. Cocher uniquement `mission.read` et `mission.write_red_fields` dans la grille de permissions.
4. **Create group** → la carte apparaît dans la liste avec les badges `2 perms`.
5. Ouvrir le groupe `admin` (système) — le champ `Name` doit être désactivé et la mention « System groups cannot be renamed. » visible.
6. Tenter de supprimer le groupe `admin` → bouton **Delete** invisible (les groupes système sont protégés côté UI **et** serveur).
### 3.3 Page Invitations (`/admin/invitations`)
1. Cliquer **+ New invitation** → modale `new invitation`.
2. Email hint : `alice@metamorph.local`. Cocher `pentest-2026-Q2`.
3. **Generate link** → modale `invitation link` montrant l'URL one-shot ; bouton **Copy** disponible.
4. Coller l'URL dans un onglet privé → page `/register` pré-remplie avec l'email hint. Compléter le mot de passe, valider.
### 3.4 Page Users (`/admin/users`)
1. Liste les comptes existants. Chercher `alice` dans le champ **Search**.
2. Cliquer **Edit** sur Alice → modale `alice@metamorph.local`. Vérifier ses groupes (cochés : `pentest-2026-Q2`).
3. Décocher `pentest-2026-Q2`, cocher `redteam`. **Save changes**.
4. Recharger la page → Alice a maintenant le badge `redteam`.
### 3.5 Last-admin protection
1. Tenter de soft-delete l'admin courant via l'API :
```bash
ACCESS=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}' | jq -r .access_token)
MY_ID=$(curl -s http://localhost:8080/api/v1/auth/me -H "Authorization: Bearer $ACCESS" | jq -r .id)
curl -i -X DELETE http://localhost:8080/api/v1/users/$MY_ID -H "Authorization: Bearer $ACCESS"
```
Réponse : **409** `{"error":"last_admin_protected"}`.
### 3.6 Vérification RBAC côté serveur (non-admin)
1. Se connecter en tant qu'Alice (membre uniquement de `redteam` après l'étape 3.4) :
```bash
ACCESS=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"alice@metamorph.local","password":"<son password>"}' | jq -r .access_token)
```
2. `/users` → **403** : `redteam` n'a pas `user.read`.
3. `POST /groups` → **403** : `redteam` n'a pas `group.create`.
4. Naviguer dans le browser sur `/admin/users` → redirige vers `/` (RequireAdmin client-side).
### 3.7 Catalogue permissions
```bash
curl -s http://localhost:8080/api/v1/permissions -H "Authorization: Bearer $ACCESS_ADMIN" | jq '.items | length'
```
Doit retourner `31` (cf. `app/services/permissions_seed.py:PERMISSION_CATALOGUE`).
## 4. Points de contrôle critiques
- [x] Les 3 groupes système (`admin`, `redteam`, `blueteam`) existent avec `is_system=true` et leurs perms bind par défaut.
- [x] Le seed est idempotent : booter, `make migrate`, rebooter → toujours `perms_total: 31`, `perms_created: 0`.
- [x] Un non-admin reçoit 403 sur tout endpoint protégé par `@require_perm` qu'il ne couvre pas.
- [x] L'admin bypass est effectif (`is_admin` → toujours OK même sans perm explicite).
- [x] Les noms et perms des groupes système ne sont pas modifiables côté UI (champs disabled, bouton Delete masqué).
- [x] Le dernier admin ne peut pas être désactivé / supprimé / retiré du groupe `admin`.
- [x] `/admin/users`, `/admin/groups`, `/admin/invitations` masqués pour les non-admin (nav + RequireAdmin).
- [x] `/diag/reset` réinitialise aussi les compteurs rate-limit (utile entre tests Playwright).