# Authorization

djust ships with a four-layer authorization onion. This guide walks through each layer, with focus on the v0.9.5 object-level lifecycle that closes a structural class of IDOR vulnerabilities for detail views.

## TL;DR

For any LiveView bound to a single object via URL kwarg (`/documents/<int:document_id>/`):

1. Set `permission_required = "app.access"` for the role check (layer 2).
2. Override `get_object()` to return the object.
3. Override `has_object_permission(self, request, obj)` to express access.

The framework runs your check at mount AND on every event handler dispatch automatically. Mount-time denial closes the WS with code 4403; per-event denial sends an error frame and keeps the WS open.

```python
from djust import LiveView
from djust.decorators import event_handler

class DocumentDetailView(LiveView):
    permission_required = "documents.access"  # role check (layer 2)

    def mount(self, request, document_id=None, **kwargs):
        self.document_id = document_id

    def get_object(self):
        return Document.objects.get(pk=self.document_id)

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

    @event_handler()
    def add_comment(self, body=""):
        # has_object_permission already ran for this event. Reuse the
        # cached object rather than re-querying:
        Comment.objects.create(document=self._object, body=body)
```

## The four-layer onion

djust calls the layers in order. The first denial wins; subsequent layers don't run.

| Layer | What | When |
|---|---|---|
| 1. `login_required` | Is user authenticated? | Before `mount()` (HTTP GET and WS mount) |
| 2. `permission_required` | Does user have Django role permission? | Before `mount()` (HTTP GET and WS mount) |
| 3. `check_permissions(request)` | Custom hook for arbitrary logic (not object-aware) | Before `mount()` (HTTP GET and WS mount) |
| 4. `has_object_permission(request, obj)` | Per-object access (NEW in v0.9.5) | At mount AND every event |

Layers 1-3 run **before** `mount()` (in `check_view_auth`). Layer 4 runs **after** `mount()` because `get_object()` typically reads a URL kwarg the user populates inside `mount()` (e.g., `self.document_id = document_id`).

## Why per-event matters

Without per-event enforcement, a session established with valid mount-time access can be exploited if access is revoked mid-session:

- User A has access to claim 99 at mount time → mount succeeds, WS stays open.
- An admin revokes User A's access to claim 99.
- User A's WS is still open. They send `add_comment` over the existing connection.
- Without per-event re-execution, the event runs because it doesn't re-check the live access state.

The v0.9.5 lifecycle (specifically v0.9.5-1b) re-runs `has_object_permission` on every event handler dispatch, so the revocation takes effect immediately.

## OWASP IDOR mitigation built in

When `get_object()` returns `None` OR raises `ObjectDoesNotExist` (parent of every `Model.DoesNotExist`) OR raises `Http404` (raised by `get_object_or_404`), the framework treats the object as absent and skips `has_object_permission`. The caller (mount or event handler) sees no object, which lets you render a 404 page or send a 404-shape error frame.

This avoids the existence leak that returning a 403 on `Model.DoesNotExist` would imply. **You don't need to manually catch `DoesNotExist`** — the framework does it for you:

```python
def get_object(self):
    return Document.objects.get(pk=self.document_id)
    # Raises DoesNotExist for missing rows. Framework catches it and
    # treats as None → no permission check runs → 404-shape response.
```

If you want to be explicit, return `None` directly:

```python
def get_object(self):
    try:
        return Document.objects.get(pk=self.document_id)
    except Document.DoesNotExist:
        return None
```

Both flows produce the same external behavior.

## The cache: `self._object`

After each successful `has_object_permission` check, the framework stores the result of `get_object()` as `self._object`. Because the check runs at mount and before every event, `self._object` is always the object that was just fetched and verified. Reuse it from event handlers and `get_context_data` rather than re-querying:

```python
@event_handler()
def add_comment(self, body=""):
    # Don't re-fetch; use the permission-verified object.
    Comment.objects.create(document=self._object, body=body)
```

`self._object` is a framework slot allocated in `LiveView.__init__` BEFORE the `_framework_attrs` snapshot, so it's NOT serialized into msgpack user-private state. After WS reconnect / state-restore, `self._object` is `None` and `get_object()` runs fresh — handles the "object reassigned while user was disconnected" case automatically.

## Cache invalidation

Access is re-checked with a fresh `get_object()` on every event, so ownership changes take effect on the next event automatically; a stale `self._object` never grants access.

`self._invalidate_object_cache()` only clears `self._object` to `None`. The render that follows the handler then sees `None`, not the old object. If the same turn's render needs the updated object, assign it yourself:

```python
@event_handler()
def reassign_owner(self, owner_id: int = 0):
    self._object.owner_id = owner_id
    self._object.save()
    # Refresh for this turn's render; the next event re-fetches anyway.
    self._object = Document.objects.get(pk=self.document_id)
```

## Wire-protocol error frames

| Path | Wire shape | Effect |
|---|---|---|
| Mount-time denial | WS close code 4403 + `{"type": "error", "error": "Permission denied"}` | Browser drops the connection; client treats as full reload |
| Per-event denial | `{"type": "error", "error": "Access denied for this object.", "code": "permission_denied"}` | WS stays open; client can revert optimistic UI updates and let the user navigate elsewhere |

Use the structured `code` field on the per-event frame to distinguish permission denial from other error types in your client-side handlers.

## Defense in depth: manager-level filtering

A custom `for_user()` queryset method is a complementary pattern. Use it alongside the lifecycle hooks for layered defense:

```python
class DocumentManager(models.Manager):
    def for_user(self, user):
        if user.is_superuser:
            return self.all()
        return self.filter(owner=user)

class Document(models.Model):
    objects = DocumentManager()
    owner = models.ForeignKey(User, ...)

class DocumentDetailView(LiveView):
    permission_required = "documents.access"

    def mount(self, request, document_id=None, **kwargs):
        self.document_id = document_id

    def get_object(self):
        # If user doesn't own it: DoesNotExist → framework treats as
        # 404-shape → no existence leak.
        return Document.objects.for_user(self.request.user).get(pk=self.document_id)

    def has_object_permission(self, request, obj):
        # Belt-and-suspenders: even if get_object returned the obj,
        # explicitly verify access here. Defends against bugs in the
        # manager's filter.
        return obj.owner_id == request.user.id
```

The manager filter and `has_object_permission` are independent; either alone would be sufficient, but together they catch each other's bugs.

## Migration: hand-rolled IDOR checks → lifecycle hooks

Many existing detail views check object access in `get_context_data`. This is the bug class the v0.9.5 lifecycle closes — `get_context_data` runs during render, AFTER `mount()` has set up the WS session. By the time you raise `PermissionDenied`, the session is established and event handlers can fire against the foreign object.

```python
# BEFORE (vulnerable):
class DocumentDetailView(LiveView):
    permission_required = "documents.access"

    def mount(self, request, document_id=None, **kwargs):
        self.document_id = document_id

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        doc = Document.objects.get(pk=self.document_id)
        if not can_access_document(self.request.user, doc):
            raise PermissionDenied()  # Too late — mount already happened
        ctx["document"] = doc
        return ctx

    @event_handler()
    def add_comment(self, body=""):
        # NO PER-EVENT CHECK — vulnerable to mid-session access revocation
        # AND to a user crafting events on a session for an object they
        # never had legitimate access to (mount-time render error doesn't
        # close the WS).
        doc = Document.objects.get(pk=self.document_id)
        Comment.objects.create(document=doc, body=body)
```

```python
# AFTER (per ADR-017):
class DocumentDetailView(LiveView):
    permission_required = "documents.access"

    def mount(self, request, document_id=None, **kwargs):
        self.document_id = document_id

    def get_object(self):
        return Document.objects.get(pk=self.document_id)

    def has_object_permission(self, request, obj):
        return can_access_document(request.user, obj)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["document"] = self._object  # set by the framework after each check
        return ctx

    @event_handler()
    def add_comment(self, body=""):
        # has_object_permission re-ran for THIS event. Safe to use
        # self._object directly.
        Comment.objects.create(document=self._object, body=body)
```

Run `python manage.py djust_audit --ast` to find views matching the IDOR shape in your codebase. The X008 check flags them with a pointer back to this guide.

## Falsy non-None return values

`get_object()` uses strict-identity comparison: only `None` is treated as "no object". Falsy non-None values (`False`, `0`, `""`, `[]`) ARE valid objects and `has_object_permission` IS called for them. This is rarely useful in practice, but if you have an unusual access model where the "object" is a sentinel like `False`, the framework respects it.

## Custom `check_permissions` (layer 3) interaction

If you override `check_permissions(request)`, it runs at mount BEFORE `has_object_permission`. Use this for non-object-aware logic (e.g., "is the user banned?", "is the system in maintenance mode?"). For per-object logic, use `has_object_permission` — it has access to the object AND it's re-run on every event.

```python
class DocumentDetailView(LiveView):
    permission_required = "documents.access"

    def check_permissions(self, request):
        # Layer 3: arbitrary logic. Cheap deny — short-circuits before
        # we even fetch the object.
        if request.user.is_banned:
            return False
        return True

    def get_object(self):
        return Document.objects.get(pk=self.document_id)

    def has_object_permission(self, request, obj):
        # Layer 4: per-object. Runs only after check_permissions passes.
        return obj.owner_id == request.user.id
```

## Performance

The lifecycle is opt-in: views that don't override `get_object()` see ZERO overhead — `_has_custom_get_object()` short-circuits before any work. For overriding views:

- **Mount**: one `get_object()` call (your typical FK lookup) + one `has_object_permission()` call.
- **Per event**: one `get_object()` call (typically one DB query) + one `has_object_permission()` call. `self._object` is refreshed on each successful check, so handlers can use it without re-querying.

Keep `get_object()` minimal — just the FK lookup. Expensive I/O in this method becomes per-mount and per-event overhead.

## Testing the per-event check (the WS-communicator pattern)

Unit tests of `check_object_permission(view, request)` cover mount-time enforcement. For the per-event re-execution path, tests must connect to the WS as an authenticated user and exchange real frames:

Illustrative sketch (not run by the test suite):

```python
# Replace `application`, `user_a`, `user_b`, `document` with your test
# fixtures. `application` is your project's ASGI application (typically
# from your project's asgi.py or testing harness), and "/ws/live/" is the
# path your routing serves the djust consumer on.
from channels.testing import WebsocketCommunicator
import pytest


async def drain(communicator):
    """Discard queued frames (connect ack, mount response, ...)."""
    while not await communicator.receive_nothing(timeout=0.2):
        await communicator.receive_json_from()


@pytest.mark.asyncio
async def test_per_event_denies_after_ownership_change(user_a, user_b, document, application):
    """Per-event object-permission re-runs after the owner FK changes
    mid-session, denying the formerly-authorized user."""
    # Connect as user A who owns document 1.
    communicator = WebsocketCommunicator(application, "/ws/live/")
    communicator.scope["user"] = user_a
    connected, _ = await communicator.connect()
    assert connected

    # Mount succeeds (user A owns the doc).
    await communicator.send_json_to({
        "type": "mount",
        "view": "myapp.views.DocumentDetailView",
        "params": {"document_id": 1},
    })
    await drain(communicator)

    # Reassign ownership to user B mid-session.
    document.owner_id = user_b.pk
    document.save()

    # User A sends a write event.
    await communicator.send_json_to({
        "type": "event",
        "event": "add_comment",
        "params": {"body": "x"},
    })

    # Per-event check fires; denial frame returned, WS stays open.
    response = await communicator.receive_json_from()
    assert response["type"] == "error"
    assert response["code"] == "permission_denied"
```

This pattern (real WS communicator + state mutation between mount and event) is the empirical proof that the per-event check fires correctly. Use it for any LiveView feature whose contract depends on between-frame state. (#1377, v0.9.5-1c retro.)

## Reference

- ADR-017 (full design): `docs/adr/017-object-permission-lifecycle.md`
- Foundation: PR #1374, commit `c3498e62` (v0.9.5-1a)
- Per-event re-execution: PR #1378, commit `a534e77d` (v0.9.5-1b)
- System check + this guide: v0.9.5-1c
- Tracking issue: [#1373](https://github.com/djust-org/djust/issues/1373)
