Skip to the content.

ADR 002 — Hybrid Role Architecture: System Roles + Job Titles

Date: March 2026 Status: Accepted Author: Ezinna (Founder)


Context

AgriOps is a multi-tenant SaaS platform serving agricultural SMEs and cooperatives across diverse markets. During Phase 1 development, a critical design question arose around user roles: how do we implement role-based access control (RBAC) in a way that is both secure and flexible enough to accommodate the diversity of job titles across different client organisations?

A cooperative in Northern Nigeria may use “Field Coordinator” where an agri-processor in Plateau State uses “Zone Supervisor” — both functionally equivalent roles with identical access requirements but entirely different titles. A fixed choices-only approach would either be too restrictive or require constant maintenance as new client organisations onboard with their own internal terminology.


Decision Drivers


Options Considered

Option 1 — Fixed choices only

A single role field with predefined choices: admin, manager, staff, viewer.

Pros: Simple, consistent, easy to enforce in permission logic.

Cons: Forces clients to use platform terminology rather than their own. A “Head of Procurement” becomes “Manager” — creates friction and adoption resistance. Does not scale across diverse markets.

Option 2 — Free text only

A single role CharField where users type whatever they want.

Pros: Maximum flexibility, feels natural to each organisation.

Cons: Catastrophic for security. Permission logic cannot reliably gate access based on free-text values. A typo, a case difference, or a deliberate manipulation could grant or deny access incorrectly. Completely unworkable for RBAC.

Option 3 — Hybrid: system_role + job_title ✅ Chosen

Two separate fields on CustomUser:

Pros:

Cons:


Decision

Implement hybrid role architecture with system_role (fixed choices) and job_title (free text) as two separate fields on CustomUser.

system_role is the single source of truth for all permission and access control logic throughout the platform. job_title is a display field only and must never be referenced in permission checks, querysets, or access control logic anywhere in the codebase.


Implementation

class CustomUser(AbstractUser):
    ROLE_CHOICES = [
        ('org_admin', 'Organisation Admin'),
        ('manager', 'Manager'),
        ('staff', 'Staff'),
        ('viewer', 'Viewer'),
    ]
    system_role = models.CharField(
        max_length=30,
        choices=ROLE_CHOICES,
        default='staff'
    )
    job_title = models.CharField(
        max_length=100,
        blank=True,
        help_text="Display title within the organisation. Does not affect permissions."
    )

Consequences


Security Note

This decision directly mitigates privilege escalation via role field manipulation. Because job_title has no bearing on permissions, a user who edits their own profile cannot affect their access level regardless of what they enter. system_role changes are restricted to OrgAdmin and logged in the audit trail.



Phase 2 Implementation Note

During Phase 2, the original Phase 1 role field (which held business job titles like ceo, operations_manager) was migrated to the hybrid architecture specified in this ADR. Migration 0002_add_system_role_job_title removed the role field and added system_role (fixed choices, RBAC-bearing) and job_title (free text, display only). All existing users were assigned system_role='staff' as the safe default during migration.

The permission layer is implemented in apps/users/permissions.py via RoleRequiredMixin and its subclasses (StaffRequiredMixin, ManagerRequiredMixin, OrgAdminRequiredMixin). The API permission layer mirrors this in apps/api/permissions.py via IsTenantMember, IsManagerOrAbove, and IsOrgAdmin.