Architecture4 min

Multi-tenant isolation you can prove in a compliance review

Scoping data by tenant is easy to claim and hard to demonstrate. The two structural decisions that turn “we isolate tenants” into something you can answer with a query.

Every multi-tenant product claims tenant isolation. Far fewer can answer the question an auditor actually asks, which is not “is data separated?” but “show me who accessed this record, and demonstrate that no one outside the tenant could have.”

Those are different problems. The first is satisfied by a WHERE clause. The second needs the isolation to be structural and the history to be recorded — decisions taken at the start, because neither can be retrofitted convincingly.

Sirona Mind is a clinic management platform for mental health practices, running an unlimited number of clinics from one deployment, with a four-level role hierarchy: Group Admin, Clinic Admin, Clinician, Patient. Two structural decisions carry the compliance story.

1. Make the tenant the root of every query

The failure mode in multi-tenant systems is rarely a missing check. It is a check that exists on nineteen endpoints and was forgotten on the twentieth — usually a reporting endpoint, an export, or something added under time pressure.

Filtering per endpoint puts the burden on whoever writes the next query, forever. The alternative is to make the clinic the root tenant and scope every queryset by role, so an unscoped query is not something a developer has to remember to avoid — it is not the default path.

This is why the role hierarchy has to be part of the data access layer rather than the view layer. A Clinician sees their own caseload, a Clinic Admin sees one clinic, a Group Admin sees the group, and a Patient sees only themselves. That resolution happens where the query is constructed, not in a decorator somebody can omit.

Thin views, thick services

Structurally this is enforced by a strict service layer. Views stay thin; all business logic lives in services.py. It is a familiar Django pattern, and its value here is specific rather than aesthetic.

  • Access rules live in one layer, so “who can see this?” has one answer per domain rather than one per endpoint.
  • The domain is testable without HTTP, so isolation can be tested directly instead of inferred from response codes.
  • New endpoints inherit the scoping by construction, because they call the same services.

The frontend mirrors it: every HTTP call goes through a single central ApiService with automatic token refresh and retry on 401. Auth handling exists once, not at each call site — the same argument, applied on the other side of the wire.

2. Write the audit log on every mutation, and never edit it

The second decision is that every create, update and delete writes to an immutable audit log with actor, entity and action. Not the sensitive tables. Not the ones someone predicted would be asked about. All of them.

Selective logging always looks sufficient at design time and never survives contact with a real review, because the question that arrives is about the entity nobody thought was interesting. Logging every mutation removes the need to predict.

Consent is an event, not a column

One detail worth stealing: SMS and email consent changes are recorded as their own timestamped, IP-stamped events, with the actor who made the change.

A boolean column tells you what the current state is. A review asks when consent was given, by whom, and from where — and if it was later withdrawn, the same about the withdrawal. A column cannot answer that at all; an event stream answers it without reconstruction.

The difference between “we believe consent was captured” and “consent was recorded at this timestamp, by this actor, from this address” is the difference between a conversation and a query.

Access control that does not generate support tickets

Sirona Mind uses passwordless authentication: a six-digit OTP emailed on login, backed by JWT access and refresh tokens, account lockout after three failed attempts, and a 15-minute inactivity auto-lock.

The security argument is the obvious one — no stored password, no password reuse, no reset flow to phish. The operational argument matters just as much in a clinic setting. Password resets are a large share of support volume for staff who log in irregularly, and every reset is a manual identity check somebody has to perform correctly under time pressure.

The 15-minute auto-lock is there because of where the software runs. Shared workstations in clinical settings do not get locked reliably by the person walking away from them.

Prove it continuously, not at review time

Isolation and audit trails are claims about behaviour, and behaviour regresses. Five classes of security scanning run in CI on every change: TruffleHog for secret detection, Bandit for Django SAST, Trivy for the Next.js side, DAST against running environments, and SBOM generation. Results publish to GitHub’s Security tab as SARIF rather than sitting in a build log nobody opens.

Behavioural coverage sits alongside it: 54 backend pytest modules and 19 Playwright end-to-end specs, the latter covering all four role portals with authenticated fixtures. Testing a role boundary means logging in as that role and confirming what is visible — a unit test cannot make that assertion honestly.

What this changes in practice

  1. Onboarding a new clinic is a configuration task, not a deployment.
  2. New intake forms ship without engineering involvement, through the dynamic form builder.
  3. Compliance questions are answered from the audit log rather than reconstructed from memory.

None of this is exotic. Scope at the data layer, log every mutation, model consent as events, and test role boundaries through the portals people actually use. The work is deciding it at the beginning — because a system that was not built this way cannot be argued into it afterwards.

The work behind this postSirona Mind case study

More insights

Applied ML4 minYour model’s 90% confidence interval is probably 85%Quantile regression produces intervals that are systematically too narrow. Here is how we measured the gap on a live valuation model, and the calibration layer that closed it.Read AI Engineering4 minOAuth 2.1 between a web app and an MCP serverConnecting a product to ChatGPT and Claude means running a real authorization server. What the flow looks like end to end, and why standards compliance beat per-vendor branches.Read