Permissions as Data: The ERP the Org Rewires Without an Engineer

How our foundation's ERP lets admins create departments and positions and assign access from the UI - enforced by Django Groups in the view and in SQL.

Permissions as Data: The ERP the Org Rewires Without an Engineer
Contents

The Ambarsthan booking engine from the last chapter put its foot down in exactly one place on purpose: the correctness of two people grabbing the same slot lives in the database — row locks, an exclusion constraint, the right isolation level — not in hopeful application code. Same Django backend, a very different neighbour app riding on it, and this time the hard part is not concurrency. It is change.

The Tejgyan Foundation runs an internal ERP on this same backend — the boring, load-bearing software that tracks departments, the people in them, donations, inventory, who is allowed to see and touch what. An NGO staffed largely by volunteers does not have a frozen org chart. A new department spins up for a festival. A “centre coordinator” position appears, then splits into regional variants. A volunteer who could only view donation summaries now needs to edit them, but still must not touch payroll. In a normal build, every one of those sentences is a code change: a new role constant, an if user.role == "coordinator" somewhere, a pull request, a review, CI, a deploy. Engineering becomes the bottleneck for what is, fundamentally, an org-chart edit. That is absurd, and this chapter is about refusing to accept it.

First, the two words people blur together

Two different questions hide under the word “login”, and keeping them apart is the whole game.

Authentication is who are you. Back in chapter six, one Keycloak realm answered that for every app on the platform — you sign in once, and every frontend trusts the same identity. Authentication is settled before the ERP ever sees a request.

Authorization is what are you allowed to do. This is the ERP’s problem, and it is entirely separate. Keycloak will happily tell me “this is Priya, and she is really Priya” — it has no opinion on whether Priya may delete an invoice. That opinion has to live somewhere, and where it lives is the decision that makes or breaks the “no deploy” promise.

The crash course: Django already ships an authorization model

Most teams reach for a permission library or roll their own. Before doing either, it is worth knowing exactly what django.contrib.auth hands you for free, because it is more than people assume.

A Permission in Django is just a row in a table. For every model you define, Django auto-creates four of them — add, change, delete, view — each with a codename like view_invoice or add_booking. A permission is data. It has a primary key. You can query it. Crucially, these are model-level: view_invoice answers “may this user look at invoices at all”, not “at which invoices” — hold that thought, it matters later.

A Group is a named bag of permissions. Membership is a many-to-many: a user can be in several groups, and a user’s effective permissions are the union of every group they belong to. Ask user.has_perm("erp.view_invoice") and Django walks the user to their groups to those groups’ permissions and answers true or false. The whole thing is three join tables and a cached lookup — I will show the actual SQL in a moment.

Now the distinction that this entire chapter turns on:

  • Permissions as code. The rules live in your source. if user.is_accountant: — readable, fast to write, and every time the org changes, an engineer has to change the code and ship it. The org chart is frozen into a binary you deploy.
  • Permissions as data. The rules live in rows an administrator can edit. Create a group, tick some permission boxes, add people. The running application reads those rows and behaves accordingly. Nobody deploys anything, because nothing in the code changed — only the data it reads.

Django’s Group and Permission tables are already permissions-as-data. Most teams then proceed to ignore that and hardcode roles anyway.

The options I actually weighed

Option A — hardcode the org chart in code. Fastest to start, and I have shipped it on smaller products. It collapses precisely when the org restructures often, which for this foundation is monthly. Every festival, every new centre, every reshuffle becomes an engineering ticket. Rejected on sight given what I knew about the client.

Option B — build a custom permission engine. A Role table, a Policy table, a rules evaluator, admin screens to manage it all, caching so it is not slow, and a test suite to prove it is correct. This is the option that feels like real engineering, and it is a trap. Everything I would build here already exists, battle-tested, in django.contrib.auth: the User/Group/Permission models, the join tables, has_perm, the admin UI to edit them, the migrations, the permission-caching backend. Building my own would be re-implementing Django’s auth with fewer eyes on it and more bugs.

I had learned this lesson once already, at the very start of this series. In chapter one we shipped the MVP on Directus — a headless CMS — specifically because its admin panel let non-engineers manage content and access as data, from a UI, with zero code. The instinct that made Directus the right throwaway MVP is the same instinct here: authorization the organisation can edit itself is a solved problem, and the solution is a good admin surface over data. I did not need to import that idea from Directus. Django had it in the box the whole time.

Option C — reuse Django’s Group + Permission, and expose it. This is what we did. Positions and departments are Django Groups. Assigning what a position can do is assigning permissions to a group — done from the admin UI, by an administrator, as data. The only thing I added was a whisper of metadata: a one-to-one profile hanging off each group recording is this group a department or a position, and if a department, which org unit’s rows it scopes to. A few columns. Not an engine.

One more option deserves a sentence, because reviewers always ask: django-guardian, the standard library for object-level permissions. Guardian stores a row per (user-or-group, object, permission) triple in its own UserObjectPermission/GroupObjectPermission tables. That is the correct tool when every single object carries an independent ACL — this document shared with those five people specifically. It is the wrong tool for us. Our row-level rule is not per-object; it is per-department: you see a row because its department_id is one of yours. Encoding that in guardian would mean, for an invoice table heading past 200k rows across a dozen departments, up to a couple of million permission rows to write, index, and keep in sync every time an invoice moves or a department is created. Our version is one indexed WHERE department_id IN (...) and zero extra rows. Guardian solves a problem we do not have and creates a synchronisation problem we would rather not own.

The data model: a Group, plus four columns

Everything about what a group can do already lives in auth_group_permissions. The only thing Django genuinely lacks is a place to record what kind of group each one is, and — for a department — which org unit’s rows it scopes to. That is one small sidecar table, one-to-one with Group:

from django.contrib.auth.models import Group
from django.db import models


class OrgUnit(models.Model):
    """A node in the foundation's org chart - a centre, a region, a wing.
    Rows that belong to the org (invoices, bookings, assets) carry an
    org_unit / department FK pointing here. This is the scope key."""

    name = models.CharField(max_length=120)
    parent = models.ForeignKey(
        "self", null=True, blank=True,
        on_delete=models.PROTECT, related_name="children",
    )

    def __str__(self) -> str:
        return self.name


class GroupProfile(models.Model):
    """Thin metadata sidecar on Django's auth Group.

    A Group is either a *department* (scopes rows) or a *position*
    (a named bundle of permissions). What the group can *do* lives in
    auth_group_permissions; this table only records the group's kind and,
    for departments, which OrgUnit's rows it scopes to. Four columns."""

    class Kind(models.TextChoices):
        DEPARTMENT = "department", "Department"
        POSITION = "position", "Position"

    group = models.OneToOneField(
        Group,
        on_delete=models.CASCADE,
        related_name="profile",
        primary_key=True,
    )
    kind = models.CharField(max_length=16, choices=Kind.choices)

    # For a department group: the OrgUnit whose rows it scopes to.
    # Null for a position. The queryset filter reads profile.scope_id.
    scope = models.ForeignKey(
        OrgUnit,
        on_delete=models.CASCADE,
        null=True, blank=True,
        related_name="department_groups",
    )

    class Meta:
        db_table = "erp_group_profile"
        constraints = [
            # A department must name its scope; a position must not.
            models.CheckConstraint(
                name="department_requires_scope",
                check=(
                    models.Q(kind="department", scope__isnull=False)
                    | models.Q(kind="position", scope__isnull=True)
                ),
            ),
        ]

    def __str__(self) -> str:
        return f"{self.get_kind_display()}: {self.group.name}"

Notice what is not here. No Role table, no Permission table of my own, no policy rows, no rules column. Permission and its ContentType back-reference are Django’s; I reuse them untouched. When an administrator ticks view_invoice for a position, Django writes a row into auth_group_permissions joining that group to the existing view_invoice permission — the same permission the admin site, the ORM’s has_perm, and my DRF classes all read. The position becomes real the instant that row exists. There is no code path that knows the string “Pune coordinator”; there is only a Group row, a GroupProfile row marking it a position, and some auth_group_permissions rows. Creating one is a data write, and the admin UI already knows how to do data writes.

Here is the whole “create a position” operation — the thing the admin action calls. It is unremarkable on purpose:

from django.contrib.auth.models import Group, Permission
from django.db import transaction


@transaction.atomic
def create_position(name: str, permission_codenames: list[str]) -> Group:
    """Turn a UI form submission into a live position. No deploy:
    a Group row, a GroupProfile row, and the join rows to existing
    Permissions - all data the running app already reads per request."""
    group = Group.objects.create(name=name)
    GroupProfile.objects.create(group=group, kind=GroupProfile.Kind.POSITION)
    # Codenames are unique per ContentType, NOT globally. With fifteen-plus
    # apps on this backend, `view_comment` can exist in three of them. A bare
    # codename__in filter would attach every collision, silently over-granting
    # the position - a privilege-escalation footgun. Qualify by app_label so a
    # codename can only resolve to the intended erp permission.
    perms = Permission.objects.filter(
        content_type__app_label="erp",
        codename__in=permission_codenames,
    )
    group.permissions.set(perms)  # writes auth_group_permissions rows
    return group

The safer variant, once the UI grows past a single app, is to stop passing codenames as strings at all: have the picker submit Permission primary keys (or content_type_id + codename pairs) so the resolution is unambiguous at the source and a typo can never silently widen a grant.

Two layers of enforcement, and why you need both

Here is the subtlety that trips people up. Django’s built-in permissions are model-level — view_invoice is all-or-nothing across the whole table. But an ERP’s real requirement is row-level: the Pune coordinator may view invoices, but only Pune’s. Django does not do row-level permissions out of the box, and that is fine, because the right tool for “which rows” is not a permission check — it is a WHERE clause.

So authorization happens in two layers:

  1. The view check — model-level gate. Can this user touch this endpoint at all? A DRF permission class maps the HTTP method to a codename and calls has_perm. Fail it, get a 403, the query never runs.
  2. The queryset filter — row scoping, in SQL. You cleared the gate; now you only get the rows your department owns. This is a .filter() that the database resolves on an index, never a loop in Python.

First, the DRF permission class. Off the shelf, DjangoModelPermissions does the Layer 1 codename check but has two gaps I care about: it leaves GET ungated (its default map only guards writes), and it has no object-level hook, so a detail route (GET /invoices/42/) is only as safe as whatever get_queryset happened to do. I want both closed, so I subclass it:

from rest_framework.permissions import DjangoModelPermissions


class DepartmentScopedModelPermissions(DjangoModelPermissions):
    """Layer 1 gate + a per-object backstop, both read from group perms."""

    # DjangoModelPermissions ships GET unguarded; we gate reads too.
    perms_map = {
        "GET": ["%(app_label)s.view_%(model_name)s"],
        "OPTIONS": [],
        "HEAD": [],
        "POST": ["%(app_label)s.add_%(model_name)s"],
        "PUT": ["%(app_label)s.change_%(model_name)s"],
        "PATCH": ["%(app_label)s.change_%(model_name)s"],
        "DELETE": ["%(app_label)s.delete_%(model_name)s"],
    }

    def has_object_permission(self, request, view, obj) -> bool:
        # The detail-route backstop. A scoped get_queryset would already
        # 404 a foreign pk, but this guarantees row isolation even if a
        # view forgets to scope. Org-wide roles carry a bypass permission.
        user = request.user
        meta = obj._meta
        if user.has_perm(f"{meta.app_label}.view_all_{meta.model_name}s"):
            return True
        dept_ids = set(
            user.groups.filter(profile__kind="department")
            .values_list("profile__scope_id", flat=True)
        )
        return obj.department_id in dept_ids

has_permission (inherited) runs before the view body: it maps the HTTP method to a codename and checks it against the union of the user’s group permissions. Fail it, get a 403, the query never runs. has_object_permission runs after get_object on detail routes, so even a hand-crafted pk cannot reach a row outside your departments. Then the viewset wires in the row-scoping queryset:

from rest_framework import viewsets


class InvoiceViewSet(viewsets.ModelViewSet):
    serializer_class = InvoiceSerializer
    # Layer 1 - the model-level gate, plus the per-object backstop.
    permission_classes = [DepartmentScopedModelPermissions]

    def get_queryset(self):
        qs = Invoice.objects.select_related("department")
        user = self.request.user

        # A finance role gets a group carrying this org-wide permission.
        if user.has_perm("erp.view_all_invoices"):
            return qs

        # Layer 2 - row scoping in SQL. You passed the gate, but you
        # only see your own departments' invoices. The department ids
        # come from the user's groups; the filter runs on an index.
        dept_ids = user.groups.filter(
            profile__kind="department"
        ).values_list("profile__scope_id", flat=True)
        return qs.filter(department_id__in=list(dept_ids))

The thing worth internalising is that neither layer contains a role name. There is no if user.is_accountant. “Accountant”, “Pune coordinator”, “front desk” are all just rows an admin created. The code only knows about permissions (view_all_invoices) and a scope key (department_id) — the stable vocabulary. Which humans hold which of those is data, editable from a screen.

And has_perm is not magic. It resolves to a plain join across the auth tables:

-- Does ANY of this user's groups grant view_invoice?
-- Pure data: three join tables, cached for the request.
SELECT 1
FROM   auth_group_permissions gp
JOIN   auth_user_groups       ug ON ug.group_id = gp.group_id
JOIN   auth_permission         p ON p.id = gp.permission_id
WHERE  ug.user_id = %s
AND    p.codename = 'view_invoice'
LIMIT  1;

Change what a position can do, and all you have changed is which rows exist in auth_group_permissions. The query above starts returning a different answer on the very next request. That is the entire trick.

Here is the full path a request takes:

Why “immediately” is literally true — and the one cache that can lie

When an admin ticks a box, why does the next request see it with no restart, no cache flush, no signal? Because permissions are read on the request path, not baked in at boot. Every incoming request, DRF’s authentication rebuilds request.user by loading that user afresh from the database. The subsequent has_perm walks live auth_group_permissions rows. A row an admin wrote a second ago is just a row the next query returns. “As data” and “immediately” are the same property viewed from two angles.

There is exactly one place this can bite you, and it is worth stating precisely because it looks like a bug when you hit it. Django caches permission results on the user instance to keep a single request from re-running the join for every check — the first has_perm populates user._perm_cache (and _group_perm_cache), and every check after that in the same request reads the cache. Across requests this is harmless: each request builds a fresh user, so the cache is born and dies within one request and never goes stale between them.

It goes stale only inside a single long-lived process that both mutates permissions and re-checks them — an admin action that grants a group a permission and then immediately asserts the new access, or a Celery worker holding one user object across a batch. There, the pre-mutation cache answers the post-mutation question. The fix is to drop the cache (or re-fetch the user), never to reach for a broader hammer:

# After changing a user's group membership or a group's permissions
# *within the same process*, the cached answer on this instance is stale.
for attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
    user.__dict__.pop(attr, None)
# ...or simply rebuild the instance, which is what the next request does anyway:
user = User.objects.get(pk=user.pk)

I flag this because the instinct on a stale-permission report is to suspect a caching layer — Redis, the query cache, CDN. It is none of those. It is a per-instance attribute doing exactly its job, checked after the data moved under it. Ninety-nine percent of the time the request boundary invalidates it for free; the one percent is intra-process, and you bust it by hand.

What it bought us

A month after launch, the foundation stood up a new “Ambarsthan front desk” position: it needed to view bookings and check guests in, nothing else. In the old world that is a role constant, a permission map, a PR, a deploy — best case a day, and it competes with everything else in the sprint. In the new world an administrator opened the admin panel, created a group, ticked view_booking and change_booking, named it, and added three volunteers. It was live before I had finished reading the message asking me to do it. I did not touch the codebase. I could not have been faster than not being involved at all.

Zero deploys for org-chart changes became the norm. The engineering team stopped being a dependency for a class of request that was never really engineering to begin with. The contrast, drawn plainly:

The lesson

Model authorization as data the organisation can edit, not as code your engineers must ship. If a business rule changes on a business cadence — org charts, pricing tiers, feature access — the rule wants to live in rows, behind a UI, not in a binary. The moment a non-technical change requires a technical deploy, you have built yourself into the critical path of someone else’s routine.

And the corollary I keep relearning: before you build the engine, check whether the framework already ships it. Django’s Group and Permission are quietly one of the best permission-as-data systems in any web framework — join tables, an admin UI, a cached has_perm, migrations, all there. The work was not building authorization. It was resisting the urge to build it, wiring the two existing layers together, and adding the few columns of scope metadata Django genuinely lacked. Reuse the engine; own only the thin part that is truly yours.

There was a bill coming for all of this, though. Every one of those has_perm joins, every scoped queryset, every select_related to a department — stacked across an ERP, a bookings app, a learning platform, and the fifteen-odd other apps sharing this one backend, over tables that were filling up fast. It worked, and then it worked slowly: a handful of endpoints crept from snappy toward the unthinkable, one of them all the way to thirty seconds. The next chapter is the one where I stop adding features and go hunting for those seconds — and drag that endpoint from thirty seconds back down to two hundred milliseconds.