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.
- msaas_audit_log-0.1.0/.gitignore +23 -0
- msaas_audit_log-0.1.0/PKG-INFO +16 -0
- msaas_audit_log-0.1.0/pyproject.toml +40 -0
- msaas_audit_log-0.1.0/src/audit_log/__init__.py +39 -0
- msaas_audit_log-0.1.0/src/audit_log/actions.py +92 -0
- msaas_audit_log-0.1.0/src/audit_log/config.py +80 -0
- msaas_audit_log-0.1.0/src/audit_log/decorators.py +109 -0
- msaas_audit_log-0.1.0/src/audit_log/middleware.py +68 -0
- msaas_audit_log-0.1.0/src/audit_log/models.py +52 -0
- msaas_audit_log-0.1.0/src/audit_log/router.py +146 -0
- msaas_audit_log-0.1.0/src/audit_log/service.py +170 -0
- msaas_audit_log-0.1.0/src/audit_log/store.py +290 -0
- msaas_audit_log-0.1.0/tests/__init__.py +0 -0
- msaas_audit_log-0.1.0/tests/conftest.py +64 -0
- msaas_audit_log-0.1.0/tests/test_middleware.py +76 -0
- msaas_audit_log-0.1.0/tests/test_service.py +253 -0
- msaas_audit_log-0.1.0/tests/test_store.py +326 -0
|
@@ -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
|