Skip to content

Restrict profile pages to their owner and check organization permissions before saving - #2547

Merged
jh-RLI merged 7 commits into
developfrom
fix-profile-owner-rule
Oct 1, 2026
Merged

jh-RLI merged 7 commits into
developfrom
fix-profile-owner-rule

Conversation

@jh-RLI

@jh-RLI jh-RLI commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary of the discussion

Pages under profile/<user_id>/ are a user's own dashboard. There is no public profile, but the views did not check that the id in the address was the caller's own, so they relied on template conditions instead. This PR adds that check in one place and applies it to every profile route. It also adds the missing permission checks to the organization views.

Profile routes (owner rule). login/access.py holds the rule as a mixin (ProfileOwnerRequiredMixin) and a decorator (profile_owner_required). Every route whose address carries a user_id uses one of them (17 routes):

caller full page htmx request
owner served served
logged in, another id 404 404
anonymous 302 to login, with next 401, empty body
  • The answer to another id is 404 rather than 403, and it is the same whether or not that id belongs to an account.
  • Platform admins are not exempt.
  • When the id matches, the view uses request.user and does not load the user named in the address.
  • An htmx request gets 401 instead of the login redirect. htmx would follow the redirect and swap the login page into the fragment.
  • The dataset routes run the existing creator check (403) after the owner rule.
  • The edit view's hand-written check is replaced by the rule.
  • The delete_acc route now answers 404 instead of raising, because handler404 did not accept user_id.
  • A structural test walks every URL pattern from the site root, including parameters captured by include() prefixes, and fails for any user_id route on which the rule does not actually run. enforces_owner_rule checks what runs: a class view must resolve dispatch to the mixin's own, so a mixin listed after View or a dispatch override is rejected. A function view must be the decorator's own wrapper as the outermost layer, and a function that merely copies its attributes is rejected. A decorator around a class view's as_view() is rejected too, because functools.wraps copies view_class onto it while the decorator runs first. Self-tests pin each rejected case.

Organization views.

  • OrganizationManagementView and OrganizationMembersView now list LoginRequiredMixin before the view class. View.dispatch never calls further along the MRO, so the mixin was not running before.
  • Editing an organization checks the caller's membership before the form is saved. A non-member gets 404, a member below admin gets 403, and nothing is written.
  • Creating an organization saves the organization and its first admin in one transaction.
  • The member list is shown to members only.
  • A member is removed only when none of the checks refused the removal. The check against a higher level now runs before the last-admin check, not after it.
  • The redirects after create, leave and delete are built with reverse instead of a written-out path.
  • The organization and membership lookup with a minimum level is one helper, membership_or_404 in login/access.py. It answers 404 for a non-member and 403 for a level that is too low, as before.
  • The refusal codes are the ones these views already used: no membership 404, too low a level 403, no login 302.

Tests changed, and why.

  • In login/tests/test_dataset_views.py, two tests asserted the old answers on another user's dashboard (403 on create, 200 on the page). They now expect 404.
  • Four "non-creator" tests reached the creator check through another user's dashboard id. They now use the caller's own id, so they still test the creator check (403).
  • In login/tests/test_views.py, the smoke GETs of profile routes now log in, so they still render the pages instead of only following the login redirect.

New tests:

  • login/tests/test_profile_owner_rule.py (structural test, owner/foreign/anonymous × page/htmx on every route, unknown ids, POST routes, a content check on the tables list, and refused GETs that write nothing)
  • login/tests/test_organization_write_checks.py (includes the create, leave and delete redirects to the caller's own organizations page)
  • shared helpers in login/tests/helpers.py

Full suite: 963 tests OK (105 skipped, the Fuseki-dependent OEKG tests). The OpenAPI drift guard is unchanged and passes.

Not in this PR:

  • The settings page still ensures an API token for every user on each visit. Only the owner reaches it now.
  • Anonymous htmx requests to the organization views still get the login redirect rather than a 401.

Type of change (CHANGELOG.md)

Bugs

  • Restrict profile pages to their owner and check organization permissions
    before saving
    (#2547)

Workflow checklist

Automation

Closes #2538

PR-Assignee

Reviewer

  • 🐙 Follow the
    Reviewer Guidelines
  • 🐙 Provided feedback and show sufficient appreciation for the work done

🤖 Generated with Claude Code

jh-RLI and others added 6 commits October 1, 2026 13:33
Every route under profile/<user_id>/ now answers only when the id is the
caller's own. The rule lives in login/access.py as a mixin and a
decorator: another id answers 404 (the same for an id that does not
exist), an anonymous page goes to the login page with next, and an
anonymous htmx request gets 401. Platform admins are not exempt.

On a match the view works with request.user and no longer loads the
user named in the URL. The dataset routes stack the creator check
under the rule, the edit view drops its hand-written check, and the
account-delete route answers 404 instead of raising.

A structural test walks login's URL patterns and fails for a user_id
route without the rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The organization views put the login mixin after the view class, where
it never ran. It now comes first, so an anonymous request goes to the
login page before any lookup or write.

Editing an organization checks the caller's membership level before
the form is saved, and creating one saves the organization and its
first admin in one transaction. The member list answers members only,
and removing a member happens only when none of the checks refused it.
Redirects after create, leave and delete are reversed from the route
instead of spelled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The structural test read an inherited marker, so a class listing the
mixin after View passed while View.dispatch skipped the mixin, and so
did a dispatch override or a function that copied the marker through
functools.wraps.

enforces_owner_rule in login/access.py now asks what runs: a class view
must resolve dispatch to the mixin's own, a function view must be the
decorator's wrapper itself (kept in a registry, which wraps cannot
copy). The walk starts at the site's root and carries parameters
captured by include() prefixes down to their children. Self-tests pin
each rejected case and the include walk on a synthetic resolver.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
HTMX, make_user and act_as move into login/tests/helpers.py, used by
both access test modules. base.tests.TestViewsTestCase does not fit:
it creates one fixed user, and these tests need several in distinct
roles.

The anonymous redirect test now checks that next is the requested path,
the POST routes are data instead of a repeated condition, and the leave
and delete redirects are tested like create: HX-Redirect to the
caller's own organizations page, followed with a user whose pk is not 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
membership_or_404 in login/access.py does the organization and
membership lookup with a minimum level: not a member 404, level too low
403, as each view did by hand before. The organization views in this
change use it.

is_htmx replaces the three inline HX-Request checks in the views this
change touches, the warning about mixin order is stated once on
ProfileOwnerRequiredMixin, _refusal is now _refusal_or_raise_404, and
the settings view uses the module-level Token import.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jh-RLI
jh-RLI marked this pull request as ready for review October 1, 2026 11:57
@jh-RLI jh-RLI self-assigned this Oct 1, 2026
functools.wraps copies view_class onto a decorator around as_view(),
so enforces_owner_rule accepted such a wrapper although the decorator
runs before the mixin. It now accepts the decorator's registered
wrapper first and rejects any other callable carrying __wrapped__
before looking at view_class. Self-tests pin login_required and
never_cache around as_view().

Also: the registry sits above the decorator, the module docstring
covers membership_or_404 and is_htmx, the organization views use the
bare permission constants, and the tests call act_as directly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jh-RLI
jh-RLI merged commit 3862590 into develop Oct 1, 2026
5 checks passed
@jh-RLI
jh-RLI deleted the fix-profile-owner-rule branch October 1, 2026 12:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Profile tables list is readable for other users

1 participant