Skip to content

RBAC

Coldtivate enforces Role-Based Access Control (RBAC) end-to-end: Django Groups and a custom object-level permission backend on the server, and CASL abilities on the mobile client. For most users, permissions are scoped to a UserCompanyMembership record that binds them to a specific company. Users who registered via the basic company self-registration flow hold a Django Group assignment only and are not subject to membership-based object-level scope — see Assignment.


Roles

Four roles exist in the system, represented by UserCompanyMembership.Role (a TextChoices enum):

Role constant Display name Typical persona
RE Registered Employee Company manager / owner
RE_SITE_OFFICIAL Site Official On-site cold room attendant
OPERATOR Operator Field agent / logistics staff
COOLING_USER Cooling User Farmer / end customer

The React Native mobile app normalizes backend roles into app roles:

  • RE is represented as ERoles.RE.
  • RE_SITE_OFFICIAL is represented as ERoles.RE_SITE_OFFICIAL.
  • OPERATOR is represented as ERoles.OPERATOR.
  • COOLING_USER is represented as ERoles.COOLING_USER.

UserCompanyMembership

UserCompanyMembership is the central join model that binds a user account to a company with a specific role.

Schema

class UserCompanyMembership(models.Model):
    user         = ForeignKey(User, on_delete=CASCADE, related_name='company_memberships')
    company      = ForeignKey(Company, on_delete=CASCADE, related_name='memberships')
    role         = CharField(max_length=20, choices=Role.choices)
    cooling_unit_ids = ArrayField(IntegerField(), default=list)  # PostgreSQL array

    class Meta:
        unique_together = ('user', 'company')
        constraints = [
            UniqueConstraint(
                fields=['user'],
                condition=Q(role__in=['RE', 'RE_SITE_OFFICIAL', 'OPERATOR']),
                name='uq_membership_one_staff_company_per_user',
            ),
        ]

Each staff user (RE, RE_SITE_OFFICIAL, OPERATOR) belongs to exactly one company, enforced at the DB level. A COOLING_USER may have profiles at multiple companies. cooling_unit_ids is a scoping array that limits which cooling units a Site Official or Operator is permitted to act upon (an empty array for RE means no extra restriction — the company dimension already scopes them).

Assignment

When a user registers via invitation, a UserCompanyMembership is created with the role specified on the invitation via service_provider_registration.py (ServiceProviderRegistrationWithInvitationSerializer):

UserCompanyMembership.objects.create(
    user=user, company=company, role=<role from invitation>
)

The basic company self-registration flow (ServiceProviderRegistrationSerializer) does not create a UserCompanyMembership record — it grants the RE role by adding the user to the corresponding Django Group only.

Cooling unit scoping

The cooling_unit_ids array is meaningful for RE_SITE_OFFICIAL and OPERATOR memberships. At object permission-check time, ObjectPermissionBackend resolves the cooling unit IDs from the object being tested (via get_cooling_unit_ids_for_object(obj)) and verifies that every resolved ID falls within the union of the user's scoped membership cooling_unit_ids. Queryset scoping (scope_to_cooling_units) is a separate layer that applies the same accessible-ID set to filter list responses. An empty array is treated as no access (fail-closed), so a freshly created Site Official membership must have cooling units explicitly assigned before the user can operate.


Django Permissions

Groups and GroupConfig

Each role maps to one Django Group object. The mapping lives in roles.py as a roles list of GroupConfig objects:

roles = [
    GroupConfig(name="RE", permissions=(...)),
    GroupConfig(name="RE_SITE_OFFICIAL", permissions=(...)),
    GroupConfig(name="OPERATOR", permissions=(...)),
    GroupConfig(name="COOLING_USER", permissions=()),
]

COOLING_USER currently has no Django group permissions. Cooling-user flows are handled by dedicated views and serializers rather than broad model permissions.

Group initialisation

Groups and their permissions are created / updated by running:

python manage.py load_roles

This command is idempotent. The security app also applies the same role definitions on Django's post_migrate signal.

Permission check flow

  1. A request arrives at a DRF view.
  2. View code checks model-level permissions with user.has_perm("app.codename"). Django's default ModelBackend answers these checks from the user's groups.
  3. For endpoints that pass an object to has_perm, ObjectPermissionBackend handles instance-level company and cooling-unit scoping.
  4. Views that need both dimensions usually perform both checks explicitly: first the model permission, then user.has_perm("app.codename", obj).

Object-Level Scoping

Dimensions

Permission checks use two scope dimensions:

  • Company scope — an RE may only act on objects that belong to their company.
  • Cooling unit scope — for RE_SITE_OFFICIAL and OPERATOR, every cooling unit associated with the object must appear in the union of the user's scoped memberships.

RE members pass the cooling-unit check automatically because their role is not subject to that dimension.

ObjectPermissionBackend

ObjectPermissionBackend is registered in AUTHENTICATION_BACKENDS alongside Django's default backend. It implements has_perm(user, perm, obj=None) and only grants object-level checks when obj is provided:

if obj is None → deny; ModelBackend handles model-level checks
if an RE membership matches the object's resolved company → grant
union cooling_unit_ids from all RE_SITE_OFFICIAL and OPERATOR memberships
resolve all cooling unit IDs associated with obj
if the IDs are non-empty and every ID is in that union → grant
if role == COOLING_USER → deny object-level permission
deny by default (fail-closed)

Scoped object checks do not require the object to resolve to a company. This supports operational objects whose company relation is null but whose cooling units can be resolved. An empty or unresolved cooling-unit set is denied.

Scoping utilities

scoping.py provides helper functions used in querysets and serializers:

  • get_accessible_companies(user) — returns companies where the user has any membership.
  • get_accessible_cooling_unit_ids(user) — returns all active company cooling units for RE memberships, explicit cooling_unit_ids for scoped staff memberships, and units associated with the user's active Cooling User profiles.
  • scope_to_company(qs, user, company_field="company") — filters a queryset to companies the user can access.
  • scope_to_cooling_units(qs, user, cooling_unit_field="cooling_unit") — filters a queryset to cooling units the user can access.

Update restrictions for Site Officials

RE Site Officials have read-only access to company and cooling-unit configuration. The company and cooling-unit update endpoints reject Site Official requests; there is no Site Official-specific writable field subset.


Frontend CASL Integration

Ability factory

mobile-app-react-native/src/common/RBAC/abilities.ts exports permissionsFactory(role, contextualCountry, coolingUnitIds):

export default function permissionsFactory(
  role = ERoles.AUTH,
  contextualCountry?: string,
  coolingUnitIds: Array<number> = []
) {
  return defineAbility((can, cannot) => {
    const assignedCoolingUnitScope = { id: { $in: coolingUnitIds } };

    switch (role) {
      case ERoles.RE:
        can(PERMISSION_KINDS.MANAGE, 'CoolingUnit');
        can(PERMISSION_KINDS.MANAGE, 'Location');
        can(PERMISSION_KINDS.MANAGE, 'Operator');
        can(PERMISSION_KINDS.MANAGE, 'RegisteredEmployee');
        can(PERMISSION_KINDS.MANAGE, 'Invitation');
        can(PERMISSION_KINDS.UPDATE, 'UserMembership');
        can(PERMISSION_KINDS.SET, 'CompanyPayoutSettings');
        break;

      case ERoles.RE_SITE_OFFICIAL:
        // cooling unit access (scoped to assigned units)
        can(PERMISSION_KINDS.READ, 'CoolingUnit', assignedCoolingUnitScope);
        cannot(PERMISSION_KINDS.CREATE, 'CoolingUnit');
        cannot(PERMISSION_KINDS.UPDATE, 'CoolingUnit');
        cannot(PERMISSION_KINDS.DELETE, 'CoolingUnit');
        cannot(PERMISSION_KINDS.MANAGE, 'Operator');
        cannot(PERMISSION_KINDS.MANAGE, 'RegisteredEmployee');
        // navigation (can view CompanyDetails; cannot reach CoolingUsers or selling settings)
        can(PERMISSION_KINDS.NAVIGATE, 'ManagementStack');
        can(PERMISSION_KINDS.NAVIGATE, 'CompanyDetails');
        cannot(PERMISSION_KINDS.NAVIGATE, 'CoolingUsers');
        cannot(PERMISSION_KINDS.NAVIGATE, 'EditSellingSettings');
        cannot(PERMISSION_KINDS.NAVIGATE, 'ManageCouponsSettings');
        cannot(PERMISSION_KINDS.SET, 'CompanyPayoutSettings');
        // ... additional navigation and feature-flag rules in abilities.ts
        break;

      case ERoles.OPERATOR:
        can(PERMISSION_KINDS.NAVIGATE, 'ManagementStack');
        can(PERMISSION_KINDS.NAVIGATE, 'CheckoutStack');
        can(PERMISSION_KINDS.NAVIGATE, 'EditSellingSettings');
        can(PERMISSION_KINDS.NAVIGATE, 'ManageCouponsSettings');
        can(PERMISSION_KINDS.NAVIGATE, 'RevenueAnalysis');
        can(PERMISSION_KINDS.NAVIGATE, 'UsageAnalysis');
        can(PERMISSION_KINDS.NAVIGATE, 'CoolingUsers');
        can(PERMISSION_KINDS.NAVIGATE, 'CratesInfo');
        can(PERMISSION_KINDS.SET, 'CompanyPayoutSettings');
        can(PERMISSION_KINDS.SET, 'Temperatures');
        can(PERMISSION_KINDS.VIEW, 'TemperatureAlertModal');
        can(PERMISSION_KINDS.VIEW, 'OperatorActions');
        cannot(PERMISSION_KINDS.NAVIGATE, 'CompanyDetails');
        cannot(PERMISSION_KINDS.NAVIGATE, 'Locations');
        cannot(PERMISSION_KINDS.NAVIGATE, 'CoolingUnits');
        cannot(PERMISSION_KINDS.NAVIGATE, 'Operators');
        cannot(PERMISSION_KINDS.NAVIGATE, 'RegisteredEmployees');
        cannot(PERMISSION_KINDS.NAVIGATE, 'DeliveryContacts');
        cannot(PERMISSION_KINDS.NAVIGATE, 'Maps');
        cannot(PERMISSION_KINDS.SET, 'FormEmailField');
        cannot(PERMISSION_KINDS.VIEW, 'CoolingUserFields');
        cannot(PERMISSION_KINDS.STORE, 'CoolingUserDetails');
        // ... country-specific feature-flag rules in abilities.ts
        break;

      case ERoles.COOLING_USER:
        can(PERMISSION_KINDS.NAVIGATE, 'EditSellingSettings');
        can(PERMISSION_KINDS.NAVIGATE, 'ManageCouponsSettings');
        can(PERMISSION_KINDS.NAVIGATE, 'Maps');
        cannot(PERMISSION_KINDS.NAVIGATE, 'CratesInfo');
        can(PERMISSION_KINDS.VIEW, 'CoolingUserFields');
        can(PERMISSION_KINDS.STORE, 'CoolingUserDetails');
        cannot(PERMISSION_KINDS.NAVIGATE, 'ManagementStack');
        cannot(PERMISSION_KINDS.NAVIGATE, 'CheckoutStack');
        cannot(PERMISSION_KINDS.NAVIGATE, 'DeliveryContacts');
        cannot(PERMISSION_KINDS.SET, 'FormEmailField');
        cannot(PERMISSION_KINDS.SET, 'Temperatures');
        cannot(PERMISSION_KINDS.VIEW, 'TemperatureAlertModal');
        cannot(PERMISSION_KINDS.VIEW, 'OperatorActions');
        // ... country-specific feature-flag rules in abilities.ts
        break;
    }
  });
}

RBAC context

mobile-app-react-native/src/common/RBAC/index.tsx provides the RBAC.useRBAC() hook, which exposes:

const { guard } = RBAC.useRBAC();

// action: 'CREATE' | 'READ' | 'UPDATE' | 'DELETE' | 'MANAGE' | 'NAVIGATE' | 'SET' | 'VIEW' | 'STORE'
// subject: 'CoolingUnit' | 'Location' | 'Operator' | ...
// field (optional): keyof of the subject's form values
guard(action, subject, field?): boolean

Use guard() for capability checks in UI code. Role helpers such as isSiteOfficial(user) still exist for role-specific branching, but they should not replace ability checks for actions that are represented in CASL.

Field-level restrictions

The RBAC API supports field-level restrictions where a role has a writable field subset. For example, a form can compute a field guard once and pass it down:

const canEditCoolingUnitField = useCallback(
  (field: keyof FormValues) => canManageCoolingUnit || guard('UPDATE', 'CoolingUnit', field),
  [guard, canManageCoolingUnit]
);

Fields the user cannot edit can be rendered with pointerEvents="none" and opacity: 0.4 via RBAC.ProtectedResource with showDisabled. Site Officials currently have no writable cooling-unit fields, so cooling-unit configuration must not use a field-level exception for that role.

RBAC.ProtectedResource = function ProtectedResource({ action, subject, field, showDisabled, children }) {
  const { guard } = RBAC.useRBAC();
  if (guard(action, subject, field)) {
    return <>{children}</>;
  }
  if (showDisabled) {
    return (
      <View pointerEvents="none" style={{ opacity: 0.4 }}>
        {children}
      </View>
    );
  }
  return null;
}

Equivalent local wrappers may use the same visible-but-disabled pattern:

function FieldWrapper({ editable, children }) {
  return (
    <View pointerEvents={editable ? 'auto' : 'none'} style={editable ? undefined : { opacity: 0.4 }}>
      {children}
    </View>
  );
}

Role Management

PATCH endpoint

PATCH /user/v1/users/{user_id}/membership/

Request body:

{
  "role": "RE_SITE_OFFICIAL",
  "cooling_unit_ids": [12, 45]
}

Constraints enforced by the view:

  • Caller must hold the RE role in the same company.
  • A user cannot change their own role.
  • cooling_unit_ids must contain IDs that belong to the caller's company.

Permitted role transitions

PERMITTED_TRANSITIONS = {
    RE:               [RE_SITE_OFFICIAL, OPERATOR],
    RE_SITE_OFFICIAL: [RE],
    OPERATOR:         [RE, RE_SITE_OFFICIAL],
    COOLING_USER:     [],   # no transitions permitted
}

A COOLING_USER membership cannot be elevated via this endpoint; that transition requires a separate registration flow.

Group sync on role change

When a staff membership role is updated, the view:

  1. Removes the user from the managed staff Groups (RE, RE_SITE_OFFICIAL, and OPERATOR).
  2. Iterates over the user's current staff memberships.
  3. Adds the user to the corresponding staff Groups.

This keeps the managed Django staff Groups consistent with the user's staff memberships. Cooling User Group membership is not managed by this endpoint.


Data Model Diagram

erDiagram
    User ||--o{ UserCompanyMembership : "belongs to"
    Company ||--o{ UserCompanyMembership : "has members"
    UserCompanyMembership {
        int user_id
        int company_id
        string role
        int[] cooling_unit_ids
    }
    Company ||--o{ Location : "has"
    Location ||--o{ CoolingUnit : "has"
    Location {
        int id
        int company_id
    }
    CoolingUnit {
        int id
        string name
        int location_id
    }
    %% cooling_unit_ids is a PostgreSQL integer array on UserCompanyMembership, not a relational join

Permission Matrix

Capability RE RE Site Official Operator Cooling User
Manage company details
Create / delete location
View location (navigate)
Create cooling unit
Edit cooling unit
Delete cooling unit
Manage operators
Manage registered employees
Change member roles
Create check-in/check-out ✅ (scoped)
View movements/check-ins/check-outs ✅ (scoped) ✅ (scoped) own / filtered flows
Edit check-in ✅ (scoped)
Revenue / usage analysis navigation ✅ (scoped)
Company payout settings

"Scoped" means the user can only access objects associated with their assigned cooling_unit_ids.