feat(backend): add pagination + user/member/audit DTOs (D-016)
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
28
backend/src/mimic/schemas/audit.py
Normal file
28
backend/src/mimic/schemas/audit.py
Normal file
@@ -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
|
||||
27
backend/src/mimic/schemas/engagement_member.py
Normal file
27
backend/src/mimic/schemas/engagement_member.py
Normal file
@@ -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)
|
||||
40
backend/src/mimic/schemas/pagination.py
Normal file
40
backend/src/mimic/schemas/pagination.py
Normal file
@@ -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
|
||||
41
backend/src/mimic/schemas/user.py
Normal file
41
backend/src/mimic/schemas/user.py
Normal file
@@ -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/<uid>` 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
|
||||
Reference in New Issue
Block a user