All Posts
Django
Django REST Framework
Security
Audit Logging
Multi-Tenancy
Backend Architecture

Read-Only Isn't a Method Check: Support Impersonation in Django

Blocking POST isn't read-only when your GETs write. This is how I built 'view as this user' for an HR platform with a transaction that's always rolled back, an audit trail kept outside it, and the same trick reused to put every settings change behind approval.

February 25, 20268 min read
Read-Only Isn't a Method Check: Support Impersonation in Django

"Just block POST, PUT, PATCH and DELETE." That's the obvious way to build a read-only mode, and it's wrong in nearly every real codebase. When I built support impersonation for BoltHR, the HR platform I work on, I found that opening a notification marked it read, a payroll status endpoint could create a salary batch, and several list handlers quietly created settings rows. All of them were GETs.

This post covers how I built "view the app as this user" in Django REST Framework so that read-only is a structural guarantee, not a checklist. Along the way the same trick, a transaction that is always rolled back, turned out to solve a second problem too: putting every settings change behind approval.

The feature

Support is easier when you can see what the user sees. A system admin starts a session against a target user and gets a session-specific bearer key. The web client sends it as Authorization: Impersonate and a banner makes the mode obvious. Sessions expire, and a cron job closes abandoned ones and notifies the impersonated user that the admin has gone.

It's also the one feature in the product that deliberately hands one person another person's view of the data. So I designed every part of it as a guard.

Rule 1: inherit permissions by being the target

Copying permissions or roles onto a temporary identity is a mistake you can't see. Some code path will read the original. Instead, the custom authentication class resolves the key and returns the target user as request.user. Every existing permission class, every org-scoped queryset and every rights check then resolves against the target, with no special cases. Nothing is done to inherit permissions, so nothing can be missed.

BoltHR is multi-tenant, and one person can belong to several organisations and switch between them. The "currently selected profile" is a flag in the database. Flipping that flag for the session would have kicked the real user out of their own organisation mid-session and forged login/logout rows in the audit log, which are derived from that same field. So the target's profile is pinned in memory on the user object for the length of the request. Nothing is written.

Who may impersonate whom is lateral-or-downward only. Roles have a level, where smaller means more access. The target's strongest role must be no stronger than the admin's own, so a peer is fine and climbing is not. I also didn't grant the rights through the usual permissive proxy ("roles holding employee-update"), because that would have handed impersonation to every HR admin.

Rule 2: read-only is two layers, and the second one is the guarantee

Layer one is an explicit refusal. Unsafe HTTP methods are refused in the authentication class, before any view runs, so a new endpoint can't forget the guard. The admin gets a clean 403 with a specific error code, and the banner says "read-only" rather than a vague "no permission".

The allowlist for that layer is an exact list of the session's own control endpoints: end, extend, heartbeat and report-events. It deliberately isn't the whole /api/impersonation/ prefix. A prefix match would let a session start a nested session or unlock its own writes.

Layer two is the actual guarantee. A middleware wraps every impersonated request in a transaction and rolls it back unless the admin explicitly unlocked writes:
def __call__(self, request):

if not looks_impersonated(request):

return self.get_response(request)

with transaction.atomic():

response = self.get_response(request)

if not writes_unlocked(request) and not is_control_path(request):

transaction.set_rollback(True)

record_audit_trail(request, response) # outside the block

return response

The GET that marks a notification read still returns its normal response. The side effect never lands. I didn't keep a list of "GETs that mutate", because the next one gets added by someone who isn't thinking about impersonation. The transaction makes the guarantee hold for code that doesn't exist yet.

The middleware can't ask for the session object before authentication has run, so it decides whether to open the transaction by sniffing the header scheme. If someone forges the header, a request that was going to 401 anyway runs inside a transaction that gets thrown away. That costs nothing.

Rule 3: the rollback must not eat the audit trail

This is the trap that took the longest to get right. If the auth class or the view writes the audit row, the rollback deletes it, and you end up with a perfectly read-only session that leaves no trace.

So the authentication class writes nothing. It only leaves flags on the request: "a write was blocked", "the session had expired". The middleware writes the API_REQUEST and WRITE_BLOCKED rows, bumps the session counters and closes expired sessions after the atomic block exits. That is also why it has to be last in MIDDLEWARE: the transaction and the status code need to cover DRF's whole dispatch, authentication included.

The control endpoints are exempt from the rollback by the same allowlist. They write only to impersonation's own tables (the activity trail and the session lifecycle) and never to business data. Before I added that exemption, a default read-only session silently lost every page view and click the client reported. The log showed only the server-side API rows.

Rule 4: log facts, compose sentences on read

The web client reports PAGE_VIEW, TAB_CLICK and ACTION events in batches. Those three are the only types a client may submit. The serializer's choice field is pinned to them, so a compromised client can't forge an API row or a lifecycle event.

Each reported action carries a correlation id, and the client stamps the same id on every API call that action triggers using a custom header. The session detail page can then group backend requests under the click that caused them. The id only groups rows. It grants nothing, and a forged one at worst mis-nests a row in the actor's own trail.

The plain-English wording in the log ("Opened the payslip for March", that kind of thing) is composed when the log is read, from raw stored columns. When I improved the phrasing or named a new route, every historical session picked it up with no data migration. That's also why the client reports structured facts like {control, field, value} instead of sentences.

The same trick, a second time: settings changes through approval

A few months later the requirement was: every add, edit or delete on the Settings screens should wait for approval, if the organisation has configured an approver. There are dozens of settings endpoints, each with its own validation, and I had no wish to write a second implementation of any of them.

The answer was the rolled-back transaction again. A mixin sits first in each settings viewset:

1. Dry run. The write is executed for real, through the real endpoint, inside a transaction that is always rolled back. Validation errors and the before/after diff come from the endpoint itself.

2. Store and answer 202. The request is stored, encrypted, and the client is told it's waiting for approval.

3. Replay on approval. The stored request is replayed through the same endpoint, as the submitter. Their profile is pinned the same way impersonation pins one. If the submitter has lost the right in the meantime, the change fails with the endpoint's own message instead of landing on the approver's authority.

The trap is the same one turned around. A dry run executes the handler, so anything outside the database has to be deferred with on_commit (which is discarded on rollback) or skipped when the request is flagged as a dry run. The Microsoft Teams integration's setup call clears statuses through Graph, and it needed exactly that guard. The other trap is that gating is an explicit list of actions per viewset, and a new settings endpoint has to opt in. Otherwise it silently skips approval, so that rule is written down where the next developer will read it.

Takeaways

  • •An HTTP method check isn't a read-only guarantee. Assume some GETs in your codebase write, because they probably do.
  • •A transaction you always roll back is a strong design tool. It gives you read-only sessions, accurate dry runs and diffs taken from the real code path, without reimplementing anything.
  • •Write the audit trail outside the thing that can roll back. Otherwise the guard deletes the evidence of what it guarded.
  • •Inherit identity, don't copy it. Returning the target as request.user reuses every permission check you already trust.
  • •Store facts and render sentences on read. Your audit log will outlive your wording.

Share this article