skip to content

In Django REST Framework, how do the &, | and ~ operators compose permission classes, and how can | quietly weaken an owner-only object check?

level: seniorimportance: nice to knowfreq 28%

answer

  1. operators on classes, not instances
  2. BasePermission's permissive defaults
  3. OR evaluates both hooks per operand
  4. negating an inherited True

basics

~20 s

DRF's &, | and ~ combine permission classes into one, applying the logic to both hooks. | weakens object checks when an operand inherits BasePermission's has_object_permission, which returns True: IsAuthenticated | IsOwner lets any signed-in user edit.

solid answer

~40 s

Permission classes carry a metaclass that overloads `&`, `|` and `~`, so `permission_classes = [IsAuthenticated & (IsOwner | IsAdminUser)]` builds one composed class; precedence follows Python's `~`, then `&`, then `|`. `AND` requires both operands on `has_permission()` and on `has_object_permission()`. `OR` passes `has_permission()` if either operand does, and since DRF 3.15 passes `has_object_permission()` only if one operand passes **both** its hooks. `NOT` inverts both hooks. The trap is `BasePermission`'s default `has_object_permission()` returning `True`: in `IsAuthenticated | IsOwner`, `IsAuthenticated` passes both hooks for any signed-in user, so the owner rule never matters. `~IsAdminUser` is the mirror trap: its object hook inverts the inherited `True` to `False`, so every detail request is refused.

code

python · 16 lines
python
from rest_framework import permissions


class IsOwner(permissions.BasePermission):
    def has_permission(self, request, view):
        return bool(request.user and request.user.is_authenticated)

    def has_object_permission(self, request, view, obj):
        return obj.owner_id == request.user.pk


# Leaks: IsAuthenticated's inherited object hook returns True for every signed-in user.
leaky = [permissions.IsAuthenticated | IsOwner]

# Owner or staff may edit; both operands define both hooks meaningfully.
safe = [permissions.IsAuthenticated & (IsOwner | permissions.IsAdminUser)]

go deeper

for a junior

Know that DRF permission classes can be combined with &, | and ~ in permission_classes, and that listing several classes already means all must pass.

for a middle

Explain how each operator treats both hooks and why BasePermission's True defaults matter once classes are combined.

for a senior

Diagnose a leaking IsAuthenticated | IsOwner or a detail view broken by ~IsAdminUser, and know which OR behaviour changed in 3.15 when auditing an older project.

for a principal

Decide when composed expressions are clearer than one explicit policy class per resource, weighing readability and error messages against reuse.

## Composing classes with operators In Django REST Framework (DRF), every permission class is built with `BasePermissionMetaclass`, which mixes in `OperationHolderMixin`. That mixin overloads three operators **on the classes themselves**, not on instances: - `A & B` — both must pass; - `A | B` — either may pass; - `~A` — the opposite of `A`. The result is a holder object that DRF instantiates like any other entry in `permission_classes`, so composed and plain classes can sit side by side: ```python permission_classes = [IsAuthenticated & (IsOwner | IsAdminUser)] ``` Precedence is Python's own: `~` binds tightest, then `&`, then `|`; parentheses group as usual. Evaluation short-circuits like Python's `and`/`or`. ## What each operator does to the two hooks A permission class has a view-level hook, `has_permission()`, and an object-level hook, `has_object_permission()`. The composed classes apply their logic to **both**: | Operator | `has_permission()` | `has_object_permission()` | |---|---|---| | `A & B` | `A.hp and B.hp` | `A.hop and B.hop` | | `A \| B` | `A.hp or B.hp` | `(A.hp and A.hop) or (B.hp and B.hop)` | | `~A` | `not A.hp` | `not A.hop` | Here `hp` is `has_permission()` and `hop` is `has_object_permission()`. The `OR` row changed in **DRF 3.15**: earlier releases computed the object hook as `A.hop or B.hop`, ignoring whether that operand had passed at view level. ## The trap: permissive defaults inside `|` `BasePermission` returns `True` from both hooks. Every built-in class except `DjangoObjectPermissions` overrides only `has_permission()`, so its `has_object_permission()` is always `True`. Inside an `OR` that default can carry the whole expression. 1. **`IsAuthenticated | IsOwner`** — for a signed-in non-owner, the first operand passes `has_permission()` and its inherited `has_object_permission()` returns `True`, so the object check passes. Any authenticated user can edit any document. This is still true on 3.18. 2. **`IsAdminUser | IsOwner`** before 3.15 — a non-staff user passed view level through `IsOwner`'s inherited `has_permission()`, then passed object level through `IsAdminUser`'s inherited `has_object_permission()`. The 3.15 change closed this case because `IsAdminUser` now contributes to the object check only if it also passed `has_permission()`. 3. **Anonymous access through `|`** — in `IsAdminUser | IsOwner`, `IsOwner.has_permission()` is inherited `True`, so even anonymous users pass the view-level check; with a list action that never reaches the object hook, they see the list. The owner-only intent is expressed safely with `&`, or with an `OR` whose operands each define both hooks meaningfully: ```python permission_classes = [IsAuthenticated & (IsOwner | IsAdminUser)] ``` ## The mirror trap: negation `~A` inverts **both** hooks. For `~IsAdminUser`, the view-level result is sensible (non-staff pass), but the object hook becomes `not True`, which is `False`, for everyone. Every `retrieve`, `update` and `destroy` on the view is refused with 403, while `list` still works. Negating a class that only defines `has_permission()` is therefore safe only on views that never call `get_object()`. ## Practical rules - Write custom classes that define **both** hooks when they will be composed, even if one simply returns `True` deliberately. - Prefer listing classes separately (`[IsAuthenticated, IsOwner]`, an implicit AND) when `&` is all you need. - Test composed permissions per action, including a non-owner detail request and an anonymous list request. - Remember that composed holders define no `message` or `code`, so failures use `PermissionDenied`'s default detail unless the view handles it.

  • Why does ~IsAdminUser on a DRF viewset break retrieve but not list?
    `NOT` inverts both hooks. `IsAdminUser` inherits `has_object_permission()` returning `True`, so the negation returns `False` for every object. `list` never calls `get_object()`, so only the view-level hook runs and non-staff users pass; every detail action fails with 403.
  • Is [IsAuthenticated, IsOwner] different from [IsAuthenticated & IsOwner] in DRF?
    Logically they are the same AND: each class must pass both hooks. The list form runs classes one by one and reports the failing class's own `message` and `code`; the composed form is a single holder with neither attribute, so its denials use `PermissionDenied`'s default detail.

saying these in an interview costs you the question

  • Composed permissions are built from instances, like IsAuthenticated() | IsOwner().
  • IsAuthenticated | IsOwner restricts edits to the document's owner.
  • ~ and | apply only to has_permission, never to object checks.
  • The DRF 3.15 OR change made every OR of built-in classes safe for object checks.
  • Negating a class that defines only has_permission is safe on detail views.