Files
ModelForge-Public/backend/src/modelforge_api/domain/audit_timeline.py
T

70 lines
2.6 KiB
Python

"""Typed, bounded query and response models for the operator audit timeline."""
from __future__ import annotations
from datetime import timedelta
from uuid import UUID
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
AUDIT_TIMELINE_MAX_PAGE_SIZE = 100
AUDIT_TIMELINE_MAX_TIME_RANGE = timedelta(days=366)
class AuditTimelineRequest(BaseModel):
"""Validated filters for one bounded page of audit events.
Text filters are case-insensitive substring matches. A time filter must specify both ends,
and its window is limited to one year plus one leap day.
"""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
principal: str | None = Field(default=None, min_length=1, max_length=255)
action: str | None = Field(default=None, min_length=1, max_length=128)
resource: str | None = Field(default=None, min_length=1, max_length=128)
outcome: str | None = Field(default=None, min_length=1, max_length=32)
occurred_after: AwareDatetime | None = None
occurred_before: AwareDatetime | None = None
correlation_id: str | None = Field(default=None, min_length=1, max_length=64)
limit: int = Field(default=50, ge=1, le=AUDIT_TIMELINE_MAX_PAGE_SIZE)
cursor: str | None = Field(default=None, min_length=1, max_length=256)
@model_validator(mode="after")
def validate_time_window(self) -> AuditTimelineRequest:
if (self.occurred_after is None) != (self.occurred_before is None):
raise ValueError("occurred_after and occurred_before must be supplied together")
if self.occurred_after is not None and self.occurred_before is not None:
if self.occurred_after >= self.occurred_before:
raise ValueError("occurred_after must be earlier than occurred_before")
if self.occurred_before - self.occurred_after > AUDIT_TIMELINE_MAX_TIME_RANGE:
raise ValueError("audit time range exceeds the 366-day maximum")
return self
class AuditTimelineEvent(BaseModel):
"""Safe event fields returned to the operator console."""
model_config = ConfigDict(frozen=True)
id: UUID
sequence: int = Field(ge=1)
occurred_at: AwareDatetime
correlation_id: str
actor_type: str
actor_id: str
action: str
resource_type: str
resource_id: str | None
outcome: str
class AuditTimelineResponse(BaseModel):
"""One page of audit events and an opaque keyset cursor for the next page."""
model_config = ConfigDict(frozen=True)
events: list[AuditTimelineEvent]
next_cursor: str | None
limit: int = Field(ge=1, le=AUDIT_TIMELINE_MAX_PAGE_SIZE)