Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Access Control

Role-Based Access Control

JHE uses a simple role-based access control (RBAC) system to manage who can read and write organizations, studies, patients, and health data. This document explains the user types, the practitioner role hierarchy, how permissions are resolved per-organization, and how patient consent fits in.

The implementation lives in core/permissions.py (the IfUserCan permission factory and the ROLE_PERMISSIONS table) and in the per-resource queryset scoping in each viewset under core/views/.

Two independent dimensions: read scope vs. write permission

The single most important thing to understand is that JHE controls reads and writes by two different mechanisms:

So “Viewer” does not mean “can read less” — it means “can read the same, but cannot write.” Keep this distinction in mind throughout.

User Types

JHE has three distinct user types with different authorization models. The user type is stored on JheUser.user_type (patient / practitioner), with Super Admin being the Django is_superuser flag.

1. Patient

Authorization model: API-only access with full control over their own data.

Capabilities (via API or mobile app):

No role assignment. Patients do not have roles. They always have complete access to their own records and consent management without requiring a role.

Organization membership. Patients can belong to multiple organizations via PatientOrganization rows. This determines which organizations’ studies they can see and consent to, but does not grant Console access or any management capability.

2. Practitioner

Authorization model: organization-scoped access with a per-organization role.

Organization membership. A practitioner must be a member of an organization via a PractitionerOrganization row. Each membership carries its own role (Viewer, Member, or Manager).

Multi-organization, per-organization roles. A practitioner can belong to several organizations and hold a different role in each. For example:

Their abilities are evaluated separately for each organization: they can manage studies in the first two but only read in the third.

Access scope. A practitioner can read data for every patient who shares one of their organizations, and can write to the extent their role in the owning organization allows.

3. Super Admin

Authorization model: system-wide access (JheUser.is_superuser).

A superuser is treated as having the synthetic super_user role for every organization, so all IfUserCan checks pass. In addition, several resources are superuser-only:

Security considerations. Super admin access should be tightly controlled and limited to technical staff, periodically reviewed, and ideally protected with MFA.

Practitioner Role Hierarchy

Each PractitionerOrganization row assigns one of three roles (core/models/practitioner.py). Roles are cumulative — a higher role has every permission of the lower one plus more.

Inheritance:

Viewer (read-only)

Intended for: data analysts, research staff, and auditors who review data without changing it.

ROLE_PERMISSIONS["viewer"] is an empty list — a viewer holds no write permission, so every create/update/delete is rejected, while reads (which are not permission-gated) still succeed.

Member (patient & study management)

Intended for: research coordinators and clinical staff who run studies and enroll participants.

Manager (organization administration)

Intended for: principal investigators and organization administrators.

A manager’s authority is per-organization: it applies only to the organization (and its direct children) where they hold the manager role.

Role–Permission Matrix

These are the actual permission strings in ROLE_PERMISSIONS. Each is checked by IfUserCan("<resource>.<action>") against the practitioner’s role in the organization that owns the target resource.

Permission (resource.action)ViewerMemberManagerSuper Admin
(read any resource in your orgs)
patient.manage_for_organization
study.manage_for_organization
organization.manage_for_practitioners
organization.create_top_level
client.manage
data_source.manage

Notes:

How authorization is enforced

IfUserCan — per-organization role resolution

Write endpoints declare a permission like IfUserCan("study.manage_for_organization"). On each request it:

  1. Confirms the request is authenticated (else 401).

  2. If the user is a superuser and the resource is one of client / data_source / organization / practitioner / patient / study, grants the synthetic super_user role (full pass).

  3. Otherwise resolves which organization the action targets (see below) and looks up the practitioner’s PractitionerOrganization.role for that exact organization.

  4. Checks permission in ROLE_PERMISSIONS[role]. No matching role link, or a role without the permission, → 403.

The targeting organization is derived differently per resource and action:

Resource & actionOrganization the role is checked against
Create patientorganization_id in the request body
Create studyorganization in the request body
Create organization (sub-org)part_of (the parent organization) in the request body
Update/delete patientorganization_id query parameter
Update/delete studythe study’s own organization
Add/remove practitioner on an orgthat organization itself (user / remove_user actions)
Update/delete the organization entitythe parent organization (or the org itself if it is top-level)

Read scoping (queryset filtering)

Reads do not use IfUserCan. Instead each viewset’s get_queryset() (and, for the FHIR API, each model’s fhir_search) restricts results to the caller’s organizations:

A practitioner of any role gets this full read scope; the role only governs writes.

Organization hierarchy and authority

Organizations form a tree via Organization.part_of. Authority flows with the tree:

Consent is recorded per study and per data-type scope in StudyPatientScopeConsent (consented = true/false), against the scopes a study requests in StudyScopeRequest. Consent governs:

Who may change a consent (the POST/PATCH/DELETE consents endpoint):

Practitioner self-service API clients

A practitioner can mint their own machine-to-machine OAuth credentials (an API key) without admin involvement, via PractitionerClient (core/views/practitioner_client.py).

See the dedicated write-up for full details (model, key format, lifecycle).

Default organization assignment for new practitioners

This is an optional System Admin feature, off by default. When enabled, the moment a practitioner JheUser is first created JHE auto-assigns organization memberships and roles from the auth.default_orgs setting (core/models/jhe_user.py). The value is a ;-separated list of <org_id>:<role> pairs, e.g. 20001:viewer;20002:manager. Each referenced organization must exist and each role must be valid, or creation fails with a validation error. Leave the setting empty to assign memberships manually.

Authorization Enforcement Flow

Layers in order

  1. Authentication — a valid OAuth 2.0 token is required; otherwise 401.

  2. User-type branch — Patient (self-scoped), Practitioner (org-scoped), or Super Admin (full).

  3. Read — queryset is filtered to the practitioner’s organizations; role is irrelevant.

  4. WriteIfUserCan resolves the target organization, looks up the practitioner’s role there, and checks ROLE_PERMISSIONS; mismatch → 403.

  5. Consent — applies to patient uploads and study-scoped Observation reads (not to the practitioner read boundary).

Example scenarios

Viewer attempts to enroll a patient403. The viewer is authenticated and can read the study, but study.manage_for_organization is not in ROLE_PERMISSIONS["viewer"].

Member creates a study → granted. study.manage_for_organization is in the member’s permissions for that organization.

A practitioner who is a member of Neptunian Pulse Lab tries to edit a study in Lifespan Lab (where they are a viewer)403. The role is resolved against Lifespan Lab (the study’s owner), where they lack the permission.

Practitioner reads a patient’s observations → granted if they share any organization with the patient, regardless of role or the patient’s consent flags.

Patient reads their own consents → granted immediately; a patient reading another patient’s record → 403.

Manager adds a practitioner to their organization → granted via organization.manage_for_practitioners, checked against that organization.

Governance Best Practices

Principle of least privilege

Assign the lowest role that lets a practitioner do their job, per organization:

Remember that lowering a role removes write ability only; to restrict what a practitioner can see, change their organization memberships.

Separation of duties

For sensitive research, split responsibilities across roles and people, e.g. Members run data collection and consent, Viewers analyze, and a small number of Managers administer the organization and its practitioners.

Audit and review