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:
169
backend/app/api/groups.py
Normal file
169
backend/app/api/groups.py
Normal 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))
|
||||
17
backend/app/api/permissions.py
Normal file
17
backend/app/api/permissions.py
Normal 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
185
backend/app/api/users.py
Normal 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))
|
||||
210
backend/app/services/groups.py
Normal file
210
backend/app/services/groups.py
Normal 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
|
||||
]
|
||||
179
backend/app/services/permissions_seed.py
Normal file
179
backend/app/services/permissions_seed.py
Normal 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}
|
||||
204
backend/app/services/users.py
Normal file
204
backend/app/services/users.py
Normal 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
344
backend/tests/test_rbac.py
Normal 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
230
e2e/tests/m3-rbac.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
frontend/src/components/RequireAdmin.tsx
Normal file
19
frontend/src/components/RequireAdmin.tsx
Normal 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}</>;
|
||||
}
|
||||
62
frontend/src/components/ui/Modal.tsx
Normal file
62
frontend/src/components/ui/Modal.tsx
Normal 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
72
frontend/src/lib/admin.ts
Normal 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;
|
||||
}
|
||||
348
frontend/src/pages/AdminGroupsPage.tsx
Normal file
348
frontend/src/pages/AdminGroupsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
frontend/src/pages/AdminInvitationsPage.tsx
Normal file
233
frontend/src/pages/AdminInvitationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
frontend/src/pages/AdminUsersPage.tsx
Normal file
260
frontend/src/pages/AdminUsersPage.tsx
Normal 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
102
tasks/testing-m3.md
Normal 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).
|
||||
Reference in New Issue
Block a user