Role
A role is a flat set of permissions granted to a user inside one organizational boundary. It carries no semantics of its own — "Doctor", "Doctor Read Only" and "Doctor Scheduler" are three unrelated permission sets, not variations on a job title. A user can hold roles in many organizations but only one role per organization chain. RolePermission join rows expand a role into its effective permission list.
Two layers back the concept. The Django model (care/security/models/role.py) is storage: name, description, flags, and the contexts array. The Pydantic resource specs (care/emr/resources/role/spec.py, care/emr/resources/permissions.py) define the API read/write schemas, bind contexts to an enum, type the permissions field, and hold every create/update validation rule.
Source:
- Model:
care/security/models/role.py - Spec:
care/emr/resources/role/spec.py - Permissions mixins:
care/emr/resources/permissions.py - Role definitions:
care/security/roles/role.py - Permission registry:
care/security/permissions/base.py
Models
| Model | Purpose |
|---|---|
RoleModel | A named role grouping a set of permissions |
RolePermission | Join table linking a RoleModel to a PermissionModel |
Both extend BaseModel (see Base model), so they get external_id, created_date/modified_date, and soft-delete via deleted — but none of the created_by/updated_by/history/meta fields that EMRBaseModel adds.
RoleModel fields
Only a superuser can create, update, or delete a role through the API. System roles (is_system=True) are exempt even then: the API blocks creating, updating, or deleting them.
Identity & description
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name | CharField(1024) | yes | — | Unique across non-deleted rows (see Meta). The spec rejects empty/blank values and any name that already exists case-insensitively |
description | TextField | no | "" | Free text |
contexts | ArrayField[CharField(24)] | yes | [] | Boundary types the role applies to. The column stores opaque strings; the spec constrains each element to the RoleContext enum (see RoleContext values) |
Status flags
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
is_system | BooleanField | no | False | Set by the platform. These roles can't be deleted, and the create/update spec rejects any payload carrying is_system=True as well as any update to an existing system role |
temp_deleted | BooleanField | no | False | Stages a role for removal independently of deleted. Not exposed in the API specs |
is_archived | BooleanField | no | False | Hides the role without deleting it. Read/write through the specs |
Meta / constraints
name is unique only among non-deleted rows, enforced by a partial constraint:
UniqueConstraint(fields=["name"], condition=Q(deleted=False), name="unique_name_if_not_deleted")
RoleCreateSpec checks the same uniqueness case-insensitively (name__iexact) before the request ever reaches the DB constraint.
Related models
RolePermission
Each row connects a role to one permission; a role accumulates its grants across many rows.
role → FK RoleModel (CASCADE, required)
permission → FK PermissionModel (CASCADE, required)
temp_deleted → BooleanField (default False)
temp_deleted excludes a grant from the role without deleting the row — permission lookups filter on rolepermission__temp_deleted=False.
PermissionModel
The permission a RolePermission points at (Permission).
slug → CharField(1024) unique, indexed
name → CharField(1024)
description → TextField (default "")
context → CharField(1024) # one of the PermissionContext values
temp_deleted → BooleanField (default False)
The set of available permissions lives in code, not as fixed DB rows. PermissionController (care/security/permissions/base.py) registers them by aggregating per-domain enum.Enum handlers such as PatientPermissions and FacilityPermissions, where each member's value is a Permission(name, description, context, roles) dataclass.
Enum / value tables
RoleContext values
Each element of contexts is validated against this enum (care/security/roles/role.py). These name the organizational boundary a role can apply to — a different axis from PermissionContext.
| Value | Meaning |
|---|---|
FACILITY | Role applies within a facility |
GOVT_ORG | Role applies within a government organization |
ROLE_ORG | Role applies within a role (user-group) organization |
PermissionContext values
PermissionModel.context / Permission.context is one of these (care/security/permissions/constants.py). The permissions mixins below filter grants by context to resolve a user's effective permissions.
| Value |
|---|
GENERIC |
FACILITY |
PATIENT |
QUESTIONNAIRE |
ORGANIZATION |
FACILITY_ORGANIZATION |
ENCOUNTER |
PermissionEnum (write field for permissions)
RoleCreateSpec.permissions is typed against PermissionController.get_enum(), a str enum built at runtime from every registered permission name across all handlers. Values are permission identifiers (slugs) like can_create_patient, can_write_patient, can_list_patients, can_view_clinical_data. The set is the union of PermissionController.internal_permission_handlers plus anything plugins register, so it varies by deployment.
Built-in (is_system) roles
RoleController (care/security/roles/role.py) seeds these with is_system=True. None can be created, updated, or deleted through the API.
| Role | Contexts |
|---|---|
Doctor | FACILITY, GOVT_ORG |
Nurse | FACILITY, GOVT_ORG |
Staff | FACILITY, GOVT_ORG |
Volunteer | FACILITY, GOVT_ORG |
Pharmacist | FACILITY |
Administrator | FACILITY, GOVT_ORG |
Facility Admin | FACILITY |
Admin | FACILITY, GOVT_ORG |
Admin (role org) | ROLE_ORG |
Manager (role org) | ROLE_ORG |
Member (role org) | ROLE_ORG |
Resource specs (API schema)
Every spec extends EMRResource (care/emr/resources/base.py), which supplies serialize (DB → Pydantic, via perform_extra_serialization) and de_serialize (Pydantic → DB, via perform_extra_deserialization). RoleBaseSpec sets __exclude__ = ["permissions"] so each subclass declares permissions itself instead of inheriting it.
| Spec class | Role | Fields |
|---|---|---|
RoleBaseSpec | shared base | id, name, description, is_system, is_archived, contexts |
RoleCreateSpec | write · create & update | base fields + permissions: list[PermissionEnum] |
RoleReadSpec | read · detail/list | base fields + permissions: list[PermissionSpec] |
RoleReadMinimalSpec | read · minimal/embedded | base fields only |
PermissionSpec | nested (read) | name, description, slug (SlugType), context |
Field shapes & bindings
| Field | Spec type | Notes |
|---|---|---|
id | UUID4 | None | The role's external_id on read (set in perform_extra_serialization); ignored on write |
name | str | None | Required and non-blank on create; case-insensitive uniqueness enforced |
description | str | None | Optional |
is_system | bool | None (default False) | Must be falsy on write — True is rejected |
is_archived | bool | None (default False) | — |
contexts | list[RoleContext] | Bound to the RoleContext enum |
permissions (write) | list[PermissionEnum] (default []) | Dynamic str enum of every registered permission name; must contain ≥ 1 entry |
permissions (read) | list[PermissionSpec] | Resolved server-side from the role's active grants |
PermissionSpec.slug | SlugType | str, length 5–50, pattern ^[a-zA-Z0-9][a-zA-Z0-9_-]*[a-zA-Z0-9]$ |
Validation (RoleCreateSpec.validate_role, mode="after")
The validator runs on both create and update; it reads the is_update flag and the target object from the serializer context.
- On create,
namemust be present and non-blank ("Role name cannot be empty"). namemust be unique case-insensitively (name__iexact), excluding the current row on update. A duplicate raises"Role with this name already exists".- Updating a role with
is_system=Trueraises"Cannot update system roles". is_system=Truein the payload raises"Cannot create system roles".permissionsmust be non-empty, otherwise"At least one permission must be assigned to the role".permissionsis de-duplicated (list(set(...))).
Server-side serialization behaviour
- Write (
RoleCreateSpec.perform_extra_deserialization): the validatedpermissionslist is attached to the instance asobj.permissions, a transient attribute —permissionsis in__exclude__and isn't a model column. The view/service layer materializes it intoRolePermissionrows. - Read detail (
RoleReadSpec.perform_extra_serialization): setsid = obj.external_idand fillspermissionsfromobj.get_permissions_for_role(), the cached{name, slug, context, description}list for every active (non-temp_deleted) grant. - Read minimal (
RoleReadMinimalSpec.perform_extra_serialization): setsid = obj.external_idand nothing else;permissionsis omitted.
Permission resolution mixins
care/emr/resources/permissions.py defines mixins that other resource specs inherit to expose the calling user's effective permissions on an object — not the role's own permission list. They populate mapping["permissions"] in perform_extra_user_serialization, but only when an authenticated user is passed to serialize.
| Mixin | Resolves | Context filter |
|---|---|---|
PermissionsMixin | base — adds permissions: list[str] | — |
PatientPermissionsMixin | roles the user holds on a patient | permission__context in ("PATIENT", "FACILITY") |
EncounterPermissionsMixin | roles the user holds on an encounter | permission__context in ("ENCOUNTER", "PATIENT") |
FacilityPermissionsMixin | roles on facility root + sub-orgs; also exposes root_org_permissions and child_org_permissions (child set excludes can_update_facility) | none (root) / excludes can_update_facility (child) |
Methods & save behaviour
RoleModel resolves its effective permissions through two cached helpers backed by the Django cache (Redis) with a 7-day TTL:
get_permission_sk_for_role() → list[str]— theslugof every active permission on the role. Cache keyrole_permissions_cache:{id}.get_permissions_for_role() → list[dict]— full detail (name,slug,context,description) for every active permission. Cache keyrole_permissions:{id}. Used byRoleReadSpec.
Both queries join through RolePermission and exclude temp_deleted=True grants.
Cache invalidation signal
invalidate_role_permissions_cache, a @receiver([post_save, post_delete], sender=RolePermission) handler, clears both cache keys for the affected role_id on every create, update, or delete of a RolePermission. ORM writes invalidate the cache automatically; raw SQL writes skip the signal and leave stale caches behind.
Viewset
RoleViewSet (care/security/api/viewsets/roles.py) extends EMRModelViewSet with database_model = RoleModel, pydantic_model = RoleCreateSpec, and pydantic_read_model = RoleReadSpec.
| Aspect | Behaviour |
|---|---|
| Authorization | permissions_controller returns True for list and retrieve; every write action requires request.user.is_superuser. No permission slug gates this viewset |
| Filters | RoleFilter — name (icontains) and context, which matches with contexts__overlap=[value] |
| Create | perform_create runs in a transaction, then materializes instance.permissions into RolePermission rows |
| Update | perform_update deletes the existing RolePermission rows and recreates them from the payload, so permissions is a full replacement, never a patch |
| Delete | validate_destroy raises "Cannot delete system roles" when is_system=True; perform_destroy also deletes the role's RolePermission rows |
_add_permissions resolves the payload against PermissionModel.slug. RoleCreateSpec.permissions is typed against permission names, so a payload only takes effect where the permission name and slug agree.
Permission and role sync
The sync_permissions_roles management command (care/security/management/commands/sync_permissions_roles.py) writes the code-declared permissions and system roles to the database. It runs under a Redis lock, marks existing rows temp_deleted=True, upserts every registered permission and role, deletes the rows that no handler still declares, then rebuilds the RolePermission rows for system roles from each permission's roles list. Run it after every deployment and plugin change.
API integration notes
- Only a superuser can create, update, or delete a role. Any authenticated caller can list and retrieve roles.
- Both create and update go through
RoleCreateSpec.permissionsis required (≥ 1) and de-duplicated; you never setis_system, andTrueis rejected. - Update replaces the role's permission set. Send the full list on every update.
- Reads use
RoleReadSpec(full, with nestedPermissionSpec[]) orRoleReadMinimalSpec(no permissions), depending on the endpoint. - To change a role's membership, write
RolePermissionrows rather than editingRoleModeldirectly. Use the ORM so the cache-invalidation signal fires; raw SQL leaves the Redis cache stale. - Other access-control models (organization memberships, patient/encounter associations) reference roles to resolve a user's effective permissions in a given context.
Related
- Concept: Role
- Flow: Create a role
- Flow: Edit or clone a role
- Reference: Permission
- Reference: Permission association
- Reference: User
- Reference: Organization
- Reference: Base model
- Source: role.py on GitHub
- Source: role/spec.py on GitHub
- Source: permissions.py on GitHub