Route maps for audit middleware: a broad False is a default, not a veto
Audit middleware tends to start as one if and end as a page of conditions: this prefix but not that one, write verbs only, skip the health check, keep the body here but not there. Every one of those conditions is legitimate and the result is still unreadable, because each exception got written where someone found it instead of next to the rest of the policy. So nobody can tell you which endpoints are in the trail without running a function in their head.
What makes this hard is that the question has two audiences. Engineers ask it while they change a view. An auditor or a compliance reviewer asks it while reading what the trail contains, and they are entitled to an answer that does not involve reading middleware. That pushes the decision into a single declaration. A declaration only helps you if it resolves predictably: if the answer for POST /api/invoices/7/void depends on which key happened to be typed first, the map is as opaque as the branches it replaced. In model-audit, the library we maintain for Django audit trails, patterns resolve by specificity, and the useful consequence is that a broad off-switch works as a default instead of a veto.
One map, read back in one place#
A RouteMap is declared as patterns to routes. A key is a path pattern with an optional verb prefix, which _parse_pattern splits off. A value is an AuditRoute, a Capture for a route that only sets what to keep, True as shorthand for a default AuditRoute(), or False to keep an endpoint out of the trail. Anything else raises a TypeError at construction, so a typo in your declaration fails at startup instead of quietly skipping an endpoint.
# billing/apps.py
from model_audit import AuditRoute, audit_routes, subscribe_requests
audit_routes({
"/api/**": False,
"POST,PUT,PATCH,DELETE /api/invoices/**": True,
"POST /api/invoices/*/void": AuditRoute(label="invoice-void"),
})
subscribe_requests(write)# settings.py
MIDDLEWARE = [
...,
"django.contrib.auth.middleware.AuthenticationMiddleware",
"model_audit.http.AuditMiddleware",
]That is the whole policy and it reads top to bottom: the API is off by default, writes under invoices are audited, and voiding an invoice is audited under a name of its own. AuditMiddleware reads MODEL_AUDIT_ROUTES once in __init__, so the map gets built when the process starts and is never rebuilt per request.
Maps compose too. extend() returns a new map with more routes declared on top of an existing one, which is how you keep a baseline in one module and let an app add its own endpoints:
from model_audit import RouteMap
base = RouteMap({"/api/**": False})
routes = base.extend({"POST,PUT,PATCH,DELETE /api/invoices/**": True})
len(routes) # 2
routes.patterns # ('/api/**', 'POST,PUT,PATCH,DELETE /api/invoices/**')Specificity, scored#
_match scores a pattern against a split path by counting the literal segments it matched. * consumes one segment and scores nothing for it. ** consumes any number of trailing segments and scores nothing either. A pattern that cannot consume the path at all returns None, and it is not a candidate.
| pattern | path | score |
|---|---|---|
/api/** |
/api/invoices/7/void |
1 |
/api/invoices/** |
/api/invoices/7/void |
2 |
/api/invoices/*/void |
/api/invoices/7/void |
3 |
Before any scoring, match skips a rule whose declared methods exclude the request verb. Among the survivors the winner is the highest key of (score, methods is not None, "**" not in segments, segment count), and a tie goes to the rule declared earlier. Each tier only breaks a tie in the one above it: more literal segments beats fewer, a rule that names its verbs beats one that applies to all of them, a rule with no trailing wildcard beats one that ends open, and a longer pattern beats a shorter one.
So for POST /api/invoices/7/void all three rules above are candidates, and the void rule wins on score alone. For GET /api/invoices/7 the write rule is skipped on its verb, the void rule does not match the path, and /api/** wins. That resolves to False, and the request is not audited. The verb prefix and the path specificity compose instead of competing: a route that names its own methods keeps them, and the prefix only fills in a route that left them unset.
Why False is a default and not a veto#
When the winning rule carries audit=False (the exported OFF route), match yields None. That is the whole mechanism, and it is worth saying plainly, because it inverts the habit most of us bring over from firewall rules and .gitignore: a broad False forbids nothing. "/api/**": False scores one literal segment, names no verbs and ends in a trailing wildcard, so it loses to every narrower declaration. It is the baseline the rest of the map is written against.
That is what makes the declaration safe to keep tight. The alternative, audit everything and list your exclusions, grows a trail nobody wants to read and quietly starts recording endpoints that were never reviewed for what they carry. Starting from a wide False means a new endpoint sits outside the trail until somebody declares it, and declaring it is a one line, reviewable diff in the same map an auditor reads. The inversion has one cost, and it is the kind that bites during an incident: you cannot pin an endpoint off with a broad rule. If one path under an audited subtree has to stay out, it needs a rule at least as specific as the one that pulled it in.
What the trail keeps, and what it admits it does not#
On a match, the middleware captures whatever the route's Capture flags ask for before it calls the view, binds the actor as ambient with set_current_actor, and resets it in a finally. That ambient binding is what ties the two halves of the trail together: model records emitted from signals inside that view resolve their actor from the same context, so a request record and the field level diffs it caused name the same party, and nobody has to thread an argument through a service layer. The RequestRecord's own actor gets resolved after the view, which is the right order for anything that authenticates or switches identity inside the handler.
Bodies are where an audit trail usually starts lying, so the rules are explicit. _body keeps form and JSON bodies only, passed through the route's redactor or DEFAULT_REDACTOR, whose data() walks mappings and sequences recursively and replaces sensitive entries with REDACTED. token, password, secret, phone, passport, iban and ssn are among the defaults, and a route can carry its own:
from model_audit import AuditRoute, DEFAULT_REDACTOR
AuditRoute(redactor=DEFAULT_REDACTOR.extend(["card_number"]))Everything else gets written as an omitted note: an unsupported content type, a body that is unavailable or unparsable, or one over MAX_BODY_BYTES (64 KB). The distinction matters more than it looks. A record with no payload reads as a request that carried nothing, while a record that says the body was omitted and why reads as a request whose payload was deliberately not kept. An audit trail that cannot explain its own gaps is worse than one with fewer entries, because the gap is invisible right where someone is about to draw a conclusion from it.
A view that raises is recorded too, with status set to None and the exception class name in error, before the exception is re-raised. The failed write sits in the trail with its actor, its route label and its duration. It is simply marked as having no status.
What it costs to run#
Per request, an unmatched path costs you splitting the path on / and scoring it against the declared rules. Patterns are parsed once when the map is constructed, not per request, and no body is read, no actor is bound, no record is built. On a None match the middleware returns get_response(request) and does nothing else. Calling it "one lookup" is the right mental model but not the implementation: rules are scanned linearly, so the cost is proportional to the number of declarations, and ** resolution recurses over the remaining path. At the size an audit policy actually reaches, tens of rules, that is probably not worth optimising, though it is worth knowing before someone generates a rule per endpoint.
Three operational edges are worth writing down. The map is read in __init__, so changing MODEL_AUDIT_ROUTES needs a process restart before it takes effect. The ambient actor is context local: it follows await inside a task, but threads and executor workers started inside the view do not inherit it, so work handed to a pool records an unknown actor unless it binds one itself. And the 64 KB cap bounds the memory a single record can take, which is also why a large upload shows up as an omitted body rather than as a log line nobody can ship.
The net of it: the decision about what gets audited lives in one declaration, resolves by a rule you can apply by hand, and leaves a trail that is explicit about its own omissions. A broad False at the top of that map does not lock the door. It states that nothing here is audited until someone says so, one line at a time.