mobius-auditlog-py 1.0.0__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.
@@ -0,0 +1,56 @@
1
+ """Mobius Audit Log for Python.
2
+
3
+ Build, sign, and publish CloudEvents-style audit events from Mobius FastAPI
4
+ services. Provides the event model, a fluent builder, integrity hashing/signing,
5
+ a Kafka publisher, and an optional per-request capture middleware.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .bootstrap import setup_auditlog
10
+ from .builder import AuditLogBuilder
11
+ from .middleware import AuditLogMiddleware
12
+ from .models import (
13
+ Action,
14
+ AuditLogEvent,
15
+ EventData,
16
+ EventMetadata,
17
+ Kubernetes,
18
+ Network,
19
+ ObjectRef,
20
+ Security,
21
+ Subject,
22
+ )
23
+ from .publisher import (
24
+ AuditLogPublisher,
25
+ LogEventSender,
26
+ PyKafkaProducerClientSender,
27
+ get_publisher,
28
+ set_publisher,
29
+ )
30
+ from .security import compute_hash, seal, sign, verify
31
+
32
+ __version__ = "1.0.0"
33
+
34
+ __all__ = [
35
+ "setup_auditlog",
36
+ "AuditLogBuilder",
37
+ "AuditLogMiddleware",
38
+ "AuditLogEvent",
39
+ "Subject",
40
+ "Kubernetes",
41
+ "ObjectRef",
42
+ "Action",
43
+ "Network",
44
+ "EventData",
45
+ "EventMetadata",
46
+ "Security",
47
+ "AuditLogPublisher",
48
+ "LogEventSender",
49
+ "PyKafkaProducerClientSender",
50
+ "set_publisher",
51
+ "get_publisher",
52
+ "seal",
53
+ "verify",
54
+ "compute_hash",
55
+ "sign",
56
+ ]
@@ -0,0 +1,89 @@
1
+ """One-call setup for FastAPI services.
2
+
3
+ Builds the publisher (from a py-kafka-producer-client config/instance or any
4
+ sender) and registers the audit middleware.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from typing import Any, Iterable, Optional
10
+
11
+ from .middleware import DEFAULT_SKIP_PATHS, AuditLogMiddleware
12
+ from .publisher import (
13
+ DEFAULT_TOPIC,
14
+ AuditLogPublisher,
15
+ LogEventSender,
16
+ PyKafkaProducerClientSender,
17
+ set_publisher,
18
+ )
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ def setup_auditlog(
24
+ app: Optional[Any] = None,
25
+ *,
26
+ topic: str = DEFAULT_TOPIC,
27
+ signing_key: Optional[str] = None,
28
+ kafka_config: Optional[Any] = None,
29
+ kafka_producer: Optional[Any] = None,
30
+ kafka_sender: Optional[LogEventSender] = None,
31
+ skip_paths: Iterable[str] = DEFAULT_SKIP_PATHS,
32
+ capture_payloads: bool = False,
33
+ compliance_category: Optional[str] = None,
34
+ ) -> Optional[AuditLogPublisher]:
35
+ """Configure audit logging and (optionally) register the middleware.
36
+
37
+ Publisher resolution: ``kafka_sender`` -> ``kafka_producer`` -> ``kafka_config``
38
+ -> the configured ``py-kafka-producer-client`` singleton. If none resolve,
39
+ the middleware simply emits nothing.
40
+
41
+ Returns the registered :class:`AuditLogPublisher`, if any.
42
+ """
43
+ sender = _resolve_sender(kafka_sender, kafka_producer, kafka_config)
44
+
45
+ publisher: Optional[AuditLogPublisher] = None
46
+ if sender is not None:
47
+ publisher = AuditLogPublisher(sender, topic=topic, signing_key=signing_key)
48
+ set_publisher(publisher)
49
+ else:
50
+ log.warning("mobius-auditlog: no Kafka producer resolved; audit events will not publish.")
51
+
52
+ if app is not None:
53
+ app.add_middleware(
54
+ AuditLogMiddleware,
55
+ publisher=publisher,
56
+ skip_paths=skip_paths,
57
+ capture_payloads=capture_payloads,
58
+ compliance_category=compliance_category,
59
+ )
60
+
61
+ return publisher
62
+
63
+
64
+ def _resolve_sender(
65
+ kafka_sender: Optional[LogEventSender],
66
+ kafka_producer: Optional[Any],
67
+ kafka_config: Optional[Any],
68
+ ) -> Optional[LogEventSender]:
69
+ if kafka_sender is not None:
70
+ return kafka_sender
71
+ if kafka_producer is not None:
72
+ return PyKafkaProducerClientSender(kafka_producer)
73
+
74
+ try:
75
+ from kafka_producer_client.action_logger import (
76
+ configure_kafka_producer,
77
+ get_kafka_producer,
78
+ )
79
+ except ImportError:
80
+ log.warning("mobius-auditlog: py-kafka-producer-client not installed.")
81
+ return None
82
+
83
+ try:
84
+ if kafka_config is not None:
85
+ configure_kafka_producer(kafka_config)
86
+ return PyKafkaProducerClientSender(get_kafka_producer())
87
+ except Exception: # noqa: BLE001 - publisher is optional, never crash setup
88
+ log.warning("mobius-auditlog: could not initialize py-kafka-producer-client.", exc_info=True)
89
+ return None
@@ -0,0 +1,90 @@
1
+ """Fluent builder for :class:`AuditLogEvent`.
2
+
3
+ Fills the envelope (id/specversion/timestamp + identity from context) and lets
4
+ you set the audit-specific sections, then ``build()`` returns the event.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Dict, List, Mapping, Optional
11
+
12
+ from . import context as ctx
13
+ from .models import (
14
+ Action,
15
+ AuditLogEvent,
16
+ DEFAULT_SPECVERSION,
17
+ EventData,
18
+ EventMetadata,
19
+ Network,
20
+ ObjectRef,
21
+ Subject,
22
+ )
23
+
24
+
25
+ class AuditLogBuilder:
26
+ def __init__(self, *, specversion: str = DEFAULT_SPECVERSION) -> None:
27
+ self._event = AuditLogEvent(
28
+ id=str(uuid.uuid4()),
29
+ specversion=specversion,
30
+ timestamp=datetime.now(timezone.utc).isoformat(),
31
+ )
32
+
33
+ def from_context(self, headers: Optional[Mapping[str, str]] = None) -> "AuditLogBuilder":
34
+ identity = ctx.gather_identity(headers)
35
+ self._event.tenantid = identity.get("tenantid")
36
+ self._event.traceid = identity.get("traceid")
37
+ self._event.transactionid = identity.get("transactionid")
38
+ self._event.subject = ctx.subject_from_identity(identity)
39
+ self._event.kubernetes = ctx.kubernetes_from_env()
40
+ return self
41
+
42
+ def envelope(
43
+ self,
44
+ *,
45
+ tenantid: Optional[str] = None,
46
+ traceid: Optional[str] = None,
47
+ transactionid: Optional[str] = None,
48
+ ) -> "AuditLogBuilder":
49
+ if tenantid:
50
+ self._event.tenantid = tenantid
51
+ if traceid:
52
+ self._event.traceid = traceid
53
+ if transactionid:
54
+ self._event.transactionid = transactionid
55
+ return self
56
+
57
+ def subject(self, userid: str = None, type: str = None, groups: List[str] = None):
58
+ self._event.subject = Subject(userid=userid, type=type, groups=groups or [])
59
+ return self
60
+
61
+ def object_ref(self, resource=None, resourceid=None, apiversion=None) -> "AuditLogBuilder":
62
+ self._event.objectref = ObjectRef(
63
+ resource=resource, resourceid=resourceid, apiversion=apiversion
64
+ )
65
+ return self
66
+
67
+ def action(self, name=None, method=None, severity=None, status=None) -> "AuditLogBuilder":
68
+ self._event.action = Action(name=name, method=method, severity=severity, status=status)
69
+ return self
70
+
71
+ def network(self, sourceip=None, useragent=None) -> "AuditLogBuilder":
72
+ self._event.network = Network(sourceip=sourceip, useragent=useragent)
73
+ return self
74
+
75
+ def event_data(
76
+ self,
77
+ requestpayload: Optional[Dict[str, Any]] = None,
78
+ responsepayload: Optional[Dict[str, Any]] = None,
79
+ issensitive: bool = False,
80
+ compliancecategory: Optional[str] = None,
81
+ ) -> "AuditLogBuilder":
82
+ self._event.eventdata = EventData(
83
+ requestpayload=requestpayload or {},
84
+ responsepayload=responsepayload or {},
85
+ metadata=EventMetadata(issensitive=issensitive, compliancecategory=compliancecategory),
86
+ )
87
+ return self
88
+
89
+ def build(self) -> AuditLogEvent:
90
+ return self._event
@@ -0,0 +1,51 @@
1
+ """Gather envelope/subject context and Kubernetes metadata.
2
+
3
+ Identity (tenant/trace/transaction/user) is read from the mobius-tracer-py
4
+ request context when that package is installed, otherwise from request headers.
5
+ Kubernetes details come from the standard downward-API environment variables.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Dict, Mapping, Optional
11
+
12
+ from .models import Kubernetes, Subject
13
+
14
+
15
+ def gather_identity(headers: Optional[Mapping[str, str]] = None) -> Dict[str, Optional[str]]:
16
+ """Return envelope identity fields from the tracer context or headers."""
17
+ try:
18
+ from mobius_tracer import current
19
+
20
+ ctx = current()
21
+ return {
22
+ "tenantid": ctx.tenant_id,
23
+ "traceid": ctx.trace_id,
24
+ "transactionid": ctx.req_transaction_id,
25
+ "userid": ctx.action_log_user_id,
26
+ "type": ctx.requester_type,
27
+ }
28
+ except Exception: # noqa: BLE001 - tracer not installed / no active context
29
+ pass
30
+
31
+ headers = headers or {}
32
+ return {
33
+ "tenantid": headers.get("tenantId"),
34
+ "traceid": headers.get("x-b3-traceid"),
35
+ "transactionid": headers.get("X-Transaction-Id"),
36
+ "userid": headers.get("actionLogUserId"),
37
+ "type": headers.get("RequesterType"),
38
+ }
39
+
40
+
41
+ def subject_from_identity(identity: Mapping[str, Optional[str]]) -> Subject:
42
+ return Subject(userid=identity.get("userid"), type=identity.get("type"))
43
+
44
+
45
+ def kubernetes_from_env() -> Kubernetes:
46
+ return Kubernetes(
47
+ namespacename=os.getenv("POD_NAMESPACE") or os.getenv("NAMESPACE"),
48
+ podname=os.getenv("POD_NAME") or os.getenv("HOSTNAME"),
49
+ containername=os.getenv("CONTAINER_NAME"),
50
+ nodename=os.getenv("NODE_NAME"),
51
+ )
@@ -0,0 +1,167 @@
1
+ """Optional FastAPI / Starlette ASGI middleware that emits an audit event per
2
+ request.
3
+
4
+ It fills `action` (route + method + status + severity), `network` (client IP +
5
+ user-agent), `objectref` (from the path), and the envelope/subject from the
6
+ request context, then publishes via the configured publisher.
7
+
8
+ Request/response payload capture is OFF by default (privacy + overhead); enable
9
+ `capture_payloads=True` to buffer bodies (JSON-parsed, size-capped).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from typing import Any, Dict, Iterable, Optional
16
+
17
+ from starlette.datastructures import Headers
18
+
19
+ from .builder import AuditLogBuilder
20
+ from .models import DEFAULT_SPECVERSION
21
+ from .publisher import AuditLogPublisher, get_publisher
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ DEFAULT_SKIP_PATHS = ("actuator", "health", "metrics", "error", "prometheus",
26
+ "swagger", "api-docs", "favicon")
27
+
28
+
29
+ class AuditLogMiddleware:
30
+ def __init__(
31
+ self,
32
+ app,
33
+ publisher: Optional[AuditLogPublisher] = None,
34
+ *,
35
+ specversion: str = DEFAULT_SPECVERSION,
36
+ skip_paths: Iterable[str] = DEFAULT_SKIP_PATHS,
37
+ capture_payloads: bool = False,
38
+ max_payload_bytes: int = 65536,
39
+ compliance_category: Optional[str] = None,
40
+ ) -> None:
41
+ self.app = app
42
+ self.publisher = publisher
43
+ self.specversion = specversion
44
+ self.skip_paths = tuple(skip_paths)
45
+ self.capture_payloads = capture_payloads
46
+ self.max_payload_bytes = max_payload_bytes
47
+ self.compliance_category = compliance_category
48
+
49
+ async def __call__(self, scope, receive, send):
50
+ if scope.get("type") != "http" or self._should_skip(scope.get("path", "")):
51
+ await self.app(scope, receive, send)
52
+ return
53
+
54
+ request_body = bytearray()
55
+ receive = await self._buffer_request(receive, request_body) if self.capture_payloads else receive
56
+
57
+ status = {"code": 0}
58
+ response_body = bytearray()
59
+
60
+ async def send_wrapper(message):
61
+ if message["type"] == "http.response.start":
62
+ status["code"] = message["status"]
63
+ elif message["type"] == "http.response.body" and self.capture_payloads:
64
+ self._append_capped(response_body, message.get("body", b""))
65
+ await send(message)
66
+
67
+ try:
68
+ await self.app(scope, receive, send_wrapper)
69
+ finally:
70
+ try:
71
+ self._emit(scope, status["code"], bytes(request_body), bytes(response_body))
72
+ except Exception: # noqa: BLE001 - auditing must never break the request
73
+ log.warning("Failed to emit audit event", exc_info=True)
74
+
75
+ # --- emit ---
76
+
77
+ def _emit(self, scope, status_code: int, req_body: bytes, resp_body: bytes) -> None:
78
+ publisher = self.publisher or get_publisher()
79
+ if publisher is None:
80
+ return
81
+
82
+ headers = Headers(scope=scope)
83
+ path = scope.get("path", "")
84
+ method = scope.get("method", "")
85
+ client = scope.get("client")
86
+ source_ip = client[0] if client else None
87
+
88
+ builder = (
89
+ AuditLogBuilder(specversion=self.specversion)
90
+ .from_context(headers)
91
+ .action(
92
+ name=path,
93
+ method=method,
94
+ severity=_severity(status_code),
95
+ status="SUCCESS" if status_code < 400 else "FAILURE",
96
+ )
97
+ .network(sourceip=source_ip, useragent=headers.get("user-agent"))
98
+ .object_ref(resource=_resource(path))
99
+ )
100
+
101
+ if self.capture_payloads:
102
+ builder.event_data(
103
+ requestpayload=_as_object(req_body, self.max_payload_bytes),
104
+ responsepayload=_as_object(resp_body, self.max_payload_bytes),
105
+ compliancecategory=self.compliance_category,
106
+ )
107
+
108
+ publisher.publish(builder.build())
109
+
110
+ # --- body buffering ---
111
+
112
+ async def _buffer_request(self, receive, sink: bytearray):
113
+ messages = []
114
+ while True:
115
+ message = await receive()
116
+ messages.append(message)
117
+ if message["type"] != "http.request":
118
+ break
119
+ self._append_capped(sink, message.get("body", b""))
120
+ if not message.get("more_body", False):
121
+ break
122
+
123
+ index = 0
124
+
125
+ async def replay():
126
+ nonlocal index
127
+ if index < len(messages):
128
+ message = messages[index]
129
+ index += 1
130
+ return message
131
+ return await receive()
132
+
133
+ return replay
134
+
135
+ def _append_capped(self, sink: bytearray, body: bytes) -> None:
136
+ room = self.max_payload_bytes - len(sink)
137
+ if room > 0 and body:
138
+ sink.extend(body[:room])
139
+
140
+ def _should_skip(self, path: str) -> bool:
141
+ return any(skip in path for skip in self.skip_paths)
142
+
143
+
144
+ def _severity(status_code: int) -> str:
145
+ if status_code >= 500:
146
+ return "ERROR"
147
+ if status_code >= 400:
148
+ return "WARNING"
149
+ return "INFO"
150
+
151
+
152
+ def _resource(path: str) -> Optional[str]:
153
+ segments = [s for s in path.split("/") if s]
154
+ return segments[0] if segments else None
155
+
156
+
157
+ def _as_object(body: bytes, cap: int) -> Dict[str, Any]:
158
+ if not body:
159
+ return {}
160
+ text = body[:cap].decode("utf-8", errors="replace")
161
+ try:
162
+ parsed = json.loads(text)
163
+ except ValueError:
164
+ return {"_raw": text}
165
+ if isinstance(parsed, dict):
166
+ return parsed
167
+ return {"_value": parsed}
@@ -0,0 +1,119 @@
1
+ """Audit log event models.
2
+
3
+ A CloudEvents-style audit event with a flat envelope plus nested sections.
4
+ Serialization rules match the platform schema:
5
+ - empty values (None, "", [], {}) are omitted;
6
+ - keys are emitted in alphabetical order;
7
+ - boolean ``false`` / numeric ``0`` are kept (not treated as empty).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ DEFAULT_SPECVERSION = "1.0"
17
+
18
+
19
+ class Subject(BaseModel):
20
+ userid: Optional[str] = None
21
+ type: Optional[str] = None
22
+ groups: List[str] = Field(default_factory=list)
23
+
24
+
25
+ class Kubernetes(BaseModel):
26
+ namespacename: Optional[str] = None
27
+ podname: Optional[str] = None
28
+ containername: Optional[str] = None
29
+ nodename: Optional[str] = None
30
+
31
+
32
+ class ObjectRef(BaseModel):
33
+ resource: Optional[str] = None
34
+ resourceid: Optional[str] = None
35
+ apiversion: Optional[str] = None
36
+
37
+
38
+ class Action(BaseModel):
39
+ name: Optional[str] = None
40
+ method: Optional[str] = None
41
+ severity: Optional[str] = None
42
+ status: Optional[str] = None
43
+
44
+
45
+ class Network(BaseModel):
46
+ sourceip: Optional[str] = None
47
+ useragent: Optional[str] = None
48
+
49
+
50
+ class EventMetadata(BaseModel):
51
+ issensitive: bool = False
52
+ compliancecategory: Optional[str] = None
53
+
54
+
55
+ class EventData(BaseModel):
56
+ requestpayload: Dict[str, Any] = Field(default_factory=dict)
57
+ responsepayload: Dict[str, Any] = Field(default_factory=dict)
58
+ metadata: Optional[EventMetadata] = None
59
+
60
+
61
+ class Security(BaseModel):
62
+ hash: Optional[str] = None
63
+ signature: Optional[str] = None
64
+
65
+
66
+ class AuditLogEvent(BaseModel):
67
+ id: Optional[str] = None
68
+ specversion: Optional[str] = None
69
+ timestamp: Optional[str] = None
70
+ traceid: Optional[str] = None
71
+ transactionid: Optional[str] = None
72
+ tenantid: Optional[str] = None
73
+
74
+ subject: Optional[Subject] = None
75
+ kubernetes: Optional[Kubernetes] = None
76
+ objectref: Optional[ObjectRef] = None
77
+ action: Optional[Action] = None
78
+ network: Optional[Network] = None
79
+ eventdata: Optional[EventData] = None
80
+ security: Optional[Security] = None
81
+
82
+ def to_dict(self, *, include_security: bool = True) -> Dict[str, Any]:
83
+ """Pruned dict: empty values dropped, ready to publish."""
84
+ data = self.model_dump()
85
+ if not include_security:
86
+ data.pop("security", None)
87
+ return prune_empty(data)
88
+
89
+ def to_json(self, *, include_security: bool = True) -> str:
90
+ """Canonical JSON: pruned + alphabetically ordered keys."""
91
+ return json.dumps(
92
+ self.to_dict(include_security=include_security),
93
+ sort_keys=True,
94
+ separators=(",", ":"),
95
+ default=str,
96
+ )
97
+
98
+
99
+ def prune_empty(value: Any) -> Any:
100
+ """Recursively drop None, empty strings, and empty lists/dicts, emitting
101
+ keys in alphabetical order.
102
+
103
+ Booleans and numbers (incl. ``False`` / ``0``) are preserved.
104
+ """
105
+ if isinstance(value, dict):
106
+ out: Dict[str, Any] = {}
107
+ for key in sorted(value.keys()):
108
+ pruned = prune_empty(value[key])
109
+ if pruned is None:
110
+ continue
111
+ if isinstance(pruned, str) and pruned == "":
112
+ continue
113
+ if isinstance(pruned, (list, dict, set)) and len(pruned) == 0:
114
+ continue
115
+ out[key] = pruned
116
+ return out
117
+ if isinstance(value, list):
118
+ return [prune_empty(item) for item in value]
119
+ return value
@@ -0,0 +1,79 @@
1
+ """Publishes audit events to Kafka.
2
+
3
+ Depends only on a small :class:`LogEventSender` protocol so the library has no
4
+ hard Kafka dependency. Mobius FastAPI services publish via
5
+ ``py-kafka-producer-client``; wrap it with :class:`PyKafkaProducerClientSender`
6
+ (or pass any object exposing ``send(value, *, topic)``).
7
+
8
+ Publishing is best-effort and runs on a background thread pool so it never
9
+ blocks the request path; failures are logged, never raised.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from typing import Any, Dict, Optional, Protocol, runtime_checkable
16
+
17
+ from .models import AuditLogEvent
18
+ from .security import seal
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ DEFAULT_TOPIC = "audit-logs"
23
+
24
+ _executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="mobius-auditlog")
25
+
26
+
27
+ @runtime_checkable
28
+ class LogEventSender(Protocol):
29
+ def send(self, value: Dict[str, Any], *, topic: str) -> Any: # pragma: no cover
30
+ ...
31
+
32
+
33
+ class PyKafkaProducerClientSender:
34
+ """Adapter for the internal ``py-kafka-producer-client`` (>=0.1.7)."""
35
+
36
+ def __init__(self, producer: Any) -> None:
37
+ self._producer = producer
38
+
39
+ def send(self, value: Dict[str, Any], *, topic: str) -> Any:
40
+ return self._producer.send(value, topic=topic)
41
+
42
+
43
+ class AuditLogPublisher:
44
+ def __init__(
45
+ self,
46
+ sender: LogEventSender,
47
+ *,
48
+ topic: str = DEFAULT_TOPIC,
49
+ signing_key: Optional[str] = None,
50
+ ) -> None:
51
+ self._sender = sender
52
+ self._topic = topic
53
+ self._signing_key = signing_key
54
+
55
+ def publish(self, event: AuditLogEvent) -> None:
56
+ """Seal (hash/sign) and publish an event, fire-and-forget."""
57
+ seal(event, self._signing_key)
58
+ payload = event.to_dict()
59
+ _executor.submit(self._send, payload)
60
+
61
+ def _send(self, value: Dict[str, Any]) -> None:
62
+ try:
63
+ self._sender.send(value, topic=self._topic)
64
+ except Exception: # noqa: BLE001 - never break the caller
65
+ log.warning("Failed to publish audit event", exc_info=True)
66
+
67
+
68
+ # --- module-level default publisher (set at app startup) ---
69
+
70
+ _default_publisher: Optional[AuditLogPublisher] = None
71
+
72
+
73
+ def set_publisher(publisher: Optional[AuditLogPublisher]) -> None:
74
+ global _default_publisher
75
+ _default_publisher = publisher
76
+
77
+
78
+ def get_publisher() -> Optional[AuditLogPublisher]:
79
+ return _default_publisher
File without changes
@@ -0,0 +1,45 @@
1
+ """Integrity for audit events: a content hash and an optional HMAC signature.
2
+
3
+ The hash is SHA-256 over the canonical JSON of the event *excluding* the
4
+ ``security`` block, so it is deterministic and verifiable. The signature is an
5
+ HMAC-SHA256 of that hash with a configured key.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import hmac
11
+ from typing import Optional
12
+
13
+ from .models import AuditLogEvent, Security
14
+
15
+
16
+ def compute_hash(event: AuditLogEvent) -> str:
17
+ canonical = event.to_json(include_security=False)
18
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
19
+
20
+
21
+ def sign(value: str, key: str) -> str:
22
+ return hmac.new(key.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()
23
+
24
+
25
+ def seal(event: AuditLogEvent, signing_key: Optional[str] = None) -> AuditLogEvent:
26
+ """Populate ``event.security`` with the hash (and signature if a key is set)."""
27
+ digest = compute_hash(event)
28
+ signature = sign(digest, signing_key) if signing_key else None
29
+ event.security = Security(hash=digest, signature=signature)
30
+ return event
31
+
32
+
33
+ def verify(event: AuditLogEvent, signing_key: Optional[str] = None) -> bool:
34
+ """Return True if the event's hash (and signature, if a key is given) match."""
35
+ if event.security is None or not event.security.hash:
36
+ return False
37
+ expected_hash = compute_hash(event)
38
+ if not hmac.compare_digest(expected_hash, event.security.hash):
39
+ return False
40
+ if signing_key:
41
+ expected_sig = sign(expected_hash, signing_key)
42
+ return bool(event.security.signature) and hmac.compare_digest(
43
+ expected_sig, event.security.signature
44
+ )
45
+ return True
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: mobius-auditlog-py
3
+ Version: 1.0.0
4
+ Summary: CloudEvents-style audit event model, builder, integrity signing and Kafka publishing for Mobius Python (FastAPI) services.
5
+ Author: Mobius Platform
6
+ Keywords: audit,auditlog,fastapi,mobius,cloudevents
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: starlette>=0.27
11
+ Requires-Dist: py-kafka-producer-client>=0.1.7
12
+ Provides-Extra: tracer
13
+ Requires-Dist: mobius-tracer-py>=1.0; extra == "tracer"
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.4; extra == "dev"
16
+ Requires-Dist: starlette>=0.27; extra == "dev"
17
+ Requires-Dist: httpx>=0.24; extra == "dev"
18
+
19
+ # mobius-auditlog-py
20
+
21
+ Build, sign, and publish **CloudEvents-style audit events** from Mobius
22
+ **Python (FastAPI / Starlette)** services.
23
+
24
+ - **PyPI:** `pip install mobius-auditlog-py`
25
+ - **Import package:** `mobius_auditlog`
26
+ - **Python:** 3.10+
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install mobius-auditlog-py
32
+ ```
33
+
34
+ `pydantic`, `starlette`, and `py-kafka-producer-client` are installed
35
+ automatically.
36
+
37
+ ## Quick start (auto-capture middleware)
38
+
39
+ ```python
40
+ from fastapi import FastAPI
41
+ from kafka_producer_client import KafkaProducerConfig
42
+ from mobius_auditlog import setup_auditlog
43
+
44
+ app = FastAPI()
45
+
46
+ setup_auditlog(
47
+ app,
48
+ topic="audit-logs",
49
+ signing_key="your-hmac-key", # optional signature
50
+ kafka_config=KafkaProducerConfig(bootstrap_servers="broker:9092"),
51
+ capture_payloads=False, # set True to buffer req/resp bodies
52
+ compliance_category="GDPR",
53
+ )
54
+ ```
55
+
56
+ The middleware emits one audit event per request: `action` (route, method,
57
+ status, severity), `network` (client IP + user-agent), `objectref` (from the
58
+ path), and the envelope/`subject` from the request context. It skips
59
+ health/metrics/swagger paths.
60
+
61
+ ## The event schema
62
+
63
+ `AuditLogEvent` — flat envelope + nested sections, serialized with empty values
64
+ omitted and keys alphabetically ordered:
65
+
66
+ | Section | Fields |
67
+ |---|---|
68
+ | envelope | `id`, `specversion`, `timestamp`, `traceid`, `transactionid`, `tenantid` |
69
+ | `subject` | `userid`, `type`, `groups[]` |
70
+ | `kubernetes` | `namespacename`, `podname`, `containername`, `nodename` |
71
+ | `objectref` | `resource`, `resourceid`, `apiversion` |
72
+ | `action` | `name`, `method`, `severity`, `status` |
73
+ | `network` | `sourceip`, `useragent` |
74
+ | `eventdata` | `requestpayload{}`, `responsepayload{}`, `metadata{issensitive, compliancecategory}` |
75
+ | `security` | `hash`, `signature` |
76
+
77
+ ```python
78
+ event.to_dict() # pruned dict (empties dropped), ready to publish
79
+ event.to_json() # canonical JSON: pruned + sorted keys (used for hashing)
80
+ ```
81
+
82
+ ## Building events manually
83
+
84
+ ```python
85
+ from mobius_auditlog import AuditLogBuilder
86
+
87
+ event = (
88
+ AuditLogBuilder()
89
+ .from_context() # tenant/trace/txn/subject from context
90
+ .object_ref(resource="order", resourceid="order-789", apiversion="v1.0")
91
+ .action(name="order.create", method="POST", severity="INFO", status="SUCCESS")
92
+ .network(sourceip="1.2.3.4", useragent="curl/8")
93
+ .event_data(requestpayload={"amount": 42}, issensitive=True, compliancecategory="PCI")
94
+ .build()
95
+ )
96
+ ```
97
+
98
+ `from_context()` pulls `tenantid`/`traceid`/`transactionid`/`subject` from the
99
+ `mobius-tracer-py` request context when that package is installed, and
100
+ `kubernetes` from the downward-API env vars (`POD_NAMESPACE`, `POD_NAME`,
101
+ `CONTAINER_NAME`, `NODE_NAME`).
102
+
103
+ ## Integrity: hash + signature
104
+
105
+ ```python
106
+ from mobius_auditlog import seal, verify, compute_hash
107
+
108
+ seal(event, signing_key="key") # sets security.hash (+ signature)
109
+ verify(event, signing_key="key") # True if hash + signature match
110
+ ```
111
+
112
+ - `hash` = SHA-256 over the canonical JSON **excluding** the `security` block.
113
+ - `signature` = HMAC-SHA256 of the hash with your key (omitted if no key).
114
+
115
+ The publisher seals events automatically before sending.
116
+
117
+ ## Publishing
118
+
119
+ The publisher depends only on a small protocol — no hard Kafka coupling:
120
+
121
+ ```python
122
+ class LogEventSender(Protocol):
123
+ def send(self, value: dict, *, topic: str) -> Any: ...
124
+ ```
125
+
126
+ `setup_auditlog` resolves a publisher from (in order) `kafka_sender` →
127
+ `kafka_producer` → `kafka_config` → the configured `py-kafka-producer-client`
128
+ singleton. Or use it directly:
129
+
130
+ ```python
131
+ from mobius_auditlog import AuditLogPublisher, PyKafkaProducerClientSender, set_publisher
132
+
133
+ publisher = AuditLogPublisher(PyKafkaProducerClientSender(client),
134
+ topic="audit-logs", signing_key="key")
135
+ set_publisher(publisher)
136
+ publisher.publish(event) # seals + sends, fire-and-forget
137
+ ```
138
+
139
+ Publishing runs on a background thread and never raises into the request path.
140
+
141
+ ## Payload capture
142
+
143
+ With `capture_payloads=True`, the middleware buffers request and response bodies
144
+ (JSON-parsed, size-capped at 64 KB) into `eventdata.requestpayload` /
145
+ `responsepayload`. Off by default for privacy and overhead. Non-JSON bodies are
146
+ stored as `{"_raw": "..."}`.
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ python -m venv .venv && source .venv/bin/activate
152
+ pip install -e ".[dev]"
153
+ pytest
154
+ ```
@@ -0,0 +1,13 @@
1
+ mobius_auditlog/__init__.py,sha256=cq3kHahKiEtg-evakXO_e4wgkFxf1TQGM8cZ1kbId5s,1216
2
+ mobius_auditlog/bootstrap.py,sha256=fETB9TTauuB8nS_4MNmT6QscjDuRSBQyJjhqc6DR6DQ,2905
3
+ mobius_auditlog/builder.py,sha256=WXiGic73LsvUC3ZBVSPlaaE-S2JJRvZElEkRS9jpcrM,3124
4
+ mobius_auditlog/context.py,sha256=87LEPIbpWHKRG-r3C8JpfnSFr1pCPuOM267QwObzoQs,1769
5
+ mobius_auditlog/middleware.py,sha256=Mb1TJ8csui1QATiQ0Yq8jpKl8L368NxLB90AtaAgWgc,5562
6
+ mobius_auditlog/models.py,sha256=MzLN-aj2O6dy7ks750cJgd9L_he5_qSqCWXnDhGkvNA,3500
7
+ mobius_auditlog/publisher.py,sha256=yng5t8nioaGOQehuvH5pXwg0MCJTSxZvSdFRQHRcA7c,2434
8
+ mobius_auditlog/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ mobius_auditlog/security.py,sha256=rPjAAI_sGm21hlL70NF0RpUvwrIw9880xTovqq-Jyng,1667
10
+ mobius_auditlog_py-1.0.0.dist-info/METADATA,sha256=kBmJdOXzq9xli8tK7HpQQYlWMAHR-7fjkEF-5lhyiyk,5087
11
+ mobius_auditlog_py-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ mobius_auditlog_py-1.0.0.dist-info/top_level.txt,sha256=QHP6jxiFMYE0AmA-pwlsa9RXLRxTcvScAyYDZk4XxTk,16
13
+ mobius_auditlog_py-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ mobius_auditlog