Skip to main content

Fast, tamper-evident event auditing (hash chain) for Python apps, with a Rust core.

Project description

rust-py-audit

PyPI Python License GitHub

๐ŸŒ rust-py-audit.vercel.app

Event audit logging library for Python applications, with a Rust core.

Records audit events (who did what, when, on which resource) in a fast, structured way, and chains each event to the previous one with SHA-256 โ€” any later edit, deletion, or reordering of the log file is detectable with verify().


Features

  • AuditLogger โ€” simple API: log(...), verify(), last_hash()
  • Hash chain (SHA-256) โ€” each event embeds the hash of the previous event; altering any recorded event breaks the chain in a detectable way
  • JSONL storage โ€” one event per line, append-only, no database required
  • Thread-safe โ€” a single AuditLogger can be shared across threads (e.g. a multi-threaded WSGI server, or one middleware instance serving concurrent requests); the hash chain stays linear under concurrency
  • Free-form metadata โ€” any JSON-serializable dict (IP, reason, request_id, etc.)
  • FastAPI middleware โ€” automatically logs state-changing requests (POST/PUT/PATCH/DELETE)
  • Django middleware โ€” same idea, supports WSGI and ASGI
  • ImmutableLog integration โ€” local/remote/hybrid modes, automatic retry, pending queue, and flush_pending() (see dedicated section)
  • Rust core โ€” hash generation, serialization, I/O, and the ImmutableLog HTTP client all run in Rust via PyO3; the Python API stays simple

Requirements

  • Python 3.10+
  • No required runtime dependencies

Optional, installed separately:

  • fastapi + starlette โ€” for rust_py_audit.fastapi.AuditMiddleware
  • django โ€” for rust_py_audit.django.AuditMiddleware

Installation

pip install rust-py-audit

With optional extras:

pip install "rust-py-audit[fastapi]"
pip install "rust-py-audit[django]"

Quick Start

from rust_py_audit import AuditLogger

audit = AuditLogger(app_name="billing-api", file_path="./audit.jsonl")

event = audit.log(
    actor_id="user_123",
    action="DELETE_INVOICE",
    resource="invoice",
    resource_id="inv_987",
    metadata={"ip": "192.168.0.10", "reason": "duplicate invoice"},
)

print(event["id"])     # uuid v4
print(event["hash"])   # sha256, 64 hex characters

print(audit.last_hash())  # hash of the last recorded event

result = audit.verify()
print(result)
# {"valid": True, "total_events": 1, "last_hash": "..."}

Chain integrity

Each event records the hash of the previous event (previous_hash) and its own hash (hash), computed from the event's content + previous_hash. The first event in the chain has previous_hash = null.

{"id":"evt_123","timestamp":"2026-06-17T10:00:00Z","app_name":"billing-api","actor_id":"user_123","action":"DELETE_INVOICE","resource":"invoice","resource_id":"inv_987","metadata":{"ip":"192.168.0.10"},"previous_hash":null,"hash":"abc123..."}

verify() re-reads the file from scratch and recomputes everything โ€” it never trusts any in-memory cache:

result = audit.verify()

If the chain is intact:

{"valid": True, "total_events": 10, "last_hash": "..."}

If any event was edited, removed, or reordered:

{"valid": False, "total_events": 10, "error_index": 4, "reason": "hash_mismatch"}
# or "reason": "broken_chain" (removed/reordered/forged event)

FastAPI

from fastapi import FastAPI
from rust_py_audit.fastapi import AuditMiddleware

app = FastAPI()
app.add_middleware(AuditMiddleware, app_name="billing-api", file_path="./audit.jsonl")


@app.delete("/invoices/{invoice_id}")
async def delete_invoice(invoice_id: str):
    return {"deleted": invoice_id}

By default, only POST/PUT/PATCH/DELETE requests are logged. actor_id comes from the X-User-Id header (adjustable via actor_header=); falls back to "anonymous" if absent.

See the full example in examples/fastapi_app.py.


Django

# settings.py
MIDDLEWARE = [
    "rust_py_audit.django.AuditMiddleware",
    # ... other middlewares ...
]

# Optional:
RUST_PY_AUDIT_APP_NAME = "my-django-app"
RUST_PY_AUDIT_FILE_PATH = "./audit.jsonl"
RUST_PY_AUDIT_METHODS = {"POST", "PUT", "PATCH", "DELETE"}

actor_id comes from request.user.pk when there's an authenticated user (via django.contrib.auth); falls back to "anonymous" otherwise. The middleware supports both WSGI and ASGI applications automatically.

See the full example in examples/django_example/.


ImmutableLog Integration

rust_py_audit can send each audit event to ImmutableLog (documentation), in addition to โ€” or instead of โ€” writing locally.

Operating modes

mode Writes local JSONL Sends to ImmutableLog Typical use
"local" (default) โœ… โŒ The library's original behavior, no external dependency
"remote" โŒ (except for pending entries, see below) โœ… ImmutableLog is the single source of truth; a delivery failure raises an exception
"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)

mode="local" is the default โ€” existing code calling AuditLogger(app_name, file_path) keeps working unchanged.

Basic example

from rust_py_audit import AuditLogger

audit = AuditLogger(
    app_name="billing-api",
    file_path="./audit.jsonl",
    mode="hybrid",
    immutablelog_url="https://api.immutablelog.com",
    immutablelog_api_key="iml_live_xxxxx",
    timeout_ms=500,
    retry_enabled=True,
    max_retries=3,
)

event = audit.log(
    actor_id="user_123",
    action="DELETE_INVOICE",
    resource="invoice",
    resource_id="inv_987",
    metadata={"ip": "192.168.0.10", "reason": "duplicate invoice"},
)

print(event["immutablelog"])
# {"status": "delivered", "tx_id": "tx_...", "payload_hash": "...", ...}
# or {"status": "pending", "tx_id": None, ...} on a transient failure (hybrid mode)
# or {"status": "failed", "tx_id": None, ...} on a permanent failure (hybrid mode)

# Retries delivery of every event still marked "pending":
print(audit.flush_pending())
# {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}

Environment variables

mode, immutablelog_url, and immutablelog_api_key accept None (the default) to fall back to an environment variable โ€” handy for not hardcoding credentials:

export RUST_PY_AUDIT_MODE=hybrid
export IMMUTABLELOG_URL=https://api.immutablelog.com
export IMMUTABLELOG_API_KEY=iml_live_xxxxx
# Without passing mode/immutablelog_url/immutablelog_api_key explicitly,
# they come from the environment variables above:
audit = AuditLogger(app_name="billing-api", file_path="./audit.jsonl")

An explicit parameter always takes priority over the environment variable. mode="remote"/"hybrid" without immutablelog_url/immutablelog_api_key (neither as a parameter nor as an env var) raises ValueError when the AuditLogger is created โ€” failing fast instead of only on the first log() call.

Severity, immutable_trail, and env

audit.log(...) accepts two optional parameters that only affect what gets sent to ImmutableLog (they never enter the hash):

event = audit.log(
    actor_id="user_123",
    action="DELETE_INVOICE",
    resource="invoice",
    resource_id="inv_987",
    severity="error",                    # meta.type โ€” defaults to "info" if omitted
    immutable_trail="order-2026-00441",  # meta.immutable_trail โ€” groups related events
)
  • severity must be one of "error", "warning", "info", "success" โ€” any other value raises ValueError, in any mode (even "local", where severity is just stored without being used).
  • immutable_trail is sanitized automatically (trimmed, : replaced with -, truncated at 256 chars); if it ends up empty after that, the field is omitted instead of being sent broken.
  • Both are preserved locally (without affecting hash) precisely so that flush_pending() can resend later with the same original classification.
  • immutablelog_env (on the AuditLogger constructor, falling back to the IMMUTABLELOG_ENV env var) sets meta.env โ€” useful for telling staging/production apart in ImmutableLog.

FastAPI

from fastapi import FastAPI
from rust_py_audit.fastapi import AuditMiddleware

app = FastAPI()
app.add_middleware(
    AuditMiddleware,
    app_name="billing-api",
    file_path="./audit.jsonl",
    mode="hybrid",
    immutablelog_url="https://api.immutablelog.com",
    immutablelog_api_key="iml_live_xxxxx",
    immutablelog_env="production",
    trail_header="X-Audit-Trail",  # default โ€” read from the request, becomes meta.immutable_trail
)

In mode="remote"/"hybrid", the middleware computes severity automatically from the response's status_code (>=400 โ†’ "error", 300-399 โ†’ "info", 200-299 โ†’ "success"). In mode="remote", if delivery fails the middleware logs a logging.warning(...) and moves on โ€” an audit failure never takes down the actual response already computed by the application.

Django

# settings.py
MIDDLEWARE = [
    "rust_py_audit.django.AuditMiddleware",
    # ... other middlewares ...
]

RUST_PY_AUDIT_MODE = "hybrid"
RUST_PY_AUDIT_FILE_PATH = "./audit.jsonl"
RUST_PY_AUDIT_IMMUTABLELOG_URL = "https://api.immutablelog.com"
RUST_PY_AUDIT_IMMUTABLELOG_API_KEY = "iml_live_xxxxx"
RUST_PY_AUDIT_IMMUTABLELOG_ENV = "production"
RUST_PY_AUDIT_TRAIL_HEADER = "X-Audit-Trail"  # default โ€” read from the request, becomes meta.immutable_trail

Same behavior as FastAPI: severity computed from status_code, and delivery failures logged via logging.warning(...) without affecting the response.

Retry and idempotency

  • retry_enabled/max_retries control how many times a retryable failure is retried (the same Idempotency-Key is used on every attempt โ€” never creates duplicate events on ImmutableLog).
  • Retryable: 5xx and timeouts.
  • Permanent (never retried): 400, 401, 403, 429, and any other client error.
  • In mode="remote", exhausting retries (or a permanent error) raises RuntimeError.
  • In mode="hybrid", delivery never raises:
    • 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;
    • 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.

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.

Integrity guarantee

The local hash (event["hash"]) is computed before any delivery attempt and never includes the immutablelog field โ€” the remote receipt is operational metadata, attached afterward, and never invalidates verify():

audit.log(...)        # hash computed, event already recorded/chained
audit.flush_pending()  # only updates event["immutablelog"]; event["hash"] doesn't change
audit.verify()         # still valid, even after flush_pending()

API Reference

AuditLogger(app_name, file_path="./audit.jsonl", mode=None, immutablelog_url=None, immutablelog_api_key=None, timeout_ms=500, retry_enabled=True, max_retries=3, immutablelog_env=None)

Parameter Type Description
app_name str Application name, recorded on every event
file_path str Path to the JSONL file. If it already exists, the chain resumes from the last recorded hash
mode str | None "local" (default) / "remote" / "hybrid". None falls back to RUST_PY_AUDIT_MODE, and finally to "local"
immutablelog_url str | None ImmutableLog base URL. None falls back to IMMUTABLELOG_URL. Required (one way or another) in mode="remote"/"hybrid"
immutablelog_api_key str | None API key (Bearer). None falls back to IMMUTABLELOG_API_KEY. Same requirement as immutablelog_url
timeout_ms int HTTP request timeout to ImmutableLog, in milliseconds
retry_enabled bool If True, retries retryable errors (5xx, timeout) up to max_retries times
max_retries int Maximum number of retries (in addition to the initial attempt)
immutablelog_env str | None Logical environment (meta.env, e.g. "production"). None falls back to IMMUTABLELOG_ENV; if neither is set, the field is omitted

See ImmutableLog Integration for details on each mode.


audit.log(actor_id, action, resource, resource_id, metadata=None, severity=None, immutable_trail=None) โ†’ dict

Records an event and returns the full event (already with id, timestamp, hash, etc.) as a dict. severity/immutable_trail are optional and only affect delivery to ImmutableLog โ€” see Severity, immutable_trail, and env.

Event field Type Description
id str UUID v4
timestamp str RFC3339 / UTC, e.g.: 2026-06-17T10:00:00Z
app_name str Comes from the AuditLogger
actor_id str Who performed the action
action str E.g.: DELETE_INVOICE
resource str E.g.: invoice
resource_id str E.g.: inv_987
metadata dict Free-form โ€” any JSON-serializable value
previous_hash str | None Hash of the previous event in the chain
hash str SHA-256 (64 hex chars) of the event + previous_hash
severity str | absent Only present if passed to log(). Becomes meta.type on ImmutableLog
immutable_trail str | absent Only present if passed to log() (and not empty after sanitization). Becomes meta.immutable_trail
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

In mode="remote", a permanent failure or exhausted retries raise RuntimeError instead of returning the dict.


audit.verify() โ†’ dict

Re-reads the file and revalidates the entire chain from scratch. See Chain integrity. Unaffected by the immutablelog field โ€” only the hashed fields matter (see Integrity guarantee).


audit.last_hash() โ†’ str | None

Hash of the last recorded event (in-memory cache, O(1)) โ€” None if no event has been recorded yet.


audit.flush_pending() โ†’ dict

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.

{"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}

For each queued event:

  • delivered โ†’ updates event["immutablelog"] in audit.jsonl to "delivered" (without changing hash) and removes it from the queue (counts toward flushed);
  • permanent failure โ†’ marks it "failed" in audit.jsonl and removes it from the queue, so it doesn't stay stuck forever (counts toward failed);
  • retryable failure โ†’ left in the queue for the next call (counts toward still_pending).

Roadmap

rust-py-audit is mature (v0.3.0): the SHA-256 hash chain, JSONL storage, FastAPI/Django middlewares, and the ImmutableLog local/remote/hybrid integration are shipped and stable. Directional ideas under consideration (log rotation, incremental verification, more delivery backends, an async client, a Flask middleware) are tracked in ROADMAP.md.


Building from Source

Requires Rust and maturin.

git clone https://github.com/robertolima-dev/rust-py-audit
cd rust-py-audit

python3 -m venv .venv
source .venv/bin/activate
pip install maturin

# Development build (installs into the current Python environment)
maturin develop

# Release wheel
maturin build --release

Running tests

# Rust unit tests
cargo test --no-default-features

# Python integration tests
pip install -e ".[dev]"
pytest tests/

Architecture

Python API (rust_py_audit)
    โ”œโ”€โ”€ AuditLogger(...)         โ”€โ”€โ–บ src/audit_logger.rs (PyO3 #[pyclass])
    โ”‚       โ”œโ”€โ”€ log()            โ”€โ”€โ–บ src/event.rs               (AuditEvent)
    โ”‚       โ”‚                    โ”€โ”€โ–บ src/hash.rs                (deterministic SHA-256)
    โ”‚       โ”‚                    โ”€โ”€โ–บ src/storage.rs             (append/update in JSONL)
    โ”‚       โ”‚                    โ”€โ”€โ–บ src/immutablelog_client.rs (POST /v1/events, via reqwest)
    โ”‚       โ”‚                    โ”€โ”€โ–บ src/retry.rs               (retry with Idempotency-Key)
    โ”‚       โ”œโ”€โ”€ verify()         โ”€โ”€โ–บ src/verifier.rs (revalidates the local chain)
    โ”‚       โ”œโ”€โ”€ flush_pending()  โ”€โ”€โ–บ redelivers audit.pending.jsonl
    โ”‚       โ””โ”€โ”€ last_hash()      โ”€โ”€โ–บ in-memory cache
    โ”‚
    โ”œโ”€โ”€ fastapi.AuditMiddleware โ”€โ”€โ–บ audit.log() on every mutating request
    โ””โ”€โ”€ django.AuditMiddleware  โ”€โ”€โ–บ same idea, WSGI/ASGI

src/immutablelog_config.rs holds AuditMode/ImmutableLogConfig; src/immutablelog_receipt.rs defines the ImmutableLogReceipt attached to each event.

The core is compiled into a native extension (.so/.pyd) by maturin and PyO3. The Python layer is thin โ€” it just routes calls and provides the framework adapters.


License

MIT โ€” see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

rust_py_audit-0.3.1.tar.gz (82.1 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

rust_py_audit-0.3.1-cp310-abi3-win_amd64.whl (1.7 MB view details)

Uploaded CPython 3.10+Windows x86-64

rust_py_audit-0.3.1-cp310-abi3-musllinux_1_2_x86_64.whl (2.3 MB view details)

Uploaded CPython 3.10+musllinux: musl 1.2+ x86-64

rust_py_audit-0.3.1-cp310-abi3-musllinux_1_2_aarch64.whl (2.3 MB view details)

Uploaded CPython 3.10+musllinux: musl 1.2+ ARM64

rust_py_audit-0.3.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (2.1 MB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ x86-64

rust_py_audit-0.3.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (2.1 MB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ ARM64

rust_py_audit-0.3.1-cp310-abi3-macosx_11_0_arm64.whl (1.9 MB view details)

Uploaded CPython 3.10+macOS 11.0+ ARM64

rust_py_audit-0.3.1-cp310-abi3-macosx_10_12_x86_64.whl (1.9 MB view details)

Uploaded CPython 3.10+macOS 10.12+ x86-64

File details

Details for the file rust_py_audit-0.3.1.tar.gz.

File metadata

  • Download URL: rust_py_audit-0.3.1.tar.gz
  • Upload date:
  • Size: 82.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: maturin/1.14.1

File hashes

Hashes for rust_py_audit-0.3.1.tar.gz
Algorithm Hash digest
SHA256 fae8913a79ac7b15cdacd5d5a365756d63b443fef2aab850857df1cfb89c525c
MD5 24666891a6f814cef2be137e3635651c
BLAKE2b-256 bbff58381ff1c3c417d7231f778598423ecf2e6f9d59c08b7dddafd94b5d9700

See more details on using hashes here.

File details

Details for the file rust_py_audit-0.3.1-cp310-abi3-win_amd64.whl.

File metadata

File hashes

Hashes for rust_py_audit-0.3.1-cp310-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 f49e89c4aa7cf7fb3a6fe13a7a18cb44841a1fd6b4e22bcfb4aae507818f7ebb
MD5 2f01bb7a29ef015ebfffbd65fd056ee0
BLAKE2b-256 8ab298d4b4f90ac99f54d9bb9b2ee89bfc4c6952010071f04e596f9676039aa0

See more details on using hashes here.

File details

Details for the file rust_py_audit-0.3.1-cp310-abi3-musllinux_1_2_x86_64.whl.

File metadata

File hashes

Hashes for rust_py_audit-0.3.1-cp310-abi3-musllinux_1_2_x86_64.whl
Algorithm Hash digest
SHA256 4af71896bb28f8379e27be2b86b8d20acfcc7a40ffb0585a7acb6374d2a5af1a
MD5 ff5fbbd8e4657772c23fcc19e28a7a9b
BLAKE2b-256 90602eb062f4f59699d9ae11e40896ebdf51c82908ef0a5e1c9eca5abe529a8a

See more details on using hashes here.

File details

Details for the file rust_py_audit-0.3.1-cp310-abi3-musllinux_1_2_aarch64.whl.

File metadata

File hashes

Hashes for rust_py_audit-0.3.1-cp310-abi3-musllinux_1_2_aarch64.whl
Algorithm Hash digest
SHA256 d8f74c1de25550fb26b918486c80dbb8ee51deb1e3b2190476e3747b2e751065
MD5 dd7a873aee2252c4d8f53cee897223ac
BLAKE2b-256 8a5d262b40b6063d653cfd5be01652bcd89cab0fc2dec838b985d345915bff93

See more details on using hashes here.

File details

Details for the file rust_py_audit-0.3.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for rust_py_audit-0.3.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 63e67bad459a3008a97c12b18034155996ccfc36613bc4f09cfad29216366ae8
MD5 ebaf31f13bbe38c46629465d4038c22e
BLAKE2b-256 2ddbea6cf5a15fe3593ecf6632eadcb7a3cd18548402c0bd15d2d003d7cbf124

See more details on using hashes here.

File details

Details for the file rust_py_audit-0.3.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for rust_py_audit-0.3.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 13bf77f8a386d343acc5264eeda5f8eb525a0eb11a429acc90058939ad32f415
MD5 0356e78bf97c4ced3d43eac7a89c3142
BLAKE2b-256 4058444955a73a15036670d2d3f39eb4c65b142c62aba1b33fe621bf611a6054

See more details on using hashes here.

File details

Details for the file rust_py_audit-0.3.1-cp310-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for rust_py_audit-0.3.1-cp310-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 e42989b6ee2e2d9d8f5b4e0bb4f441f1f1a7507729be57d3822756c39452d2e1
MD5 3cd449e474916e93e28e6b94001b595f
BLAKE2b-256 e231cda2f9956295bad653f0c2ec9de517b22413922d9f5239097bdbeda1c2dd

See more details on using hashes here.

File details

Details for the file rust_py_audit-0.3.1-cp310-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for rust_py_audit-0.3.1-cp310-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 44b8f8cd64be41caa85ae881cc30ea25e7b0e8f8de6d1cbf5f3798497a5a94c2
MD5 028fec7c3c9cbb4e024d27e8eb943ac3
BLAKE2b-256 12093a9a01aa5bc320b975e5a1581dc758de32b4747264c598231687180f2f97

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page