In Django REST Framework, how do the &, | and ~ operators compose permission classes, and how can | quietly weaken an owner-only object check?
answer
- operators on classes, not instances
- BasePermission's permissive defaults
- OR evaluates both hooks per operand
- negating an inherited True
basics
~20 sDRF'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 sPermission 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 linesfrom 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
Know that DRF permission classes can be combined with &, | and ~ in permission_classes, and that listing several classes already means all must pass.
Explain how each operator treats both hooks and why BasePermission's True defaults matter once classes are combined.
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.
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.