DjangoPlay — audit App
audit (verbose name "Audit Trail", AppConfig.name = "audit") is DjangoPlay's system-wide, append-only observability subsystem. It is not a place other apps write to directly — it is a passive subsc...
On this page ▾
- 1. Summary
- 2. Architecture
- 2.1 audit is a subscriber, not a service other apps call
- 2.2 Two independent ways events reach audit
- 2.3 The persistence decision is dynamic, not hardcoded per-event
- 2.4 Severity resolution
- 3. Implementation Details
- 3.1 Package layout
- 3.2 Django app registration quirk
- 3.3 Dependencies
- 4. Data Model — AuditEvent
- 5. Target & Actor Labeling
- 6. Governance Layer (what actually gets written)
- Retention
- 7. Security & Permissions
- 8. Admin UI (AuditEventAdmin)
- 9. Integration Points (cross-app)
- 10. Operational Visibility
- 11. Known Gaps
- 12. Quick Reference — How to Add Audit Tracking to a New Model
Runtime dependency
webapp/core/events/andwebapp/core/execution_context/,webapp/paystream/app_settings/celery.py, andwebapp/devtools/management/commands/cleanup_audit_events.py. Verbose name: audit · Registered as"audit.apps.AicoreConfig"inINSTALLED_APPS
1. Summary
Key properties, enforced directly in code:
- Append-only / immutable:
AuditEventhas no FK relationships to domain models (fully denormalized), no soft-delete, no update path used anywhere in the codebase — onlycreate(). - Decoupled from business logic:
audit_persistence_subscriberis invoked asynchronously-in-process by the event dispatcher, wrapped in its own try/except so a persistence failure never bubbles into the transaction that triggered it, and dispatch failures are only logged (core/events/runtime/dispatching.py). - Not every model, not every event is audited — persistence is governed by an explicit policy (
core.events.policies.should_persist_event) tied to a curated allow-list of ~40 models (audit.lifecycle.registry.AUDIT_TRACKED_MODELS). - Fail-closed on bad event names: malformed event names are dropped before dispatch (
core.events.emitter.emit). - Recursion-safe: automatic model-lifecycle tracking uses a
ContextVar-based guard so that the audit write itself never re-triggerspre_save/post_saveauditing. - Governance layer for what actually gets stored: sensitive keys are redacted, values are recursively truncated/depth-limited before being written into the
metadataJSONField.
2. Architecture
2.1 audit is a subscriber, not a service other apps call
DjangoPlay's actual event architecture (verified in core/events/) is a lightweight, in-process pub/sub system, not a queue:
Business code / Django signal
│
▼
core.events.helpers.emit_event(name=..., target=..., metadata=..., category=..., severity=...)
│
▼
core.events.builders.build_event() → core.events.contracts.DomainEvent (dataclass)
│
▼
core.events.emitter.emit(event)
│ - validates event name via audit.governance.validation.validate_event_name
│ - iterates core.events.registry.get_subscribers()
▼
core.events.runtime.dispatching.safely_dispatch_event() (per-subscriber try/except + timing + slow-subscriber warning)
│
┌────┼─────────────────────┬───────────────────────────┐
▼ ▼ ▼
audit.subscribers.persistence core.events.subscribers.logging core.telemetry.subscribers
.audit_persistence_subscriber .logging_subscriber .telemetry_subscriber
│ │ │
▼ ▼ ▼
AuditEvent row (DB, if structured log line in-memory counters
policy allows persistence) (event/category/severity volume,
anomaly hook)All three subscribers are registered once, idempotently, in AuditConfig.ready() (audit/apps.py) — this is the actual wiring point, not core's own AppConfig. ready() guards against duplicate registration with both a module-level _SUBSCRIBERS_REGISTERED flag and a get_subscribers() membership check.
2.2 Two independent ways events reach audit
-
Automatic model lifecycle tracking (
audit/lifecycle/tracking.py) — global Djangopre_save/post_savereceivers (attached to every model, filtered in-handler) that:- On
pre_saveof an existing row: snapshot the "before" state if the model is inAUDIT_TRACKED_MODELS. - On
post_save: ifcreated, emit<model_name>.created; if updated, diff the before/after snapshots and — only if there's an actual field-level change — emit<model_name>.updatedwith achangesmetadata payload ({field: {before, after}}). - Soft-delete/restore are not covered by
pre_save/post_save(a soft delete is normally anUPDATE, not aDELETE) — insteadaudit/signals/lifecycle.pylistens on two custom Django signals,post_soft_deleteandpost_restore(defined inaudit/signals/events.py), which other apps' soft-delete mixins presumably fire, and emits<model>.deleted/<model>.restored.
- On
-
Manual/explicit emission for things that aren't a model save:
- Auth events (
audit/security/auth.py) — hooks Django's built-inuser_logged_in,user_logged_out,user_login_failedsignals → emitsauth.login,auth.logout,auth.login_failed(the last one atwarningseverity, capturing only the attempted username, not the password). - Impersonation (
audit/security/impersonation.py) —emit_impersonation_started/emit_impersonation_stopped, called by whatever app implements "login as user" (not owned byaudit; these are just emitter helpers for that feature to call into). - Permission changes (
audit/security/permissions.py) —emit_permission_change_event, presumably called frompolicyengine. - Admin actions (
audit/admin_events/*.py) —emit_bulk_action_event,emit_export_event,emit_admin_mutation_event; helper functions other apps' Django-admin customizations can call for bulk actions/exports/manual admin edits.
- Auth events (
2.3 The persistence decision is dynamic, not hardcoded per-event
This is the least obvious but most important architectural detail: core.events.policies.should_persist_event() does not hardcode a list of persisted event names. It:
- Rejects an exact-match denylist (
email.queued,email.sent,email.delivered,cache.refreshed,task.started,task.completed) and anything containing.email_,.task_,.cache_. - Otherwise derives a set of allowed prefixes at call time from
audit.lifecycle.registry.AUDIT_TRACKED_MODELSby lower-casing each model's class name and appending.(e.g.helpdesk.SupportTicket→supporticket.... actuallysupportticket.), plus two manually added static prefixes:auth.andadmin.. - An event is persisted only if its name starts with one of those derived prefixes.
Practical implication: adding a model to AUDIT_TRACKED_MODELS in audit/lifecycle/registry.py is both what turns on automatic diff-tracking for that model AND what makes any <modelname>.* event (from anywhere in the codebase, not just the automatic tracker) eligible for persistence. The registry is the single source of truth for both concerns.
2.4 Severity resolution
If a caller doesn't pass severity explicitly to emit_event, core.events.severity.resolve_event_severity() derives it from the suffix of the event name (created/updated/restored → info, deleted/failed/error → warning, login_failed → warning, permission_changed/payment_failed → critical, refund_issued → warning). Unrecognized suffixes default to info. This is a simple lookup table, not a rules engine — new "high-severity" event types must be added here manually or they'll silently default to info.
3. Implementation Details
3.1 Package layout
audit/
├── apps.py # AuditConfig — subscriber registration in ready()
├── constants.py # AUDIT_ADMIN_ROLES (see §7 — appears unused elsewhere)
├── exceptions.py # empty
├── models/
│ └── audit_event.py # AuditEvent — the only model in this app
├── lifecycle/ # automatic model create/update/delete/restore tracking
│ ├── constants.py # TRACKED_EVENT_* action name constants (declared, not directly referenced elsewhere)
│ ├── registry.py # AUDIT_TRACKED_MODELS allow-list + is_model_tracked()
│ ├── tracking.py # pre_save/post_save receivers
│ ├── diffing.py # calculate_diff(before, after)
│ ├── snapshots.py # capture_instance_snapshot(instance)
│ ├── serializers.py # serialize_field_value() — per-field JSON-safe coercion
│ ├── exclusions.py # AUDIT_EXCLUDED_FIELDS (updated_at, created_at, search_vector, last_login, deleted_at)
│ └── recursion.py # ContextVar re-entrancy guard
├── governance/ # what actually lands in AuditEvent.metadata
│ ├── constants.py # SENSITIVE_KEYS, size/depth limits
│ ├── pii.py # redact_sensitive_value()
│ ├── sanitization.py # sanitize_metadata() — entry point used by the persistence subscriber
│ ├── serialization.py # safe_serialize() — recursive, depth/size bounded
│ ├── truncation.py # truncate_string()
│ ├── retention.py # delete_expired_audit_events() — 365-day batched cleanup
│ └── validation.py # validate_event_name()
├── security/ # manual security-event emitters
│ ├── auth.py # login/logout/login_failed signal receivers
│ ├── impersonation.py # emit_impersonation_started/stopped()
│ └── permissions.py # emit_permission_change_event()
├── signals/
│ ├── events.py # post_soft_delete, post_restore (custom Django Signal objects)
│ └── lifecycle.py # receivers for the above → emits deleted/restored events
├── subscribers/
│ └── persistence.py # audit_persistence_subscriber — the actual DB write
├── admin_events/ # emitter helpers for Django-admin bulk/export/manual actions
│ ├── bulk_actions.py
│ ├── exports.py
│ └── mutations.py
├── admin.py # AuditEventAdmin — read-mostly changelist UI
├── tasks.py # cleanup_expired_audit_events (Celery task)
├── migrations/ # 0001–0006
└── tests.py # empty — no test coverage currently in this app3.2 Django app registration quirk
audit/apps.py contains a fully commented-out second AuditConfig class (older version, verbose name "Logging Journal", only wiring audit.signals.lifecycle) left in the file below the active class. It's dead code, not a toggle — the active class is the one Django loads. Worth cleaning up but not a functional risk.
3.3 Dependencies
audit depends on:
core.events.*— the entire pub/sub contract, dispatcher, and policy/severity resolution it relies on.core.execution_context.*— reads current-requestactor,client_ip,request_id,user_agentviaContextVars populated bycore.execution_context.middleware.ExecutionContextMiddleware(or, for management commands, viaset_management_command_context(), which stamps aSystemActorand127.0.0.1).core.telemetry.*andcore.events.subscribers.logging— siblings on the same event bus, not called directly byaudit, but registered alongside it inAuditConfig.ready().utilities.admin.*andusers.views.ui.errors.custom_403— for the admin UI only.
Nothing in audit imports business-domain apps (invoices, teamcentral, etc.) directly — the coupling runs the other way, through the AUDIT_TRACKED_MODELS string registry, which is intentionally string-based ("app_label.ModelName") precisely so audit never has to import those apps.
4. Data Model — AuditEvent
Single model, table audit_event, BigAutoField PK, default ordering -occurred_at.
| Field | Type | Notes |
|---|---|---|
id |
BigAutoField | PK |
occurred_at |
DateTimeField | indexed; defaults to timezone.now at construction time (i.e. can be backdated if a caller passes occurred_at into build_event) |
action |
CharField(64) | indexed; canonical event name, e.g. invoice.updated, auth.login_failed |
category |
CharField(100), nullable | indexed; added in migration 0006 — one of security / financial / data_governance / administrative / system (core.events.constants) |
severity |
CharField(50), nullable | indexed; added in migration 0006 — info / warning / critical |
actor_id |
CharField(64), nullable | indexed; stringified actor PK |
actor_type |
CharField(32), nullable | e.g. user, System |
actor_label |
CharField(255), nullable | human-readable (email, etc.) |
target_type |
CharField(64), nullable | indexed; app_label.ModelClassName for ORM targets |
target_id |
CharField(64), nullable | indexed |
target_label |
CharField(255), nullable | resolved via core.events.serializers.resolve_target_label (see §5) |
request_id |
CharField(64), nullable | indexed; for cross-service correlation |
client_ip |
GenericIPAddressField, nullable | |
user_agent |
TextField, nullable | |
metadata |
JSONField | default {}; sanitized/truncated before write (see §6) |
is_system_event |
BooleanField | default False — defined on the model but not actually set by the persistence subscriber (see §7, known gaps) |
Indexes beyond the per-field ones: composite (action, occurred_at) and (target_type, target_id) — clearly tuned for "show me the history of this action type over time" and "show me everything that happened to this specific record" queries, which is exactly what the admin changelist filters expose.
Meta.managed = True is explicit (slightly redundant but harmless). Two managers exist: default objects (implicit) is not overridden — the model only explicitly defines all_objects = models.Manager(), which is what the persistence subscriber and admin both use (AuditEvent.all_objects.create(...), self.model.all_objects.all()), i.e. there is no soft-delete manager filtering here; all_objects is really just "the plain manager," named defensively.
5. Target & Actor Labeling
Two label-resolution systems live in core.events, used by the persistence subscriber to fill actor_* / target_*:
-
Actor (
core.events.actors.serialize_actor): if the actor has apk(Django user), returns{id: pk, type: class name, label: str(actor)}. Otherwise falls back to duck-typedid/type/labelattributes — this is what letsSystemActor(id=1, type="System", label="System") flow through uniformly for management-command-triggered events. -
Target (
core.events.serializers.serialize_target/resolve_target_label): for ORM objects,typeisapp_label.ModelName,idis the PK, andlabelis resolved through a priority system:- If the model defines an
audit_label()method, use it. - Else look up a per-model field priority tuple in
MODEL_LABEL_PRIORITY(e.g.invoices.Invoice→ triesinvoice_numberthendescription;users.UserIdentity→full_name→email→username). - Else fall back to a generic priority list (
full_name,get_full_name,display_name,title,subject,summary,name,label,description, business identifiers,username/email,slug/code/key). - Final fallback:
str(target), then the class name if even that raises.
Field resolution supports plain attributes,
@property, and zero-arg callables transparently (safe_getattr). - If the model defines an
This is a generic, extensible system — new apps don't need to touch audit to get sensible labels; they either implement audit_label() on the model or add an entry to MODEL_LABEL_PRIORITY.
6. Governance Layer (what actually gets written)
Before persistence, event.metadata passes through audit.governance.sanitize_metadata():
- Keys are lower-cased and checked against
SENSITIVE_KEYS(password,token,access_token,refresh_token,authorization,api_key,secret,client_secret,session,sessionid,csrftoken) — matches are replaced with the literal string"[REDACTED]"(not removed, so the presence of the key is still visible). - Everything else goes through
safe_serialize(), which recursively:- Passes through primitives (
str/int/float/bool), truncating strings overMAX_METADATA_STRING_LENGTH(2000 chars,...[TRUNCATED]suffix). - Stringifies
Decimal/UUID, ISO-formats anything with.isoformat()(datetimes). - Recurses into
dict/list/tuple/setup toMAX_NESTED_DEPTH(5) andMAX_COLLECTION_ITEMS(100 items — collections beyond that are cut with a"[TRUNCATED]"sentinel entry /"__truncated__": Truekey). - Reduces any Django model instance encountered inside metadata to
{id, label}rather than serializing its fields. - Falls back to
truncate_string(str(value))for anything unrecognized.
- Passes through primitives (
This is a defense-in-depth measure against three separate failure modes: secret leakage into logs, unbounded JSONField growth from careless callers, and JSON-serialization crashes from arbitrary Python objects being passed as metadata.
Separately, field-level diffs produced by the automatic lifecycle tracker (audit.lifecycle.diffing.calculate_diff) go through a narrower, earlier-stage safety net: audit.lifecycle.exclusions.AUDIT_EXCLUDED_FIELDS (updated_at, created_at, search_vector, last_login, deleted_at) — these fields are dropped from the snapshot entirely, before diffing, so no-op timestamp churn never produces spurious .updated events. Note this exclusion list is not the same mechanism as SENSITIVE_KEYS — a tracked model's password-named field, for instance, would still be captured in the diff snapshot (via serialize_field_value, which has no redaction logic itself) and would only be redacted downstream by sanitize_metadata() once it's inside event.metadata["changes"]. In other words: redaction does happen for diffs too, but only because diffs are packaged into metadata before persistence, not because the lifecycle layer itself is secret-aware.
Retention
audit.governance.retention.delete_expired_audit_events():
-
Fixed 365-day retention (
DEFAULT_RETENTION_DAYS), not currently exposed as a Django setting — it's a hardcoded module constant. -
Deletes in batches of 5000 (
DELETE_BATCH_SIZE) via awhileloop ofSELECT ids LIMIT 5000+DELETE WHERE id IN (...), explicitly to avoid long-held table locks on what is expected to be a large table. -
Returns a single
int(total rows deleted). -
Wired to Celery:
audit.tasks.cleanup_expired_audit_events(retries up to 3 times with backoff on any exception) is scheduled daily at 02:00 viaCELERY_BEAT_SCHEDULE["cleanup-expired-audit-events"]inpaystream/app_settings/celery.py(crontab(hour=2, minute=0)). -
Also exposed as a management command:
python manage.py cleanup_audit_events(devtools/management/commands/cleanup_audit_events.py).⚠️ Bug worth flagging: the management command does
deleted_count, _ = delete_expired_audit_events()— unpacking two values — but the function actuallyreturns a singleint. As written this command will raiseTypeError: cannot unpack non-iterable int objectevery time it's run. The Celery task (audit/tasks.py) calls the same function correctly (deleted_count = delete_expired_audit_events()), so the automatic daily cleanup is unaffected — only the manual CLI entrypoint is broken.
7. Security & Permissions
- No dedicated audit permission class in this app beyond what
BaseAdminPage/Django admin's standardhas_change_permissionprovides.AuditEventAdmin.change_viewexplicitly blocks edits with a custom 403 page ifhas_change_permissionis false — this is the app's only real "immutability enforcement" in the admin UI; there's nothing at the model/manager level preventing a.save()or.delete()from code (i.e. immutability is a UI/process convention, not a database or model-layer constraint). actions = NoneonAuditEventAdmin— bulk admin actions (including bulk delete) are disabled for this model.AUDIT_ADMIN_ROLES(audit/constants.py) declares a default role set (DJGO,CEO,CFO,SSO) intended to gate who can view/manage audit data, overridable via Django setting — but a repo-wide search found no other file referencingAUDIT_ADMIN_ROLES. It's currently a defined-but-unused constant; access control for the audit admin page appears to run entirely through the generic Django admin permission system instead. Worth confirming with whoever ownspolicyengine/admin role wiring whether this was meant to gate the audit changelist and the wiring was dropped, or whether it's reserved for a not-yet-built feature.- Login/logout/login-failure auditing captures the attempted username on failure but never a password or token — consistent with the governance layer's
SENSITIVE_KEYSredaction, though in this specific path the value simply isn't collected in the first place rather than being redacted after the fact. - Impersonation start/stop are captured as first-class security events (
auth.impersonation_started/_stopped) with the impersonating actor's PK in metadata — but noteauditonly defines the emitter functions; the actual impersonation feature (session switching logic, who's allowed to impersonate whom) is owned by another app and simply calls these helpers.
8. Admin UI (AuditEventAdmin)
Registered via the shared AdminIconDecorator.register_with_icon pattern (consistent with the rest of DjangoPlay's admin, see the platform overview doc), on top of the project's BaseAdminPage base class (utilities.admin.base_admin_page) rather than plain ModelAdmin.
- List display: computed actor/event/category/severity/target/timestamp/IP display methods rather than raw fields —
actor_id,actor_label,request_id,metadataare explicitly excluded from the list view (list_display_exclude) since audit tables get huge and those are either low-signal in a list or too wide. - Filters: all major dimensions (
action,actor_type,target_type,category,severity,client_ip) are exposed as AJAX-backed filters hitting a shared endpoint (/ops/admin-filters/options/, owned bycore.admin_filters) rather than Django's default staticlist_filterdropdowns — necessary because the cardinality ofaction/target_idvalues on a system-wide log would make static filter rendering slow. - Form layout: read-oriented, grouped into
Core/Actor/Target/Request/Metadatatabs via aform_layoutsconfig consumed byBaseAdminPage(a DjangoPlay-wide declarative admin-form-layout convention, not audit-specific). - Search:
actor_label,target_label,request_id,action. auto_load_threshold = 500: the changelist template (admin/custom/audit/changelist.html) is custom and clearly designed to avoid loading the full unfiltered table by default once the row count crosses this threshold —changelist_viewexplicitly computestotal_count,filtered_count, and anauto_load_threshold_exceededflag and pushes them into the template context.- No custom
has_add_permissionoverride — the admin doesn't explicitly forbid manual row creation via/admin/audit/auditevent/add/; immutability there relies on whatever default Django permission the deploying admin role has been granted, which is a small gap relative to the "never mutated except by the system" design intent stated elsewhere in the docs.
9. Integration Points (cross-app)
| App | How it touches audit |
|---|---|
core |
Owns the entire event bus (core.events) and execution-context (core.execution_context) that audit is built on top of. audit is a consumer of core, not the other way round. |
users, teamcentral, entities, invoices, fincore, locations, industries, helpdesk, mailer, utilities |
Own models listed in AUDIT_TRACKED_MODELS — get automatic create/update diff tracking for free, with zero code in those apps beyond being listed in the registry. |
genericissuetracker (3rd-party, via helpdesk) |
Five of its models (Issue, IssueComment, IssueAttachment, Label, IssueStatusHistory) are also in the tracked registry — audit reaches into the issue-tracker integration's domain the same way it reaches into first-party apps. |
policyengine |
Presumed caller of audit.security.permissions.emit_permission_change_event (not verified by reading policyengine itself in this pass — flagged for confirmation when that app's doc is produced). |
devtools |
Owns the CLI entrypoint (cleanup_audit_events management command) for the retention job — see the bug noted in §6. |
apidocs |
Separate, parallel logging system (APIRequestLog) for raw API traffic — not part of audit and not built on the core.events bus; the two systems are adjacent but structurally independent (worth keeping distinct when reasoning about "where is X logged"). |
Celery / paystream.app_settings.celery |
Schedules the daily retention cleanup task owned by audit. |
Django admin (utilities.admin, users.views.ui.errors) |
Provides the base admin page framework and the custom 403 handler AuditEventAdmin uses to block direct edits. |
10. Operational Visibility
Runtime dispatch metrics for the whole event bus (not audit-specific, but the primary way to observe whether audit's subscriber is healthy) are exposed at:
GET /.../metrics/→core.events.runtime.views.EventRuntimeMetricsAPIView(IsAdminUser-only), returning per-subscriber last-dispatch-duration (dispatch_times, keyed by fully-qualified subscriber name e.g.audit.subscribers.persistence.audit_persistence_subscriber) and total subscriber count. Slow subscribers (≥SLOW_EVENT_THRESHOLD_SECONDS = 0.5s) are logged as warnings bycore.events.runtime.safety/dispatchingon every dispatch, not just sampled.- This is in-memory, per-process state (
EVENT_DISPATCH_TIMES = {}module-level dict) — it resets on worker/process restart and isn't aggregated across multiple app server processes, so it's a "is this specific worker's audit subscriber currently slow" signal, not a fleet-wide metric.
11. Known Gaps
- No test coverage —
audit/tests.pyis empty. Given this app underpins compliance/security auditing across ~40 tracked models, this is probably the single highest-value gap to close before treating the audit trail as trustworthy for compliance purposes.
12. Quick Reference — How to Add Audit Tracking to a New Model
Based on how every existing tracked model is wired (verified pattern, not speculative):
- Add
"app_label.ModelName"toAUDIT_TRACKED_MODELSinaudit/lifecycle/registry.py. This alone enables automaticcreated/updatedevents with field-level diffs. - If the model supports soft delete, ensure its soft-delete/restore code path fires
audit.signals.events.post_soft_delete/post_restore— these are not automatic likepre_save/post_save. - (Optional but recommended) Add an
audit_label()method to the model, or aMODEL_LABEL_PRIORITYentry incore/events/serializers.py, so audit log rows display something more useful than a raw fallback string. - Nothing else is required — persistence eligibility is derived automatically from step 1 (see §2.3), and metadata sanitization/truncation happens for free.