Skip to main content

Command Palette

Search for a command to run...

Authorization in Django: From Permissions to Policies : Part 2 — What a Permission Really Is in Django

Updated
7 min read
Authorization in Django: From Permissions to Policies : Part 2 — What a Permission Really Is in Django

If authorization in Django feels confusing, a big reason is that permissions often get described as if they do work. They don’t.

A Django permission is not a rule engine. It is not a policy. It is not an authorization decision. It is simply a named capability label, stored as a database record.

Once that expectation is set correctly, the system becomes calmer. More importantly, it becomes buildable. We can add the missing pieces—policies, invariants, tenant boundaries—intentionally, instead of forcing Django’s permission system to do a job it was never designed to do.

What This Post Covers (and What It Does Not)

Covered here

  • What a permission actually is in Django (and what it is not)

  • Why permissions scale well as a capability system

  • The “may attempt” vs “is valid” split that keeps authorization maintainable

Covered later in the series

  • How permissions appear in concrete database tables (Part 5)

  • ContentTypes and how Django identifies models (Part 3)

  • How default add / change / delete / view permissions are created (Part 4)

  • Policy objects and invariant-driven authorization (Parts 8–10)

If it feels like something “practical” is missing at this stage, that is intentional. This part establishes the conceptual foundation.

A Permission Is Data

A Django permission is data:

A row in auth_permission that represents a named capability: “A user (or group) may attempt X on model Y.

Because a permission is data, it cannot contain business logic. And because it cannot contain business logic, it cannot answer questions like “is this action valid right now?” It cannot see state, time, ownership, or workflow context.

The only question it can answer—by design—is a narrower one:

May this action be attempted at all?

That limitation is not a flaw. It is the boundary that keeps the permission system stable, predictable, and scalable.

How Permissions Exist as Relationships

At the database level, permissions exist as concrete rows and relationships:

  • django_content_type: Django’s internal model registry, used to answer: “which model does this permission apply to?”

  • auth_permission: permission rows that reference a content type

  • auth_group: group rows

Users and groups connect to permissions through join tables, so Django can represent: users ↔ permissions, groups ↔ permissions, and users ↔ groups.

The key idea is that permissions remain composable: we can assign capabilities directly to users, assign capabilities to groups, and place users into groups to inherit those capabilities.

  • If permissions are relational data, they scale well because they are composable (user ↔ group ↔ permission).

  • If they are composable, we can manage role assignments administratively without rewriting business code.

  • Therefore, Django’s permission system is optimized for role/capability assignment, not domain correctness.

This design choice is deliberate.

The Mental Model: “May Attempt” vs “Is Valid”

Permissions and policies solve different parts of the authorization problem.

A permission answers the coarse question:

Permission: “May we attempt this type of action at all?”

This is a capability check.

A policy answers the contextual question:

Policy: “Is this action valid right now, for this specific object, in this specific context?”

This is contextual validity.

A permission check asks: “Do we have blog.change_post?”

A policy check asks:

  • “Is the post in Draft?”

  • “Are we the owner (or otherwise allowed) for this object?”

  • “Is the object within our tenant scope?”

  • “Is editing allowed after publish?”

  • “Would this transition violate an invariant?”

  • If permissions represent capabilities, we use them as an outer gate.

  • If policies represent context-specific rules, we apply them as an inner gate.

  • Therefore, authorization becomes layered and predictable:
    capability gate → policy gate → perform action

Permissions provide the foundation; policies carry the domain meaning.

Predictability by Design: Django’s Permission Loop

Django relies on conventions for its built-in permissions. For every model, it automatically creates a small, standard set of model-level permissions:

  • add_<model>

  • change_<model>

  • delete_<model>

  • view_<model>

Those permissions are exposed using canonical strings of the form:

  • app_label.add_modelname

  • app_label.change_modelname

  • app_label.delete_modelname

  • app_label.view_modelname

For example, if we have a Post model inside the blog app, the defaults are:

  • blog.add_post

  • blog.change_post

  • blog.delete_post

  • blog.view_post

Derivation

  • If permissions follow conventions, tooling (Admin, introspection, frameworks) can rely on predictable names.

  • If those names are predictable, we get stable tooling without introducing a rule engine.

  • Therefore, Django favors predictability and ecosystem compatibility over expressiveness.

Because Django generates permissions using a fixed convention (per model, with predictable codenames) and stores them as plain relational data, the system forms a closed loop: models define a stable surface area, migrations create corresponding permission rows, and every consumer—Admin, has_perm(), DRF, internal tooling—relies on the same canonical identifiers without custom wiring.

That loop keeps permissions simple and stable across environments and deployments, as long as app labels and model names remain stable.

Checking Permissions vs Enforcing Authorization

Django exposes permission checks through a small, standard API. The most common entry point is:


  user.has_perm("app_label.codename")

For example:


  request.user.has_perm("blog.change_post")
  request.user.has_perm("blog.view_post")

Related utilities include:

  • user.has_perms([...]) for checking multiple permissions together

  • user.get_all_permissions() for inspecting the full permission set (direct + via groups)

What matters is not how these methods work, but what they intentionally do not do. has_perm() checks direct and group-derived permissions (with superusers bypassing checks). It does not evaluate ownership, workflow state, tenant boundaries, or other domain constraints.

That omission is intentional. Object-level and context-level rules do not belong in the permission layer. When that boundary is not explicit, authorization logic fragments across views, serializers, templates, and model methods.

The Common Beginner Misunderstanding

A natural early assumption is:

“If we grant change_post, Django will ensure changes are safe.”

What actually happens is more limited—and more intentional.

Django can enforce permissions in the places it integrates with (Admin, view-level checks, DRF permission classes) when we wire those checks into enforcement points. But Django does not know our business rules. It cannot infer ownership, workflow meaning, or tenant scope from a capability label.

So blog.change_post usually means something simpler:

“We are in a role that may edit posts.”

Whether we can edit this post right now, under these conditions, is a policy decision.

Permissions answer who may try, not who must succeed.

Custom Permissions Expand Capability Vocabulary

We can add our own permissions to a model to represent domain-specific capabilities:

class Post(models.Model):
    class Meta:
        permissions = [
            ("publish_post", "Can publish post"),
            ("archive_post", "Can archive post"),
        ]

This creates capability labels like:

  • blog.publish_post

  • blog.archive_post

These labels are useful because they express intent more directly than overloading change_post. But they still do not enforce workflow correctness by themselves. They only answer the coarse question: who may attempt publishing or archiving.

Custom permissions work best as vocabulary. Policies remain responsible for when—and whether—those actions are valid.

What Permissions Are Designed to Handle

Django’s permission system is deliberately optimized for:

  • coarse-grained access control

  • role-based authorization (typically modeled via groups by convention)

  • strong Django Admin integration

  • fast, predictable capability checks

It works best when answering:

Should this user be allowed to attempt this operation at all?

Because of that, permission design should evolve slowly and remain stable. Domain rules and workflows, by contrast, can and should evolve independently.

What Permissions Cannot Express

Because Django permissions are intentionally coarse, there are common real-world constraints they cannot express well:

  • object ownership (“only the author can edit their own post”)

  • object state / workflow (“can edit only in Draft”)

  • conditional business rules (“can refund only within 7 days”)

  • tenant scoping in multi-tenant systems (“must be within the same tenant”)

  • cross-model invariants (“published content must have at least one section”)

When we try to encode these constraints into permissions anyway, the system tends to degrade in predictable ways:

  • exploding permission sets (too many combinations to manage)

  • inconsistent enforcement (some code paths check the “right” permission, others do not)

  • scattered conditionals (authorization logic leaks across layers)

  • bypass vulnerabilities (a forgotten check becomes a security bug)

The problem is not misuse. It is misplacement. Permissions are not where contextual correctness belongs

The Takeaway

A Django permission is not protection unless something checks it.

Permissions are capability labels. They matter only at enforcement points—Admin, views, DRF permission classes, services, background tasks, and any code path that performs actions.

When the question becomes “who can do what to which object under what conditions,” we are in policy and domain logic.

Permissions decide who may attempt. Domain/application logic decides whether it is valid.

Where We Go Next (Part 3 Preview)

Now that permissions are understood as capability labels, the next question becomes: labels for what?

That is what ContentTypes solve. They provide Django’s internal model registry, allowing permissions to reference models consistently across apps and deployments.

Part 3 explores how ContentTypes make Django’s authorization system stable—and what happens when model identity changes.

More from this blog

A

Abhilash PS — Engineering Thought & Software Architecture

23 posts