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
Add apps.common.functions.get_owned_object_or_403, an ownership-enforcing
variant of get_object_or_404, and apply it across every rules handler that
resolved a TransactionRule / (UpdateOrCreate)TransactionRuleAction from a URL id
without an owner check. Mirrors the check in transaction_rule_edit
(obj.owner and obj.owner != request.user); objects with no owner remain
accessible, preserving existing behaviour. Nested ownership (actions owned via
their parent rule) is handled with owner_path='rule.owner'.
The copy button passed the token through Django's escapejs filter into the
hyperscript writeText() call, which turns every "-" into -. hyperscript
does not decode \u escapes, so any token containing "-" (common with
token_urlsafe) was copied corrupted and failed auth on paste. Copy from the
input's value instead, which holds the unescaped raw token.
Revoked tokens previously stayed in the list with no way to remove them.
Adds a delete action (hard delete, scoped to the owner, gated behind
demo mode) shown on revoked rows, alongside the existing revoke action on
active ones.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Personal API tokens (model, user-settings UI, admin, management command,
DRF auth class) for non-interactive API access from automations like n8n.
Raw token shown once; only a SHA-256 hash is stored; last_used_at writes
are throttled.
- OAuth2 authorization server via django-oauth-toolkit with authorization
server metadata and optional, off-by-default Dynamic Client Registration
(RFC 7591), so remote OAuth/MCP clients can authenticate and self-register.
- Tests for token auth, DCR gating and the management commands, plus
.env.example and README documentation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>