Skip to main content
Version: 3.1

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:

Models

ModelPurpose
RoleModelA named role grouping a set of permissions
RolePermissionJoin 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

FieldTypeRequiredDefaultNotes
nameCharField(1024)yesUnique across non-deleted rows (see Meta). The spec rejects empty/blank values and any name that already exists case-insensitively
descriptionTextFieldno""Free text
contextsArrayField[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

FieldTypeRequiredDefaultNotes
is_systemBooleanFieldnoFalseSet 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_deletedBooleanFieldnoFalseStages a role for removal independently of deleted. Not exposed in the API specs
is_archivedBooleanFieldnoFalseHides 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.

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.

ValueMeaning
FACILITYRole applies within a facility
GOVT_ORGRole applies within a government organization
ROLE_ORGRole 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.

RoleContexts
DoctorFACILITY, GOVT_ORG
NurseFACILITY, GOVT_ORG
StaffFACILITY, GOVT_ORG
VolunteerFACILITY, GOVT_ORG
PharmacistFACILITY
AdministratorFACILITY, GOVT_ORG
Facility AdminFACILITY
AdminFACILITY, 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 classRoleFields
RoleBaseSpecshared baseid, name, description, is_system, is_archived, contexts
RoleCreateSpecwrite · create & updatebase fields + permissions: list[PermissionEnum]
RoleReadSpecread · detail/listbase fields + permissions: list[PermissionSpec]
RoleReadMinimalSpecread · minimal/embeddedbase fields only
PermissionSpecnested (read)name, description, slug (SlugType), context

Field shapes & bindings

FieldSpec typeNotes
idUUID4 | NoneThe role's external_id on read (set in perform_extra_serialization); ignored on write
namestr | NoneRequired and non-blank on create; case-insensitive uniqueness enforced
descriptionstr | NoneOptional
is_systembool | None (default False)Must be falsy on write — True is rejected
is_archivedbool | None (default False)
contextslist[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.slugSlugTypestr, 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, name must be present and non-blank ("Role name cannot be empty").
  • name must 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=True raises "Cannot update system roles".
  • is_system=True in the payload raises "Cannot create system roles".
  • permissions must be non-empty, otherwise "At least one permission must be assigned to the role".
  • permissions is de-duplicated (list(set(...))).

Server-side serialization behaviour

  • Write (RoleCreateSpec.perform_extra_deserialization): the validated permissions list is attached to the instance as obj.permissions, a transient attribute — permissions is in __exclude__ and isn't a model column. The view/service layer materializes it into RolePermission rows.
  • Read detail (RoleReadSpec.perform_extra_serialization): sets id = obj.external_id and fills permissions from obj.get_permissions_for_role(), the cached {name, slug, context, description} list for every active (non-temp_deleted) grant.
  • Read minimal (RoleReadMinimalSpec.perform_extra_serialization): sets id = obj.external_id and nothing else; permissions is 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.

MixinResolvesContext filter
PermissionsMixinbase — adds permissions: list[str]
PatientPermissionsMixinroles the user holds on a patientpermission__context in ("PATIENT", "FACILITY")
EncounterPermissionsMixinroles the user holds on an encounterpermission__context in ("ENCOUNTER", "PATIENT")
FacilityPermissionsMixinroles 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] — the slug of every active permission on the role. Cache key role_permissions_cache:{id}.
  • get_permissions_for_role() → list[dict] — full detail (name, slug, context, description) for every active permission. Cache key role_permissions:{id}. Used by RoleReadSpec.

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.

AspectBehaviour
Authorizationpermissions_controller returns True for list and retrieve; every write action requires request.user.is_superuser. No permission slug gates this viewset
FiltersRoleFiltername (icontains) and context, which matches with contexts__overlap=[value]
Createperform_create runs in a transaction, then materializes instance.permissions into RolePermission rows
Updateperform_update deletes the existing RolePermission rows and recreates them from the payload, so permissions is a full replacement, never a patch
Deletevalidate_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. permissions is required (≥ 1) and de-duplicated; you never set is_system, and True is rejected.
  • Update replaces the role's permission set. Send the full list on every update.
  • Reads use RoleReadSpec (full, with nested PermissionSpec[]) or RoleReadMinimalSpec (no permissions), depending on the endpoint.
  • To change a role's membership, write RolePermission rows rather than editing RoleModel directly. 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.