fix(rules): enforce object-level authorization on rule endpoints

SharedObjectManager scopes querysets to what a user may see, which
includes other people's public and shared-with-them objects. Several
mutating rule endpoints treated that visibility as permission to write.

Generalise get_owned_object_or_403 into get_shared_object_or_error, which
takes an explicit access level instead of inferring one:

- READ requires the object to be visible; denial is 404 so the response
  does not confirm that an id exists.
- EDIT requires ownership; denial is 403, but only after the visibility
  check, so 403 never leaks the existence of an invisible object. This
  matters for TransactionRuleAction, whose manager is unscoped.

The previous owner_path resolved to a User and discarded the object, so
it could not express visibility at all. via= now points at the governing
SharedObject and is resolved with a plain getattr, so an unresolvable
path raises instead of silently granting access.

is_visible_to/is_editable_by replace is_accessible_by, which was never
called and tested visibility == "shared", a value that does not exist in
Visibility.

Also fixes two further holes in the same module:

- transaction_rule_delete fell through to delete() whenever the caller
  was not in shared_with, so any user could delete a public rule. Now
  only the owner deletes; a shared user revokes their own access.
- transaction_rule_view was read-only but is now explicitly READ, so
  rules shared with a user stay viewable.

The activate/deactivate control is hidden for rules the user does not
own, instead of rendering a button that always fails.

Refs GHSA-83g9-vjqf-2j5q
This commit is contained in:
Herculino Trotta
2026-09-01 21:25:46 -03:00
parent 68a9286ce5
commit 18d4ab7d11
4 changed files with 143 additions and 86 deletions
+43 -21
View File
@@ -1,36 +1,58 @@
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.shortcuts import get_object_or_404
READ = "read"
EDIT = "edit"
def get_owned_object_or_403(klass, request, *args, owner_path="owner", **kwargs):
"""Fetch an object like ``get_object_or_404`` while enforcing ownership.
Returns the object when it has no owner, or when it is owned by
``request.user``; otherwise raises :class:`~django.core.exceptions.PermissionDenied`
(HTTP 403). This mirrors the owner check used by ``transaction_rule_edit``
(``if obj.owner and obj.owner != request.user``) so authorization is applied
uniformly across handlers that resolve an object from a URL id.
def get_shared_object_or_error(klass, request, *, level=EDIT, via=None, **kwargs):
"""Fetch an object like ``get_object_or_404`` while enforcing access control.
An object with no owner stays accessible to everyone, preserving the
existing behaviour for legacy/unowned objects.
``SharedObjectManager`` scopes querysets to what a user may *see*, which is
not the same as what they may *change*. Views that resolve an object from a
URL id must state which of the two they need, otherwise a shared or public
object becomes writable by anyone who can see it.
``owner_path`` is a dotted attribute path to the owning user, so nested
ownership is supported for objects owned through a relation, e.g. a rule
action owned via its parent rule::
``level`` selects the check applied to the governing ``SharedObject``:
get_owned_object_or_403(
TransactionRuleAction, request, id=pk, owner_path="rule.owner"
``READ``
The object must be visible to the user. Denial raises :class:`Http404`
so the response does not confirm that the id exists.
``EDIT``
The object must be owned by the user. Denial raises
:class:`~django.core.exceptions.PermissionDenied` (HTTP 403), which the
frontend surfaces as an "Access Denied" dialog. An object the user
cannot even see raises :class:`Http404` instead, so 403 never confirms
the existence of an object they were not allowed to know about.
Objects with no owner stay accessible to everyone, preserving the existing
behaviour for legacy/unowned objects.
``via`` is a dotted path to the ``SharedObject`` that governs access, for
models owned through a relation, e.g. a rule action governed by its parent
rule::
get_shared_object_or_error(
TransactionRuleAction, request, id=pk, level=EDIT, via="rule"
)
The path is resolved with a plain ``getattr``, so a path that does not
resolve raises ``AttributeError`` rather than silently granting access.
"""
obj = get_object_or_404(klass, *args, **kwargs)
obj = get_object_or_404(klass, **kwargs)
owner = obj
for attr in owner_path.split("."):
owner = getattr(owner, attr, None)
if owner is None:
break
guard = obj
for attr in via.split(".") if via else []:
guard = getattr(guard, attr)
if owner is not None and owner != request.user:
if level not in (READ, EDIT):
raise ValueError(f"Unknown access level: {level!r}")
if not guard.is_visible_to(request.user):
raise Http404
if level == EDIT and not guard.is_editable_by(request.user):
raise PermissionDenied
return obj