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 onLiveView— 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 checkIDOR-shape heuristic,authorization.mdguide,djust-devskill 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 lookupModel.objects.get(pk=self.<x>_id)). Default returnsNoneso views that don't override see zero behavior change.has_object_permission(self, request, obj) -> bool— returnTrueif the request user may accessobj. Default returnsTrue. Called at mount-time whenget_objectis 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 cachedself._objectwould let a formerly-authorized user retain access until WS reconnect.
The framework caches the result of
get_object()asself._objectafter mount — reuse it from event handlers andget_context_datarather 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()returnsNone,has_object_permissionis not called (the caller raises 404 if it wants to). The framework also catches Django'sObjectDoesNotExist(parent of everyModel.DoesNotExist) ANDdjango.http.Http404(raised byget_object_or_404) insidecheck_object_permissionand treats them asNone— automating the 404-shape pattern so a naiveModel.objects.get(pk=missing)orget_object_or_404(...)doesn't leak existence viaDEBUG=Truetraceback. Note the two are listed as separate catches becauseHttp404inherits fromExceptiondirectly, not fromObjectDoesNotExist.Order of auth checks (logical onion):
login_required→permission_required→check_permissions(existing) →has_object_permission(NEW). The new step has its own physical call site atwebsocket.py:handle_mountpost-mount (not insidecheck_view_auth), becauseget_object()readsself.<x>_idpopulated by the user'smount()body —check_view_authruns pre-mount whenself.kwargsisn't yet bound. ADR-017 § Decision 5 documents the rationale.New helpers in
djust.auth.core:check_object_permission(view_instance, request)— re-exported fromdjust.auth. Wiresget_object+has_object_permissiontogether; raisesPermissionDeniedon 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 atwebsocket.py:1953-1955.Backwards compatible: views that don't override
get_objectsee zero behavior change (verified empirically — full pytest suite of 4670 tests + 1563 JS tests passes unchanged). Apps that already usecheck_permissionskeep working; the new step runs after.9 regression tests in
tests/integration/test_object_permission_mount.py: denial viareturn FalseraisesPermissionDenied; allow populatesself._object; no-override is a no-op;_invalidate_object_cacheresets the cache (verified via call counter);get_object()=Noneskipshas_object_permission;get_object()raisingObjectDoesNotExistis treated asNone;get_object()raisingHttp404is treated asNone(defense-in-depth against theDEBUG=Truetraceback leak);has_object_permissionraisingPermissionDenieddirectly (vsreturn 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_permissionis called (locks the strict-identityis Nonecontract).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 inwebsocket.py, plus HTTP-runtime inruntime.pyand SSE insse.py— five call sites total). Adding the check there covers all transports without per-site changes.Per-event denial semantics:
has_object_permission(...)returnsFalse→PermissionDeniedraised bycheck_object_permission→ caught by_validate_event_security→send_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()returningNoneor raisingObjectDoesNotExist/Http404→ no denial, handler proceeds (consistent with mount-time semantics; the developer'sget_object()already implements the OWASP 404-shape).- View doesn't override
get_object→_has_custom_get_objectshort-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 structuredcodefield 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_permissionnow setsself._object = objonly AFTERhas_object_permissionreturnsTrue— never on denial, never on DNE/Http404 (those reset toNone). 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._objectis allocated inLiveView.__init__BEFORE the_framework_attrssnapshot, so it's classified as a framework slot and excluded from msgpack-serialized user-private state. After WS reconnect / state-restore, the cache isNoneandget_object()re-runs fresh — handles "object reassigned while user was disconnected" automatically. New regression testtest_object_cache_is_framework_slot_excluded_from_user_statelocks this contract.Embedded child views (
{% live_render %}): when an event targets a child view viaview_id, the dispatch sites pass the resolvedtarget_view(the child) to_validate_event_security. The check uses the CHILD'sget_object/has_object_permission, NOT the parent's. New regression testtest_embedded_child_view_uses_child_get_objectverifies.Fail-closed on developer-code exceptions (Stage 11 🟡 finding): if
get_object()orhas_object_permission()raise anything other thanPermissionDenied(e.g., anAttributeErrorin 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_objectoverride see zero behavior change. Existing handler-level@permission_requireddecorators 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 checkheuristic —X008(python/djust/audit_ast.py). Flags any view matching the IDOR shape: extendsLiveView(or matches the existing detail-view heuristic), haspermission_requiredset,mount()assigns from a URL kwarg ending in_id(the canonicalself.document_id = document_idpattern), at least one@event_handler-decorated method readsself.<that>_id, AND does NOT overridehas_object_permissionorcheck_permissions. Severity: warning. Details point todocs/website/guides/authorization.mdfor the migration recipe. Distinct from existingX001(.get(pk=user_input)pattern);X008is structural — it flags the shape regardless of fetch mechanism. Runpython manage.py djust_audit --astto find matches.New guide
docs/website/guides/authorization.md. Walks through the four-layer auth onion (login → role → custom → object), the canonicalget_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-eventcode: permission_denied), defense-in-depth via manager-levelfor_user()filtering, and a worked migration example (before/after diff for hand-rolledget_context_dataIDOR checks).djust-devskill principle catalog updated. Two new entries: "Object-level authorization (post-v0.9.5)" with the canonical pattern, OWASP rationale, cache discipline, migration recipe, anddjust check X008reference; and "Security-class code defaults to fail-closed at every catch block" — when implementing auth/permission/validation code, catchException(not just the specific expected error), log vialogger.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_permissionoverride OK,check_permissionsoverride OK, nopermission_requiredno 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.