From 9f75f119f0b450628226d75dcaa4258963e25107 Mon Sep 17 00:00:00 2001 From: knacky Date: Sat, 23 May 2026 15:53:22 +0200 Subject: [PATCH] feat(backend): engagement members + audit log viewer (sprint 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engagement members on `/api/v1/engagements//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/` 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`. --- backend/src/mimic/api/audit.py | 83 +++++++++++++++++++++++ backend/src/mimic/api/engagements.py | 99 +++++++++++++++++++++++++++- 2 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 backend/src/mimic/api/audit.py diff --git a/backend/src/mimic/api/audit.py b/backend/src/mimic/api/audit.py new file mode 100644 index 0000000..c81c169 --- /dev/null +++ b/backend/src/mimic/api/audit.py @@ -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"] diff --git a/backend/src/mimic/api/engagements.py b/backend/src/mimic/api/engagements.py index 25ac0f3..a22ee68 100644 --- a/backend/src/mimic/api/engagements.py +++ b/backend/src/mimic/api/engagements.py @@ -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("//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("//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("//members/") +@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