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_uuid,
)
from mimic.db.models import Engagement, EngagementMember
from mimic.db.models import Engagement, EngagementMember, User
from mimic.db.types import EngagementStatus
from mimic.extensions import db
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__)
@@ -123,3 +129,92 @@ def delete_engagement(eid: str) -> ResponseReturnValue:
resource_id=engagement.id,
)
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