mobius-auditlog-py 1.0.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.
- mobius_auditlog_py-1.0.0/PKG-INFO +154 -0
- mobius_auditlog_py-1.0.0/README.md +136 -0
- mobius_auditlog_py-1.0.0/pyproject.toml +34 -0
- mobius_auditlog_py-1.0.0/setup.cfg +4 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog/__init__.py +56 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog/bootstrap.py +89 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog/builder.py +90 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog/context.py +51 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog/middleware.py +167 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog/models.py +119 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog/publisher.py +79 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog/py.typed +0 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog/security.py +45 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog_py.egg-info/PKG-INFO +154 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog_py.egg-info/SOURCES.txt +17 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog_py.egg-info/dependency_links.txt +1 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog_py.egg-info/requires.txt +11 -0
- mobius_auditlog_py-1.0.0/src/mobius_auditlog_py.egg-info/top_level.txt +1 -0
- mobius_auditlog_py-1.0.0/tests/test_auditlog.py +139 -0
|
@@ -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,136 @@
|
|
|
1
|
+
# mobius-auditlog-py
|
|
2
|
+
|
|
3
|
+
Build, sign, and publish **CloudEvents-style audit events** from Mobius
|
|
4
|
+
**Python (FastAPI / Starlette)** services.
|
|
5
|
+
|
|
6
|
+
- **PyPI:** `pip install mobius-auditlog-py`
|
|
7
|
+
- **Import package:** `mobius_auditlog`
|
|
8
|
+
- **Python:** 3.10+
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install mobius-auditlog-py
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`pydantic`, `starlette`, and `py-kafka-producer-client` are installed
|
|
17
|
+
automatically.
|
|
18
|
+
|
|
19
|
+
## Quick start (auto-capture middleware)
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from fastapi import FastAPI
|
|
23
|
+
from kafka_producer_client import KafkaProducerConfig
|
|
24
|
+
from mobius_auditlog import setup_auditlog
|
|
25
|
+
|
|
26
|
+
app = FastAPI()
|
|
27
|
+
|
|
28
|
+
setup_auditlog(
|
|
29
|
+
app,
|
|
30
|
+
topic="audit-logs",
|
|
31
|
+
signing_key="your-hmac-key", # optional signature
|
|
32
|
+
kafka_config=KafkaProducerConfig(bootstrap_servers="broker:9092"),
|
|
33
|
+
capture_payloads=False, # set True to buffer req/resp bodies
|
|
34
|
+
compliance_category="GDPR",
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The middleware emits one audit event per request: `action` (route, method,
|
|
39
|
+
status, severity), `network` (client IP + user-agent), `objectref` (from the
|
|
40
|
+
path), and the envelope/`subject` from the request context. It skips
|
|
41
|
+
health/metrics/swagger paths.
|
|
42
|
+
|
|
43
|
+
## The event schema
|
|
44
|
+
|
|
45
|
+
`AuditLogEvent` — flat envelope + nested sections, serialized with empty values
|
|
46
|
+
omitted and keys alphabetically ordered:
|
|
47
|
+
|
|
48
|
+
| Section | Fields |
|
|
49
|
+
|---|---|
|
|
50
|
+
| envelope | `id`, `specversion`, `timestamp`, `traceid`, `transactionid`, `tenantid` |
|
|
51
|
+
| `subject` | `userid`, `type`, `groups[]` |
|
|
52
|
+
| `kubernetes` | `namespacename`, `podname`, `containername`, `nodename` |
|
|
53
|
+
| `objectref` | `resource`, `resourceid`, `apiversion` |
|
|
54
|
+
| `action` | `name`, `method`, `severity`, `status` |
|
|
55
|
+
| `network` | `sourceip`, `useragent` |
|
|
56
|
+
| `eventdata` | `requestpayload{}`, `responsepayload{}`, `metadata{issensitive, compliancecategory}` |
|
|
57
|
+
| `security` | `hash`, `signature` |
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
event.to_dict() # pruned dict (empties dropped), ready to publish
|
|
61
|
+
event.to_json() # canonical JSON: pruned + sorted keys (used for hashing)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Building events manually
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from mobius_auditlog import AuditLogBuilder
|
|
68
|
+
|
|
69
|
+
event = (
|
|
70
|
+
AuditLogBuilder()
|
|
71
|
+
.from_context() # tenant/trace/txn/subject from context
|
|
72
|
+
.object_ref(resource="order", resourceid="order-789", apiversion="v1.0")
|
|
73
|
+
.action(name="order.create", method="POST", severity="INFO", status="SUCCESS")
|
|
74
|
+
.network(sourceip="1.2.3.4", useragent="curl/8")
|
|
75
|
+
.event_data(requestpayload={"amount": 42}, issensitive=True, compliancecategory="PCI")
|
|
76
|
+
.build()
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`from_context()` pulls `tenantid`/`traceid`/`transactionid`/`subject` from the
|
|
81
|
+
`mobius-tracer-py` request context when that package is installed, and
|
|
82
|
+
`kubernetes` from the downward-API env vars (`POD_NAMESPACE`, `POD_NAME`,
|
|
83
|
+
`CONTAINER_NAME`, `NODE_NAME`).
|
|
84
|
+
|
|
85
|
+
## Integrity: hash + signature
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from mobius_auditlog import seal, verify, compute_hash
|
|
89
|
+
|
|
90
|
+
seal(event, signing_key="key") # sets security.hash (+ signature)
|
|
91
|
+
verify(event, signing_key="key") # True if hash + signature match
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- `hash` = SHA-256 over the canonical JSON **excluding** the `security` block.
|
|
95
|
+
- `signature` = HMAC-SHA256 of the hash with your key (omitted if no key).
|
|
96
|
+
|
|
97
|
+
The publisher seals events automatically before sending.
|
|
98
|
+
|
|
99
|
+
## Publishing
|
|
100
|
+
|
|
101
|
+
The publisher depends only on a small protocol — no hard Kafka coupling:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
class LogEventSender(Protocol):
|
|
105
|
+
def send(self, value: dict, *, topic: str) -> Any: ...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`setup_auditlog` resolves a publisher from (in order) `kafka_sender` →
|
|
109
|
+
`kafka_producer` → `kafka_config` → the configured `py-kafka-producer-client`
|
|
110
|
+
singleton. Or use it directly:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from mobius_auditlog import AuditLogPublisher, PyKafkaProducerClientSender, set_publisher
|
|
114
|
+
|
|
115
|
+
publisher = AuditLogPublisher(PyKafkaProducerClientSender(client),
|
|
116
|
+
topic="audit-logs", signing_key="key")
|
|
117
|
+
set_publisher(publisher)
|
|
118
|
+
publisher.publish(event) # seals + sends, fire-and-forget
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Publishing runs on a background thread and never raises into the request path.
|
|
122
|
+
|
|
123
|
+
## Payload capture
|
|
124
|
+
|
|
125
|
+
With `capture_payloads=True`, the middleware buffers request and response bodies
|
|
126
|
+
(JSON-parsed, size-capped at 64 KB) into `eventdata.requestpayload` /
|
|
127
|
+
`responsepayload`. Off by default for privacy and overhead. Non-JSON bodies are
|
|
128
|
+
stored as `{"_raw": "..."}`.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
python -m venv .venv && source .venv/bin/activate
|
|
134
|
+
pip install -e ".[dev]"
|
|
135
|
+
pytest
|
|
136
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mobius-auditlog-py"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "CloudEvents-style audit event model, builder, integrity signing and Kafka publishing for Mobius Python (FastAPI) services."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "Mobius Platform" }]
|
|
12
|
+
keywords = ["audit", "auditlog", "fastapi", "mobius", "cloudevents"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"pydantic>=2.0",
|
|
15
|
+
"starlette>=0.27",
|
|
16
|
+
"py-kafka-producer-client>=0.1.7",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
tracer = ["mobius-tracer-py>=1.0"]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=7.4",
|
|
23
|
+
"starlette>=0.27",
|
|
24
|
+
"httpx>=0.24",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
mobius_auditlog = ["py.typed"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -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,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/mobius_auditlog/__init__.py
|
|
4
|
+
src/mobius_auditlog/bootstrap.py
|
|
5
|
+
src/mobius_auditlog/builder.py
|
|
6
|
+
src/mobius_auditlog/context.py
|
|
7
|
+
src/mobius_auditlog/middleware.py
|
|
8
|
+
src/mobius_auditlog/models.py
|
|
9
|
+
src/mobius_auditlog/publisher.py
|
|
10
|
+
src/mobius_auditlog/py.typed
|
|
11
|
+
src/mobius_auditlog/security.py
|
|
12
|
+
src/mobius_auditlog_py.egg-info/PKG-INFO
|
|
13
|
+
src/mobius_auditlog_py.egg-info/SOURCES.txt
|
|
14
|
+
src/mobius_auditlog_py.egg-info/dependency_links.txt
|
|
15
|
+
src/mobius_auditlog_py.egg-info/requires.txt
|
|
16
|
+
src/mobius_auditlog_py.egg-info/top_level.txt
|
|
17
|
+
tests/test_auditlog.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mobius_auditlog
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from starlette.applications import Starlette
|
|
4
|
+
from starlette.responses import JSONResponse
|
|
5
|
+
from starlette.routing import Route
|
|
6
|
+
from starlette.testclient import TestClient
|
|
7
|
+
|
|
8
|
+
from mobius_auditlog import (
|
|
9
|
+
AuditLogBuilder,
|
|
10
|
+
AuditLogEvent,
|
|
11
|
+
AuditLogPublisher,
|
|
12
|
+
EventMetadata,
|
|
13
|
+
compute_hash,
|
|
14
|
+
seal,
|
|
15
|
+
set_publisher,
|
|
16
|
+
setup_auditlog,
|
|
17
|
+
verify,
|
|
18
|
+
)
|
|
19
|
+
from mobius_auditlog.models import EventData, Security
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --- model serialization ---
|
|
23
|
+
|
|
24
|
+
def test_to_dict_prunes_empty_but_keeps_false():
|
|
25
|
+
event = AuditLogEvent(
|
|
26
|
+
id="e1",
|
|
27
|
+
specversion="1.0",
|
|
28
|
+
tenantid="", # empty -> dropped
|
|
29
|
+
traceid=None, # none -> dropped
|
|
30
|
+
eventdata=EventData(metadata=EventMetadata(issensitive=False)),
|
|
31
|
+
)
|
|
32
|
+
data = event.to_dict()
|
|
33
|
+
assert data["id"] == "e1"
|
|
34
|
+
assert "tenantid" not in data # empty string pruned
|
|
35
|
+
assert "traceid" not in data # none pruned
|
|
36
|
+
# issensitive False kept; empty payload dicts pruned
|
|
37
|
+
assert data["eventdata"]["metadata"]["issensitive"] is False
|
|
38
|
+
assert "requestpayload" not in data["eventdata"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_to_json_is_sorted():
|
|
42
|
+
event = AuditLogEvent(id="e1", tenantid="t1", specversion="1.0")
|
|
43
|
+
keys = list(json.loads(event.to_json()).keys())
|
|
44
|
+
assert keys == sorted(keys)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- security ---
|
|
48
|
+
|
|
49
|
+
def test_seal_and_verify():
|
|
50
|
+
event = AuditLogEvent(id="e1", specversion="1.0", tenantid="t1")
|
|
51
|
+
seal(event, signing_key="secret")
|
|
52
|
+
assert event.security.hash
|
|
53
|
+
assert event.security.signature
|
|
54
|
+
assert verify(event, signing_key="secret")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_verify_detects_tampering():
|
|
58
|
+
event = AuditLogEvent(id="e1", specversion="1.0", tenantid="t1")
|
|
59
|
+
seal(event)
|
|
60
|
+
event.tenantid = "tampered"
|
|
61
|
+
assert verify(event) is False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_hash_excludes_security_block():
|
|
65
|
+
event = AuditLogEvent(id="e1", specversion="1.0")
|
|
66
|
+
h1 = compute_hash(event)
|
|
67
|
+
event.security = Security(hash="x", signature="y")
|
|
68
|
+
assert compute_hash(event) == h1 # security ignored in hash
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- builder ---
|
|
72
|
+
|
|
73
|
+
def test_builder_envelope_and_sections():
|
|
74
|
+
event = (
|
|
75
|
+
AuditLogBuilder()
|
|
76
|
+
.envelope(tenantid="t-1", traceid="tr-1")
|
|
77
|
+
.action(name="/orders", method="POST", status="SUCCESS")
|
|
78
|
+
.network(sourceip="1.2.3.4", useragent="curl")
|
|
79
|
+
.build()
|
|
80
|
+
)
|
|
81
|
+
assert event.id and event.specversion == "1.0" and event.timestamp
|
|
82
|
+
assert event.tenantid == "t-1"
|
|
83
|
+
assert event.action.method == "POST"
|
|
84
|
+
assert event.network.sourceip == "1.2.3.4"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- middleware end to end ---
|
|
88
|
+
|
|
89
|
+
class _RecordingSender:
|
|
90
|
+
def __init__(self):
|
|
91
|
+
self.sent = []
|
|
92
|
+
|
|
93
|
+
def send(self, value, *, topic):
|
|
94
|
+
self.sent.append((topic, value))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_middleware_emits_event():
|
|
98
|
+
sender = _RecordingSender()
|
|
99
|
+
|
|
100
|
+
async def create(request):
|
|
101
|
+
return JSONResponse({"ok": True}, status_code=201)
|
|
102
|
+
|
|
103
|
+
app = Starlette(routes=[Route("/orders", create, methods=["POST"])])
|
|
104
|
+
setup_auditlog(app, kafka_sender=sender, topic="audit-logs", signing_key="k")
|
|
105
|
+
try:
|
|
106
|
+
client = TestClient(app)
|
|
107
|
+
resp = client.post("/orders", json={"x": 1})
|
|
108
|
+
assert resp.status_code == 201
|
|
109
|
+
|
|
110
|
+
import time
|
|
111
|
+
time.sleep(0.1) # background publish
|
|
112
|
+
assert sender.sent, "expected an audit event"
|
|
113
|
+
topic, payload = sender.sent[0]
|
|
114
|
+
assert topic == "audit-logs"
|
|
115
|
+
assert payload["action"]["method"] == "POST"
|
|
116
|
+
assert payload["action"]["status"] == "SUCCESS"
|
|
117
|
+
assert payload["action"]["name"] == "/orders"
|
|
118
|
+
assert payload["objectref"]["resource"] == "orders"
|
|
119
|
+
assert payload["security"]["hash"]
|
|
120
|
+
assert payload["security"]["signature"]
|
|
121
|
+
finally:
|
|
122
|
+
set_publisher(None)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_middleware_skips_health():
|
|
126
|
+
sender = _RecordingSender()
|
|
127
|
+
|
|
128
|
+
async def health(request):
|
|
129
|
+
return JSONResponse({"status": "UP"})
|
|
130
|
+
|
|
131
|
+
app = Starlette(routes=[Route("/health/live", health)])
|
|
132
|
+
setup_auditlog(app, kafka_sender=sender)
|
|
133
|
+
try:
|
|
134
|
+
TestClient(app).get("/health/live")
|
|
135
|
+
import time
|
|
136
|
+
time.sleep(0.1)
|
|
137
|
+
assert sender.sent == [] # skipped
|
|
138
|
+
finally:
|
|
139
|
+
set_publisher(None)
|