Filter concepts by levelShowing all levels.

Django · Section 85

Auditing

Level
intermediate
Read
26 min
Concepts
3

The section's own fenced block names three things people call logging, and the first job is to stop treating them as one. Application logs are written for engineers debugging this week: enormous volume, retention in days, and losing some under load is acceptable. Audit logs are written for people who are not engineers — compliance, support, a security reviewer — reading months or years later, always asking the same shape of question: which human, which action, which object, when, from where. That audience makes them append-only (a trail application code can edit proves nothing) and makes completeness matter (a gap and a cover-up are indistinguishable afterwards). Business event history is different again: it is part of the domain rather than an observation of it, because "cancelled on the 3rd, refunded on the 5th" is something the product itself renders and reasons about. Most projects should start smaller than a full audit system, with the roadmap's own first items — `created_at`/`updated_at` and `created_by`/`updated_by` — but they come with a sharp limitation: `auto_now` is applied during `save()`, and `QuerySet.update()` runs no `save()` methods and emits no `pre_save`/`post_save` signals. So the bulk path leaves timestamps stale *and* skips any signal-based auditing, which puts the hole exactly where the largest and least-reviewed changes happen. Write the audit row in the same transaction as the change it describes, and store a diff rather than a snapshot — a full before-and-after copy duplicates data you already have and drags personal data into the store with your longest retention. The second half is which events earn a row at all. Four categories: security events, including the failed logins that make a successful one meaningful; administrative actions, because staff acting on a customer's data is legitimate and must still be attributable — with impersonation recorded explicitly, or the log says the customer did it themselves; payment events, every state change with its amount and provider reference; and data access logging, the expensive one. Reads outnumber writes by orders of magnitude, so recording all of them can grow the audit table faster than the data it describes. Scope it instead to genuinely sensitive records, and treat bulk access as its own event — one person reading one record is normal, and one person reading ten thousand is the thing you wanted to detect.

What is true here

  1. Application log, audit log and domain event history have three different audiences and lifetimes.
  2. update() bypasses save(), auto_now and signals — the bulk path is the audit blind spot.
  3. The audit row belongs in the same transaction as the change, and holds a diff, not a copy.
  4. Append-only is enforced by permissions, not by intention.
  5. Security, administrative, payment — and read logging only where it is worth the volume.

What you will be able to do

  • Choose the right store for a record instead of putting everything in logs
  • Build auditing that does not silently miss bulk operations
  • Keep personal data out of your longest-retention table
  • Reconstruct an account compromise from audit rows alone
Where each kind of record lives, and how long it survives

Application logs — days

engineers, debugging now; high volume, droppable under load

Traces and metrics — weeks

the same audience, aggregated; joined to the rest by request_id

Business event history — the object's lifetime

part of the domain: the product queries and renders it

Audit log — years, append-only

actor, action, target, time, source — and a diff, never a snapshot

Retention policy

the only thing permitted to delete audit rows, on a documented schedule

  1. Application logs — days — engineers, debugging now; high volume, droppable under load
  2. Traces and metrics — weeks — the same audience, aggregated; joined to the rest by request_id
  3. Business event history — the object's lifetime — part of the domain: the product queries and renders it
  4. Audit log — years, append-only — actor, action, target, time, source — and a diff, never a snapshot
  5. Retention policy — the only thing permitted to delete audit rows, on a documented schedule

Four fields, and where they stop

Timestamps and actors for almost nothing — and the question that proves they are not enough.

Timestamps and actors — the cheapest auditing there is

corebeginner

Before any audit system, add four fields: `created_at`, `updated_at`, `created_by`, `updated_by`. They cost almost nothing and answer a surprising share of real questions — when did this appear, when did it last change, and who touched it. What they cannot answer is *history*: `updated_by` holds only the most recent actor, so the previous five edits are gone. That is the gap a state-change history table fills, and knowing exactly where these four fields stop is what tells you when you need one.

Think of it as

Think of it as three tiers you climb only as far as the questions require. The first tier is timestamps. `auto_now_add=True` sets a field once on creation and `auto_now=True` updates it on every save, which covers "when" for free. The catch is in the word *save*: both are applied during `Model.save()`, so any path that bypasses `save()` bypasses them — and `QuerySet.update()` does exactly that, running one SQL statement and, as Django's documentation states, running no `save()` methods and emitting no `pre_save`/`post_save` signals. So a bulk update leaves `updated_at` showing a date before the change it made, which is worse than having no field at all because it looks authoritative. When you use `update()` deliberately, set the timestamp in the same call. The second tier is actors, and the obstacle is structural: a model has no idea who is logged in, because the ORM has no access to the request. The two honest options are to pass the actor explicitly into a service function — verbose, and completely clear about where the value came from — or to put the current user in a `ContextVar` set by middleware and read it in `save()`, which is convenient and has the well-known failure that anything running outside a request (a management command, a Celery task, a migration) has no user, so the field must be nullable and the code must cope. Prefer explicit passing for anything that matters. The third tier is history, and the trigger for climbing to it is precise: you need it when a question involves *more than one* change. "Who set the price to £49?" is answerable from `updated_by` only if nobody has touched the row since. "How long was this order in processing?", "who approved it before it was rejected?", "what did this look like in March?" are all unanswerable from a current-state field by construction, no matter how many actor columns you add. A row per change — with the previous value, the new value, the actor and the time — answers all of them, and once it exists the four fields become a convenient cache of the latest row rather than the record itself.

python
created_at = models.DateTimeField(auto_now_add=True)   # set on save(), not on update()

What we're doing: A base model giving every table timestamps and actors, with the actor supplied explicitly and the bulk path handled honestly.

core/models.pypython
class TimeStampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
    updated_at = models.DateTimeField(auto_now=True)

    # Nullable, and deliberately so: a management command, a Celery task and
    # a data migration all write rows, and none of them has a logged-in user.
    created_by = models.ForeignKey(
        "auth.User", null=True, blank=True, on_delete=models.SET_NULL,
        related_name="+", editable=False,
    )
    updated_by = models.ForeignKey(
        "auth.User", null=True, blank=True, on_delete=models.SET_NULL,
        related_name="+", editable=False,
    )

    class Meta:
        abstract = True

    def save(self, *args, actor=None, **kwargs):
        # Explicit beats implicit here. A ContextVar set by middleware is
        # more convenient and hides where the value came from — which
        # matters when the value is "who did this".
        if actor is not None:
            if self._state.adding:
                self.created_by = actor
            self.updated_by = actor
            if "update_fields" in kwargs and kwargs["update_fields"] is not None:
                kwargs["update_fields"] = {*kwargs["update_fields"], "updated_by"}
        super().save(*args, **kwargs)


def cancel_stale_drafts(cutoff, *, actor):
    # update() skips save(), so auto_now does NOT fire and updated_by is not
    # set. Both have to be written explicitly, or the row will claim it was
    # last changed before the change that just happened.
    return Order.objects.filter(status="draft", created_at__lt=cutoff).update(
        status="cancelled",
        updated_at=timezone.now(),
        updated_by=actor,
    )
7–10
`null=True` is not laziness. Anything running outside a request has no user, and a non-nullable actor field forces every command to invent a fake one — which is worse than an honest `NULL`.
9
`editable=False` keeps these out of `ModelForm`s and the admin, so nobody can set "who did this" from a form.
20–22
Passing the actor is more typing than a `ContextVar` and it makes the provenance of the value visible at the call site, which is the point for a field whose whole purpose is attribution.
27–28
A `save(update_fields=[…])` that omits `updated_by` writes the other fields and silently drops the actor — so the set has to be widened here.
35–39
The bulk path, done honestly. Because `update()` never calls `save()`, both the timestamp and the actor are set by hand; omitting them leaves rows whose `updated_at` predates their own last change.

Why this works: Every table gets when-and-who for two fields of cost, values written outside a request are honestly `NULL` rather than fabricated, and the bulk path does not leave timestamps lying.

Reading the current user from a global in `save()`

Wrong

python
CURRENT_USER = None            # module level: shared by every thread

def save(self, *args, **kwargs):
    self.updated_by = CURRENT_USER   # request B's user, on request A's row

Better

python
def save(self, *args, actor=None, **kwargs):
    if actor is not None:
        self.updated_by = actor
# or a contextvars.ContextVar, set by middleware and cleared in a finally

What you see: Under concurrent load, rows are occasionally attributed to the wrong user — and the audit trail is confidently, unreproducibly wrong.

Why: A module-level variable is shared by every thread in the process, so two overlapping requests write to the same slot and the second one wins for both. The result is misattribution: an audit field that says user A changed a row user B changed. It cannot be reproduced in single-threaded development, and because the field looks populated nobody suspects it. A `ContextVar` is isolated per thread and per async task; passing the actor explicitly avoids the question entirely.

Three tiers — climb only as far as your questions require

Nothing

you cannot even say when the row appeared

created_at / updated_at

two fields, near-zero cost — but stale after any update()

+ created_by / updated_by

the last actor only; nullable, because commands and tasks have no user

+ a state-change history row

from, to, actor, when — the first tier that survives a second edit

+ an append-only audit log

a different audience and a different retention — see the next concept

  1. Nothing — you cannot even say when the row appeared
  2. created_at / updated_at — two fields, near-zero cost — but stale after any update()
  3. + created_by / updated_by — the last actor only; nullable, because commands and tasks have no user
  4. + a state-change history row — from, to, actor, when — the first tier that survives a second edit
  5. + an append-only audit log — a different audience and a different retention — see the next concept

What each tier can answer — and where it stops

What each tier can answer — and where it stops
QuestionTimestamps+ actors+ history
when was this created?yesyesyes
when did it last change?yesyesyes
who created it?noyesyes
who made the *last* change?noyesyes
who changed the price to £49?noonly if nobody touched it sinceyes
how long was it in `processing`?nonoyes
what did it look like in March?nonoyes

Together

python
class TimeStampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True

Which write paths keep the fields honest

Which write paths keep the fields honest
Write path`auto_now` fires?Signals fire?
`obj.save()`yesyes
`obj.save(update_fields=[…])`yes — include the fieldyes
`Model.objects.create(...)`yesyes
`QuerySet.update(...)`**no****no**
`bulk_create()` / `bulk_update()`no — `save()` is skippedno
a raw SQL `UPDATE`nono

Together

python
Order.objects.filter(status="draft").update(
    status="cancelled", updated_at=timezone.now()   # set it yourself
)

Remember: Four fields answer a lot for almost nothing — but know exactly where they stop. `auto_now`/`auto_now_add` fire in `save()`, so `QuerySet.update()`, `bulk_create` and raw SQL all leave them stale; set them by hand on those paths, or rows will claim they were last changed before the change that just happened. Actor fields must be nullable, because commands, tasks and migrations have no user, and should be passed explicitly rather than read from a global — a module-level current user misattributes rows under concurrency. And `updated_by` holds one actor: any question spanning two edits needs a history row per change.

See also: three kinds of record · what actually needs auditing · idempotency events and history

Advertisement

Three records, not one

The section's own distinction, plus the timestamp and actor fields most projects start with.

Three kinds of record, and why they are not interchangeable

coreintermediate

The roadmap names three things that people call "logging" and treats them as distinct, because they are. **Application logs** are for engineers debugging behaviour — high volume, short retention, disposable. **Audit logs** answer "who did what, to what, when" for someone who is not an engineer — compliance, a customer dispute, a security review — so they are append-only and kept for years. **Business event history** is the record of what happened to a domain object: the states an order moved through. Mixing them gives you one store that serves none of the three well.

Think of it as

Sort them by who asks the question and how long the answer must survive, and the differences become concrete rather than philosophical. An application log is read by you, this week, while something is broken; volume is enormous, precision about *who* is often unnecessary, and thirty days of retention is usually generous. An audit log is read months or years later by a compliance officer, a customer's lawyer, or an incident reviewer, and the questions are always the same shape: which human, which action, which object, when, and from where. That audience changes the engineering. It must be append-only, because an audit trail that can be edited proves nothing; it must be complete for the actions it covers, because a gap is indistinguishable from a cover-up; and it cannot live only in a log-shipping pipeline that drops messages under load. Business event history is different again: it is *part of the domain*, not an observation of it. "This order was cancelled on the 3rd, refunded on the 5th" is something the product itself needs — to render a timeline, to compute a refund window, to answer a support question — so it belongs in your database, is queried by ordinary application code, and outlives any logging decision. The starting point most projects actually need is smaller than a full audit system, and it is the roadmap's first two items: `created_at`/`updated_at`, and `created_by`/`updated_by`. Those cost almost nothing and answer a surprising share of real questions. They also have a specific limitation worth knowing before relying on them: `auto_now`/`auto_now_add` are applied during `save()`, and `QuerySet.update()` bypasses `save()` entirely — so a bulk update leaves `updated_at` stale, and any audit built on model signals misses the same writes. The last distinction is what an audit row should store. Storing a full before-and-after copy of the object is tempting and usually wrong: it duplicates data you already have, and it copies personal data into a store you keep for seven years. Store the *diff* — which fields changed, and to what — and for sensitive fields store only that they changed.

python
updated_at = models.DateTimeField(auto_now=True)   # set on save(), NOT on update()

What we're doing: Write the audit row in the same transaction as the change it describes, recording a diff rather than a snapshot, and keeping sensitive fields out of a store kept for years.

audit/services.pypython
SENSITIVE = {"password", "tax_id", "bank_account", "date_of_birth"}


def audited_update(instance, *, actor, action, request=None, **fields):
    """Apply a change and record it atomically. If the audit write fails,
    the change is rolled back too — a change with no audit row is exactly
    the gap an audit trail exists to make impossible."""

    before = {f: getattr(instance, f) for f in fields}

    with transaction.atomic():
        for field, value in fields.items():
            setattr(instance, field, value)
        # save(), not update(): auto_now only fires on save(), and update()
        # emits no signals either, so a bulk path would skip the audit.
        instance.save(update_fields=[*fields, "updated_at"])

        changes = {}
        for field, new_value in fields.items():
            if before[field] == new_value:
                continue                       # unchanged fields are noise
            if field in SENSITIVE:
                # The FACT of the change, never the values. This row will
                # still exist in seven years.
                changes[field] = ["redacted", "redacted"]
            else:
                changes[field] = [str(before[field]), str(new_value)]

        if not changes:
            return instance                    # a no-op is not an event

        AuditEntry.objects.create(
            actor=actor,
            action=action,
            target_type=instance.__class__.__name__,
            target_id=str(instance.pk),
            changes=changes,
            ip=client_ip(request) if request else "",
            user_agent=(request.META.get("HTTP_USER_AGENT", "")[:200] if request else ""),
            request_id=getattr(request, "request_id", ""),
            occurred_at=timezone.now(),
        )

    return instance
11–12
The change and its audit row share one transaction. Writing the audit afterwards means a crash in between produces a change nobody can account for — which is the one outcome an audit trail exists to prevent.
15–16
`save()` rather than `update()`. `auto_now` is applied during `save()`, and `update()` emits no `pre_save`/`post_save` signals — so the fast path silently skips both the timestamp and any signal-based auditing.
22–25
Sensitive fields record that they changed and nothing more. An audit store has the longest retention in the system, so it is the worst place to accumulate personal data.
29–30
A save that changed nothing is not an event. Recording it fills the trail with noise and makes the real entries harder to find.
41–42
IP, user agent and request id are what turn an entry into an investigation. The request id joins it to the application logs and traces for the same request.

Why this works: No change can exist without its audit row, the row holds a diff rather than a duplicate, sensitive values never enter long-term storage, and every entry can be correlated back to the request that caused it.

Auditing with `post_save` and then using `update()`

Wrong

python
@receiver(post_save, sender=Order)
def audit(sender, instance, **kwargs): ...

Order.objects.filter(status="paid").update(status="archived")
# no signal, no audit row, and updated_at is stale too

Better

python
for order in Order.objects.filter(status="paid"):
    audited_update(order, actor=actor, action="order.archived", status="archived")
# or write the audit rows explicitly alongside the bulk update

What you see: The audit trail is complete for everything done through the admin and the UI, and silently missing every change made by a management command or a bulk operation.

Why: `QuerySet.update()` "doesn't run any `save()` methods, or emit the `pre_save` or `post_save` signals" — it issues one SQL statement. Any auditing built on signals therefore has a hole exactly where bulk changes happen, which is where the largest and least-reviewed changes are. The gap is invisible: the audit table looks healthy, because everything a person did by hand is in it. Auditing explicitly at the service layer has no such blind spot, and if a bulk update genuinely is required for performance, the audit rows have to be written deliberately alongside it.

One refund, recorded three ways — and what each one can answer

An application log line

  • +Written for whoever is debugging this week
  • +Retention measured in days
  • +May be dropped under load, and that is acceptable
  • +Answers: "why did the refund call fail?"
  • +Cannot answer: "who authorised this in March?"

An audit row and a domain event

  • Written for compliance, support and security
  • Retention measured in years, by policy
  • Append-only, in the database, inside the transaction
  • Answers: "who authorised this, from where, when?"
  • And the event answers: "what happened to this order?"
  • An application log line
    • Written for whoever is debugging this week
    • Retention measured in days
    • May be dropped under load, and that is acceptable
    • Answers: "why did the refund call fail?"
    • Cannot answer: "who authorised this in March?"
  • An audit row and a domain event
    • Written for compliance, support and security
    • Retention measured in years, by policy
    • Append-only, in the database, inside the transaction
    • Answers: "who authorised this, from where, when?"
    • And the event answers: "what happened to this order?"

The three, side by side

The three, side by side
PropertyApplication logAudit logBusiness event history
read byengineers, debuggingcompliance, support, securitythe product, and users
answers"why did this break?""who did this, and when?""what happened to this order?"
lives ina log pipelinea database tablea database table
retentiondays to weeksyears, by policythe object's lifetime
mutable?irrelevant — it is discarded**append-only**append-only in practice
may lose entries?yes, acceptableno — a gap looks like a cover-upno
containsanything usefulactor, action, target, time, sourcedomain states and transitions

Together

python
log.info("refund issued", extra={"order_id": o.id})   # application log
AuditEntry.objects.create(actor=user, action="order.refunded", target=o)
OrderEvent.objects.create(order=o, kind="refunded", amount=o.total)

What to record, from cheapest to most complete

What to record, from cheapest to most complete
LevelGives youCost
`created_at` / `updated_at`when, roughlytwo fields — and stale after `update()`
`created_by` / `updated_by`the last actor onlytwo more fields; history still lost
an append-only audit tableevery action, forevera write per action, and a retention policy
a domain event tablethe object's own timelinemodelling work; but the product wants it anyway
full before/after snapshotseverythingduplicated data, and personal data kept for years

Together

python
# The diff, not the object.
changes = {f: [str(old[f]), str(new[f])] for f in changed if f not in SENSITIVE}
changes.update({f: ["redacted", "redacted"] for f in changed & SENSITIVE})

Remember: Three records, three audiences. Application logs are for engineers this week and may be dropped; audit logs are for non-engineers years later and must be append-only and complete; business event history is part of the domain and the product queries it directly. Start with `created_at`/`updated_at` and `created_by`/`updated_by` — but know `auto_now` only fires on `save()`, and `QuerySet.update()` emits no signals, so signal-based auditing has a hole exactly where bulk changes happen. Write the audit row in the same transaction as the change, store the diff rather than a snapshot, and redact sensitive fields — the audit store has your longest retention.

See also: what actually needs auditing · json logs and context fields · per tenant limits configuration and audit

Advertisement

What earns a row

Security, administrative, payment — and the read logging that has to be scoped rather than assumed.

What actually needs auditing — and what data access logging costs

standardintermediate

Four categories earn an audit row rather than a log line. **Security events**: sign-in, sign-out, failed attempts, password and email changes, permission grants, token issue and revocation. **Administrative actions**: anything staff do to somebody else's data. **Payment events**: every state change to money. **Data access logging**: recording *reads*, not just writes — which is the expensive one, and the only one where "audit everything" is usually the wrong answer.

Think of it as

Ask what question the row will be used to answer, and audit the events that appear in those questions. Three questions recur. "Was this account compromised?" needs the security events, and specifically needs the *failed* attempts too — a successful login is unremarkable on its own and becomes evidence when it follows forty failures from a new country. "Did staff do something they should not have?" needs administrative actions, which is a different category from ordinary user activity because the actor is not the data subject; support looking at a customer's record is a legitimate action that must still be attributable. Django already gives you part of this for free — `user_logged_in`, `user_logged_out` and `user_login_failed` signals, and `django.contrib.admin`'s own `LogEntry` for additions, changes and deletions made through the admin — and the mistake is assuming that coverage extends to your own staff tooling, which it does not. "Where did this money go?" needs payment events, and money is the category where the audit trail is not optional at any scale: every transition, with the provider reference and the amount, because reconciliation and disputes both depend on it. Data access logging is genuinely different and deserves an explicit decision rather than a default. Recording every read is what lets you answer "who looked at this patient record?", and in regulated contexts that is a requirement. Everywhere else it is expensive in a way that compounds: reads outnumber writes by orders of magnitude, so the audit table can grow faster than the data it describes, and a synchronous write on every read adds latency to your hottest path. The workable middle is to scope it — log access to the specific data that warrants it (a customer's payment details, a patient record, an export of many records), not to every page — and to treat *bulk* access differently from single-record access, because one person reading one record is normal and one person reading ten thousand is the thing you actually want to detect.

python
@receiver(user_login_failed)   # the signal most systems forget to audit

What we're doing: Audit the security events Django already announces, and record staff impersonation so a support action is never attributed to the customer.

audit/receivers.pypython
from django.contrib.auth.signals import (
    user_logged_in, user_logged_out, user_login_failed,
)
from django.dispatch import receiver


@receiver(user_login_failed)
def on_login_failed(sender, credentials, request=None, **kwargs):
    # `credentials` is the dict that was submitted. Django masks the password
    # in its own logging, and this row must never carry it either.
    record(request, action="auth.login_failed", actor=None,
           target_id=str(credentials.get("username", ""))[:150])


@receiver(user_logged_in)
def on_logged_in(sender, user, request=None, **kwargs):
    # Rotate the session key on login elsewhere; here, just record the fact.
    record(request, action="auth.login_succeeded", actor=user,
           target_id=str(user.pk))


@receiver(user_logged_out)
def on_logged_out(sender, user, request=None, **kwargs):
    if user is not None:            # logout() may be called with no user
        record(request, action="auth.logout", actor=user, target_id=str(user.pk))


def start_impersonation(request, target_user):
    """Support viewing a customer's account. Both identities are recorded,
    or every subsequent action reads as the customer's own."""
    record(request, action="admin.impersonation_started",
           actor=request.user, target_id=str(target_user.pk))
    request.session["impersonator_id"] = request.user.pk
    request.session["impersonating_id"] = target_user.pk
9–12
The submitted credentials dict contains the password attempt. Recording the username alone — truncated to the field length — is the whole point; a failed-login audit that stores the password is worse than none.
12
`actor=None` because nobody authenticated. The identity here is a *claim* about who someone tried to be, which is why it goes in the target field rather than the actor field.
17
A successful login is only meaningful next to the failures. Auditing one without the other is why "was this compromised?" so often cannot be answered.
31–33
The impersonation row is written before the session flag is set, so even a crash between the two lines leaves evidence that support started looking.

Why this works: The signals Django already emits become an audit trail, a brute-force attempt is visible next to the login that followed it, and support activity stays attributable to support.

An account compromise, reconstructed from audit rows alone
  1. 02:14

    41 × auth.login_failed

    same username, one IP in a country the account has never used

  2. 02:15

    auth.login_succeeded

    same IP — unremarkable on its own; damning after the line above

  3. 02:16

    auth.email_changed

    the recovery address is moved first, so the real owner cannot reset

  4. 02:16

    auth.mfa_disabled

    the second factor removed while the session is still valid

  5. 02:19

    data.bulk_access

    8,400 customer records read in one query — the read that mattered

  6. 02:23

    payment.payout_created

    new bank details, entered four minutes earlier

  7. 09:40

    admin.impersonation_started

    support investigating — recorded as support, not as the customer

  1. 02:14: 41 × auth.login_failed — same username, one IP in a country the account has never used
  2. 02:15: auth.login_succeeded — same IP — unremarkable on its own; damning after the line above
  3. 02:16: auth.email_changed — the recovery address is moved first, so the real owner cannot reset
  4. 02:16: auth.mfa_disabled — the second factor removed while the session is still valid
  5. 02:19: data.bulk_access — 8,400 customer records read in one query — the read that mattered
  6. 02:23: payment.payout_created — new bank details, entered four minutes earlier
  7. 09:40: admin.impersonation_started — support investigating — recorded as support, not as the customer

The four categories, and the question each one answers

The four categories, and the question each one answers
CategoryRecordAnswers
**Security**login, logout, failed login, password/email change, MFA, token issue+revoke"was this account compromised?"
**Administrative**staff action, target user, impersonation start/end, permission grants"did staff do something they should not have?"
**Payment**every state change, amount, currency, provider reference"where did this money go?"
**Data access**reads of sensitive records; bulk reads especially"who looked at this?"
(not audited)page views, ordinary reads, search queriesnothing worth years of storage

Together

python
@receiver(user_login_failed)
def audit_failed_login(sender, credentials, request, **kwargs):
    AuditEntry.objects.create(
        actor=None, action="auth.login_failed",
        target_id=credentials.get("username", "")[:150],   # never the password
        ip=client_ip(request),
    )

Deciding how much read logging to do

Deciding how much read logging to do
ApproachCostRight when
nonezerothe data is not sensitive — most systems
sensitive records onlya write per sensitive viewpayment details, personal data, customer records
bulk access onlya write per export or wide querythe signal you actually want: one actor, many records
every readmore rows than the data itselfa regulator asks for it — and then it is async

Together

python
if len(results) > BULK_THRESHOLD:
    record_bulk_access.delay(user.id, model="Customer", count=len(results))

Remember: Audit four categories: security events (including the *failed* logins — a successful one is only meaningful beside them), administrative actions by staff on other people's data, every payment state change, and — deliberately, not by default — access to sensitive data. Django gives you `user_logged_in`, `user_logged_out` and `user_login_failed` for free, and `admin.LogEntry` covers the admin and nothing outside it. Read logging is the expensive category because reads vastly outnumber writes, so scope it to sensitive records and to *bulk* access, which is the signal you actually want.

See also: three kinds of record · login logout and authentication backends · the payment state model

Advertisement