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