×

Enforcing Multi-Tenant Data Isolation at the Database Layer

Abstract illustration of multi-tenant database isolation

HealthJourney is a platform I built for small clinics: one to three physicians, seeing a few dozen patients a day. It wraps agentic AI around intake, consult, labs, and post-visit follow-up.

The AI parts get the attention when I describe this project. The engineering problem I spent the most care on was multi-tenancy. This system serves multiple clinics off one deployment. A bug in application logic is not an acceptable way to keep one clinic's patient records separate from another's.

Intake Starts on WhatsApp, Not in an App

Patients don't download anything. Reception staff sends an OTP-gated intake link over Twilio WhatsApp, with SMS as a fallback. The patient answers a conversational questionnaire from there.

Gemini 2.5 Flash turns those answers into a clinical summary tailored to the doctor's specialty. That summary is waiting in the staff interface before the doctor even sees the patient. Having it ready ahead of the visit is most of the intake value on its own. It beats a doctor reading raw questionnaire answers cold.

One AI Abstraction, Several Jobs

Rather than scattering model calls across the codebase, every AI interaction goes through a single provider-agnostic AIService. Gemini Flash sits behind that abstraction today.

It handles intake summaries, natural-language prescription parsing, structured diagnosis extraction, and lab result image processing. One seam instead of many means the model behind it can change later without touching every call site.

During a live consult, a doctor writes prescriptions in plain language, something like Tab Paracetamol 500mg BD x 5 days. The AIService parses the drug and dosage and tags the diagnosis.

That happens asynchronously so it never blocks the consult. A doctor typing a prescription shouldn't wait on a model call before moving to the next patient.

Two Queues, Because Two Kinds of Urgent Aren't the Same

That asynchronous AI work runs on a Celery queue. It's deliberately a separate queue from notifications. Prescription parsing and diagnosis tagging can tolerate a few seconds of delay without anyone noticing.

OTP delivery for patient onboarding cannot. A delayed OTP is a broken login screen, not a slow feature. Putting both on the same queue means a burst of AI processing can back up and delay something unrelated and much more time-sensitive.

Two separate queues, ai_tasks and notifications, keep those failure modes from taking each other down.

Isolation as a Database Guarantee, Not an Application Convention

This is the part of the system I think about the most. The easy way to build multi-tenancy is to add a tenant_id column and remember to filter by it in every query.

That works until someone forgets. Maybe in one endpoint, on one code path, six months from now. Then one clinic's data becomes visible to another. For clinical data, that's not a bug you get to apologize for after the fact.

So isolation is enforced at the database layer with PostgreSQL Row-Level Security. Specifically with FORCE ROW LEVEL SECURITY, so even the table owner can't accidentally bypass the policy.

FastAPI middleware sets app.current_tenant_id on the database session at the start of each request. The database filters every clinical row against that value itself. The application never gets the chance to forget a WHERE clause. The filter isn't the application's job anymore.

-- Simplified shape of the policy
ALTER TABLE patient_records ENABLE ROW LEVEL SECURITY;
ALTER TABLE patient_records FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON patient_records
    USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

There's a real infrastructure consequence. RLS depends on session state (the current tenant id on the connection) persisting across the transaction. A transaction pooler resets session state between transactions, which silently breaks that guarantee.

So the connection layer uses Supabase's session pooler on port 5432 specifically, not the transaction pooler. The transaction pooler can't hold the session state RLS depends on. It's a detail that's easy to get wrong without noticing until tenant isolation quietly stops working.

Lab Results Without Blind Trust

Lab results come in as PDFs or photos. AI vision extracts numeric values and maps them to an internal test-code dictionary. Low-confidence extractions get queued for manual staff review instead of being trusted automatically.

For returning patients, the system builds trend charts over time, flags duplicate investigations, and surfaces allergy conflicts against history. All of that comes from the same extracted data.

Care in the Patient's Language

Post-visit summaries (findings, medications, warning signs, follow-up instructions) go back out over WhatsApp in English, Hindi, or Telugu. Those are the languages patients at these clinics actually speak. A summary a patient can't read is not a summary.

What's Deliberately Not in v1

Worth naming plainly rather than implying a more finished product. This doesn't yet have Docker Compose deployment, appointment scheduling, ABHA linkage, HL7 or FHIR support, insurance integration, ICD-10 automation, predictive diagnostics, or a native mobile app.

The system targets small clinics specifically: roughly one to three physicians seeing twenty to fifty patients a day. There's no published adoption or performance data. What's here is the architecture, not a results scoreboard.

The General Lesson

For anything handling data across tenants, a leak has real consequences. It's not just an inconvenience. Isolation belongs at the database layer as a guarantee, not in application code as a convention everyone is expected to remember.

Conventions get violated by one forgotten line. A database constraint doesn't have that failure mode.

Learn More

The full project is open source: HealthJourney on GitHub.

Building something with AI?

I design and ship production systems across ML, deep learning, GenAI, LLMs, and RAG. Happy to talk through what you're working on.

Book a Free Call