msaas-audit-log 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ node_modules/
2
+ dist/
3
+ .next/
4
+ .turbo/
5
+ *.pyc
6
+ __pycache__/
7
+ .venv/
8
+ *.egg-info/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .env
12
+ .env.*
13
+ !.env.example
14
+ !.env.*.example
15
+ !.env.*.template
16
+ .DS_Store
17
+ coverage/
18
+
19
+ # Runtime artifacts
20
+ logs_llm/
21
+ vectors.db
22
+ vectors.db-shm
23
+ vectors.db-wal
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: msaas-audit-log
3
+ Version: 0.1.0
4
+ Summary: Immutable audit log library with PostgreSQL storage for the Willian SaaS platform
5
+ License: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: asyncpg>=0.30.0
8
+ Requires-Dist: fastapi>=0.115.0
9
+ Requires-Dist: msaas-api-core
10
+ Requires-Dist: msaas-errors
11
+ Requires-Dist: pydantic>=2.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: httpx>=0.27.0; extra == 'dev'
14
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0; extra == 'dev'
16
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "msaas-audit-log"
7
+ version = "0.1.0"
8
+ description = "Immutable audit log library with PostgreSQL storage for the Willian SaaS platform"
9
+ requires-python = ">=3.12"
10
+ license = { text = "MIT" }
11
+ dependencies = [
12
+ "msaas-api-core",
13
+ "msaas-errors",
14
+ "fastapi>=0.115.0",
15
+ "pydantic>=2.0",
16
+ "asyncpg>=0.30.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.0",
22
+ "pytest-asyncio>=0.24.0",
23
+ "httpx>=0.27.0",
24
+ "ruff>=0.8.0",
25
+ ]
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/audit_log"]
29
+
30
+ [tool.pytest.ini_options]
31
+ asyncio_mode = "auto"
32
+ testpaths = ["tests"]
33
+
34
+ [tool.ruff]
35
+ target-version = "py312"
36
+ line-length = 100
37
+
38
+ [tool.uv.sources]
39
+ msaas-api-core = { workspace = true }
40
+ msaas-errors = { workspace = true }
@@ -0,0 +1,39 @@
1
+ """Willian Audit -- Immutable audit log library with PostgreSQL storage."""
2
+
3
+ from audit_log.config import AuditConfig, init_audit
4
+ from audit_log.middleware import AuditMiddleware
5
+ from audit_log.models import AuditEvent, AuditQuery
6
+ from audit_log.router import AuditRouter
7
+ from audit_log.actions import AuditActions
8
+ from audit_log.decorators import (
9
+ audit_document,
10
+ audit_folder,
11
+ audit_log,
12
+ audit_organization,
13
+ audit_user,
14
+ )
15
+ from audit_log.service import (
16
+ get_audit_trail,
17
+ get_resource_history,
18
+ log_action,
19
+ log_change,
20
+ )
21
+
22
+ __all__ = [
23
+ "AuditActions",
24
+ "AuditConfig",
25
+ "AuditEvent",
26
+ "AuditMiddleware",
27
+ "AuditQuery",
28
+ "AuditRouter",
29
+ "audit_document",
30
+ "audit_folder",
31
+ "audit_log",
32
+ "audit_organization",
33
+ "audit_user",
34
+ "get_audit_trail",
35
+ "get_resource_history",
36
+ "init_audit",
37
+ "log_action",
38
+ "log_change",
39
+ ]
@@ -0,0 +1,92 @@
1
+ """Predefined audit action types registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AuditActions:
7
+ """Registry of standard audit action types.
8
+
9
+ Usage:
10
+ from audit_log.actions import AuditActions
11
+ await log_action(action=AuditActions.USER_LOGIN, ...)
12
+ """
13
+
14
+ # Authentication
15
+ USER_LOGIN = "user.login"
16
+ USER_LOGOUT = "user.logout"
17
+ USER_LOGIN_FAILED = "user.login_failed"
18
+ USER_PASSWORD_RESET = "user.password_reset"
19
+ USER_PASSWORD_CHANGED = "user.password_changed"
20
+ USER_MFA_ENABLED = "user.mfa_enabled"
21
+ USER_MFA_DISABLED = "user.mfa_disabled"
22
+ API_KEY_CREATED = "api_key.created"
23
+ API_KEY_REVOKED = "api_key.revoked"
24
+
25
+ # User management
26
+ USER_CREATED = "user.created"
27
+ USER_UPDATED = "user.updated"
28
+ USER_DELETED = "user.deleted"
29
+ USER_INVITED = "user.invited"
30
+ USER_ROLE_CHANGED = "user.role_changed"
31
+ USER_DEACTIVATED = "user.deactivated"
32
+
33
+ # Documents
34
+ DOCUMENT_CREATED = "document.created"
35
+ DOCUMENT_UPDATED = "document.updated"
36
+ DOCUMENT_DELETED = "document.deleted"
37
+ DOCUMENT_VIEWED = "document.viewed"
38
+ DOCUMENT_DOWNLOADED = "document.downloaded"
39
+ DOCUMENT_SHARED = "document.shared"
40
+ DOCUMENT_UNSHARED = "document.unshared"
41
+ DOCUMENT_MOVED = "document.moved"
42
+ DOCUMENT_COPIED = "document.copied"
43
+ DOCUMENT_RESTORED = "document.restored"
44
+ DOCUMENT_VERSION_CREATED = "document.version_created"
45
+
46
+ # Folders
47
+ FOLDER_CREATED = "folder.created"
48
+ FOLDER_UPDATED = "folder.updated"
49
+ FOLDER_DELETED = "folder.deleted"
50
+ FOLDER_MOVED = "folder.moved"
51
+ FOLDER_SHARED = "folder.shared"
52
+ FOLDER_UNSHARED = "folder.unshared"
53
+
54
+ # Processing
55
+ PROCESSING_STARTED = "processing.started"
56
+ PROCESSING_COMPLETED = "processing.completed"
57
+ PROCESSING_FAILED = "processing.failed"
58
+
59
+ # Organization
60
+ ORG_CREATED = "organization.created"
61
+ ORG_UPDATED = "organization.updated"
62
+ ORG_DELETED = "organization.deleted"
63
+ ORG_MEMBER_ADDED = "organization.member_added"
64
+ ORG_MEMBER_REMOVED = "organization.member_removed"
65
+ ORG_SETTINGS_CHANGED = "organization.settings_changed"
66
+ ORG_PLAN_CHANGED = "organization.plan_changed"
67
+
68
+ # Billing
69
+ SUBSCRIPTION_CREATED = "subscription.created"
70
+ SUBSCRIPTION_UPDATED = "subscription.updated"
71
+ SUBSCRIPTION_CANCELLED = "subscription.cancelled"
72
+ PAYMENT_SUCCEEDED = "payment.succeeded"
73
+ PAYMENT_FAILED = "payment.failed"
74
+
75
+ # Sharing
76
+ SHARE_LINK_CREATED = "share_link.created"
77
+ SHARE_LINK_ACCESSED = "share_link.accessed"
78
+ SHARE_LINK_REVOKED = "share_link.revoked"
79
+
80
+ # System
81
+ SYSTEM_MAINTENANCE = "system.maintenance"
82
+ SYSTEM_CONFIG_CHANGED = "system.config_changed"
83
+ SYSTEM_EXPORT_REQUESTED = "system.export_requested"
84
+
85
+ @classmethod
86
+ def all_actions(cls) -> list[str]:
87
+ """Return all registered action types."""
88
+ return [
89
+ v
90
+ for k, v in vars(cls).items()
91
+ if isinstance(v, str) and not k.startswith("_") and k != "all_actions"
92
+ ]
@@ -0,0 +1,80 @@
1
+ """Audit module configuration and initialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncpg
6
+ from pydantic import BaseModel
7
+
8
+
9
+ _config: AuditConfig | None = None
10
+ _pool: asyncpg.Pool | None = None
11
+
12
+
13
+ class AuditConfig(BaseModel):
14
+ """Configuration for the audit log module.
15
+
16
+ Args:
17
+ database_url: PostgreSQL connection string.
18
+ table_name: Name of the audit events table.
19
+ retention_days: Number of days to retain events before purge eligibility.
20
+ pool_min_size: Minimum connections in the asyncpg pool.
21
+ pool_max_size: Maximum connections in the asyncpg pool.
22
+ """
23
+
24
+ database_url: str
25
+ table_name: str = "audit_events"
26
+ retention_days: int = 90
27
+ pool_min_size: int = 2
28
+ pool_max_size: int = 10
29
+
30
+
31
+ async def init_audit(config: AuditConfig) -> asyncpg.Pool:
32
+ """Initialize the audit module: store config and create the connection pool.
33
+
34
+ Must be called before any store or service operations.
35
+
36
+ Returns:
37
+ The created asyncpg connection pool.
38
+ """
39
+ global _config, _pool
40
+ _config = config
41
+ _pool = await asyncpg.create_pool(
42
+ dsn=config.database_url,
43
+ min_size=config.pool_min_size,
44
+ max_size=config.pool_max_size,
45
+ )
46
+ return _pool
47
+
48
+
49
+ def get_config() -> AuditConfig:
50
+ """Return the current module configuration.
51
+
52
+ Raises:
53
+ RuntimeError: If init_audit() has not been called.
54
+ """
55
+ if _config is None:
56
+ raise RuntimeError("Audit module not initialized. Call init_audit() first.")
57
+ return _config
58
+
59
+
60
+ def get_pool() -> asyncpg.Pool:
61
+ """Return the current connection pool.
62
+
63
+ Raises:
64
+ RuntimeError: If init_audit() has not been called.
65
+ """
66
+ if _pool is None:
67
+ raise RuntimeError("Audit module not initialized. Call init_audit() first.")
68
+ return _pool
69
+
70
+
71
+ def set_config(config: AuditConfig) -> None:
72
+ """Override the configuration (useful for testing)."""
73
+ global _config
74
+ _config = config
75
+
76
+
77
+ def set_pool(pool: asyncpg.Pool) -> None:
78
+ """Override the connection pool (useful for testing)."""
79
+ global _pool
80
+ _pool = pool
@@ -0,0 +1,109 @@
1
+ """Decorator-based audit logging for FastAPI route handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import logging
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ from audit_log.service import log_action
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def audit_log(
16
+ action: str,
17
+ resource_type: str,
18
+ resource_id_param: str = "id",
19
+ actor_id_param: str | None = None,
20
+ ) -> Callable:
21
+ """Decorator that automatically logs an audit event after a route handler succeeds.
22
+
23
+ Usage:
24
+ @router.post("/documents/{id}/approve")
25
+ @audit_log(action="document.approved", resource_type="document")
26
+ async def approve_document(id: str, current_user: User = Depends(get_user)):
27
+ ...
28
+
29
+ Args:
30
+ action: The action identifier (e.g., "document.created").
31
+ resource_type: The type of resource being acted upon.
32
+ resource_id_param: Name of the function parameter containing the resource ID.
33
+ actor_id_param: Name of the parameter containing actor ID. If None,
34
+ looks for 'current_user' with an 'id' attribute.
35
+ """
36
+
37
+ def decorator(func: Callable) -> Callable:
38
+ @functools.wraps(func)
39
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
40
+ result = await func(*args, **kwargs)
41
+
42
+ try:
43
+ resource_id = str(kwargs.get(resource_id_param, "unknown"))
44
+ actor_id = _extract_actor_id(kwargs, actor_id_param)
45
+ ip_address = _extract_ip(kwargs)
46
+ user_agent = _extract_user_agent(kwargs)
47
+
48
+ await log_action(
49
+ actor_id=actor_id,
50
+ action=action,
51
+ resource_type=resource_type,
52
+ resource_id=resource_id,
53
+ ip_address=ip_address,
54
+ user_agent=user_agent,
55
+ metadata={"decorated": True},
56
+ )
57
+ except Exception:
58
+ logger.warning("Failed to log audit event for %s", action, exc_info=True)
59
+
60
+ return result
61
+
62
+ return wrapper
63
+
64
+ return decorator
65
+
66
+
67
+ def audit_document(action: str, resource_id_param: str = "id") -> Callable:
68
+ """Shorthand for @audit_log with resource_type='document'."""
69
+ return audit_log(action=action, resource_type="document", resource_id_param=resource_id_param)
70
+
71
+
72
+ def audit_folder(action: str, resource_id_param: str = "id") -> Callable:
73
+ """Shorthand for @audit_log with resource_type='folder'."""
74
+ return audit_log(action=action, resource_type="folder", resource_id_param=resource_id_param)
75
+
76
+
77
+ def audit_user(action: str, resource_id_param: str = "id") -> Callable:
78
+ """Shorthand for @audit_log with resource_type='user'."""
79
+ return audit_log(action=action, resource_type="user", resource_id_param=resource_id_param)
80
+
81
+
82
+ def audit_organization(action: str, resource_id_param: str = "id") -> Callable:
83
+ """Shorthand for @audit_log with resource_type='organization'."""
84
+ return audit_log(
85
+ action=action, resource_type="organization", resource_id_param=resource_id_param
86
+ )
87
+
88
+
89
+ def _extract_actor_id(kwargs: dict[str, Any], actor_id_param: str | None) -> str:
90
+ if actor_id_param and actor_id_param in kwargs:
91
+ return str(kwargs[actor_id_param])
92
+ current_user = kwargs.get("current_user")
93
+ if current_user and hasattr(current_user, "id"):
94
+ return str(current_user.id)
95
+ return "system"
96
+
97
+
98
+ def _extract_ip(kwargs: dict[str, Any]) -> str | None:
99
+ request = kwargs.get("request")
100
+ if request and hasattr(request, "client") and request.client:
101
+ return request.client.host
102
+ return None
103
+
104
+
105
+ def _extract_user_agent(kwargs: dict[str, Any]) -> str | None:
106
+ request = kwargs.get("request")
107
+ if request and hasattr(request, "headers"):
108
+ return request.headers.get("user-agent")
109
+ return None
@@ -0,0 +1,68 @@
1
+ """FastAPI middleware that captures request metadata for audit events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar
6
+ from typing import TYPE_CHECKING
7
+
8
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
9
+ from starlette.requests import Request
10
+ from starlette.responses import Response
11
+
12
+ if TYPE_CHECKING:
13
+ from starlette.types import ASGIApp
14
+
15
+
16
+ _audit_ip: ContextVar[str | None] = ContextVar("audit_ip", default=None)
17
+ _audit_user_agent: ContextVar[str | None] = ContextVar("audit_user_agent", default=None)
18
+
19
+
20
+ def get_request_ip() -> str | None:
21
+ """Return the client IP address captured by the audit middleware."""
22
+ return _audit_ip.get()
23
+
24
+
25
+ def get_request_user_agent() -> str | None:
26
+ """Return the client User-Agent captured by the audit middleware."""
27
+ return _audit_user_agent.get()
28
+
29
+
30
+ class AuditMiddleware(BaseHTTPMiddleware):
31
+ """Middleware that extracts IP address and User-Agent into context variables.
32
+
33
+ When this middleware is active, audit service functions can automatically
34
+ pick up request metadata without the caller having to pass them explicitly.
35
+
36
+ The middleware reads ``X-Forwarded-For`` when present (common behind
37
+ reverse proxies), falling back to the direct client IP.
38
+
39
+ Usage::
40
+
41
+ from fastapi import FastAPI
42
+ from audit_log import AuditMiddleware
43
+
44
+ app = FastAPI()
45
+ app.add_middleware(AuditMiddleware)
46
+ """
47
+
48
+ def __init__(self, app: ASGIApp) -> None:
49
+ super().__init__(app)
50
+
51
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
52
+ # Extract client IP, preferring X-Forwarded-For for proxied setups.
53
+ forwarded = request.headers.get("x-forwarded-for")
54
+ if forwarded:
55
+ ip = forwarded.split(",")[0].strip()
56
+ else:
57
+ ip = request.client.host if request.client else None
58
+
59
+ user_agent = request.headers.get("user-agent")
60
+
61
+ ip_token = _audit_ip.set(ip)
62
+ ua_token = _audit_user_agent.set(user_agent)
63
+
64
+ try:
65
+ return await call_next(request)
66
+ finally:
67
+ _audit_ip.reset(ip_token)
68
+ _audit_user_agent.reset(ua_token)
@@ -0,0 +1,52 @@
1
+ """Pydantic models for audit events and queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from datetime import datetime
7
+ from uuid import UUID
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class ActorType(str, enum.Enum):
13
+ """Type of actor that triggered the audit event."""
14
+
15
+ USER = "user"
16
+ SYSTEM = "system"
17
+ API_KEY = "api_key"
18
+
19
+
20
+ class AuditEvent(BaseModel):
21
+ """An immutable audit event record.
22
+
23
+ Captures who did what, to which resource, and when.
24
+ """
25
+
26
+ id: UUID
27
+ timestamp: datetime
28
+ actor_id: str
29
+ actor_type: ActorType = ActorType.USER
30
+ action: str
31
+ resource_type: str
32
+ resource_id: str
33
+ changes: dict = Field(default_factory=dict)
34
+ metadata: dict = Field(default_factory=dict)
35
+ ip_address: str | None = None
36
+ user_agent: str | None = None
37
+
38
+
39
+ class AuditQuery(BaseModel):
40
+ """Filters for querying audit events.
41
+
42
+ All filter fields are optional. Only non-None fields are applied.
43
+ """
44
+
45
+ actor_id: str | None = None
46
+ action: str | None = None
47
+ resource_type: str | None = None
48
+ resource_id: str | None = None
49
+ start_date: datetime | None = None
50
+ end_date: datetime | None = None
51
+ limit: int = Field(default=50, ge=1, le=1000)
52
+ offset: int = Field(default=0, ge=0)
@@ -0,0 +1,146 @@
1
+ """FastAPI router factory for audit log endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ from fastapi import APIRouter, Depends, Query
9
+ from pydantic import BaseModel
10
+
11
+ from audit_log.config import get_config
12
+ from audit_log.models import AuditEvent
13
+ from audit_log.service import get_audit_trail, get_resource_history
14
+ from audit_log.store import get_event, purge_old_events
15
+ from errors import NotFoundError
16
+
17
+
18
+ class AuditListResponse(BaseModel):
19
+ """Response model for paginated audit event list."""
20
+
21
+ items: list[AuditEvent]
22
+ total: int
23
+ page: int
24
+ per_page: int
25
+
26
+
27
+ class PurgeResponse(BaseModel):
28
+ """Response model for purge operations."""
29
+
30
+ deleted: int
31
+ retention_days: int
32
+
33
+
34
+ def AuditRouter(
35
+ *,
36
+ get_user_id: Any | None = None,
37
+ is_admin: Any | None = None,
38
+ prefix: str = "/audit",
39
+ tags: list[str] | None = None,
40
+ ) -> APIRouter:
41
+ """Create a FastAPI router for audit log endpoints.
42
+
43
+ Args:
44
+ get_user_id: Optional FastAPI dependency returning the current user's ID.
45
+ Used for actor-scoped queries. If None, actor endpoints are still
46
+ available but require explicit actor_id.
47
+ is_admin: Optional FastAPI dependency that gates admin-only endpoints
48
+ (e.g., purge). Should raise HTTPException(403) if not authorized.
49
+ If None, purge endpoint is available without restriction.
50
+ prefix: URL prefix for all routes. Defaults to ``/audit``.
51
+ tags: OpenAPI tags for the router.
52
+
53
+ Returns:
54
+ A configured APIRouter.
55
+
56
+ Example::
57
+
58
+ from audit_log import AuditRouter
59
+
60
+ async def require_admin(request: Request) -> None:
61
+ if not request.state.user.is_admin:
62
+ raise AuthorizationError("Admin required")
63
+
64
+ router = AuditRouter(is_admin=require_admin)
65
+ app.include_router(router)
66
+ """
67
+ router = APIRouter(prefix=prefix, tags=tags or ["audit"])
68
+
69
+ @router.get("", response_model=AuditListResponse)
70
+ async def list_audit_events(
71
+ actor_id: str | None = Query(None, description="Filter by actor ID"),
72
+ action: str | None = Query(None, description="Filter by action"),
73
+ resource_type: str | None = Query(None, description="Filter by resource type"),
74
+ resource_id: str | None = Query(None, description="Filter by resource ID"),
75
+ page: int = Query(1, ge=1, description="Page number"),
76
+ per_page: int = Query(50, ge=1, le=1000, description="Items per page"),
77
+ ) -> AuditListResponse:
78
+ """List audit events with optional filters and pagination."""
79
+ result = await get_audit_trail(
80
+ actor_id=actor_id,
81
+ action=action,
82
+ resource_type=resource_type,
83
+ resource_id=resource_id,
84
+ page=page,
85
+ per_page=per_page,
86
+ )
87
+ return AuditListResponse(**result)
88
+
89
+ @router.get("/{event_id}", response_model=AuditEvent)
90
+ async def get_audit_event(event_id: UUID) -> AuditEvent:
91
+ """Get a single audit event by ID."""
92
+ event = await get_event(event_id)
93
+ if event is None:
94
+ raise NotFoundError("Audit event not found")
95
+ return event
96
+
97
+ @router.get("/resource/{resource_type}/{resource_id}", response_model=AuditListResponse)
98
+ async def get_resource_audit_history(
99
+ resource_type: str,
100
+ resource_id: str,
101
+ page: int = Query(1, ge=1, description="Page number"),
102
+ per_page: int = Query(50, ge=1, le=1000, description="Items per page"),
103
+ ) -> AuditListResponse:
104
+ """Get the audit trail for a specific resource."""
105
+ result = await get_resource_history(
106
+ resource_type,
107
+ resource_id,
108
+ page=page,
109
+ per_page=per_page,
110
+ )
111
+ return AuditListResponse(**result)
112
+
113
+ @router.get("/actor/{actor_id}", response_model=AuditListResponse)
114
+ async def get_actor_audit_trail(
115
+ actor_id: str,
116
+ page: int = Query(1, ge=1, description="Page number"),
117
+ per_page: int = Query(50, ge=1, le=1000, description="Items per page"),
118
+ ) -> AuditListResponse:
119
+ """Get the audit trail for a specific actor."""
120
+ result = await get_audit_trail(
121
+ actor_id=actor_id,
122
+ page=page,
123
+ per_page=per_page,
124
+ )
125
+ return AuditListResponse(**result)
126
+
127
+ # Build the purge endpoint dependencies list.
128
+ purge_deps: list[Any] = []
129
+ if is_admin is not None:
130
+ purge_deps.append(Depends(is_admin))
131
+
132
+ @router.post("/purge", response_model=PurgeResponse, dependencies=purge_deps)
133
+ async def purge_events(
134
+ retention_days: int | None = Query(
135
+ None,
136
+ ge=1,
137
+ description="Override retention period (days). Defaults to config value.",
138
+ ),
139
+ ) -> PurgeResponse:
140
+ """Purge audit events older than the retention period. Admin only."""
141
+ config = get_config()
142
+ days = retention_days if retention_days is not None else config.retention_days
143
+ deleted = await purge_old_events(days)
144
+ return PurgeResponse(deleted=deleted, retention_days=days)
145
+
146
+ return router