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:
83
backend/src/mimic/api/audit.py
Normal file
83
backend/src/mimic/api/audit.py
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user