djust 0.9.5rc1

Pre-releaseReleased

This is a pre-release. djust 0.9.5 has shipped since: read the djust 0.9.5 release notes.

Added

  • get_object() + has_object_permission() lifecycle hooks on LiveView — Foundation 1 of object-level authorization (#1373, ADR-017). Iter 1 of 3 toward closing a structural IDOR class that affects any djust app where the LiveView is bound to a single object via URL kwarg (document_id, user_id, <resource>_id, etc.). The natural placement for object-level checks (get_context_data) runs too late: by the time it fails, mount() has set up the WS-session-scoped state and event handlers can fire against the foreign object — the exact bug class this lifecycle closes.

    This iteration ships mount-time enforcement only. Per-event re-execution lands in v0.9.5-1b after the API surface soaks one release; tooling (djust check IDOR-shape heuristic, authorization.md guide, djust-dev skill principle) lands in v0.9.5-1c. The split-foundation rollout follows the canon from Action #1122.

    Two new public methods on LiveView, both default to no-op (the lifecycle is opt-in via override):

    • get_object(self) -> Optional[Any] — return the view's primary object (typically the FK lookup Model.objects.get(pk=self.<x>_id)). Default returns None so views that don't override see zero behavior change.
    • has_object_permission(self, request, obj) -> bool — return True if the request user may access obj. Default returns True. Called at mount-time when get_object is overridden.
    • _invalidate_object_cache(self) -> None — handlers call this when they mutate state affecting access (e.g. reassigning the FK that determines ownership). Without invalidation, a cached self._object would let a formerly-authorized user retain access until WS reconnect.

    The framework caches the result of get_object() as self._object after mount — reuse it from event handlers and get_context_data rather than re-querying. Cache is automatically reset on snapshot/state restore (it's a framework slot, not user-private state), which handles the "object reassigned while user was disconnected" case automatically.

    OWASP IDOR mitigation built in: when get_object() returns None, has_object_permission is not called (the caller raises 404 if it wants to). The framework also catches Django's ObjectDoesNotExist (parent of every Model.DoesNotExist) AND django.http.Http404 (raised by get_object_or_404) inside check_object_permission and treats them as None — automating the 404-shape pattern so a naive Model.objects.get(pk=missing) or get_object_or_404(...) doesn't leak existence via DEBUG=True traceback. Note the two are listed as separate catches because Http404 inherits from Exception directly, not from ObjectDoesNotExist.

    Order of auth checks (logical onion): login_requiredpermission_requiredcheck_permissions (existing) → has_object_permission (NEW). The new step has its own physical call site at websocket.py:handle_mount post-mount (not inside check_view_auth), because get_object() reads self.<x>_id populated by the user's mount() body — check_view_auth runs pre-mount when self.kwargs isn't yet bound. ADR-017 § Decision 5 documents the rationale.

    New helpers in djust.auth.core:

    • check_object_permission(view_instance, request) — re-exported from djust.auth. Wires get_object + has_object_permission together; raises PermissionDenied on denial.
    • _has_custom_get_object(view_instance) — MRO walk that gates the lifecycle as opt-in; mirrors _has_custom_check_permissions.

    Wire-protocol semantics: mount-time denial closes the WS with code 4403 + {"type": "error", "message": "Permission denied"} error frame, mirroring the existing pre-mount denial path at websocket.py:1953-1955.

    Backwards compatible: views that don't override get_object see zero behavior change (verified empirically — full pytest suite of 4670 tests + 1563 JS tests passes unchanged). Apps that already use check_permissions keep working; the new step runs after.

    9 regression tests in tests/integration/test_object_permission_mount.py: denial via return False raises PermissionDenied; allow populates self._object; no-override is a no-op; _invalidate_object_cache resets the cache (verified via call counter); get_object()=None skips has_object_permission; get_object() raising ObjectDoesNotExist is treated as None; get_object() raising Http404 is treated as None (defense-in-depth against the DEBUG=True traceback leak); has_object_permission raising PermissionDenied directly (vs return False) preserves the developer's custom message; get_object() returning a falsy non-None value (False, 0, "") IS treated as a valid object — has_object_permission is called (locks the strict-identity is None contract).

  • Per-event object-permission re-execution — Foundation 2 of object-level authorization (#1373, ADR-017 § Decision 7). Iter 2 of 3, stacking on the v0.9.5-1a foundation. Closes the IDOR class END-TO-END at the per-event surface, not just at mount.

    Without this iteration, an attacker with a valid session for an object they no longer have access to (e.g., access was revoked mid-session, or they crafted a session via timing) could still fire event-handler frames against that object — the foundation only checked permission at mount time. With this iteration, every event handler dispatch re-runs has_object_permission(request, obj) before the handler body executes, automatically.

    Wired into djust.websocket_utils._validate_event_security — the centralized helper called by all event-dispatch paths in djust (actor, component, view dispatch in websocket.py, plus HTTP-runtime in runtime.py and SSE in sse.py — five call sites total). Adding the check there covers all transports without per-site changes.

    Per-event denial semantics:

    • has_object_permission(...) returns FalsePermissionDenied raised by check_object_permission → caught by _validate_event_securitysend_error("Access denied for this object.", code="permission_denied")return None (caller skips handler dispatch). WS stays open — the user is still authenticated; only this specific action against this specific object is forbidden. (Compare to mount-time denial, which closes the WS with code 4403.)
    • get_object() returning None or raising ObjectDoesNotExist/Http404 → no denial, handler proceeds (consistent with mount-time semantics; the developer's get_object() already implements the OWASP 404-shape).
    • View doesn't override get_object_has_custom_get_object short-circuit fires; zero overhead, zero behavior change.

    Wire-protocol error frame for per-event denial: {"type": "error", "error": "Access denied for this object.", "code": "permission_denied"}. The structured code field lets clients distinguish permission-denied from other error types and revert optimistic UI updates accordingly.

    Cache-population order fix (Stage 11 nit from -1a, addressed here): check_object_permission now sets self._object = obj only AFTER has_object_permission returns True — never on denial, never on DNE/Http404 (those reset to None). Prevents cache poisoning across denials, which becomes load-bearing for per-event re-checks (a stale "allowed" cache could let a denied user retain access).

    State-restore interaction: self._object is allocated in LiveView.__init__ BEFORE the _framework_attrs snapshot, so it's classified as a framework slot and excluded from msgpack-serialized user-private state. After WS reconnect / state-restore, the cache is None and get_object() re-runs fresh — handles "object reassigned while user was disconnected" automatically. New regression test test_object_cache_is_framework_slot_excluded_from_user_state locks this contract.

    Embedded child views ({% live_render %}): when an event targets a child view via view_id, the dispatch sites pass the resolved target_view (the child) to _validate_event_security. The check uses the CHILD's get_object/has_object_permission, NOT the parent's. New regression test test_embedded_child_view_uses_child_get_object verifies.

    Fail-closed on developer-code exceptions (Stage 11 🟡 finding): if get_object() or has_object_permission() raise anything other than PermissionDenied (e.g., an AttributeError in the developer's body), the new check catches it, logs an exception-level traceback, and treats it as denial. Security code must not fail-OPEN when the auth predicate crashes. Default-deny is the safe response.

    Backwards compatible: views without get_object override see zero behavior change. Existing handler-level @permission_required decorators continue to work; the new check runs after them.

    9 new regression tests in tests/integration/test_object_permission_event.py: cache-not-poisoned-on-denial; cache-populated-only-on-success; per-event denial sends error frame and keeps WS open (handler body verified to NOT execute via sentinel); per-event allow returns the handler; per-event no-override is a no-op; DNE handling; framework-slot exclusion from user state; fail-closed on non-PermissionDenied developer exceptions; embedded-child resolution.

  • Tooling layer for object-level authorization — Foundation 3 of object-level authorization (#1373, ADR-017 § Decision 8). Final iteration of the split-foundation rollout. Closes the documentation, lint, and skill gap so app authors can DISCOVER the lifecycle and migrate to it.

    Three artifacts:

    • New djust check heuristic — X008 (python/djust/audit_ast.py). Flags any view matching the IDOR shape: extends LiveView (or matches the existing detail-view heuristic), has permission_required set, mount() assigns from a URL kwarg ending in _id (the canonical self.document_id = document_id pattern), at least one @event_handler-decorated method reads self.<that>_id, AND does NOT override has_object_permission or check_permissions. Severity: warning. Details point to docs/website/guides/authorization.md for the migration recipe. Distinct from existing X001 (.get(pk=user_input) pattern); X008 is structural — it flags the shape regardless of fetch mechanism. Run python manage.py djust_audit --ast to find matches.

    • New guide docs/website/guides/authorization.md. Walks through the four-layer auth onion (login → role → custom → object), the canonical get_object() + has_object_permission() pattern, OWASP 404-shape mitigation, cache invariants and _invalidate_object_cache() discipline, wire-protocol error frames (mount close 4403 vs per-event code: permission_denied), defense-in-depth via manager-level for_user() filtering, and a worked migration example (before/after diff for hand-rolled get_context_data IDOR checks).

    • djust-dev skill principle catalog updated. Two new entries: "Object-level authorization (post-v0.9.5)" with the canonical pattern, OWASP rationale, cache discipline, migration recipe, and djust check X008 reference; and "Security-class code defaults to fail-closed at every catch block" — when implementing auth/permission/validation code, catch Exception (not just the specific expected error), log via logger.exception, and default to deny. Failing-OPEN on unexpected exceptions is a security antipattern. Carries forward from v0.9.5-1b PR #1378's Stage 11 finding.

    6 new regression tests in python/tests/test_audit_ast.py::TestX008IDORShapeNeedsObjectPermission (positive case: classic IDOR shape triggers; negatives: has_object_permission override OK, check_permissions override OK, no permission_required no trigger, no URL-kwarg id no trigger; plus message-references-guide test).

    The split-foundation rollout is now complete. Issue #1373's IDOR class is structurally closed across mount and event surfaces; downstream consumers have the migration recipe and a static check to find affected views. Apps that override get_object() get end-to-end enforcement automatically.

All releases · Atom feed