Skip to Content
Ai LogRole Management & Multi-Tenancy Design

Role Management & Multi-Tenancy Design

Date: 2026-03-24 Status: Approved — implementing Phase 1

Current State

We have three roles as a string field on the users table: patient, physician, admin. Authorization is enforced via FastAPI dependencies (get_current_physician, get_current_admin, require_role). Admin bypasses all role checks.

A tenants table exists (migrated from legacy vendor_id values — 19 tenants). Users have an optional tenant_id FK. Tenant isolation is not enforced in any queries yet.

Requirements

  1. Patients log in via mobile apps or web portal to submit and track cases (assessments)
  2. Providers (physicians/dermatologists) log into the portal to review patient cases
  3. Admins manage users, organizations, and system configuration
  4. Multi-org (future): Multiple provider organizations, each seeing only their own patients
  5. Patient-provider relationship: A patient may have multiple providers (across orgs), but any given assessment belongs to exactly one provider/org
  6. HIPAA: All access patterns must support audit logging (designed separately)

Decisions

  • Audit logging: Will be designed and implemented separately from role management.
  • Rename tenants to organizations now — clearer domain language, no reason to wait.
  • Single org for now: Create one “Demo” organization; all users belong to it.
  • Registration: All new users register as patient. Admins can change roles via an admin screen.
  • No superadmin vs admin distinction — one admin tier is enough. When multi-org arrives, org-level admin is handled via org_role on the membership table.

Role Model

Roles

Three roles on the users table:

RoleDescriptionAccess
patientEnd user submitting skin casesOwn assessments only. View own history and results.
physicianProvider reviewing/annotating casesCases assigned to them or their org. Annotate, diagnose.
adminSystem administratorFull access. Manage users, orgs, system config. Role changes.

Why not fine-grained permissions? The access patterns are simple and role-aligned. Adding a permission table now would be premature — if we need it later (e.g., “physician who can also manage their org’s users”), we can add an org_role on the membership table (see below).

Registration & Role Assignment

  • All new registrations create a patient user. No role selection at signup.
  • Admins promote users to physician or admin via an admin UI (user lookup + role change).
  • New users are automatically assigned to the default organization at registration.

Future: Org-Level Roles

When multi-org lands, we’ll likely need:

Org RoleDescription
memberStandard physician in the org
org_adminCan manage org settings, invite physicians

This would live on the organization_members join table, not on the users table.

Organization Design

Phase 1: Now (single org)

Rename tenants -> organizations. Create one “Demo” organization. All users (patients and physicians) get assigned to it.

-- Migration ALTER TABLE tenants RENAME TO organizations; ALTER TABLE users RENAME COLUMN tenant_id TO organization_id; -- Seed the demo org (if not already present) INSERT INTO organizations (name, slug, is_active) VALUES ('Demo', 'demo', true) ON CONFLICT (slug) DO NOTHING; -- Assign all existing users to the demo org UPDATE users SET organization_id = (SELECT id FROM organizations WHERE slug = 'demo') WHERE organization_id IS NULL;

Schema after migration:

users.organization_id -> organizations.id (NOT NULL, all users belong to an org)

Phase 1: What to Enforce

  1. Organization scoping in physician queries — physicians only see cases belonging to their org.
  2. Patient self-service scoping — patients only see/modify their own cases.
  3. Admin scoping — admins see everything.
  4. New user registration — always patient role, assigned to the default org.
  5. Admin role management endpoint — lookup user, change role.
  6. Add organization_id to JWT token payload so we don’t need a DB lookup on every request for org scope.

Phase 2: Future (multi-org)

When we need multiple organizations:

-- Physicians can belong to multiple orgs CREATE TABLE organization_members ( id SERIAL PRIMARY KEY, organization_id INT REFERENCES organizations(id), user_id INT REFERENCES users(id), org_role VARCHAR(50) DEFAULT 'member', -- member, org_admin is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT now(), UNIQUE(organization_id, user_id) ); -- Assessments are scoped to one org ALTER TABLE rn_requests ADD COLUMN organization_id INT REFERENCES organizations(id); -- Patients can be seen by multiple orgs CREATE TABLE patient_organization ( id SERIAL PRIMARY KEY, patient_user_id INT REFERENCES users(id), organization_id INT REFERENCES organizations(id), created_at TIMESTAMPTZ DEFAULT now(), UNIQUE(patient_user_id, organization_id) );

At that point, users.organization_id becomes the user’s “primary” org, and the join tables handle the many-to-many relationships.

Data Isolation Strategy

Every query that returns patient data or assessments must be scoped:

  • Physicians: Filter by their org. WHERE rn_requests.organization_id = :physician_org_id
  • Patients: Filter by their own user_id. WHERE rn_requests.user_id = :patient_user_id
  • Admins: No filter (full access), or org-scoped if they’re an org admin

This should be enforced at the repository/query layer, not in individual endpoints — a single get_visible_cases(user) function that applies the right filter based on role.

Access Control Matrix

ActionPatientPhysicianAdmin
RegisterSelf (always as patient)N/A (promoted by admin)N/A (promoted by admin)
Submit assessmentOwn onlyN/AN/A
View assessmentOwn onlyOwn org’s casesAll
Annotate assessmentN/AOwn org’s casesAll
View patient listN/AOwn org’s patientsAll
Look up user / change roleN/AN/AAll
Manage orgsN/AN/AAll
System configN/AN/AAll

Admin User Management UI

Admins need an endpoint (and portal screen) to:

  1. Search/lookup users by email or name
  2. View user details — role, org, registration date, last login
  3. Change user role — patient -> physician, physician -> admin, etc.
  4. Deactivate/reactivate users

API Endpoints

GET /api/admin/users?search=<email>&role=<role>&page=<n> — list/search users GET /api/admin/users/:uuid — get user details PATCH /api/admin/users/:uuid/role { role: "physician" } — change role PATCH /api/admin/users/:uuid/active { is_active: false } — deactivate

All admin endpoints require admin role (enforced via get_current_admin dependency).

When a user is promoted to physician, a physicians profile row should be auto-created if it doesn’t exist.

Authentication Flow Notes

  • Mobile (patients): JWT bearer tokens, same as now
  • Web portal (providers + patients): Session cookies wrapping JWT, same as now
  • Web portal (admins): Same as providers, just different UI based on role
  • Token payload: includes role, sub (user UUID), and organization_id

Implementation Order

  1. DB migration: rename tenants -> organizations, rename tenant_id -> organization_id DONE
  2. Seed “Demo” organization, assign all users to it DONE
  3. Update models, schemas, and auth code for the rename DONE
  4. Add organization_id to JWT payload DONE
  5. Enforce org scoping in case queries DONE
  6. Lock registration to patient role only + auto-assign org DONE
  7. Add admin user management endpoints DONE
  8. Add admin UI in the portal DONE

Portal UI — Role-Based Sections (Implemented)

The physician portal now has role-based navigation and pages:

SectionRoutePatientPhysicianAdmin
Assessment/assessmentYesYesYes
Results/resultsYesYesYes
Case Review/casesNoYesYes
Case Detail/cases/:uuidNoYesYes
User Management/admin/usersNoNoYes

Case Persistence

The /api/diagnosis endpoint now persists every completed assessment to rn_requests with:

  • request = anamnesis data (JSONB)
  • response = AI results including predictions, image validity, red flags (JSONB)
  • user_id = submitting user’s UUID
  • vendor_id = user’s organization slug (for org scoping)
  • status = 1 (completed)
  • Returns case_uuid in the response so the frontend can link to it

Case Review API

GET /api/cases?page=<n>&page_size=<n>&status=<int> — list cases (scoped by role) GET /api/cases/:uuid — case detail (scoped by role) POST /api/cases/:uuid/annotate { annotation: {...} } — add physician review

Scoping: patients see own cases, physicians see their org’s cases, admins see all.

Last updated on