From feda5d1485f54db8ac9aff226aa1a9de8f80dcb2 Mon Sep 17 00:00:00 2001 From: knacky Date: Sat, 23 May 2026 15:52:56 +0200 Subject: [PATCH] feat(backend): add pagination + user/member/audit DTOs (D-016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `Page[T]` envelope `{items, total, page, page_size}` documented in D-016, the matching `PageQuery` for `?page=&page_size=` parsing (default 50, max 200), and `parse_page_query()` helper for blueprints. DTOs: - `UserRead` / `UserCreate` / `UserUpdate` (sprint 2). `UserRead` never exposes `local_password_hash`. `UserCreate` validates email via pydantic-email-validator and pins password to 8..128 chars. - `EngagementMemberRead` / `EngagementMemberCreate`. `role` is a free-form string ≤ 40 chars (D-017), defaulting to `"member"`. - `AuditLogEntry` for the upcoming audit viewer. --- backend/src/mimic/api/_helpers.py | 10 +++++ backend/src/mimic/schemas/__init__.py | 12 ++++++ backend/src/mimic/schemas/audit.py | 28 +++++++++++++ .../src/mimic/schemas/engagement_member.py | 27 ++++++++++++ backend/src/mimic/schemas/pagination.py | 40 ++++++++++++++++++ backend/src/mimic/schemas/user.py | 41 +++++++++++++++++++ 6 files changed, 158 insertions(+) create mode 100644 backend/src/mimic/schemas/audit.py create mode 100644 backend/src/mimic/schemas/engagement_member.py create mode 100644 backend/src/mimic/schemas/pagination.py create mode 100644 backend/src/mimic/schemas/user.py diff --git a/backend/src/mimic/api/_helpers.py b/backend/src/mimic/api/_helpers.py index 1e9c34b..31cc6ac 100644 --- a/backend/src/mimic/api/_helpers.py +++ b/backend/src/mimic/api/_helpers.py @@ -15,6 +15,7 @@ from mimic.auth.identity import AuthUser from mimic.extensions import db from mimic.rbac.matrix import GroupName from mimic.schemas import CurrentUser +from mimic.schemas.pagination import PageQuery def parse_body[T: BaseModel](model: type[T]) -> T: @@ -69,6 +70,15 @@ def api_error(code: str, message: str, status: int) -> tuple[Response, int]: return jsonify({"error": code, "message": message}), status +def parse_page_query() -> PageQuery: + """Read `?page=` / `?page_size=` from the current request (D-016).""" + raw = {key: value for key, value in request.args.items() if key in {"page", "page_size"}} + try: + return PageQuery.model_validate(raw) + except ValidationError as exc: + abort(422, description=exc.errors()) + + def audit_write( *, action: str, diff --git a/backend/src/mimic/schemas/__init__.py b/backend/src/mimic/schemas/__init__.py index 3f6d395..b647cb4 100644 --- a/backend/src/mimic/schemas/__init__.py +++ b/backend/src/mimic/schemas/__init__.py @@ -1,12 +1,15 @@ """Pydantic 2 request/response DTOs.""" +from mimic.schemas.audit import AuditLogEntry from mimic.schemas.auth import CurrentUser, LoginRequest from mimic.schemas.engagement import ( EngagementCreate, EngagementRead, EngagementUpdate, ) +from mimic.schemas.engagement_member import EngagementMemberCreate, EngagementMemberRead from mimic.schemas.host import HostCreate, HostRead, HostUpdate +from mimic.schemas.pagination import Page, PageQuery from mimic.schemas.scenario import ( ScenarioCreate, ScenarioRead, @@ -15,16 +18,22 @@ from mimic.schemas.scenario import ( ScenarioUpdate, ) from mimic.schemas.ttp import TtpCreate, TtpRead, TtpUpdate +from mimic.schemas.user import UserCreate, UserRead, UserUpdate __all__ = [ + "AuditLogEntry", "CurrentUser", "EngagementCreate", + "EngagementMemberCreate", + "EngagementMemberRead", "EngagementRead", "EngagementUpdate", "HostCreate", "HostRead", "HostUpdate", "LoginRequest", + "Page", + "PageQuery", "ScenarioCreate", "ScenarioRead", "ScenarioStepCreate", @@ -33,4 +42,7 @@ __all__ = [ "TtpCreate", "TtpRead", "TtpUpdate", + "UserCreate", + "UserRead", + "UserUpdate", ] diff --git a/backend/src/mimic/schemas/audit.py b/backend/src/mimic/schemas/audit.py new file mode 100644 index 0000000..4c7a64b --- /dev/null +++ b/backend/src/mimic/schemas/audit.py @@ -0,0 +1,28 @@ +"""Audit log viewer DTO (sprint 2).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class AuditLogEntry(BaseModel): + """Single audit log row as exposed by `GET /api/v1/audit/log`.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID + ts: datetime + actor_id: UUID | None + action: str + resource_type: str + resource_id: str | None + metadata_json: dict[str, Any] + prev_hash: str | None + row_hash: str + source_ip: str | None + user_agent: str | None + comment: str | None diff --git a/backend/src/mimic/schemas/engagement_member.py b/backend/src/mimic/schemas/engagement_member.py new file mode 100644 index 0000000..4f154e6 --- /dev/null +++ b/backend/src/mimic/schemas/engagement_member.py @@ -0,0 +1,27 @@ +"""Engagement membership DTOs (sprint 2). + +`role` is a free-form label per D-017 — not a permission gate. Application- +level RBAC stays the responsibility of the F11 `group` membership; per- +engagement role is informational (e.g. "lead", "shadow", "binôme A"). +""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class EngagementMemberRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + engagement_id: UUID + user_id: UUID + role: str + added_at: datetime + + +class EngagementMemberCreate(BaseModel): + user_id: UUID + role: str = Field(default="member", min_length=1, max_length=40) diff --git a/backend/src/mimic/schemas/pagination.py b/backend/src/mimic/schemas/pagination.py new file mode 100644 index 0000000..7e1b7f0 --- /dev/null +++ b/backend/src/mimic/schemas/pagination.py @@ -0,0 +1,40 @@ +"""Generic pagination envelope (D-016). + +Frontend reads `{items, total, page, page_size}`. Sprint 2 uses this on +`/users` and `/audit/log`; existing endpoints (`/engagements`) stay +non-paginated for backwards-compatibility and will migrate together in a +later opt-in (`?paginate=true` or `/api/v2/`). +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +DEFAULT_PAGE_SIZE = 50 +MAX_PAGE_SIZE = 200 + + +class Page[T](BaseModel): + """Paginated response envelope.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + items: list[T] + total: int = Field(ge=0) + page: int = Field(ge=1) + page_size: int = Field(ge=1, le=MAX_PAGE_SIZE) + + +class PageQuery(BaseModel): + """Parsed `?page=` / `?page_size=` query string (always normalised).""" + + page: int = Field(default=1, ge=1) + page_size: int = Field(default=DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE) + + @property + def offset(self) -> int: + return (self.page - 1) * self.page_size + + @property + def limit(self) -> int: + return self.page_size diff --git a/backend/src/mimic/schemas/user.py b/backend/src/mimic/schemas/user.py new file mode 100644 index 0000000..8828c96 --- /dev/null +++ b/backend/src/mimic/schemas/user.py @@ -0,0 +1,41 @@ +"""User management DTOs (sprint 2).""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from mimic.db.types import UserType + + +class UserRead(BaseModel): + """Public user representation. Never exposes `local_password_hash`.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID + email: str + display_name: str | None + type: UserType + disabled_at: datetime | None + last_login_at: datetime | None + created_at: datetime + + +class UserCreate(BaseModel): + """`POST /api/v1/users` body.""" + + email: EmailStr + display_name: str | None = Field(default=None, max_length=120) + password: str = Field(min_length=8, max_length=128) + type: UserType + + +class UserUpdate(BaseModel): + """`PATCH /api/v1/users/` body (all fields optional).""" + + display_name: str | None = Field(default=None, max_length=120) + password: str | None = Field(default=None, min_length=8, max_length=128) + type: UserType | None = None