Techdots

September 10, 2025

Why Off-the-Shelf RBAC Fails in Complex Multi-Tenant Healthcare

Why Off-the-Shelf RBAC Fails in Complex Multi-Tenant Healthcare

When we started building our healthcare platform, we thought standard role-based access control would be enough. After all, most SaaS applications work fine with simple roles like "admin," "user," or "moderator." But healthcare proved us wrong — fast.

Within weeks, we discovered that basic RBAC couldn't handle the intricate web of permissions that real medical operations demand. A single user might need different access levels across multiple clinics, temporary permissions for specific patients, and instant revocation capabilities for compliance. Traditional RBAC systems simply weren't designed for this level of complexity.

What we needed wasn't just user roles — we needed contextual access control that considered who was accessing what, when, where, and under what circumstances. Here's how we built it.

The Problem: Healthcare Isn't Your Average SaaS

Basic role-based access control (RBAC) might work fine for consumer apps, but healthcare demands something much more sophisticated. Here's why:

  • A nurse in one clinic should never accidentally see another clinic's patients
  • A regional medical director might oversee 12 different clinics, each with different access levels
  • Visiting specialists need temporary access to specific patients for limited timeframes
  • Every single access attempt must be logged and instantly revocable for compliance

We weren't just building RBAC. We were creating Contextual Access Control — a system that considers role + tenant + data + time + device all at once.

Our Solution: A 6-Phase Approach

Phase 1: Modeling Tenancy with acts_as_tenant

First, we used acts_as_tenant to make sure all data stayed within organization boundaries.


class Appointment < ApplicationRecord
  acts_as_tenant(:organization)
  belongs_to :organization
  # add other associations, e.g.:
  # belongs_to :user
  # belongs_to :doctor
  # add validations if needed
  # validates :scheduled_at, presence: true
end
  

We set the current organization early in every request:


class ApplicationController < ActionController::Base
  before_action :set_current_tenant
  private
  def set_current_tenant
    return unless user_signed_in?
    ActsAsTenant.current_tenant = current_user.active_organization
  end
end
  

This ensured all database queries respected tenant boundaries automatically — even in background jobs.

Phase 2: Database-Level Protection with Postgres RLS

To prevent data leaks even if our code had bugs, we added PostgreSQL Row-Level Security:


CREATE POLICY patient_access ON patients
  USING (
    organization_id = current_setting('app.current_org')::uuid
    AND current_user_has_permission(user_id, 'read', 'Patient')
  );
  

We pushed user and organization context directly into the database connection. The current_user_has_permission function used a fast materialized view for permission checks.

This locked down access at the database level — protecting us even if Rails made mistakes.

Phase 3: Flexible Role & Permission System

Instead of fixed roles, we built a dynamic system:

Membership

  - user_id

  - organization_id

  - role_id

Role

  - name

  has_many :permissions

Permission

  - action (e.g. "read")

  - subject_class (e.g. "Appointment")

  - conditions (JSON field for dynamic rules)

This supported:

  • Global permissions (read all appointments)
  • Scoped permissions (edit only appointments where you're the assigned doctor)
  • Temporary access grants with automatic expiry
  • Organization-specific permission overrides

Phase 4: Smart Policy Enforcement with Pundit

We extended Pundit to handle our complex permission logic:


class ApplicationPolicy
  def allow?(action, record)
    return false unless membership
    # find a permission for this subject and action
    permission = membership.permissions.find do |perm|
      perm.subject_class == record.class.name && perm.action == action.to_s
    end
    return false unless permission
    # if permission exists, evaluate any conditions
    evaluate_conditions(permission.conditions, record)
  end
  def evaluate_conditions(conditions, record)
    # Example: { "field": "assigned_doctor_id", "equals": "current_user.id" }
    field = conditions["field"]
    value = record.send(field)
    expected = eval(conditions["equals"]) # <- ⚠️ risky
    value == expected
  end
end
  

This enabled record-level access control with flexible policy rules.

Phase 5: Real-Time Frontend Enforcement

We sent permission data to the frontend as compiled JSON:


{
  "Appointment": {
    "edit": ["assigned_doctor_id == current_user.id"],
    "read": true
  },
  "Patient": {
    "read": true
  }
}
  

A simple helper function handled access checks:


const permissions = {
  Appointment: {
    edit: ["assigned_doctor_id == currentUser.id"],
    read: true,
  },
  Patient: {
    read: true,
  },
};
  

We considered using CASL or Permissive, but built our own system to ensure perfect alignment between frontend and backend policies.

Phase 6: Testing & Audit Tools

We built comprehensive testing and monitoring tools:

  • RSpec tests validated Pundit coverage across all tenant and role combinations
  • Cypress tests confirmed UI permissions matched backend reality
  • Admin simulation tools let managers preview any user-role-organization combination
  • Complete audit logs tracked every permission change, per tenant

The Results: A Production-Ready System

Our system now handles: 

  • 70+ clinics with 50+ dynamic roles serving 10,000+ users 
  • Secure access during high-traffic real-time sessions 
  • Perfect UI-backend permission synchronization 
  • Zero tenant data leaks (confirmed by security testing) 
  • Live permission changes without code deployments

Common Questions

Q: Why use Postgres RLS when you already have acts_as_tenant? 

acts_as_tenant protects at the application level, but RLS protects at the database level. RLS prevents data leaks even if developers accidentally write unsafe queries or admin tools.

Q: Can users work with multiple organizations? 

Yes. Our membership model supports users having different roles across multiple organizations.

Q: How do you handle per-record permissions?

We store conditional logic in JSON (like "only allow edit if assigned_doctor_id equals current_user.id"). These conditions work on both backend and frontend.

Q: What happens when permissions change after login? 

Permissions update dynamically. We can push changes through real-time channels or refresh them during key actions.

Q: Why not use existing permission libraries? 

We needed perfect alignment between frontend and backend. Our custom system uses the same permission schema everywhere, eliminating inconsistencies.

Conclusion

This wasn't just a permission system — it was the foundation that enabled safe expansion across a national healthcare network. When dealing with multi-tenant applications requiring fine-grained access control, you don't need another gem. You need a comprehensive strategy.

Ready to build robust access control for your complex application? Contact TechDots to discuss your specific requirements.

Ready to Launch Your AI MVP with Techdots?

Techdots has helped 15+ founders transform their visions into market-ready AI products. Each started exactly where you are now - with an idea and the courage to act on it.

Techdots: Where Founder Vision Meets AI Reality

Book Meeting