Files
WYGIWYH/app/apps/common/models.py
T
Herculino Trotta 18d4ab7d11 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
2026-09-01 21:25:46 -03:00

126 lines
3.8 KiB
Python

from django.db import models
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.common.middleware.thread_local import get_current_user
class SharedObjectManager(models.Manager):
def get_queryset(self):
"""Return only objects the user can access"""
user = get_current_user()
base_qs = super().get_queryset()
if user and user.is_authenticated:
return base_qs.filter(
Q(visibility="public")
| Q(owner=user)
| Q(shared_with=user)
| Q(visibility="private", owner=None)
).distinct()
return base_qs.filter(visibility="public")
class SharedObject(models.Model):
# Access control enum
class Visibility(models.TextChoices):
private = "private", _("Private")
is_paid = "public", _("Public")
# Core sharing fields
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="%(class)s_owned",
null=True,
blank=True,
verbose_name=_("Owner"),
)
visibility = models.CharField(
max_length=10,
choices=Visibility.choices,
default=Visibility.private,
verbose_name=_("Visibility"),
)
shared_with = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name="%(class)s_shared",
blank=True,
verbose_name=_("Shared with users"),
)
# Use as abstract base class
class Meta:
abstract = True
indexes = [
models.Index(fields=["visibility"]),
]
# NOTE: these two predicates must stay in sync with the ``Q`` objects built
# by ``SharedObjectManager.get_queryset`` above. The manager filters at the
# queryset level and these check a single instance, so they cannot share an
# implementation; ``SharedObjectPredicateParityTests`` asserts they agree.
def is_visible_to(self, user):
"""Whether ``user`` may read this object.
Mirrors ``SharedObjectManager``: public objects, objects with no owner,
the owner's own objects, and objects explicitly shared with the user.
"""
if self.owner is None or self.visibility == "public":
return True
if not user or not user.is_authenticated:
return False
return self.owner_id == user.pk or self.shared_with.filter(pk=user.pk).exists()
def is_editable_by(self, user):
"""Whether ``user`` may mutate this object.
Sharing grants read access only; mutation stays with the owner. Objects
with no owner remain editable by everyone, preserving the behaviour of
legacy/unowned objects.
"""
if self.owner is None:
return True
return bool(user and user.is_authenticated and self.owner_id == user.pk)
def save(self, *args, **kwargs):
if not self.pk and not self.owner:
self.owner = get_current_user()
super().save(*args, **kwargs)
class OwnedObjectManager(models.Manager):
def get_queryset(self):
"""Return only objects the user can access"""
user = get_current_user()
base_qs = super().get_queryset()
if user and user.is_authenticated:
return base_qs.filter(Q(owner=user) | Q(owner=None)).distinct()
return base_qs
class OwnedObject(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="%(class)s_owned",
null=True,
blank=True,
)
# Use as abstract base class
class Meta:
abstract = True
def save(self, *args, **kwargs):
if not self.pk and not self.owner:
self.owner = get_current_user()
super().save(*args, **kwargs)