Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions login/access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""
SPDX-FileCopyrightText: 2026 Jonas Huber <https://github.com/jh-RLI> 漏 Reiner Lemoine Institut
SPDX-License-Identifier: AGPL-3.0-or-later

Access checks for the ``login`` views: who may reach a profile view, and who
may act on an organization.

- ``ProfileOwnerRequiredMixin`` / ``profile_owner_required``: the owner rule
for every view under ``profile/<user_id>/`` (below), and
``enforces_owner_rule`` to tell whether it runs for a URL callback.
- ``membership_or_404``: the caller's membership in an organization, with a
minimum level; not a member answers 404, a level too low 403.
- ``is_htmx``: whether a request came from htmx, which decides between a
page answer and a fragment answer.

The owner rule: a profile page is a user's own dashboard. There is no public profile and no
"someone else's dashboard" to show, so every route carrying a ``user_id``
answers only when that id is the caller's own:

- anonymous, full page: 302 to the login page, with ``next``
- anonymous, htmx request: 401, no body
- logged in with another id: 404, page and htmx alike
- the owner: the view runs

Platform admins are not exempt: support goes through the Django shell, not
through someone's dashboard.

A foreign id answers 404, never 403, and the same 404 for an id that exists
and one that does not, so the answer does not reveal which accounts exist. On
a match the view is handed ``request.user``; the user named in the URL is
never loaded.

An htmx request gets a bare 401 rather than the login redirect, because htmx
follows a redirect and would swap the login page into the fragment it was
asked to fill.

This lives in its own module rather than in ``login/permissions.py`` because
that one holds the permission levels and is imported by ``login/models.py`` at
app-loading time, before the auth views this module needs can be imported.

``login/tests/test_profile_owner_rule`` walks every URL pattern and fails for
a ``user_id`` route on which ``enforces_owner_rule`` says the rule does not
run.
""" # noqa: 501

from functools import wraps
from weakref import WeakSet

from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404

from login.models import Membership, Organization
from login.permissions import NO_PERM


def is_htmx(request) -> bool:
"""Whether the request came from htmx (it sends ``HX-Request``)."""
return "HX-Request" in request.headers


def _refusal_or_raise_404(request, user_id):
"""Settle the caller: None for the owner, a refusal for an anonymous
caller, and ``Http404`` raised for a logged-in caller with another id.
"""
if not request.user.is_authenticated:
if is_htmx(request):
return HttpResponse(status=401)
return redirect_to_login(request.get_full_path())
if str(request.user.pk) != str(user_id):
raise Http404
return None


class ProfileOwnerRequiredMixin:
"""The owner rule for class-based views.

List it FIRST in the bases: ``View.dispatch`` does not call further along
the MRO, so a mixin placed after the view class never runs. The same holds
for every mixin that guards ``dispatch``, Django's ``LoginRequiredMixin``
included; the organization views point here for that reason.

On a match ``self.profile_user`` is the caller.
"""

def dispatch(self, request, *args, **kwargs):
refusal = _refusal_or_raise_404(request, kwargs.get("user_id"))
if refusal is not None:
return refusal
self.profile_user = request.user
return super().dispatch(request, *args, **kwargs)


# The wrappers profile_owner_required made. A registry rather than an
# attribute, because functools.wraps copies attributes onto whatever wraps a
# view, so an attribute would also mark a function that never runs the rule.
_GUARDED_FUNCTIONS = WeakSet()


def profile_owner_required(view_func):
"""The owner rule for function views.

The view is called as ``view_func(request, profile_user, ...)`` without
the ``user_id``, and ``profile_user`` is the caller.
"""

@wraps(view_func)
def wrapper(request, user_id, *args, **kwargs):
refusal = _refusal_or_raise_404(request, user_id)
if refusal is not None:
return refusal
return view_func(request, request.user, *args, **kwargs)

_GUARDED_FUNCTIONS.add(wrapper)
return wrapper


def enforces_owner_rule(view) -> bool:
"""Whether the owner rule runs first for this URL callback.

A class-based view must resolve ``dispatch`` to the mixin's own: that
rejects the mixin listed after ``View`` (``View.dispatch`` wins and never
calls along the MRO) and a ``dispatch`` override that could skip it. A
function view must be the decorator's wrapper itself. Either way the rule
is the outermost layer, so nothing runs before it: any other decorated
callable is rejected, including a decorator around ``as_view()``, which
``functools.wraps`` makes look like the class view by copying
``view_class`` onto it.
"""
if view in _GUARDED_FUNCTIONS:
return True
if hasattr(view, "__wrapped__"):
return False
view_class = getattr(view, "view_class", None)
if view_class is not None:
return view_class.dispatch is ProfileOwnerRequiredMixin.dispatch
return False


def membership_or_404(user, organization_id, min_level=NO_PERM):
"""The organization and ``user``'s membership in it, or a refusal.

404 when the organization does not exist or ``user`` is not a member, so
a non-member learns nothing about it; ``PermissionDenied`` (403) when the
member's level is below ``min_level``. Returns
``(organization, membership)``.
"""
organization = get_object_or_404(Organization, id=organization_id)
membership = get_object_or_404(Membership, group=organization, user=user)
if membership.level < min_level:
raise PermissionDenied
return organization, membership
35 changes: 35 additions & 0 deletions login/tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""
SPDX-FileCopyrightText: 2026 Jonas Huber <https://github.com/jh-RLI> 漏 Reiner Lemoine Institut
SPDX-License-Identifier: AGPL-3.0-or-later

Small helpers shared by the profile and organization access tests.

``base.tests.TestViewsTestCase`` is not used for these: it creates one fixed
user in ``setUpClass``, while every access test needs several users in
distinct roles (owner, stranger, admin, members at each level).
""" # noqa: 501

from login.models import myuser

HTMX = {"HTTP_HX_REQUEST": "true"}


def make_user(name, **extra):
"""A verified user who has agreed to the terms, named ``name``; its
email is derived from the name. ``extra`` sets further fields, such as
``is_admin``. Returns the existing user if one already matches."""
user, _ = myuser.objects.get_or_create(
name=name,
email=f"{name.lower()}@test.com",
did_agree=True,
is_mail_verified=True,
**extra,
)
return user


def act_as(client, user):
"""Log the test client in as ``user``, or leave it anonymous for None."""
client.logout()
if user is not None:
client.force_login(user)
32 changes: 24 additions & 8 deletions login/tests/test_dataset_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,15 @@ def test_create_duplicate_normalized_name_shows_inline_error(self):
self.assertContains(response, "taken_name")

def test_cannot_create_on_foreign_profile(self):
# another user's dashboard does not exist for this caller: 404
response = self.client.post(
reverse("login:datasets", args=[self.other_user.id]),
{
"title": "Sneaky Dataset",
"description": "Posting on someone else's dashboard",
},
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 404)
self.assertFalse(Dataset.objects.filter(name="sneaky_dataset").exists())

def test_card_links_to_public_detail_in_new_tab(self):
Expand Down Expand Up @@ -345,9 +346,10 @@ def test_edit_validation_error_is_shown_inline(self):
self.assertContains(response, "invalid-feedback")

def test_edit_forbidden_for_non_creator(self):
# through the caller's own dashboard, so the creator check answers
self.client.force_login(self.other_user)
response = self.client.post(
self.edit_url,
reverse("login:dataset-edit", args=[self.other_user.id, "quick_dataset"]),
{"title": "Hijacked", "description": "Should fail"},
)
self.assertEqual(response.status_code, 403)
Expand Down Expand Up @@ -395,8 +397,11 @@ def test_delete_removes_only_its_own_card(self):
self.assertNotContains(response, "Create dataset")

def test_delete_forbidden_for_non_creator(self):
# through the caller's own dashboard, so the creator check answers
self.client.force_login(self.other_user)
response = self.client.post(self.delete_url)
response = self.client.post(
reverse("login:dataset-delete", args=[self.other_user.id, "quick_dataset"])
)
self.assertEqual(response.status_code, 403)
self.assertTrue(Dataset.objects.filter(name="quick_dataset").exists())

Expand All @@ -406,11 +411,11 @@ def test_delete_confirm_copy_mentions_tables_survive(self):
self.assertContains(response, "not deleted")

def test_actions_not_rendered_for_other_users(self):
# another user's dashboard is refused outright (owner rule)
self.client.force_login(self.other_user)
response = self.client.get(reverse("login:datasets", args=[self.user.id]))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "quick_dataset")
self.assertNotContains(response, "hx-confirm")
self.assertNotContains(response, "quick_dataset", status_code=404)
self.assertNotContains(response, "hx-confirm", status_code=404)


class DatasetResourceManagementTests(TestCase):
Expand Down Expand Up @@ -476,8 +481,13 @@ def test_manage_close_swaps_only_its_own_card(self):
self.assertNotContains(response, 'hx-target="#datasets-container"')

def test_manage_view_creator_only(self):
# through the caller's own dashboard, so the creator check answers
self.client.force_login(self.other_user)
response = self.client.get(self.manage_url)
response = self.client.get(
reverse(
"login:dataset-manage", args=[self.other_user.id, "managed_dataset"]
)
)
self.assertEqual(response.status_code, 403)

def test_manage_lists_resources_with_links_and_status_badges(self):
Expand Down Expand Up @@ -557,9 +567,15 @@ def test_assign_foreign_draft_forbidden(self):

def test_assign_forbidden_for_non_creator(self):
self.make_table("t_free_for_all", published=True)
# through the caller's own dashboard, so the creator check answers
self.client.force_login(self.other_user)

response = self.client.post(self.assign_url, {"table": "t_free_for_all"})
response = self.client.post(
reverse(
"login:dataset-assign", args=[self.other_user.id, "managed_dataset"]
),
{"table": "t_free_for_all"},
)
self.assertEqual(response.status_code, 403)
self.assertFalse(self.dataset.tables.filter(name="t_free_for_all").exists())

Expand Down
Loading
Loading