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
+15 -8
View File
@@ -64,14 +64,21 @@
</div>
</td>
<td class="table-col-auto">
<a class="no-underline cursor-pointer"
role="button"
data-tippy-content="
{% if rule.active %}{% translate "Deactivate" %}{% else %}{% translate "Activate" %}{% endif %}"
hx-get="{% url 'transaction_rule_toggle_activity' transaction_rule_id=rule.id %}">
{% if rule.active %}<i class="fa-solid fa-toggle-on text-success"></i>{% else %}
<i class="fa-solid fa-toggle-off text-error"></i>{% endif %}
</a>
{% if not rule.owner or user == rule.owner %}
<a class="no-underline cursor-pointer"
role="button"
data-tippy-content="
{% if rule.active %}{% translate "Deactivate" %}{% else %}{% translate "Activate" %}{% endif %}"
hx-get="{% url 'transaction_rule_toggle_activity' transaction_rule_id=rule.id %}">
{% if rule.active %}<i class="fa-solid fa-toggle-on text-success"></i>{% else %}
<i class="fa-solid fa-toggle-off text-error"></i>{% endif %}
</a>
{% else %}
<span data-tippy-content="{% translate "Only the owner can change this" %}">
{% if rule.active %}<i class="fa-solid fa-toggle-on text-success opacity-50"></i>{% else %}
<i class="fa-solid fa-toggle-off text-error opacity-50"></i>{% endif %}
</span>
{% endif %}
</td>
<td class="table-col-auto text-center">
<div>{{ rule.order }}</div>