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:
REis represented asERoles.RE.RE_SITE_OFFICIALis represented asERoles.RE_SITE_OFFICIAL.OPERATORis represented asERoles.OPERATOR.COOLING_USERis represented asERoles.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):
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:
This command is idempotent. The security app also applies the same role definitions on Django's post_migrate signal.
Permission check flow
- A request arrives at a DRF view.
- View code checks model-level permissions with
user.has_perm("app.codename"). Django's defaultModelBackendanswers these checks from the user's groups. - For endpoints that pass an object to
has_perm,ObjectPermissionBackendhandles instance-level company and cooling-unit scoping. - 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_OFFICIALandOPERATOR, 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 forREmemberships, explicitcooling_unit_idsfor 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
Request body:
Constraints enforced by the view:
- Caller must hold the
RErole in the same company. - A user cannot change their own role.
cooling_unit_idsmust 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:
- Removes the user from the managed staff Groups (
RE,RE_SITE_OFFICIAL, andOPERATOR). - Iterates over the user's current staff memberships.
- 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.