universal-audit-logs 1.0.2__py3-none-any.whl
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.
- audit_sdk/__init__.py +67 -0
- audit_sdk/audit.py +544 -0
- audit_sdk/config/__init__.py +10 -0
- audit_sdk/config/settings.py +224 -0
- audit_sdk/core/__init__.py +12 -0
- audit_sdk/core/event_bus.py +130 -0
- audit_sdk/core/exceptions.py +209 -0
- audit_sdk/core/interfaces.py +251 -0
- audit_sdk/middleware/__init__.py +38 -0
- audit_sdk/middleware/auto_audit.py +781 -0
- audit_sdk/middleware/base.py +11 -0
- audit_sdk/middleware/enhanced.py +673 -0
- audit_sdk/middleware/fastapi.py +782 -0
- audit_sdk/models/__init__.py +23 -0
- audit_sdk/models/audit_event.py +545 -0
- audit_sdk/models/enums.py +76 -0
- audit_sdk/plugins/__init__.py +28 -0
- audit_sdk/plugins/base.py +11 -0
- audit_sdk/plugins/registry.py +183 -0
- audit_sdk/py.typed +0 -0
- audit_sdk/security/__init__.py +25 -0
- audit_sdk/security/encryption.py +138 -0
- audit_sdk/security/signing.py +133 -0
- audit_sdk/transport/__init__.py +21 -0
- audit_sdk/transport/base.py +11 -0
- audit_sdk/transport/database.py +425 -0
- audit_sdk/transport/kafka.py +130 -0
- audit_sdk/transport/mongodb.py +240 -0
- audit_sdk/transport/rest.py +274 -0
- audit_sdk/transport/simple_database.py +200 -0
- audit_sdk/utils/__init__.py +12 -0
- audit_sdk/utils/logger.py +130 -0
- audit_sdk/utils/retry.py +117 -0
- audit_sdk/utils/retry_manager.py +485 -0
- audit_sdk/utils/serializer.py +177 -0
- universal_audit_logs-1.0.2.dist-info/METADATA +225 -0
- universal_audit_logs-1.0.2.dist-info/RECORD +39 -0
- universal_audit_logs-1.0.2.dist-info/WHEEL +4 -0
- universal_audit_logs-1.0.2.dist-info/licenses/LICENSE +21 -0
audit_sdk/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Universal Audit Framework (UAF) SDK
|
|
3
|
+
====================================
|
|
4
|
+
|
|
5
|
+
A production-ready, framework-agnostic audit logging SDK for Python 3.12+.
|
|
6
|
+
|
|
7
|
+
Quick-start — REST transport::
|
|
8
|
+
|
|
9
|
+
from audit_sdk import AuditClient, AuditSettings
|
|
10
|
+
|
|
11
|
+
settings = AuditSettings(endpoint="https://audit.example.com/api/v1/events")
|
|
12
|
+
audit = AuditClient(settings=settings)
|
|
13
|
+
|
|
14
|
+
Quick-start — Database transport::
|
|
15
|
+
|
|
16
|
+
from audit_sdk import AuditClient
|
|
17
|
+
from audit_sdk.transport.database import DatabaseTransport
|
|
18
|
+
|
|
19
|
+
transport = DatabaseTransport.for_postgres(
|
|
20
|
+
host="localhost", database="audit_db",
|
|
21
|
+
username="user", password="pass",
|
|
22
|
+
)
|
|
23
|
+
audit = AuditClient(transport=transport)
|
|
24
|
+
|
|
25
|
+
Quick-start — FastAPI middleware::
|
|
26
|
+
|
|
27
|
+
from audit_sdk.middleware.fastapi import AuditMiddleware, AuditMiddlewareConfig
|
|
28
|
+
|
|
29
|
+
app.add_middleware(AuditMiddleware, audit_client=audit, config=AuditMiddlewareConfig(...))
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from audit_sdk.audit import AuditClient
|
|
33
|
+
from audit_sdk.config.settings import AuditSettings
|
|
34
|
+
from audit_sdk.core.exceptions import QueueFullError, TransportError
|
|
35
|
+
from audit_sdk.models.audit_event import AuditEvent
|
|
36
|
+
from audit_sdk.utils.retry_manager import RetryManager, RetryMetrics
|
|
37
|
+
|
|
38
|
+
__all__: list[str] = [
|
|
39
|
+
# Core
|
|
40
|
+
"AuditClient",
|
|
41
|
+
"AuditSettings",
|
|
42
|
+
"AuditEvent",
|
|
43
|
+
# Retry
|
|
44
|
+
"RetryManager",
|
|
45
|
+
"RetryMetrics",
|
|
46
|
+
# Exceptions
|
|
47
|
+
"QueueFullError",
|
|
48
|
+
"TransportError",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# Optional: FastAPI middleware (requires `pip install universal-audit-logs[fastapi]`)
|
|
52
|
+
try:
|
|
53
|
+
from audit_sdk.middleware.fastapi import AuditMiddleware, AuditMiddlewareConfig
|
|
54
|
+
|
|
55
|
+
__all__ += ["AuditMiddleware", "AuditMiddlewareConfig"]
|
|
56
|
+
except ImportError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
# Optional: Database transport (requires `pip install universal-audit-logs[postgres]` or `[sqlite]`)
|
|
60
|
+
try:
|
|
61
|
+
from audit_sdk.transport.database import DatabaseTransport
|
|
62
|
+
|
|
63
|
+
__all__ += ["DatabaseTransport"]
|
|
64
|
+
except ImportError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
__version__: str = "1.0.2"
|
audit_sdk/audit.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""
|
|
2
|
+
audit — Public-facing AuditClient: the SDK's primary entry point.
|
|
3
|
+
|
|
4
|
+
:class:`AuditClient` is the single object developers interact with.
|
|
5
|
+
It orchestrates the full audit event lifecycle:
|
|
6
|
+
|
|
7
|
+
1. **Build** — assemble an :class:`~audit_sdk.models.audit_event.AuditEvent`
|
|
8
|
+
from caller-supplied parameters (with auto-populated ``event_id`` / ``timestamp``).
|
|
9
|
+
2. **Validate** — Pydantic v2 validates the event; invalid events raise
|
|
10
|
+
:class:`~audit_sdk.core.exceptions.ValidationError` immediately.
|
|
11
|
+
3. **Enrich** — ``on_before_send`` plugin hooks may add metadata.
|
|
12
|
+
4. **Secure** — signing and/or encryption are applied when enabled.
|
|
13
|
+
5. **Dispatch** — the transport delivers the event (with retry).
|
|
14
|
+
6. **Observe** — ``on_after_send`` plugin hooks are called with the result.
|
|
15
|
+
|
|
16
|
+
Design:
|
|
17
|
+
- No global state. Every AuditClient holds its own configuration,
|
|
18
|
+
transport, serializer, plugin registry, and optional security components.
|
|
19
|
+
- AuditClient is an async context manager for clean resource management.
|
|
20
|
+
- All dependencies are injected; the client does not create its own transport
|
|
21
|
+
unless a factory helper (``AuditClient.create()``) is used.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from audit_sdk.config.settings import AuditSettings
|
|
30
|
+
from audit_sdk.core.event_bus import AuditEventBus
|
|
31
|
+
from audit_sdk.core.exceptions import ConfigurationError, TransportError, ValidationError
|
|
32
|
+
from audit_sdk.core.interfaces import (
|
|
33
|
+
AbstractEncryptor,
|
|
34
|
+
AbstractSerializer,
|
|
35
|
+
AbstractSigner,
|
|
36
|
+
AbstractTransport,
|
|
37
|
+
)
|
|
38
|
+
from audit_sdk.models.audit_event import AuditEvent
|
|
39
|
+
from audit_sdk.plugins.registry import PluginRegistry
|
|
40
|
+
from audit_sdk.utils.retry_manager import RetryManager
|
|
41
|
+
|
|
42
|
+
log = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuditClient:
|
|
46
|
+
"""The primary SDK entry point for emitting structured audit events.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
settings: SDK configuration object (endpoint, API key, retry settings, etc.).
|
|
50
|
+
transport: The delivery transport (REST, Kafka, etc.). If ``None``, a
|
|
51
|
+
:class:`~audit_sdk.transport.rest.RestTransport` is created automatically.
|
|
52
|
+
serializer: Event serializer. Defaults to
|
|
53
|
+
:class:`~audit_sdk.utils.serializer.AuditEventSerializer`.
|
|
54
|
+
signer: Optional HMAC signer. Created automatically if
|
|
55
|
+
``settings.signing_enabled`` is ``True``.
|
|
56
|
+
encryptor: Optional field encryptor. Created automatically if
|
|
57
|
+
``settings.encryption_enabled`` is ``True``.
|
|
58
|
+
plugin_registry: Plugin registry. A fresh empty registry is used if not supplied.
|
|
59
|
+
event_bus: Internal event bus. A fresh bus is used if not supplied.
|
|
60
|
+
|
|
61
|
+
Example::
|
|
62
|
+
|
|
63
|
+
from audit_sdk import AuditClient, AuditSettings
|
|
64
|
+
|
|
65
|
+
settings = AuditSettings(endpoint="https://audit.example.com/api/v1/events")
|
|
66
|
+
audit = AuditClient(settings=settings)
|
|
67
|
+
|
|
68
|
+
await audit.log(
|
|
69
|
+
action="CREATE",
|
|
70
|
+
entity="Employee",
|
|
71
|
+
entity_id="100",
|
|
72
|
+
user={"id": "1", "name": "Admin"},
|
|
73
|
+
old_value=None,
|
|
74
|
+
new_value={"name": "John"},
|
|
75
|
+
)
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
settings: AuditSettings | None = None,
|
|
81
|
+
transport: AbstractTransport | None = None,
|
|
82
|
+
serializer: AbstractSerializer | None = None,
|
|
83
|
+
signer: AbstractSigner | None = None,
|
|
84
|
+
encryptor: AbstractEncryptor | None = None,
|
|
85
|
+
plugin_registry: PluginRegistry | None = None,
|
|
86
|
+
event_bus: AuditEventBus | None = None,
|
|
87
|
+
retry_manager: RetryManager | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
# settings is optional when a transport is supplied directly
|
|
90
|
+
# (e.g. DatabaseTransport / MongoDBTransport — no endpoint needed).
|
|
91
|
+
if settings is None and transport is None:
|
|
92
|
+
raise ConfigurationError(
|
|
93
|
+
"AuditClient requires either 'settings' (for HTTP/REST transport) "
|
|
94
|
+
"or 'transport' (for direct DB/MongoDB transport)."
|
|
95
|
+
)
|
|
96
|
+
self._settings = settings
|
|
97
|
+
self._transport = transport # lazy-initialized in _get_transport()
|
|
98
|
+
self._serializer = serializer # lazy-initialized in _get_serializer()
|
|
99
|
+
self._signer = signer
|
|
100
|
+
self._encryptor = encryptor
|
|
101
|
+
self._plugin_registry = plugin_registry or PluginRegistry()
|
|
102
|
+
self._event_bus = event_bus or AuditEventBus()
|
|
103
|
+
self._retry_manager = retry_manager
|
|
104
|
+
self._closed = False
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# Primary public API
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
async def log(
|
|
111
|
+
self,
|
|
112
|
+
action: str,
|
|
113
|
+
entity: str,
|
|
114
|
+
entity_id: str,
|
|
115
|
+
user: dict[str, Any],
|
|
116
|
+
old_value: Any = None,
|
|
117
|
+
new_value: Any = None,
|
|
118
|
+
metadata: dict[str, Any] | None = None,
|
|
119
|
+
tenant_id: str | None = None,
|
|
120
|
+
application: str | None = None,
|
|
121
|
+
module: str | None = None,
|
|
122
|
+
service: str | None = None,
|
|
123
|
+
event_type: str | None = None,
|
|
124
|
+
status: str | None = None,
|
|
125
|
+
request: dict[str, Any] | None = None,
|
|
126
|
+
device: dict[str, Any] | None = None,
|
|
127
|
+
) -> AuditEvent:
|
|
128
|
+
"""Build and validate a single structured audit event.
|
|
129
|
+
|
|
130
|
+
Constructs an :class:`~audit_sdk.models.audit_event.AuditEvent` from
|
|
131
|
+
the supplied parameters, merging caller-provided values with context
|
|
132
|
+
drawn from :attr:`settings` (e.g. ``service_name``). Pydantic v2
|
|
133
|
+
validates every field at construction time.
|
|
134
|
+
|
|
135
|
+
The event is returned immediately — no transport, retry, or plugin
|
|
136
|
+
hooks are invoked at this stage.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
action: The action performed (e.g. ``"CREATE"``, ``"UPDATE"``).
|
|
140
|
+
Accepts any :class:`~audit_sdk.models.enums.AuditAction` value
|
|
141
|
+
or a custom non-empty string.
|
|
142
|
+
entity: Entity type acted upon (e.g. ``"Employee"``, ``"Invoice"``).
|
|
143
|
+
entity_id: Unique identifier of the specific entity instance.
|
|
144
|
+
user: Actor who performed the action. Must contain at least
|
|
145
|
+
``"id"`` and ``"name"`` keys.
|
|
146
|
+
old_value: State of the entity *before* the action.
|
|
147
|
+
Pass ``None`` for CREATE operations.
|
|
148
|
+
new_value: State of the entity *after* the action.
|
|
149
|
+
Pass ``None`` for DELETE operations.
|
|
150
|
+
metadata: Arbitrary key-value pairs for custom enrichment
|
|
151
|
+
(tags, batch IDs, feature flags, etc.).
|
|
152
|
+
tenant_id: Tenant / organisation identifier for multi-tenant
|
|
153
|
+
deployments (e.g. ``"tenant-acme"``).
|
|
154
|
+
application: Top-level application name (e.g. ``"HRPortal"``).
|
|
155
|
+
module: Sub-module or feature area (e.g. ``"Payroll"``).
|
|
156
|
+
service: Emitting service name. Falls back to
|
|
157
|
+
``settings.service_name`` when not supplied.
|
|
158
|
+
event_type: High-level event category. Use
|
|
159
|
+
:class:`~audit_sdk.models.audit_event.EventType` values or any
|
|
160
|
+
custom string. Defaults to ``EventType.GENERIC`` when omitted.
|
|
161
|
+
status: Outcome of the audited operation. Use
|
|
162
|
+
:class:`~audit_sdk.models.audit_event.AuditOutcome` values or
|
|
163
|
+
any custom string. Defaults to ``AuditOutcome.SUCCESS`` when
|
|
164
|
+
omitted.
|
|
165
|
+
request: HTTP request context dict. Accepted keys match
|
|
166
|
+
:class:`~audit_sdk.models.audit_event.RequestModel`.
|
|
167
|
+
device: Client device context dict. Accepted keys match
|
|
168
|
+
:class:`~audit_sdk.models.audit_event.DeviceModel`.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
A fully validated :class:`~audit_sdk.models.audit_event.AuditEvent`
|
|
172
|
+
instance. ``correlation_id`` and ``timestamp`` are auto-populated.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValidationError: If any field fails Pydantic v2 validation.
|
|
176
|
+
AuditSDKError: For any other SDK-level error.
|
|
177
|
+
|
|
178
|
+
Example::
|
|
179
|
+
|
|
180
|
+
event = await audit.log(
|
|
181
|
+
action="UPDATE",
|
|
182
|
+
entity="Invoice",
|
|
183
|
+
entity_id="INV-9999",
|
|
184
|
+
user={"id": "42", "name": "Finance Bot"},
|
|
185
|
+
tenant_id="tenant-001",
|
|
186
|
+
application="FinanceSystem",
|
|
187
|
+
module="Invoicing",
|
|
188
|
+
event_type="BUSINESS",
|
|
189
|
+
status="SUCCESS",
|
|
190
|
+
old_value={"status": "draft"},
|
|
191
|
+
new_value={"status": "approved"},
|
|
192
|
+
metadata={"approved_by": "cfo@example.com"},
|
|
193
|
+
)
|
|
194
|
+
"""
|
|
195
|
+
return self._build_event(
|
|
196
|
+
action=action,
|
|
197
|
+
entity=entity,
|
|
198
|
+
entity_id=entity_id,
|
|
199
|
+
user=user,
|
|
200
|
+
old_value=old_value,
|
|
201
|
+
new_value=new_value,
|
|
202
|
+
metadata=metadata,
|
|
203
|
+
tenant_id=tenant_id,
|
|
204
|
+
application=application,
|
|
205
|
+
module=module,
|
|
206
|
+
service=service,
|
|
207
|
+
event_type=event_type,
|
|
208
|
+
status=status,
|
|
209
|
+
request=request,
|
|
210
|
+
device=device,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
async def log_event(self, event: AuditEvent) -> None:
|
|
214
|
+
"""Dispatch a pre-built :class:`AuditEvent` directly.
|
|
215
|
+
|
|
216
|
+
Attempts to deliver *event* via the configured transport. On a
|
|
217
|
+
retryable failure (network error, timeout, HTTP 5xx/429) the event
|
|
218
|
+
is handed off to the :class:`~audit_sdk.utils.retry_manager.RetryManager`
|
|
219
|
+
if one is configured and ``settings.retry_enabled`` is ``True``.
|
|
220
|
+
On a non-retryable failure (HTTP 4xx) the :class:`TransportError`
|
|
221
|
+
is re-raised immediately.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
event: A fully validated :class:`~audit_sdk.models.audit_event.AuditEvent`.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
ConfigurationError: If no transport has been injected.
|
|
228
|
+
TransportError: For non-retryable HTTP errors, or retryable errors
|
|
229
|
+
when no :class:`RetryManager` is configured.
|
|
230
|
+
QueueFullError: If the retry queue is at capacity.
|
|
231
|
+
"""
|
|
232
|
+
if self._transport is None:
|
|
233
|
+
raise ConfigurationError(
|
|
234
|
+
"No transport configured. "
|
|
235
|
+
"Pass transport= when constructing AuditClient."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
await self._transport.send(event)
|
|
240
|
+
|
|
241
|
+
except TransportError as exc:
|
|
242
|
+
is_retryable = (
|
|
243
|
+
exc.status_code is None # network / timeout
|
|
244
|
+
or exc.status_code in RetryManager.RETRYABLE_STATUS_CODES
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
retry_enabled = self._settings.retry_enabled if self._settings is not None else True
|
|
248
|
+
if is_retryable and self._retry_manager is not None and retry_enabled:
|
|
249
|
+
log.warning(
|
|
250
|
+
"Transport delivery failed (retryable). "
|
|
251
|
+
"Enqueuing event for retry. "
|
|
252
|
+
"correlation_id=%s, status=%s",
|
|
253
|
+
event.correlation_id,
|
|
254
|
+
exc.status_code,
|
|
255
|
+
)
|
|
256
|
+
await self._retry_manager.enqueue(event)
|
|
257
|
+
return # caller is not blocked; retry proceeds in background
|
|
258
|
+
|
|
259
|
+
raise # non-retryable or no retry manager
|
|
260
|
+
|
|
261
|
+
async def publish(
|
|
262
|
+
self,
|
|
263
|
+
action: str,
|
|
264
|
+
entity: str,
|
|
265
|
+
entity_id: str,
|
|
266
|
+
user: dict[str, Any],
|
|
267
|
+
old_value: Any = None,
|
|
268
|
+
new_value: Any = None,
|
|
269
|
+
metadata: dict[str, Any] | None = None,
|
|
270
|
+
tenant_id: str | None = None,
|
|
271
|
+
application: str | None = None,
|
|
272
|
+
module: str | None = None,
|
|
273
|
+
service: str | None = None,
|
|
274
|
+
event_type: str | None = None,
|
|
275
|
+
status: str | None = None,
|
|
276
|
+
request: dict[str, Any] | None = None,
|
|
277
|
+
device: dict[str, Any] | None = None,
|
|
278
|
+
) -> AuditEvent:
|
|
279
|
+
"""Build, validate, and dispatch a single audit event in one call.
|
|
280
|
+
|
|
281
|
+
Convenience method that combines :meth:`log` (build + validate) and
|
|
282
|
+
:meth:`log_event` (transport dispatch + retry hand-off).
|
|
283
|
+
|
|
284
|
+
On a **retryable** transport failure the event is enqueued in the
|
|
285
|
+
:class:`~audit_sdk.utils.retry_manager.RetryManager` (if configured)
|
|
286
|
+
and this method returns normally — the caller is not blocked.
|
|
287
|
+
|
|
288
|
+
On a **non-retryable** failure the :class:`TransportError` is
|
|
289
|
+
propagated immediately.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
action: Action verb (e.g. ``"CREATE"``, ``"UPDATE"``).
|
|
293
|
+
entity: Entity type acted upon (e.g. ``"Employee"``).
|
|
294
|
+
entity_id: Unique identifier of the entity instance.
|
|
295
|
+
user: Actor dict — must contain ``"id"`` and ``"name"``.
|
|
296
|
+
old_value: Entity state *before* the action (``None`` for CREATE).
|
|
297
|
+
new_value: Entity state *after* the action (``None`` for DELETE).
|
|
298
|
+
metadata: Arbitrary key-value enrichment.
|
|
299
|
+
tenant_id: Tenant / organisation identifier.
|
|
300
|
+
application: Top-level application name.
|
|
301
|
+
module: Sub-module or feature area.
|
|
302
|
+
service: Emitting service name (falls back to
|
|
303
|
+
``settings.service_name``).
|
|
304
|
+
event_type: High-level event category (default: ``GENERIC``).
|
|
305
|
+
status: Outcome of the audited operation (default: ``SUCCESS``).
|
|
306
|
+
request: HTTP request context dict.
|
|
307
|
+
device: Client device context dict.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
The fully validated :class:`~audit_sdk.models.audit_event.AuditEvent`
|
|
311
|
+
that was dispatched (or queued for retry).
|
|
312
|
+
|
|
313
|
+
Raises:
|
|
314
|
+
ValidationError: If the event parameters fail Pydantic validation.
|
|
315
|
+
ConfigurationError: If no transport has been injected.
|
|
316
|
+
TransportError: For non-retryable delivery failures.
|
|
317
|
+
QueueFullError: If the retry queue is at capacity.
|
|
318
|
+
|
|
319
|
+
Example::
|
|
320
|
+
|
|
321
|
+
event = await audit.publish(
|
|
322
|
+
action="CREATE",
|
|
323
|
+
entity="Employee",
|
|
324
|
+
entity_id="EMP-001",
|
|
325
|
+
user={"id": "u1", "name": "Alice"},
|
|
326
|
+
tenant_id="tenant-acme",
|
|
327
|
+
application="HRPortal",
|
|
328
|
+
new_value={"name": "Alice", "department": "Engineering"},
|
|
329
|
+
)
|
|
330
|
+
"""
|
|
331
|
+
event = await self.log(
|
|
332
|
+
action=action,
|
|
333
|
+
entity=entity,
|
|
334
|
+
entity_id=entity_id,
|
|
335
|
+
user=user,
|
|
336
|
+
old_value=old_value,
|
|
337
|
+
new_value=new_value,
|
|
338
|
+
metadata=metadata,
|
|
339
|
+
tenant_id=tenant_id,
|
|
340
|
+
application=application,
|
|
341
|
+
module=module,
|
|
342
|
+
service=service,
|
|
343
|
+
event_type=event_type,
|
|
344
|
+
status=status,
|
|
345
|
+
request=request,
|
|
346
|
+
device=device,
|
|
347
|
+
)
|
|
348
|
+
await self.log_event(event)
|
|
349
|
+
return event
|
|
350
|
+
|
|
351
|
+
# ------------------------------------------------------------------
|
|
352
|
+
# Lifecycle
|
|
353
|
+
# ------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
async def close(self) -> None:
|
|
356
|
+
"""Flush pending retries, then release all held resources.
|
|
357
|
+
|
|
358
|
+
Stops the :class:`~audit_sdk.utils.retry_manager.RetryManager`
|
|
359
|
+
(waiting for in-flight retry tasks to complete) before closing the
|
|
360
|
+
transport connection.
|
|
361
|
+
|
|
362
|
+
After calling this method, the client must not be used.
|
|
363
|
+
|
|
364
|
+
This method is idempotent: calling it multiple times is safe.
|
|
365
|
+
"""
|
|
366
|
+
if self._closed:
|
|
367
|
+
return
|
|
368
|
+
self._closed = True
|
|
369
|
+
if self._retry_manager is not None:
|
|
370
|
+
await self._retry_manager.stop()
|
|
371
|
+
if self._transport is not None:
|
|
372
|
+
await self._transport.close()
|
|
373
|
+
log.debug("AuditClient closed.")
|
|
374
|
+
|
|
375
|
+
async def __aenter__(self) -> AuditClient:
|
|
376
|
+
return self
|
|
377
|
+
|
|
378
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
379
|
+
await self.close()
|
|
380
|
+
|
|
381
|
+
# ------------------------------------------------------------------
|
|
382
|
+
# Lazy component initialization (stubs)
|
|
383
|
+
# ------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
def _get_transport(self) -> AbstractTransport:
|
|
386
|
+
"""Return the configured transport, creating a default RestTransport if needed.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
The active :class:`~audit_sdk.core.interfaces.AbstractTransport`.
|
|
390
|
+
|
|
391
|
+
Raises:
|
|
392
|
+
NotImplementedError: Until factory logic is implemented.
|
|
393
|
+
"""
|
|
394
|
+
# TODO: Auto-create RestTransport / KafkaTransport based on settings.transport.
|
|
395
|
+
raise NotImplementedError("AuditClient._get_transport() is not yet implemented.")
|
|
396
|
+
|
|
397
|
+
def _get_serializer(self) -> AbstractSerializer:
|
|
398
|
+
"""Return the configured serializer, defaulting to AuditEventSerializer.
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
The active :class:`~audit_sdk.core.interfaces.AbstractSerializer`.
|
|
402
|
+
|
|
403
|
+
Raises:
|
|
404
|
+
NotImplementedError: Until factory logic is implemented.
|
|
405
|
+
"""
|
|
406
|
+
# TODO: Return self._serializer or instantiate AuditEventSerializer().
|
|
407
|
+
raise NotImplementedError("AuditClient._get_serializer() is not yet implemented.")
|
|
408
|
+
|
|
409
|
+
def _build_event(
|
|
410
|
+
self,
|
|
411
|
+
action: str,
|
|
412
|
+
entity: str,
|
|
413
|
+
entity_id: str,
|
|
414
|
+
user: Any,
|
|
415
|
+
old_value: Any,
|
|
416
|
+
new_value: Any,
|
|
417
|
+
metadata: dict[str, Any] | None,
|
|
418
|
+
tenant_id: str | None,
|
|
419
|
+
application: str | None,
|
|
420
|
+
module: str | None,
|
|
421
|
+
service: str | None,
|
|
422
|
+
event_type: str | None,
|
|
423
|
+
status: str | None,
|
|
424
|
+
request: Any,
|
|
425
|
+
device: Any,
|
|
426
|
+
) -> AuditEvent:
|
|
427
|
+
"""Construct a validated :class:`AuditEvent` from individual parameters.
|
|
428
|
+
|
|
429
|
+
Merges caller-supplied values with context drawn from :attr:`_settings`:
|
|
430
|
+
|
|
431
|
+
- ``service`` falls back to ``settings.service_name`` when not supplied.
|
|
432
|
+
- All other optional fields fall back to :class:`AuditEvent`'s own
|
|
433
|
+
defaults (``None``, ``EventType.GENERIC``, ``AuditOutcome.SUCCESS``)
|
|
434
|
+
when the caller passes ``None``.
|
|
435
|
+
|
|
436
|
+
Only non-``None`` optional values are forwarded to the model, keeping
|
|
437
|
+
AuditEvent's own defaults intact for anything the caller did not supply.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
action: The action verb.
|
|
441
|
+
entity: Entity type name.
|
|
442
|
+
entity_id: Entity instance ID.
|
|
443
|
+
user: Actor dict or :class:`~audit_sdk.models.audit_event.ActorModel`.
|
|
444
|
+
old_value: Pre-action entity state (``None`` for CREATE).
|
|
445
|
+
new_value: Post-action entity state (``None`` for DELETE).
|
|
446
|
+
metadata: Arbitrary key-value enrichment dict.
|
|
447
|
+
tenant_id: Tenant / organisation identifier.
|
|
448
|
+
application: Top-level application name.
|
|
449
|
+
module: Sub-module or feature area name.
|
|
450
|
+
service: Emitting service name (falls back to ``settings.service_name``).
|
|
451
|
+
event_type: High-level event category string.
|
|
452
|
+
status: Outcome string of the audited operation.
|
|
453
|
+
request: HTTP request context (dict or RequestModel).
|
|
454
|
+
device: Client device context (dict or DeviceModel).
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
A fully validated :class:`~audit_sdk.models.audit_event.AuditEvent`.
|
|
458
|
+
|
|
459
|
+
Raises:
|
|
460
|
+
ValidationError: Wraps any Pydantic v2 ``ValidationError`` raised
|
|
461
|
+
during model construction so callers only need to handle SDK
|
|
462
|
+
exception types.
|
|
463
|
+
"""
|
|
464
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
465
|
+
|
|
466
|
+
from audit_sdk.core.exceptions import ValidationError as SDKValidationError
|
|
467
|
+
|
|
468
|
+
# Required fields — always forwarded.
|
|
469
|
+
kwargs: dict[str, Any] = {
|
|
470
|
+
"action": action,
|
|
471
|
+
"entity": entity,
|
|
472
|
+
"entity_id": entity_id,
|
|
473
|
+
"user": user,
|
|
474
|
+
"old_value": old_value,
|
|
475
|
+
"new_value": new_value,
|
|
476
|
+
# Fall back to the settings service_name when the caller does not
|
|
477
|
+
# explicitly pass a service name.
|
|
478
|
+
"service": service if service is not None else (
|
|
479
|
+
self._settings.service_name if self._settings is not None else "unknown-service"
|
|
480
|
+
),
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
# Optional fields — only injected when the caller provided a value,
|
|
484
|
+
# so AuditEvent's own defaults remain in effect for omitted fields.
|
|
485
|
+
if tenant_id is not None:
|
|
486
|
+
kwargs["tenant_id"] = tenant_id
|
|
487
|
+
if application is not None:
|
|
488
|
+
kwargs["application"] = application
|
|
489
|
+
if module is not None:
|
|
490
|
+
kwargs["module"] = module
|
|
491
|
+
if event_type is not None:
|
|
492
|
+
kwargs["event_type"] = event_type
|
|
493
|
+
if status is not None:
|
|
494
|
+
kwargs["status"] = status
|
|
495
|
+
if metadata is not None:
|
|
496
|
+
kwargs["metadata"] = metadata
|
|
497
|
+
if request is not None:
|
|
498
|
+
kwargs["request"] = request
|
|
499
|
+
if device is not None:
|
|
500
|
+
kwargs["device"] = device
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
return AuditEvent(**kwargs)
|
|
504
|
+
except PydanticValidationError as exc:
|
|
505
|
+
raise SDKValidationError(
|
|
506
|
+
f"AuditEvent validation failed: {exc}",
|
|
507
|
+
errors=exc.errors(),
|
|
508
|
+
) from exc
|
|
509
|
+
|
|
510
|
+
# ------------------------------------------------------------------
|
|
511
|
+
# Properties
|
|
512
|
+
# ------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
@property
|
|
515
|
+
def settings(self) -> AuditSettings | None:
|
|
516
|
+
"""The SDK configuration object (``None`` when using direct transport)."""
|
|
517
|
+
return self._settings
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def plugin_registry(self) -> PluginRegistry:
|
|
521
|
+
"""The plugin registry for this client instance."""
|
|
522
|
+
return self._plugin_registry
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def is_closed(self) -> bool:
|
|
526
|
+
"""Return ``True`` if the client has been closed."""
|
|
527
|
+
return self._closed
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def retry_manager(self) -> RetryManager | None:
|
|
531
|
+
"""The injected :class:`~audit_sdk.utils.retry_manager.RetryManager`, if any."""
|
|
532
|
+
return self._retry_manager
|
|
533
|
+
|
|
534
|
+
def __repr__(self) -> str:
|
|
535
|
+
svc = self._settings.service_name if self._settings else "direct-transport"
|
|
536
|
+
env = self._settings.environment if self._settings else "n/a"
|
|
537
|
+
tpt = str(self._settings.transport) if self._settings else type(self._transport).__name__
|
|
538
|
+
return (
|
|
539
|
+
f"AuditClient(service={svc!r}, "
|
|
540
|
+
f"env={env!r}, "
|
|
541
|
+
f"transport={tpt!r}, "
|
|
542
|
+
f"retry={'enabled' if self._retry_manager else 'disabled'}, "
|
|
543
|
+
f"closed={self._closed})"
|
|
544
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
config — SDK-wide configuration management.
|
|
3
|
+
|
|
4
|
+
All configuration is expressed as a single :class:`~audit_sdk.config.settings.AuditSettings`
|
|
5
|
+
Pydantic model that reads values from environment variables with the ``AUDIT_``
|
|
6
|
+
prefix (e.g. ``AUDIT_ENDPOINT``, ``AUDIT_API_KEY``).
|
|
7
|
+
|
|
8
|
+
No defaults require external services; the SDK is always safe to import even
|
|
9
|
+
when environment variables are absent (except for required fields like ``endpoint``).
|
|
10
|
+
"""
|