feat(m6): missions + snapshot CRUD, membership visibility, status state machine
Adds the mission layer that materialises template snapshots, plus the SPA list / 3-step wizard / detail page. Backend: - app/services/missions.py — create_mission snapshots scenarios, tests, MITRE tags in a 4-query write; list/get apply a non-admin membership filter that collapses to 404 (no existence leak); status state machine enforces draft → in_progress → completed → archived with archived as a sink; the non-admin creator is auto-added as role_hint='red' to retain visibility. - app/api/missions.py — 8 endpoints (list, get, create, update, add scenarios, set members, transition, soft-delete) with strict pydantic schemas. The transition endpoint splits the perm gate manually so archive requires mission.archive while other targets use mission.update. - app/api/users.py — new GET /users/roster returning (id, email, display_name) only, gated by user.read OR mission.create OR mission.update — lets non-admin wizard users see assignable peers without exposing the admin /users payload. - app/api/diag.py — /diag/reset truncates the mission_* tables before the template tables because the source_*_template_id FKs are ON DELETE SET NULL, which is cheaper to short-circuit by removing the children first. Frontend: - lib/missions.ts — typed client, queryKey factory, status accent map. - pages/MissionsListPage.tsx — list cards with status accent + filters (q, client, status). - pages/MissionsCreatePage.tsx — 3-step wizard (meta → scenarios → members) with member roster fed by /users/roster. - pages/MissionDetailPage.tsx — header + transition buttons (legal next states only) + Tests/Members/Synthesis/Export tabs. - Routes + nav entry (visible to anyone with mission.read or admin). Tests: - backend/tests/test_missions.py — 22 pytest covering snapshot fidelity, MITRE propagation, membership visibility, transition state machine, perm gating, member set replace, append scenarios, soft-delete, partial update, inverted-date rejection. - e2e/tests/m6-missions.spec.ts — 5 Playwright (snapshot freezing, non-admin visibility, status transitions + 409, SPA wizard end-to-end, list filter). Docs: - CHANGELOG, tasks/testing-m6.md, tasks/lessons.md (snapshot tradeoffs, membership=404 pattern, /diag/reset order, auto-creator add). - README + tasks/todo.md updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,20 @@ def reset_test_state():
|
||||
"user_groups, settings, groups RESTART IDENTITY CASCADE"
|
||||
)
|
||||
)
|
||||
# Mission catalogue reset (M6). Truncated before the template tables
|
||||
# below because `mission_scenarios.source_scenario_template_id` and
|
||||
# `mission_tests.source_test_template_id` are ON DELETE SET NULL — a
|
||||
# cascade-truncate of templates would attempt to null those columns
|
||||
# and stall on the constraint check. Wiping the mission tables first
|
||||
# avoids that round-trip; cascades from `missions` then take care of
|
||||
# members, scenarios, tests, mitre_tags, categories.
|
||||
conn.execute(
|
||||
text(
|
||||
"TRUNCATE mission_test_mitre_tags, mission_tests, "
|
||||
"mission_scenarios, mission_categories, mission_members, "
|
||||
"missions RESTART IDENTITY CASCADE"
|
||||
)
|
||||
)
|
||||
# Template catalogue reset (M5). The MITRE truncate below cascades to
|
||||
# the polymorphic tag join, but the template rows themselves must be
|
||||
# wiped first because `scenario_template_tests.test_template_id` is
|
||||
|
||||
494
backend/app/api/missions.py
Normal file
494
backend/app/api/missions.py
Normal file
@@ -0,0 +1,494 @@
|
||||
"""Missions API.
|
||||
|
||||
Per spec §4: a non-admin user can only see (or edit) missions they are a
|
||||
member of. The decorator stack here gates the *action type* by permission
|
||||
code; the service layer applies the membership filter. Both layers fail
|
||||
closed.
|
||||
|
||||
Status transitions are routed through a single POST endpoint that accepts a
|
||||
target status. We accept either `mission.update` or `mission.archive` at the
|
||||
gate — archiving requires the dedicated perm if the target is `archived`, and
|
||||
the service enforces the lifecycle graph (`_VALID_TRANSITIONS`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, g, jsonify, request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from app.core.auth_decorators import AuthenticatedUser, require_auth, require_perm
|
||||
from app.services import missions as svc
|
||||
|
||||
bp = Blueprint("missions", __name__, url_prefix="/missions")
|
||||
log = logging.getLogger("metamorph.api.missions")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Payloads
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class MemberPayload(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
role_hint: str = Field(min_length=1, max_length=8)
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class CreateMissionPayload(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
client_target: str | None = Field(default=None, max_length=255)
|
||||
date_start: date | None = None
|
||||
date_end: date | None = None
|
||||
description_md: str | None = Field(default=None, max_length=20_000)
|
||||
scenario_template_ids: list[uuid.UUID] = Field(default_factory=list, max_length=64)
|
||||
members: list[MemberPayload] = Field(default_factory=list, max_length=128)
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class UpdateMissionPayload(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
client_target: str | None = Field(default=None, max_length=255)
|
||||
date_start: date | None = None
|
||||
date_end: date | None = None
|
||||
description_md: str | None = Field(default=None, max_length=20_000)
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class AddScenariosPayload(BaseModel):
|
||||
scenario_template_ids: list[uuid.UUID] = Field(default_factory=list, max_length=64)
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class SetMembersPayload(BaseModel):
|
||||
members: list[MemberPayload] = Field(default_factory=list, max_length=128)
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class TransitionPayload(BaseModel):
|
||||
status: str = Field(min_length=1, max_length=16)
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Serialisers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _serialize_member(m: svc.MissionMemberView) -> dict[str, Any]:
|
||||
return {
|
||||
"user_id": str(m.user_id),
|
||||
"user_email": m.user_email,
|
||||
"user_display_name": m.user_display_name,
|
||||
"role_hint": m.role_hint,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_mitre_tag(tag: svc.MissionMitreTagView) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": tag.kind,
|
||||
"external_id": tag.external_id,
|
||||
"name": tag.name,
|
||||
"url": tag.url,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_test(t: svc.MissionTestView) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(t.id),
|
||||
"position": t.position,
|
||||
"snapshot_name": t.snapshot_name,
|
||||
"snapshot_description": t.snapshot_description,
|
||||
"snapshot_objective": t.snapshot_objective,
|
||||
"snapshot_procedure_md": t.snapshot_procedure_md,
|
||||
"snapshot_prerequisites_md": t.snapshot_prerequisites_md,
|
||||
"snapshot_expected_red_md": t.snapshot_expected_red_md,
|
||||
"snapshot_expected_blue_md": t.snapshot_expected_blue_md,
|
||||
"snapshot_opsec_level": t.snapshot_opsec_level,
|
||||
"snapshot_tags": t.snapshot_tags,
|
||||
"snapshot_expected_iocs": t.snapshot_expected_iocs,
|
||||
"state": t.state,
|
||||
"executed_at": t.executed_at.isoformat() if t.executed_at else None,
|
||||
"executed_at_overridden": t.executed_at_overridden,
|
||||
"mitre_tags": [_serialize_mitre_tag(tag) for tag in t.mitre_tags],
|
||||
"source_test_template_id": (
|
||||
str(t.source_test_template_id) if t.source_test_template_id else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_scenario(sc: svc.MissionScenarioView) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(sc.id),
|
||||
"position": sc.position,
|
||||
"snapshot_name": sc.snapshot_name,
|
||||
"snapshot_description": sc.snapshot_description,
|
||||
"tests": [_serialize_test(t) for t in sc.tests],
|
||||
"source_scenario_template_id": (
|
||||
str(sc.source_scenario_template_id)
|
||||
if sc.source_scenario_template_id
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_list_item(m: svc.MissionListItemView) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(m.id),
|
||||
"name": m.name,
|
||||
"client_target": m.client_target,
|
||||
"date_start": m.date_start.isoformat() if m.date_start else None,
|
||||
"date_end": m.date_end.isoformat() if m.date_end else None,
|
||||
"status": m.status,
|
||||
"description_md": m.description_md,
|
||||
"visibility_mode": m.visibility_mode,
|
||||
"scenarios_count": m.scenarios_count,
|
||||
"tests_count": m.tests_count,
|
||||
"members_count": m.members_count,
|
||||
"deleted_at": m.deleted_at.isoformat() if m.deleted_at else None,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
"updated_at": m.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_detail(m: svc.MissionView) -> dict[str, Any]:
|
||||
base = {
|
||||
"id": str(m.id),
|
||||
"name": m.name,
|
||||
"client_target": m.client_target,
|
||||
"date_start": m.date_start.isoformat() if m.date_start else None,
|
||||
"date_end": m.date_end.isoformat() if m.date_end else None,
|
||||
"status": m.status,
|
||||
"description_md": m.description_md,
|
||||
"visibility_mode": m.visibility_mode,
|
||||
"scenarios_count": m.scenarios_count,
|
||||
"tests_count": m.tests_count,
|
||||
"members_count": m.members_count,
|
||||
"deleted_at": m.deleted_at.isoformat() if m.deleted_at else None,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
"updated_at": m.updated_at.isoformat(),
|
||||
}
|
||||
base["scenarios"] = [_serialize_scenario(sc) for sc in m.scenarios]
|
||||
base["members"] = [_serialize_member(mb) for mb in m.members]
|
||||
return base
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _parse_uuid_or_400(raw: str) -> uuid.UUID | None:
|
||||
try:
|
||||
return uuid.UUID(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _pagination_args() -> tuple[int, int] | tuple[None, tuple[int, str]]:
|
||||
try:
|
||||
limit = int(request.args.get("limit", "100"))
|
||||
offset = int(request.args.get("offset", "0"))
|
||||
except ValueError:
|
||||
return None, (400, "invalid_pagination")
|
||||
return max(1, min(limit, 500)), max(0, offset)
|
||||
|
||||
|
||||
def _current_user() -> AuthenticatedUser:
|
||||
user: AuthenticatedUser | None = getattr(g, "current_user", None)
|
||||
if user is None:
|
||||
abort(401, description="not authenticated")
|
||||
assert user is not None # for Pyright; abort raises HTTPException
|
||||
return user
|
||||
|
||||
|
||||
def _to_assignments(payload_members: list[MemberPayload]) -> list[svc.MemberAssignment]:
|
||||
return [
|
||||
svc.MemberAssignment(user_id=m.user_id, role_hint=m.role_hint)
|
||||
for m in payload_members
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Endpoints
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth
|
||||
@require_perm("mission.read")
|
||||
def list_missions():
|
||||
paging = _pagination_args()
|
||||
if paging[0] is None:
|
||||
return jsonify({"error": paging[1][1]}), paging[1][0]
|
||||
limit, offset = paging
|
||||
q = request.args.get("q") or None
|
||||
status = request.args.get("status") or None
|
||||
client = request.args.get("client") or None
|
||||
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
|
||||
|
||||
user = _current_user()
|
||||
if include_deleted and not user.is_admin:
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
try:
|
||||
items, total = svc.list_missions(
|
||||
viewer_id=user.id,
|
||||
viewer_is_admin=user.is_admin,
|
||||
q=q,
|
||||
status=status,
|
||||
client=client,
|
||||
include_deleted=include_deleted,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"items": [_serialize_list_item(it) for it in items],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<mission_id>")
|
||||
@require_auth
|
||||
@require_perm("mission.read")
|
||||
def get_mission(mission_id: str):
|
||||
mid = _parse_uuid_or_400(mission_id)
|
||||
if mid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
|
||||
user = _current_user()
|
||||
if include_deleted and not user.is_admin:
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
try:
|
||||
view = svc.get_mission(
|
||||
mid,
|
||||
viewer_id=user.id,
|
||||
viewer_is_admin=user.is_admin,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
except svc.MissionNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
return jsonify(_serialize_detail(view))
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@require_auth
|
||||
@require_perm("mission.create")
|
||||
def create_mission():
|
||||
try:
|
||||
payload = CreateMissionPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
user = _current_user()
|
||||
try:
|
||||
view = svc.create_mission(
|
||||
name=payload.name,
|
||||
creator_id=user.id,
|
||||
creator_is_admin=user.is_admin,
|
||||
client_target=payload.client_target,
|
||||
date_start=payload.date_start,
|
||||
date_end=payload.date_end,
|
||||
description_md=payload.description_md,
|
||||
scenario_template_ids=list(payload.scenario_template_ids),
|
||||
members=_to_assignments(payload.members),
|
||||
)
|
||||
except svc.UnknownScenarioTemplate as e:
|
||||
return jsonify({"error": "unknown_scenario_template", "message": str(e)}), 400
|
||||
except svc.UnknownUser as e:
|
||||
return jsonify({"error": "unknown_user", "message": str(e)}), 400
|
||||
except svc.InvalidMemberPayload as e:
|
||||
return jsonify({"error": "invalid_member", "message": str(e)}), 400
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
log.info(
|
||||
"metamorph.mission.created",
|
||||
extra={
|
||||
"mission_id": str(view.id),
|
||||
"scenarios": view.scenarios_count,
|
||||
"tests": view.tests_count,
|
||||
"members": view.members_count,
|
||||
},
|
||||
)
|
||||
return jsonify(_serialize_detail(view)), 201
|
||||
|
||||
|
||||
@bp.put("/<mission_id>")
|
||||
@require_auth
|
||||
@require_perm("mission.update")
|
||||
def update_mission(mission_id: str):
|
||||
mid = _parse_uuid_or_400(mission_id)
|
||||
if mid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
raw = request.get_json(silent=True) or {}
|
||||
try:
|
||||
payload = UpdateMissionPayload.model_validate(raw)
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
# Distinguish "not provided" from "explicitly null" by looking at the raw body.
|
||||
kwargs: dict[str, Any] = {}
|
||||
if "name" in raw and payload.name is not None:
|
||||
kwargs["name"] = payload.name
|
||||
if "client_target" in raw:
|
||||
kwargs["client_target"] = payload.client_target
|
||||
if "date_start" in raw:
|
||||
kwargs["date_start"] = payload.date_start
|
||||
if "date_end" in raw:
|
||||
kwargs["date_end"] = payload.date_end
|
||||
if "description_md" in raw:
|
||||
kwargs["description_md"] = payload.description_md
|
||||
user = _current_user()
|
||||
try:
|
||||
view = svc.update_mission_metadata(
|
||||
mid,
|
||||
viewer_id=user.id,
|
||||
viewer_is_admin=user.is_admin,
|
||||
**kwargs,
|
||||
)
|
||||
except svc.MissionNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
return jsonify(_serialize_detail(view))
|
||||
|
||||
|
||||
@bp.post("/<mission_id>/scenarios")
|
||||
@require_auth
|
||||
@require_perm("mission.update")
|
||||
def add_scenarios(mission_id: str):
|
||||
mid = _parse_uuid_or_400(mission_id)
|
||||
if mid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
try:
|
||||
payload = AddScenariosPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
user = _current_user()
|
||||
try:
|
||||
view = svc.add_scenarios_to_mission(
|
||||
mid,
|
||||
list(payload.scenario_template_ids),
|
||||
viewer_id=user.id,
|
||||
viewer_is_admin=user.is_admin,
|
||||
)
|
||||
except svc.MissionNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except svc.UnknownScenarioTemplate as e:
|
||||
return jsonify({"error": "unknown_scenario_template", "message": str(e)}), 400
|
||||
log.info(
|
||||
"metamorph.mission.scenarios_added",
|
||||
extra={
|
||||
"mission_id": str(mid),
|
||||
"added": len(payload.scenario_template_ids),
|
||||
},
|
||||
)
|
||||
return jsonify(_serialize_detail(view))
|
||||
|
||||
|
||||
@bp.put("/<mission_id>/members")
|
||||
@require_auth
|
||||
@require_perm("mission.update")
|
||||
def set_members(mission_id: str):
|
||||
mid = _parse_uuid_or_400(mission_id)
|
||||
if mid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
try:
|
||||
payload = SetMembersPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
user = _current_user()
|
||||
try:
|
||||
view = svc.set_mission_members(
|
||||
mid,
|
||||
_to_assignments(payload.members),
|
||||
viewer_id=user.id,
|
||||
viewer_is_admin=user.is_admin,
|
||||
)
|
||||
except svc.MissionNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except svc.UnknownUser as e:
|
||||
return jsonify({"error": "unknown_user", "message": str(e)}), 400
|
||||
except svc.InvalidMemberPayload as e:
|
||||
return jsonify({"error": "invalid_member", "message": str(e)}), 400
|
||||
return jsonify(_serialize_detail(view))
|
||||
|
||||
|
||||
@bp.post("/<mission_id>/transition")
|
||||
@require_auth
|
||||
def transition(mission_id: str):
|
||||
"""Status transition. Gate logic mirrors the perm seed: `mission.archive`
|
||||
is required when the target is `archived`; `mission.update` covers the
|
||||
other transitions. Admins bypass both checks.
|
||||
"""
|
||||
mid = _parse_uuid_or_400(mission_id)
|
||||
if mid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
try:
|
||||
payload = TransitionPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
user = _current_user()
|
||||
required = "mission.archive" if payload.status == "archived" else "mission.update"
|
||||
if not user.is_admin and required not in user.permissions:
|
||||
log.info(
|
||||
"metamorph.auth.permission_denied",
|
||||
extra={
|
||||
"user_id": str(user.id),
|
||||
"required": [required],
|
||||
"had": sorted(user.permissions),
|
||||
},
|
||||
)
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
try:
|
||||
view = svc.transition_mission_status(
|
||||
mid,
|
||||
payload.status,
|
||||
viewer_id=user.id,
|
||||
viewer_is_admin=user.is_admin,
|
||||
)
|
||||
except svc.MissionNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except svc.InvalidTransition as e:
|
||||
return jsonify({"error": "invalid_transition", "message": str(e)}), 409
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
log.info(
|
||||
"metamorph.mission.transitioned",
|
||||
extra={"mission_id": str(mid), "status": view.status},
|
||||
)
|
||||
return jsonify(_serialize_detail(view))
|
||||
|
||||
|
||||
@bp.delete("/<mission_id>")
|
||||
@require_auth
|
||||
@require_perm("mission.delete")
|
||||
def soft_delete_mission(mission_id: str):
|
||||
mid = _parse_uuid_or_400(mission_id)
|
||||
if mid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
user = _current_user()
|
||||
try:
|
||||
svc.soft_delete_mission(
|
||||
mid,
|
||||
viewer_id=user.id,
|
||||
viewer_is_admin=user.is_admin,
|
||||
)
|
||||
except svc.MissionNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
log.info("metamorph.mission.soft_deleted", extra={"mission_id": str(mid)})
|
||||
return jsonify({"ok": True})
|
||||
@@ -56,6 +56,34 @@ def _parse_uuid_or_400(raw: str):
|
||||
return None
|
||||
|
||||
|
||||
@bp.get("/roster")
|
||||
@require_auth
|
||||
@require_perm("user.read", "mission.create", "mission.update")
|
||||
def list_roster():
|
||||
"""Minimal user list for mission member assignment.
|
||||
|
||||
Returns only `id`, `email`, `display_name` of active, non-deleted users.
|
||||
Accessible to anyone who can create or update a mission — strictly lighter
|
||||
than `GET /users`, which leaks `is_admin` (via groups), `is_active`, and
|
||||
group memberships and is therefore reserved to `user.read`.
|
||||
"""
|
||||
q = request.args.get("q") or None
|
||||
rows = users_svc.list_users(q=q, is_active=True, limit=200, offset=0)[0]
|
||||
return jsonify(
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": str(u.id),
|
||||
"email": u.email,
|
||||
"display_name": u.display_name,
|
||||
}
|
||||
for u in rows
|
||||
if u.deleted_at is None
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth
|
||||
@require_perm("user.read")
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.api.diag import bp as diag_bp
|
||||
from app.api.groups import bp as groups_bp
|
||||
from app.api.health import bp as health_bp
|
||||
from app.api.invitations import bp as invitations_bp
|
||||
from app.api.missions import bp as missions_bp
|
||||
from app.api.mitre import bp as mitre_bp
|
||||
from app.api.permissions import bp as permissions_bp
|
||||
from app.api.scenario_templates import bp as scenario_templates_bp
|
||||
@@ -28,3 +29,4 @@ bp.register_blueprint(permissions_bp)
|
||||
bp.register_blueprint(mitre_bp)
|
||||
bp.register_blueprint(test_templates_bp)
|
||||
bp.register_blueprint(scenario_templates_bp)
|
||||
bp.register_blueprint(missions_bp)
|
||||
|
||||
Reference in New Issue
Block a user