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
- Patients log in via mobile apps or web portal to submit and track cases (assessments)
- Providers (physicians/dermatologists) log into the portal to review patient cases
- Admins manage users, organizations, and system configuration
- Multi-org (future): Multiple provider organizations, each seeing only their own patients
- Patient-provider relationship: A patient may have multiple providers (across orgs), but any given assessment belongs to exactly one provider/org
- 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_roleon the membership table.
Role Model
Roles
Three roles on the users table:
| Role | Description | Access |
|---|---|---|
patient | End user submitting skin cases | Own assessments only. View own history and results. |
physician | Provider reviewing/annotating cases | Cases assigned to them or their org. Annotate, diagnose. |
admin | System administrator | Full 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
patientuser. No role selection at signup. - Admins promote users to
physicianoradminvia 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 Role | Description |
|---|---|
member | Standard physician in the org |
org_admin | Can 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
- Organization scoping in physician queries — physicians only see cases belonging to their org.
- Patient self-service scoping — patients only see/modify their own cases.
- Admin scoping — admins see everything.
- New user registration — always
patientrole, assigned to the default org. - Admin role management endpoint — lookup user, change role.
- Add
organization_idto 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
| Action | Patient | Physician | Admin |
|---|---|---|---|
| Register | Self (always as patient) | N/A (promoted by admin) | N/A (promoted by admin) |
| Submit assessment | Own only | N/A | N/A |
| View assessment | Own only | Own org’s cases | All |
| Annotate assessment | N/A | Own org’s cases | All |
| View patient list | N/A | Own org’s patients | All |
| Look up user / change role | N/A | N/A | All |
| Manage orgs | N/A | N/A | All |
| System config | N/A | N/A | All |
Admin User Management UI
Admins need an endpoint (and portal screen) to:
- Search/lookup users by email or name
- View user details — role, org, registration date, last login
- Change user role — patient -> physician, physician -> admin, etc.
- 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 } — deactivateAll 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), andorganization_id
Implementation Order
DB migration: renameDONEtenants->organizations, renametenant_id->organization_idSeed “Demo” organization, assign all users to itDONEUpdate models, schemas, and auth code for the renameDONEAddDONEorganization_idto JWT payloadEnforce org scoping in case queriesDONELock registration toDONEpatientrole only + auto-assign orgAdd admin user management endpointsDONEAdd admin UI in the portalDONE
Portal UI — Role-Based Sections (Implemented)
The physician portal now has role-based navigation and pages:
| Section | Route | Patient | Physician | Admin |
|---|---|---|---|---|
| Assessment | /assessment | Yes | Yes | Yes |
| Results | /results | Yes | Yes | Yes |
| Case Review | /cases | No | Yes | Yes |
| Case Detail | /cases/:uuid | No | Yes | Yes |
| User Management | /admin/users | No | No | Yes |
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 UUIDvendor_id= user’s organization slug (for org scoping)status= 1 (completed)- Returns
case_uuidin 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 reviewScoping: patients see own cases, physicians see their org’s cases, admins see all.