feat(backend): engagement members + audit log viewer (sprint 2)

Engagement members on `/api/v1/engagements/<eid>/members`:
- `GET` lists members (flat array, ordered by `added_at`). Permission
  `ENGAGEMENT_READ`.
- `POST` adds a member. Permission `ENGAGEMENT_MEMBER_MANAGE`. Body
  `{user_id, role?}`; `role` defaults to `"member"` (D-017). Returns 201
  with `EngagementMemberRead`, 404 if the user is disabled/unknown, or
  `409 already_member` on duplicate.
- `DELETE /members/<uid>` revokes. 204 on success, 404 if the membership
  doesn't exist.

Every route reuses `_engagement_or_404` *before* any membership query, so
an RT operator targeting a foreign engagement receives the same 404 as for
a non-existent ID — matching the MA6 anti-leak posture flagged by
spec-analyst on this sprint.

Audit log viewer on `/api/v1/audit/log`:
- Single endpoint `GET`, paginated `Page[AuditLogEntry]`, gated by
  `AUDIT_READ` (rt_lead only).
- Filters: `?action=`, `?actor_id=`, `?resource_type=`, `?since=`,
  `?until=`. Times are ISO 8601; invalid input goes through the global 422
  envelope with a `loc` field for the bad parameter.
- Exposes `prev_hash` / `row_hash` to support future client-side
  chain-verification (D-013 stayed v1).
- Sorted by `ts DESC` so the most recent activity is the first page.

Blueprints registered in `api/__init__.py`.
This commit is contained in:
knacky
2026-05-23 15:53:22 +02:00
parent e2f030e0e1
commit 9f75f119f0
2 changed files with 180 additions and 2 deletions

View File

@@ -0,0 +1,83 @@
"""Audit log viewer (rt_lead only — F11 AUDIT_READ)."""
from __future__ import annotations
from datetime import datetime
from flask import Blueprint, abort, jsonify, request
from flask.typing import ResponseReturnValue
from sqlalchemy import func, select
from sqlalchemy.sql.elements import ColumnElement
from mimic.api._helpers import parse_page_query, parse_uuid
from mimic.db.models import AuditLog
from mimic.extensions import db
from mimic.rbac import Permission, require_perm
from mimic.schemas import AuditLogEntry, Page
bp = Blueprint("audit", __name__)
@bp.get("/log")
@require_perm(Permission.AUDIT_READ)
def list_audit_log() -> ResponseReturnValue:
page_query = parse_page_query()
filters = _parse_filters()
base = select(AuditLog)
for clause in filters:
base = base.where(clause)
total = db.session.execute(select(func.count()).select_from(base.subquery())).scalar_one()
rows = (
db.session.execute(
base.order_by(AuditLog.ts.desc()).offset(page_query.offset).limit(page_query.limit)
)
.scalars()
.all()
)
page = Page[AuditLogEntry](
items=[AuditLogEntry.model_validate(row) for row in rows],
total=total,
page=page_query.page,
page_size=page_query.page_size,
)
return jsonify(page.model_dump(mode="json"))
def _parse_filters() -> list[ColumnElement[bool]]:
"""Translate the query string into SQLAlchemy where-clauses.
Supported filters: `action`, `actor_id`, `resource_type`, `since`, `until`.
Times use ISO 8601; invalid inputs yield a 422 through the global handler.
"""
clauses: list[ColumnElement[bool]] = []
args = request.args
if (action := args.get("action")) is not None:
clauses.append(AuditLog.action == action)
if (resource_type := args.get("resource_type")) is not None:
clauses.append(AuditLog.resource_type == resource_type)
if (actor_id := args.get("actor_id")) is not None:
clauses.append(AuditLog.actor_id == parse_uuid(actor_id, field="actor_id"))
if (since := args.get("since")) is not None:
clauses.append(AuditLog.ts >= _parse_iso(since, "since"))
if (until := args.get("until")) is not None:
clauses.append(AuditLog.ts <= _parse_iso(until, "until"))
return clauses
def _parse_iso(raw: str, field: str) -> datetime:
try:
return datetime.fromisoformat(raw)
except ValueError:
abort(
422,
description=[
{"loc": [field], "msg": "invalid ISO 8601 datetime", "type": "value_error"}
],
)
__all__ = ["bp"]

View File

@@ -16,11 +16,17 @@ from mimic.api._helpers import (
parse_body, parse_body,
parse_uuid, parse_uuid,
) )
from mimic.db.models import Engagement, EngagementMember from mimic.db.models import Engagement, EngagementMember, User
from mimic.db.types import EngagementStatus from mimic.db.types import EngagementStatus
from mimic.extensions import db from mimic.extensions import db
from mimic.rbac import Permission, require_perm from mimic.rbac import Permission, require_perm
from mimic.schemas import EngagementCreate, EngagementRead, EngagementUpdate from mimic.schemas import (
EngagementCreate,
EngagementMemberCreate,
EngagementMemberRead,
EngagementRead,
EngagementUpdate,
)
bp = Blueprint("engagements", __name__) bp = Blueprint("engagements", __name__)
@@ -123,3 +129,92 @@ def delete_engagement(eid: str) -> ResponseReturnValue:
resource_id=engagement.id, resource_id=engagement.id,
) )
return "", 204 return "", 204
# ---------------------------------------------------------------- members
# `_engagement_or_404` runs BEFORE any membership query so a non-member RT
# operator gets the same 404 as a non-existent engagement (spec-analyst
# anti-enumeration requirement).
@bp.get("/<eid>/members")
@require_perm(Permission.ENGAGEMENT_READ)
def list_members(eid: str) -> ResponseReturnValue:
engagement = _engagement_or_404(eid)
rows = (
db.session.execute(
select(EngagementMember)
.where(EngagementMember.engagement_id == engagement.id)
.order_by(EngagementMember.added_at)
)
.scalars()
.all()
)
return jsonify(
[EngagementMemberRead.model_validate(row).model_dump(mode="json") for row in rows]
)
@bp.post("/<eid>/members")
@require_perm(Permission.ENGAGEMENT_MEMBER_MANAGE)
def add_member(eid: str) -> ResponseReturnValue:
engagement = _engagement_or_404(eid)
payload = parse_body(EngagementMemberCreate)
user = db.session.get(User, payload.user_id)
if user is None or not user.is_active:
abort(404, description="user not found")
existing = db.session.execute(
select(EngagementMember).where(
EngagementMember.engagement_id == engagement.id,
EngagementMember.user_id == payload.user_id,
)
).scalar_one_or_none()
if existing is not None:
from mimic.api._helpers import api_error # noqa: PLC0415
return api_error("already_member", "user is already a member of this engagement", 409)
member = EngagementMember(
engagement_id=engagement.id,
user_id=payload.user_id,
role=payload.role,
)
db.session.add(member)
db.session.commit()
audit_write(
action="engagement_member.add",
resource_type="engagement_member",
resource_id=f"{engagement.id}:{payload.user_id}",
metadata={
"engagement_id": str(engagement.id),
"user_id": str(payload.user_id),
"role": payload.role,
},
)
return jsonify_model(EngagementMemberRead.model_validate(member), status=201)
@bp.delete("/<eid>/members/<uid>")
@require_perm(Permission.ENGAGEMENT_MEMBER_MANAGE)
def remove_member(eid: str, uid: str) -> ResponseReturnValue:
engagement = _engagement_or_404(eid)
user_uuid = parse_uuid(uid, field="user id")
member = db.session.execute(
select(EngagementMember).where(
EngagementMember.engagement_id == engagement.id,
EngagementMember.user_id == user_uuid,
)
).scalar_one_or_none()
if member is None:
abort(404)
db.session.delete(member)
db.session.commit()
audit_write(
action="engagement_member.remove",
resource_type="engagement_member",
resource_id=f"{engagement.id}:{user_uuid}",
metadata={"engagement_id": str(engagement.id), "user_id": str(user_uuid)},
)
return "", 204