rust-py-audit 0.2.4__tar.gz → 0.3.1__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.
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/Cargo.lock +1 -1
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/Cargo.toml +1 -1
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/PKG-INFO +26 -8
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/README.md +25 -7
- rust_py_audit-0.3.1/ROADMAP.md +68 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/pyproject.toml +1 -1
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/python/rust_py_audit/__init__.py +1 -1
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/audit_logger.rs +81 -47
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/immutablelog_receipt.rs +12 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_flush_pending.py +37 -4
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_immutablelog_hybrid.py +28 -3
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/.github/workflows/release.yml +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/.gitignore +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/LICENSE +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/__init__.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/basic_usage.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/django_example/__init__.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/django_example/settings.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/django_example/urls.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/django_example/views.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/fastapi_app.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/immutablelog_basic.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/python/rust_py_audit/django.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/python/rust_py_audit/fastapi.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/errors.rs +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/event.rs +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/hash.rs +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/immutablelog_client.rs +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/immutablelog_config.rs +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/lib.rs +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/pyconv.rs +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/retry.rs +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/storage.rs +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/verifier.rs +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/conftest.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_audit_logger.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_concurrency.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_django_immutablelog.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_django_middleware.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_fastapi_immutablelog.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_fastapi_middleware.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_hello.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_immutablelog_remote.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_metadata_bigint.py +0 -0
- {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_verify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rust-py-audit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -198,7 +198,7 @@ See the full example in [`examples/django_example/`](examples/django_example/).
|
|
|
198
198
|
|---|---|---|---|
|
|
199
199
|
| `"local"` (default) | ✅ | ❌ | The library's original behavior, no external dependency |
|
|
200
200
|
| `"remote"` | ❌ (except for pending entries, see below) | ✅ | ImmutableLog is the single source of truth; a delivery failure raises an exception |
|
|
201
|
-
| `"hybrid"` | ✅ | ✅ | Local chain + remote receipt; a delivery failure NEVER raises —
|
|
201
|
+
| `"hybrid"` | ✅ | ✅ | Local chain + remote receipt; a delivery failure NEVER raises — a transient (retryable) failure becomes `status="pending"` (queued for `flush_pending()`), a permanent one becomes `status="failed"` (not queued) |
|
|
202
202
|
|
|
203
203
|
`mode="local"` is the default — existing code calling `AuditLogger(app_name, file_path)` keeps working unchanged.
|
|
204
204
|
|
|
@@ -228,11 +228,12 @@ event = audit.log(
|
|
|
228
228
|
|
|
229
229
|
print(event["immutablelog"])
|
|
230
230
|
# {"status": "delivered", "tx_id": "tx_...", "payload_hash": "...", ...}
|
|
231
|
-
# or {"status": "pending", "tx_id": None, ...}
|
|
231
|
+
# or {"status": "pending", "tx_id": None, ...} on a transient failure (hybrid mode)
|
|
232
|
+
# or {"status": "failed", "tx_id": None, ...} on a permanent failure (hybrid mode)
|
|
232
233
|
|
|
233
234
|
# Retries delivery of every event still marked "pending":
|
|
234
235
|
print(audit.flush_pending())
|
|
235
|
-
# {"flushed": 1, "still_pending": 0, "total": 1}
|
|
236
|
+
# {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
|
|
236
237
|
```
|
|
237
238
|
|
|
238
239
|
### Environment variables
|
|
@@ -319,7 +320,11 @@ Same behavior as FastAPI: `severity` computed from `status_code`, and delivery f
|
|
|
319
320
|
- **Retryable**: `5xx` and timeouts.
|
|
320
321
|
- **Permanent** (never retried): `400`, `401`, `403`, `429`, and any other client error.
|
|
321
322
|
- In `mode="remote"`, exhausting retries (or a permanent error) raises `RuntimeError`.
|
|
322
|
-
- In `mode="hybrid"`,
|
|
323
|
+
- In `mode="hybrid"`, delivery never raises:
|
|
324
|
+
- a **retryable** failure (5xx/timeout) marks the event `status="pending"` and queues it in `audit.pending.jsonl` — call `audit.flush_pending()` (manually, or from a cron/worker) to retry later;
|
|
325
|
+
- a **permanent** failure (4xx) marks the event `status="failed"` and does **not** queue it (retrying would never succeed, so it stays out of the queue instead of getting stuck there forever). The event is still recorded locally and the chain stays valid — `"failed"` is just operational metadata.
|
|
326
|
+
|
|
327
|
+
In `mode="hybrid"`, the event is appended to the local JSONL **once**, already carrying its final receipt (an O(1) append per event, not a full-file rewrite). A network failure still records the event locally (as `pending`/`failed`), so it is never lost to a failed delivery; the only loss window is the process being killed mid-request, and even then a delivery that did reach ImmutableLog is preserved in the remote store.
|
|
323
328
|
|
|
324
329
|
### Integrity guarantee
|
|
325
330
|
|
|
@@ -371,7 +376,7 @@ Records an event and returns the full event (already with `id`, `timestamp`, `ha
|
|
|
371
376
|
| `hash` | `str` | SHA-256 (64 hex chars) of the event + `previous_hash` |
|
|
372
377
|
| `severity` | `str \| absent` | Only present if passed to `log()`. Becomes `meta.type` on ImmutableLog |
|
|
373
378
|
| `immutable_trail` | `str \| absent` | Only present if passed to `log()` (and not empty after sanitization). Becomes `meta.immutable_trail` |
|
|
374
|
-
| `immutablelog` | `dict \| absent` | Only present in `mode="remote"`/`"hybrid"`. `status` is `"delivered"` or `"
|
|
379
|
+
| `immutablelog` | `dict \| absent` | Only present in `mode="remote"`/`"hybrid"`. `status` is `"delivered"`, `"pending"` (transient failure, queued), or `"failed"` (permanent failure, not queued); other fields (`tx_id`, `payload_hash`, `duplicate`, `request_id`, ...) come from the ImmutableLog response |
|
|
375
380
|
|
|
376
381
|
In `mode="remote"`, a permanent failure or exhausted retries raise `RuntimeError` instead of returning the dict.
|
|
377
382
|
|
|
@@ -394,10 +399,23 @@ Hash of the last recorded event (in-memory cache, O(1)) — `None` if no event h
|
|
|
394
399
|
Attempts to redeliver to ImmutableLog every event marked as `pending` (recorded in `audit.pending.jsonl`, derived from `file_path`). Only relevant in `mode="hybrid"` — other modes never populate this queue.
|
|
395
400
|
|
|
396
401
|
```python
|
|
397
|
-
{"flushed": 1, "still_pending": 0, "total": 1}
|
|
402
|
+
{"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
|
|
398
403
|
```
|
|
399
404
|
|
|
400
|
-
|
|
405
|
+
For each queued event:
|
|
406
|
+
- **delivered** → updates `event["immutablelog"]` in `audit.jsonl` to `"delivered"` (without changing `hash`) and removes it from the queue (counts toward `flushed`);
|
|
407
|
+
- **permanent failure** → marks it `"failed"` in `audit.jsonl` and removes it from the queue, so it doesn't stay stuck forever (counts toward `failed`);
|
|
408
|
+
- **retryable failure** → left in the queue for the next call (counts toward `still_pending`).
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
## Roadmap
|
|
413
|
+
|
|
414
|
+
`rust-py-audit` is mature (v0.3.0): the SHA-256 hash chain, JSONL storage,
|
|
415
|
+
FastAPI/Django middlewares, and the ImmutableLog `local`/`remote`/`hybrid`
|
|
416
|
+
integration are shipped and stable. Directional ideas under consideration (log
|
|
417
|
+
rotation, incremental verification, more delivery backends, an async client, a
|
|
418
|
+
Flask middleware) are tracked in [ROADMAP.md](./ROADMAP.md).
|
|
401
419
|
|
|
402
420
|
---
|
|
403
421
|
|
|
@@ -161,7 +161,7 @@ See the full example in [`examples/django_example/`](examples/django_example/).
|
|
|
161
161
|
|---|---|---|---|
|
|
162
162
|
| `"local"` (default) | ✅ | ❌ | The library's original behavior, no external dependency |
|
|
163
163
|
| `"remote"` | ❌ (except for pending entries, see below) | ✅ | ImmutableLog is the single source of truth; a delivery failure raises an exception |
|
|
164
|
-
| `"hybrid"` | ✅ | ✅ | Local chain + remote receipt; a delivery failure NEVER raises —
|
|
164
|
+
| `"hybrid"` | ✅ | ✅ | Local chain + remote receipt; a delivery failure NEVER raises — a transient (retryable) failure becomes `status="pending"` (queued for `flush_pending()`), a permanent one becomes `status="failed"` (not queued) |
|
|
165
165
|
|
|
166
166
|
`mode="local"` is the default — existing code calling `AuditLogger(app_name, file_path)` keeps working unchanged.
|
|
167
167
|
|
|
@@ -191,11 +191,12 @@ event = audit.log(
|
|
|
191
191
|
|
|
192
192
|
print(event["immutablelog"])
|
|
193
193
|
# {"status": "delivered", "tx_id": "tx_...", "payload_hash": "...", ...}
|
|
194
|
-
# or {"status": "pending", "tx_id": None, ...}
|
|
194
|
+
# or {"status": "pending", "tx_id": None, ...} on a transient failure (hybrid mode)
|
|
195
|
+
# or {"status": "failed", "tx_id": None, ...} on a permanent failure (hybrid mode)
|
|
195
196
|
|
|
196
197
|
# Retries delivery of every event still marked "pending":
|
|
197
198
|
print(audit.flush_pending())
|
|
198
|
-
# {"flushed": 1, "still_pending": 0, "total": 1}
|
|
199
|
+
# {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
|
|
199
200
|
```
|
|
200
201
|
|
|
201
202
|
### Environment variables
|
|
@@ -282,7 +283,11 @@ Same behavior as FastAPI: `severity` computed from `status_code`, and delivery f
|
|
|
282
283
|
- **Retryable**: `5xx` and timeouts.
|
|
283
284
|
- **Permanent** (never retried): `400`, `401`, `403`, `429`, and any other client error.
|
|
284
285
|
- In `mode="remote"`, exhausting retries (or a permanent error) raises `RuntimeError`.
|
|
285
|
-
- In `mode="hybrid"`,
|
|
286
|
+
- In `mode="hybrid"`, delivery never raises:
|
|
287
|
+
- a **retryable** failure (5xx/timeout) marks the event `status="pending"` and queues it in `audit.pending.jsonl` — call `audit.flush_pending()` (manually, or from a cron/worker) to retry later;
|
|
288
|
+
- a **permanent** failure (4xx) marks the event `status="failed"` and does **not** queue it (retrying would never succeed, so it stays out of the queue instead of getting stuck there forever). The event is still recorded locally and the chain stays valid — `"failed"` is just operational metadata.
|
|
289
|
+
|
|
290
|
+
In `mode="hybrid"`, the event is appended to the local JSONL **once**, already carrying its final receipt (an O(1) append per event, not a full-file rewrite). A network failure still records the event locally (as `pending`/`failed`), so it is never lost to a failed delivery; the only loss window is the process being killed mid-request, and even then a delivery that did reach ImmutableLog is preserved in the remote store.
|
|
286
291
|
|
|
287
292
|
### Integrity guarantee
|
|
288
293
|
|
|
@@ -334,7 +339,7 @@ Records an event and returns the full event (already with `id`, `timestamp`, `ha
|
|
|
334
339
|
| `hash` | `str` | SHA-256 (64 hex chars) of the event + `previous_hash` |
|
|
335
340
|
| `severity` | `str \| absent` | Only present if passed to `log()`. Becomes `meta.type` on ImmutableLog |
|
|
336
341
|
| `immutable_trail` | `str \| absent` | Only present if passed to `log()` (and not empty after sanitization). Becomes `meta.immutable_trail` |
|
|
337
|
-
| `immutablelog` | `dict \| absent` | Only present in `mode="remote"`/`"hybrid"`. `status` is `"delivered"` or `"
|
|
342
|
+
| `immutablelog` | `dict \| absent` | Only present in `mode="remote"`/`"hybrid"`. `status` is `"delivered"`, `"pending"` (transient failure, queued), or `"failed"` (permanent failure, not queued); other fields (`tx_id`, `payload_hash`, `duplicate`, `request_id`, ...) come from the ImmutableLog response |
|
|
338
343
|
|
|
339
344
|
In `mode="remote"`, a permanent failure or exhausted retries raise `RuntimeError` instead of returning the dict.
|
|
340
345
|
|
|
@@ -357,10 +362,23 @@ Hash of the last recorded event (in-memory cache, O(1)) — `None` if no event h
|
|
|
357
362
|
Attempts to redeliver to ImmutableLog every event marked as `pending` (recorded in `audit.pending.jsonl`, derived from `file_path`). Only relevant in `mode="hybrid"` — other modes never populate this queue.
|
|
358
363
|
|
|
359
364
|
```python
|
|
360
|
-
{"flushed": 1, "still_pending": 0, "total": 1}
|
|
365
|
+
{"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
|
|
361
366
|
```
|
|
362
367
|
|
|
363
|
-
|
|
368
|
+
For each queued event:
|
|
369
|
+
- **delivered** → updates `event["immutablelog"]` in `audit.jsonl` to `"delivered"` (without changing `hash`) and removes it from the queue (counts toward `flushed`);
|
|
370
|
+
- **permanent failure** → marks it `"failed"` in `audit.jsonl` and removes it from the queue, so it doesn't stay stuck forever (counts toward `failed`);
|
|
371
|
+
- **retryable failure** → left in the queue for the next call (counts toward `still_pending`).
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## Roadmap
|
|
376
|
+
|
|
377
|
+
`rust-py-audit` is mature (v0.3.0): the SHA-256 hash chain, JSONL storage,
|
|
378
|
+
FastAPI/Django middlewares, and the ImmutableLog `local`/`remote`/`hybrid`
|
|
379
|
+
integration are shipped and stable. Directional ideas under consideration (log
|
|
380
|
+
rotation, incremental verification, more delivery backends, an async client, a
|
|
381
|
+
Flask middleware) are tracked in [ROADMAP.md](./ROADMAP.md).
|
|
364
382
|
|
|
365
383
|
---
|
|
366
384
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Roadmap — rust-py-audit
|
|
2
|
+
|
|
3
|
+
Direction for `rust-py-audit`: a tamper-evident audit log for Python with a Rust
|
|
4
|
+
core (PyO3 + maturin). The library is already mature (v0.3.0) — the SHA-256 hash
|
|
5
|
+
chain, JSONL storage, FastAPI/Django middlewares, and the ImmutableLog
|
|
6
|
+
`local`/`remote`/`hybrid` integration are shipped and stable. This document tracks
|
|
7
|
+
what is done and the **directional** ideas under consideration.
|
|
8
|
+
|
|
9
|
+
> Status legend: ✅ shipped · 🔜 planned (next) · 💡 idea (no version yet) · ⚠️ note
|
|
10
|
+
|
|
11
|
+
> ⚠️ Beyond "shipped", items below are **inferred directions, not commitments**.
|
|
12
|
+
> Confirm priorities with the maintainer before planning a release around them.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Shipped — up to v0.3.0
|
|
17
|
+
|
|
18
|
+
- ✅ `AuditLogger` with `log(...)`, `verify()`, `last_hash()`.
|
|
19
|
+
- ✅ **SHA-256 hash chain** — each event embeds the previous event's hash; any
|
|
20
|
+
edit, deletion, or reordering is detected by `verify()` (re-read from disk,
|
|
21
|
+
never trusts an in-memory cache).
|
|
22
|
+
- ✅ Append-only **JSONL** storage — one event per line, no database.
|
|
23
|
+
- ✅ Thread-safe — the chain stays linear under concurrency.
|
|
24
|
+
- ✅ Free-form JSON-serializable `metadata`.
|
|
25
|
+
- ✅ FastAPI and Django middlewares (log state-changing requests:
|
|
26
|
+
POST/PUT/PATCH/DELETE).
|
|
27
|
+
- ✅ **ImmutableLog integration** — `local`/`remote`/`hybrid` modes, automatic
|
|
28
|
+
retry with idempotency key, pending queue (`audit.pending.jsonl`),
|
|
29
|
+
`flush_pending()`, and transient (`pending`) vs permanent (`failed`) failure
|
|
30
|
+
handling that never takes down the application response.
|
|
31
|
+
- ✅ `severity`, `immutable_trail`, and `env` delivery metadata (never enter the
|
|
32
|
+
hash, so `verify()` stays valid after `flush_pending()`).
|
|
33
|
+
- ✅ ImmutableLog HTTP client runs in the Rust core (PyO3 + reqwest).
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Directional ideas (no version assigned — confirm before planning)
|
|
38
|
+
|
|
39
|
+
- 💡 **Log rotation / segmentation** — roll the JSONL by size or date while
|
|
40
|
+
preserving chain continuity across segments (carry the last hash forward).
|
|
41
|
+
- 💡 **Incremental verification** — verify only events appended since the last
|
|
42
|
+
known-good hash, instead of re-reading the whole file every time.
|
|
43
|
+
- 💡 **More delivery backends** — additional sinks alongside ImmutableLog (e.g.
|
|
44
|
+
S3/object storage, syslog, a generic webhook), reusing the retry/pending logic.
|
|
45
|
+
- 💡 **Async client** — non-blocking delivery path for high-throughput async apps.
|
|
46
|
+
- 💡 **Flask middleware** — parity with the FastAPI/Django adapters.
|
|
47
|
+
- 💡 **Query/read helpers** — iterate or filter recorded events (by actor, action,
|
|
48
|
+
resource, time range) without hand-parsing the JSONL.
|
|
49
|
+
- 💡 **Benchmarks suite** — hashing + append throughput under concurrency.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Known limitations (by design, for now)
|
|
54
|
+
|
|
55
|
+
- Local JSONL is single-file and append-only (no built-in rotation yet).
|
|
56
|
+
- `verify()` re-reads the entire file (O(n)); fine for typical logs, see the
|
|
57
|
+
incremental-verification idea above for very large files.
|
|
58
|
+
- ImmutableLog delivery is synchronous on the `log()` path (bounded by
|
|
59
|
+
`timeout_ms` + retries); `hybrid` mode never raises, queuing transient failures.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Contributing to the roadmap
|
|
64
|
+
|
|
65
|
+
Versions and ordering are indicative and may shift. Bump the version in **both**
|
|
66
|
+
`Cargo.toml` and `pyproject.toml` (kept in sync) plus `__version__`, ship tests
|
|
67
|
+
(`cargo test --no-default-features` + `pytest`), then tag `vX.Y.Z` to trigger the
|
|
68
|
+
release workflow (Trusted Publishing / OIDC to PyPI).
|
|
@@ -354,38 +354,54 @@ impl AuditLogger {
|
|
|
354
354
|
|
|
355
355
|
// Igual a `log()`: tudo que trava o `Mutex` ou faz rede roda com
|
|
356
356
|
// o GIL liberado (REGRA CRÍTICA na doc da struct).
|
|
357
|
-
let (flushed, still_pending) =
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
let
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
357
|
+
let (flushed, failed, still_pending) =
|
|
358
|
+
py.allow_threads(|| -> Result<(usize, usize, usize), crate::errors::AuditError> {
|
|
359
|
+
// Primeiro a parte cara/lenta (rede), SEM segurar o lock:
|
|
360
|
+
// acumulamos os receipts a gravar e os ids a remover da fila.
|
|
361
|
+
// `receipts` junta entregues e permanentes-falhados (ambos
|
|
362
|
+
// saem da fila); `handled` é o conjunto de ids a remover.
|
|
363
|
+
let mut receipts: HashMap<String, ImmutableLogReceipt> = HashMap::new();
|
|
364
|
+
let mut handled: HashSet<String> = HashSet::new();
|
|
365
|
+
let mut flushed = 0usize;
|
|
366
|
+
let mut failed = 0usize;
|
|
367
|
+
|
|
368
|
+
for event in &pending_events {
|
|
369
|
+
let severity = resolve_severity(event.severity.as_deref())
|
|
370
|
+
.map_err(crate::errors::AuditError::Config)?;
|
|
371
|
+
match self.deliver(event, severity, event.immutable_trail.as_deref()) {
|
|
372
|
+
Ok(receipt) => {
|
|
373
|
+
receipts.insert(event.id.clone(), receipt);
|
|
374
|
+
handled.insert(event.id.clone());
|
|
375
|
+
flushed += 1;
|
|
376
|
+
}
|
|
377
|
+
Err(err) if err.is_retryable() => {
|
|
378
|
+
// Transitório: continua na fila para a próxima
|
|
379
|
+
// chamada de `flush_pending()` tentar de novo.
|
|
380
|
+
}
|
|
381
|
+
Err(_) => {
|
|
382
|
+
// Permanente: marca `failed` e tira da fila —
|
|
383
|
+
// retentar não muda o resultado (#8).
|
|
384
|
+
receipts.insert(event.id.clone(), ImmutableLogReceipt::failed());
|
|
385
|
+
handled.insert(event.id.clone());
|
|
386
|
+
failed += 1;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
370
389
|
}
|
|
371
|
-
// Em falha o evento simplesmente continua na fila para a
|
|
372
|
-
// próxima chamada de `flush_pending()` tentar de novo.
|
|
373
|
-
}
|
|
374
390
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
391
|
+
// Só agora travamos, para a parte que mexe nos arquivos —
|
|
392
|
+
// serializando contra um `log()` concorrente. As funções em
|
|
393
|
+
// lote releem os arquivos aqui dentro, então eventos
|
|
394
|
+
// acrescentados nesse meio-tempo são preservados.
|
|
395
|
+
let _guard = self.lock_last_hash();
|
|
396
|
+
storage::update_event_receipts(&self.file_path, &receipts)?;
|
|
397
|
+
storage::remove_events(&pending_path, &handled)?;
|
|
382
398
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
})?;
|
|
399
|
+
Ok((flushed, failed, total - flushed - failed))
|
|
400
|
+
})?;
|
|
386
401
|
|
|
387
402
|
let dict = PyDict::new_bound(py);
|
|
388
403
|
dict.set_item("flushed", flushed)?;
|
|
404
|
+
dict.set_item("failed", failed)?;
|
|
389
405
|
dict.set_item("still_pending", still_pending)?;
|
|
390
406
|
dict.set_item("total", total)?;
|
|
391
407
|
Ok(dict.into())
|
|
@@ -499,29 +515,47 @@ impl AuditLogger {
|
|
|
499
515
|
*guard = Some(event.hash.clone());
|
|
500
516
|
}
|
|
501
517
|
AuditMode::Hybrid => {
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
518
|
+
// Entrega ANTES de gravar, para anexar o evento ao JSONL
|
|
519
|
+
// local UMA única vez (append O(1)) já com o receipt
|
|
520
|
+
// final — em vez de gravar e depois reescrever o arquivo
|
|
521
|
+
// inteiro (O(n)) só para anexar o receipt. Falha em hybrid
|
|
522
|
+
// NUNCA é exceção Python.
|
|
523
|
+
//
|
|
524
|
+
// Isso preserva a garantia "uma falha de REDE nunca perde
|
|
525
|
+
// o registro local": uma falha de rede (timeout, conexão
|
|
526
|
+
// recusada, 5xx) cai num dos ramos `Err` abaixo, que grava
|
|
527
|
+
// o evento localmente do mesmo jeito. O único cenário novo
|
|
528
|
+
// de perda é o processo ser MORTO no meio da chamada de
|
|
529
|
+
// rede (janela limitada por `timeout_ms`) — e, mesmo aí, se
|
|
530
|
+
// a entrega chegou ao ImmutableLog o registro existe no
|
|
531
|
+
// destino remoto (a fonte de verdade no modo hybrid).
|
|
532
|
+
let (receipt, queue_for_retry) =
|
|
533
|
+
match self.deliver(&event, resolved_severity, event.immutable_trail.as_deref()) {
|
|
534
|
+
Ok(receipt) => (receipt, false),
|
|
535
|
+
Err(err) if err.is_retryable() => {
|
|
536
|
+
// Transitório: vira `pending` e entra na fila
|
|
537
|
+
// para `flush_pending()` tentar de novo depois.
|
|
538
|
+
(ImmutableLogReceipt::pending(), true)
|
|
539
|
+
}
|
|
540
|
+
Err(_) => {
|
|
541
|
+
// Permanente (400/401/403/429/...): retentar não
|
|
542
|
+
// muda nada, então `failed` e NÃO enfileira.
|
|
543
|
+
(ImmutableLogReceipt::failed(), false)
|
|
544
|
+
}
|
|
545
|
+
};
|
|
508
546
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
// tentar de novo depois.
|
|
519
|
-
let pending_receipt = ImmutableLogReceipt::pending();
|
|
520
|
-
event.immutablelog = Some(pending_receipt.clone());
|
|
521
|
-
storage::update_event_receipt(&self.file_path, &event.id, &pending_receipt)?;
|
|
522
|
-
storage::append_event(&storage::pending_path_for(&self.file_path), &event)?;
|
|
523
|
-
}
|
|
547
|
+
event.immutablelog = Some(receipt);
|
|
548
|
+
|
|
549
|
+
// Enfileira ANTES de gravar no arquivo principal: se o
|
|
550
|
+
// processo morrer entre os dois appends, o evento ainda
|
|
551
|
+
// está na fila e `flush_pending()` o reentrega — preferível
|
|
552
|
+
// a ficar gravado como `pending` no arquivo principal sem
|
|
553
|
+
// estar na fila (preso sem reenvio).
|
|
554
|
+
if queue_for_retry {
|
|
555
|
+
storage::append_event(&storage::pending_path_for(&self.file_path), &event)?;
|
|
524
556
|
}
|
|
557
|
+
storage::append_event(&self.file_path, &event)?;
|
|
558
|
+
*guard = Some(event.hash.clone());
|
|
525
559
|
}
|
|
526
560
|
}
|
|
527
561
|
|
|
@@ -39,4 +39,16 @@ impl ImmutableLogReceipt {
|
|
|
39
39
|
..Default::default()
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
/// Entrega abandonada por erro PERMANENTE (400/401/403/429/...):
|
|
44
|
+
/// diferente de `pending`, um evento `failed` NÃO entra na fila de
|
|
45
|
+
/// reenvio — retentar não mudaria o resultado, então deixá-lo
|
|
46
|
+
/// pendente só o prenderia para sempre numa fila que
|
|
47
|
+
/// `flush_pending()` nunca conseguiria esvaziar.
|
|
48
|
+
pub fn failed() -> Self {
|
|
49
|
+
ImmutableLogReceipt {
|
|
50
|
+
status: "failed".to_string(),
|
|
51
|
+
..Default::default()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
42
54
|
}
|
|
@@ -14,7 +14,7 @@ def test_flush_pending_on_empty_queue_is_a_no_op(tmp_path, immutablelog_server):
|
|
|
14
14
|
|
|
15
15
|
result = audit.flush_pending()
|
|
16
16
|
|
|
17
|
-
assert result == {"flushed": 0, "still_pending": 0, "total": 0}
|
|
17
|
+
assert result == {"flushed": 0, "failed": 0, "still_pending": 0, "total": 0}
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def test_flush_pending_retries_and_updates_receipt_on_recovery(tmp_path, immutablelog_server):
|
|
@@ -32,7 +32,7 @@ def test_flush_pending_retries_and_updates_receipt_on_recovery(tmp_path, immutab
|
|
|
32
32
|
assert event["immutablelog"]["status"] == "pending"
|
|
33
33
|
|
|
34
34
|
still_failing = audit.flush_pending()
|
|
35
|
-
assert still_failing == {"flushed": 0, "still_pending": 1, "total": 1}
|
|
35
|
+
assert still_failing == {"flushed": 0, "failed": 0, "still_pending": 1, "total": 1}
|
|
36
36
|
|
|
37
37
|
immutablelog_server.set_default_response(202, {
|
|
38
38
|
"ok": True,
|
|
@@ -43,7 +43,7 @@ def test_flush_pending_retries_and_updates_receipt_on_recovery(tmp_path, immutab
|
|
|
43
43
|
"request_id": "req_flushed",
|
|
44
44
|
})
|
|
45
45
|
recovered = audit.flush_pending()
|
|
46
|
-
assert recovered == {"flushed": 1, "still_pending": 0, "total": 1}
|
|
46
|
+
assert recovered == {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
|
|
47
47
|
|
|
48
48
|
pending_path = tmp_path / "audit.pending.jsonl"
|
|
49
49
|
assert pending_path.read_text().strip() == ""
|
|
@@ -123,4 +123,37 @@ def test_flush_pending_without_pending_file_is_a_no_op(tmp_path):
|
|
|
123
123
|
|
|
124
124
|
result = audit.flush_pending()
|
|
125
125
|
|
|
126
|
-
assert result == {"flushed": 0, "still_pending": 0, "total": 0}
|
|
126
|
+
assert result == {"flushed": 0, "failed": 0, "still_pending": 0, "total": 0}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_flush_pending_gives_up_on_permanent_error_and_dequeues(tmp_path, immutablelog_server):
|
|
130
|
+
# Um evento que ficou "pending" por uma falha transitória (500), mas
|
|
131
|
+
# cuja causa virou PERMANENTE (401) na hora do flush, precisa sair da
|
|
132
|
+
# fila marcado como "failed" — em vez de ficar preso para sempre (#8).
|
|
133
|
+
immutablelog_server.set_default_response(500, {"ok": False, "reason": "internal_error"})
|
|
134
|
+
audit = AuditLogger(
|
|
135
|
+
app_name="billing-api",
|
|
136
|
+
file_path=str(tmp_path / "audit.jsonl"),
|
|
137
|
+
mode="hybrid",
|
|
138
|
+
immutablelog_url=immutablelog_server.url,
|
|
139
|
+
immutablelog_api_key="iml_live_ok",
|
|
140
|
+
retry_enabled=False,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
event = audit.log(actor_id="user_1", action="DELETE_INVOICE", resource="invoice", resource_id="inv_1")
|
|
144
|
+
assert event["immutablelog"]["status"] == "pending"
|
|
145
|
+
|
|
146
|
+
immutablelog_server.set_default_response(401, {"ok": False, "reason": "invalid_api_key"})
|
|
147
|
+
result = audit.flush_pending()
|
|
148
|
+
assert result == {"flushed": 0, "failed": 1, "still_pending": 0, "total": 1}
|
|
149
|
+
|
|
150
|
+
# Saiu da fila e ficou marcado como "failed" no arquivo principal.
|
|
151
|
+
pending_path = tmp_path / "audit.pending.jsonl"
|
|
152
|
+
assert pending_path.read_text().strip() == ""
|
|
153
|
+
|
|
154
|
+
persisted = json.loads((tmp_path / "audit.jsonl").read_text().strip())
|
|
155
|
+
assert persisted["immutablelog"]["status"] == "failed"
|
|
156
|
+
assert persisted["hash"] == event["hash"]
|
|
157
|
+
|
|
158
|
+
# Uma nova chamada não tem mais nada para tentar.
|
|
159
|
+
assert audit.flush_pending() == {"flushed": 0, "failed": 0, "still_pending": 0, "total": 0}
|
|
@@ -39,9 +39,10 @@ def test_hybrid_does_not_raise_on_remote_failure(tmp_path, immutablelog_server):
|
|
|
39
39
|
assert event["immutablelog"]["status"] == "pending"
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
def test_hybrid_retryable_failure_keeps_local_event_and_queues_pending_file(tmp_path, immutablelog_server):
|
|
43
|
+
# 500 é RETRYABLE: vira "pending" e entra na fila para reenvio.
|
|
44
|
+
immutablelog_server.set_default_response(500, {"ok": False, "reason": "internal_error"})
|
|
45
|
+
audit = _audit_logger(tmp_path, immutablelog_server, retry_enabled=False)
|
|
45
46
|
|
|
46
47
|
event = audit.log(actor_id="user_1", action="DELETE_INVOICE", resource="invoice", resource_id="inv_1")
|
|
47
48
|
|
|
@@ -58,6 +59,30 @@ def test_hybrid_failure_keeps_local_event_and_queues_pending_file(tmp_path, immu
|
|
|
58
59
|
assert json.loads(pending_lines[0])["id"] == event["id"]
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
def test_hybrid_permanent_failure_marks_failed_and_does_not_queue(tmp_path, immutablelog_server):
|
|
63
|
+
# 401 é PERMANENTE (#8): vira "failed" e NÃO entra na fila — retentar
|
|
64
|
+
# não mudaria o resultado, então não fica preso para sempre.
|
|
65
|
+
immutablelog_server.set_default_response(401, {"ok": False, "reason": "invalid_api_key"})
|
|
66
|
+
audit = _audit_logger(tmp_path, immutablelog_server)
|
|
67
|
+
|
|
68
|
+
event = audit.log(actor_id="user_1", action="DELETE_INVOICE", resource="invoice", resource_id="inv_1")
|
|
69
|
+
|
|
70
|
+
assert event["immutablelog"]["status"] == "failed"
|
|
71
|
+
|
|
72
|
+
audit_lines = (tmp_path / "audit.jsonl").read_text().strip().splitlines()
|
|
73
|
+
assert len(audit_lines) == 1
|
|
74
|
+
persisted = json.loads(audit_lines[0])
|
|
75
|
+
assert persisted["hash"] == event["hash"]
|
|
76
|
+
assert persisted["immutablelog"]["status"] == "failed"
|
|
77
|
+
|
|
78
|
+
# Erro permanente não cria/popula a fila de pendências.
|
|
79
|
+
pending_path = tmp_path / "audit.pending.jsonl"
|
|
80
|
+
assert not pending_path.exists() or pending_path.read_text().strip() == ""
|
|
81
|
+
|
|
82
|
+
# E a cadeia local continua válida (o "failed" é só metadado).
|
|
83
|
+
assert audit.verify()["valid"] is True
|
|
84
|
+
|
|
85
|
+
|
|
61
86
|
def test_hybrid_local_chain_stays_valid_after_failure(tmp_path, immutablelog_server):
|
|
62
87
|
immutablelog_server.set_default_response(500, {"ok": False, "reason": "internal_error"})
|
|
63
88
|
audit = _audit_logger(tmp_path, immutablelog_server, retry_enabled=False)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|