PostgreSQL adapters for audit-framework — append-only AuditStore, NotificationStore, and a LISTEN/NOTIFY EventBus.
Project description
audit-framework-postgres
The PostgreSQL adapters for audit-framework, three
implementations sharing one database:
| Adapter | Port | Role |
|---|---|---|
PostgresAuditStore |
AuditStore |
append-only, queryable system of record for the audit log (vs. the best-effort ExternalSink fan-out) |
PostgresNotificationStore |
NotificationStore |
the mutable notification delivery lifecycle (pending → delivered → read / failed) |
PgListenNotifyBus |
EventBus |
cross-context bus on LISTEN/NOTIFY (e.g. audit → case-management escalation.requested) without Redis/NATS |
Ships the audit_log schema (compact LISTEN/NOTIFY trigger + append-only
guards) and the notifications schema.
Install
pip install audit-framework-postgres # bring your own executor
pip install audit-framework-postgres[asyncpg] # + asyncpg for a real pool
Use
import asyncpg
from audit_framework_postgres import PostgresAuditStore, apply_schema
from audit_framework.core.middlewares.store import StoreMiddleware
pool = await asyncpg.create_pool(dsn, min_size=5, max_size=20)
await apply_schema(pool, app_role="app_user") # create table + triggers (run once, as a privileged role)
store = PostgresAuditStore(pool) # the asyncpg pool *is* the executor
pipeline.use(StoreMiddleware(store)) # now events are persisted authoritatively
Query it back (this is what backs an admin audit-log view — sinks can't do this):
await store.query({"actor_id": "alice", "action": "DELETE", "from": "2026-06-01", "to": "2026-07-01"},
offset=0, limit=50)
await store.get_by_resource("contract", "c-42")
Notifications + event bus
from audit_framework_postgres import (
PostgresNotificationStore, PgListenNotifyBus, apply_notifications_schema,
)
await apply_notifications_schema(pool) # create the notifications table (run once)
notifications = PostgresNotificationStore(pool) # mutable delivery lifecycle
await notifications.get_unread("alice") # initial UI load (not read, not failed)
# The bus needs a *dedicated* asyncpg connection (LISTEN holds it open), not the pool:
conn = await asyncpg.connect(dsn)
bus = PgListenNotifyBus(conn, max_queue=1000) # per-subscription buffer (bounded)
sub = await bus.subscribe("escalation.requested")
msg = await sub.receive(timeout=30) # None on timeout; raises once closed
# sub.dropped counts messages evicted when a slow consumer overflows the buffer
publish() raises NotifyPayloadTooLargeError (before the DB call) if the
serialised payload would reach Postgres's NOTIFY size limit — the backend rejects
payloads of 8000 bytes or more, so keep bus payloads to identifiers/ids and
let the subscriber fetch the full row by id (the same trade the audit_log
NOTIFY trigger makes). Each subscription's buffer is bounded (max_queue ≥ 1;
default 1000); when a slow consumer overflows it the oldest message is evicted,
logged once, and counted in sub.dropped rather than growing without limit.
All three register under the postgres provider name via the
audit_framework.plugins entry point (audit_store, notification_store,
event_bus), so they're discoverable through the registry.
No hard driver dependency
All DB access goes through an injected Executor — anything exposing
fetchval / fetch / execute (an asyncpg pool or connection satisfies this
structurally). So the SQL logic is fully unit-testable without a database
(50 stdlib-only tests use a fake executor/connection), and you control pooling.
The PgListenNotifyBus likewise depends only on a ListenConnection
(execute / add_listener / remove_listener), satisfied structurally by a
dedicated asyncpg connection.
Schema design (validated against PostgreSQL guidance)
schema_sql() / apply_schema() emit:
- an append-only
audit_logtable —REVOKE UPDATE, DELETEfrom the app role and aBEFORE UPDATE OR DELETEguard trigger that raises, so the log is immutable even for the table owner (only a superuser can bypass it). Run the app under an unprivileged, non-owning role that can onlyINSERT/SELECT; - a compact
AFTER INSERTpg_notifytrigger on channelaudit_eventsthat sends identifiers only — never the unboundedchangesdiff — so every payload stays well under PostgreSQL's hard 8000-byteNOTIFYlimit. ALISTENconsumer fetches the full row by id when it needs detail (this is also the upgrade path to the multi-worker event bus, AD-6); request_id/ip_addressasTEXT(notUUID/INET), because the event model treats them as free-form strings and redaction can replaceip_addresswith a mask.
Tamper-evidence:
REVOKE+ guards make the log append-only, but a superuser can still rewrite history. For tamper-detection, add per-row hash chaining (prev_hash/hash) on top — a natural follow-up.
notifications_schema_sql() / apply_notifications_schema() emit the
notifications table. It is intentionally not append-only — a
notification has a mutable lifecycle (pending → delivered → read / failed),
so there is no REVOKE/guard trigger. created_at / delivered_at are TEXT
(not TIMESTAMPTZ) so the store round-trips the event model's free-form ISO-8601
strings without a custom asyncpg timestamptz codec; ISO-8601 UTC strings sort
chronologically for the inbox (newest-first) and pending-worker (oldest-first)
read paths.
SQL-injection posture
The table name is validated against a strict identifier whitelist at
construction; every value is a bound $n parameter; query filters are mapped
through a fixed column allow-list (unknown keys raise ValueError). The only
interpolation is the validated table name (Postgres can't bind an identifier as
a parameter).
The whitelist accepts a bare or schema-qualified name (audit_log or
reporting.audit_log) — exactly the set the schema_sql() /
notifications_schema_sql() DDL generators also accept, so any table a store
can target is one the package can also create. For a schema-qualified table the
generated DDL is self-contained: it emits a leading CREATE SCHEMA IF NOT EXISTS, references the table qualified, derives the index/trigger names from
the bare table part (Postgres forbids schema-qualifying those), and places the
trigger functions in the table's schema.
Development
pip install -e ".[dev]"
pytest # 50 stdlib-only tests (fake executor/connection; no database needed)
A live-DB integration test (real asyncpg + a throwaway Postgres) is the
recommended next layer; the unit suite already pins the generated SQL, the
parameter binding, the append-only DDL, and the compact-notify payload.
License
MIT
For AI agents & coding assistants
This package ships its agent guide — AGENTS.md — inside the
wheel (installed at <site-packages>/audit_framework_postgres/AGENTS.md). Read it offline, with no
docs site and no network, even from an airgapped Nexus PyPI mirror:
python -m audit_framework_postgres
python -c "import audit_framework_postgres; print(audit_framework_postgres.overview())"
AGENTS.md is the single source of truth: mental model, one runnable quickstart,
and the exact public API. audit_framework_postgres.overview() returns it, and tests/guide_test.py
compiles its examples so the guide can't drift from the code.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file audit_framework_postgres-0.1.2.tar.gz.
File metadata
- Download URL: audit_framework_postgres-0.1.2.tar.gz
- Upload date:
- Size: 23.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fe3f69e3e3eb122e73cc4e3bbc548f6e219e8130cb2bb0bcfcc7ef2301e7d821
|
|
| MD5 |
9887f7030a09ab4dcfbfb00307acb364
|
|
| BLAKE2b-256 |
84b67956d63c536d682acde24db23ea7302f190f65dbf78453884b0bd32c8269
|
Provenance
The following attestation bundles were made for audit_framework_postgres-0.1.2.tar.gz:
Publisher:
release.yml on vanmarkic/audit-logger
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
audit_framework_postgres-0.1.2.tar.gz -
Subject digest:
fe3f69e3e3eb122e73cc4e3bbc548f6e219e8130cb2bb0bcfcc7ef2301e7d821 - Sigstore transparency entry: 2060482792
- Sigstore integration time:
-
Permalink:
vanmarkic/audit-logger@3ac6823128c64ab93b2487aac60a0315824964f9 -
Branch / Tag:
refs/tags/audit-framework-postgres-v0.1.2 - Owner: https://github.com/vanmarkic
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3ac6823128c64ab93b2487aac60a0315824964f9 -
Trigger Event:
push
-
Statement type:
File details
Details for the file audit_framework_postgres-0.1.2-py3-none-any.whl.
File metadata
- Download URL: audit_framework_postgres-0.1.2-py3-none-any.whl
- Upload date:
- Size: 22.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c7a3523e147aa350453ece5a1df10a72d8c503761a55363df0358bde2d99a438
|
|
| MD5 |
8a7aefe9873eb0ab4d3082c4a662e8ac
|
|
| BLAKE2b-256 |
caa45465c24b68295c2c1be90fe53a7fc77861c3019b2760e0ee445962adfcc5
|
Provenance
The following attestation bundles were made for audit_framework_postgres-0.1.2-py3-none-any.whl:
Publisher:
release.yml on vanmarkic/audit-logger
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
audit_framework_postgres-0.1.2-py3-none-any.whl -
Subject digest:
c7a3523e147aa350453ece5a1df10a72d8c503761a55363df0358bde2d99a438 - Sigstore transparency entry: 2060483234
- Sigstore integration time:
-
Permalink:
vanmarkic/audit-logger@3ac6823128c64ab93b2487aac60a0315824964f9 -
Branch / Tag:
refs/tags/audit-framework-postgres-v0.1.2 - Owner: https://github.com/vanmarkic
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3ac6823128c64ab93b2487aac60a0315824964f9 -
Trigger Event:
push
-
Statement type: