From d34ecd53f9057570a84aabbeb2ab3c1f07a83f62 Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Thu, 8 Oct 2026 23:33:50 +0000 Subject: [PATCH 1/2] fix(geo): reject foreign relay IDs in mutations, and don't cache private tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_obj_from_relay_id` resolves a public ID through DocumentId and returns the object of ANY registered model. The geo mutations used it without a type check, so a user holding only the geo model permissions could reach other blocks' rows: - geoFeatureDelete called obj.delete() on whatever came back. A User's (or Comment's, Follow's, Rate's) relay ID deleted that row. - geoFeatureUpdate passed it to GeoJSONFeatureForm(instance=obj). With all four optional inputs supplied, construct_instance wrote `name` and `description` onto the foreign instance and form.save() persisted it. Both now resolve through _get_feature(), which type-checks and raises `not_found`. The resolver's own exceptions are caught and logged rather than surfaced, since they leak which IDs exist. geoFeatureCreate rejects an unresolvable targetObjectId the same way, instead of failing later with an IntegrityError on the NOT NULL target_document_id. Also: - Tiles sent `Cache-Control: public` unconditionally, even though the view's permission is overridable precisely so projects can hide features from anonymous users. Visibility is now derived from whether an anonymous user may view, and Vary: Authorization is set so token-auth deployments aren't shared-cached on the session cookie alone. - baseapp/geo/graphql/__init__.py re-exported the ObjectTypes, so importing the FilterSet from the REST tile view pulled in graphene, graphene_django, query_optimizer and baseapp_auth.graphql — making the REST surface depend on the `graphql` extra and defeating the laziness filters.py documents. Emptied, matching baseapp/files and baseapp/content_feed. Not addressed here: object-level authorization on update/delete (no owner column exists on AbstractGeoJSONFeature yet) and the generic `GeometryObjectType` type name. baseapp/geo/tests: 114 passed. black, isort and flake8 clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015fczpSCpN3kCRs3UBjbity --- baseapp/geo/graphql/__init__.py | 15 ----- baseapp/geo/graphql/mutations.py | 43 +++++++++++- baseapp/geo/rest_framework/views.py | 17 ++++- baseapp/geo/tests/test_graphql_mutations.py | 75 +++++++++++++++++++++ baseapp/geo/tests/test_rest_tiles.py | 23 +++++++ 5 files changed, 154 insertions(+), 19 deletions(-) diff --git a/baseapp/geo/graphql/__init__.py b/baseapp/geo/graphql/__init__.py index bdff4e51..e69de29b 100644 --- a/baseapp/geo/graphql/__init__.py +++ b/baseapp/geo/graphql/__init__.py @@ -1,15 +0,0 @@ -from .object_types import ( - BaseGeoJSONFeatureObjectType, - GeoJSONFeatureObjectType, - GeoJSONFeatureProperties, - GeometryObjectType, -) -from .scalars import Geometry - -__all__ = [ - "BaseGeoJSONFeatureObjectType", - "GeoJSONFeatureObjectType", - "GeoJSONFeatureProperties", - "GeometryObjectType", - "Geometry", -] diff --git a/baseapp/geo/graphql/mutations.py b/baseapp/geo/graphql/mutations.py index f2a60c08..fd7ea8f0 100644 --- a/baseapp/geo/graphql/mutations.py +++ b/baseapp/geo/graphql/mutations.py @@ -1,3 +1,5 @@ +import logging + import graphene import swapper from django import forms @@ -13,11 +15,41 @@ from ..models import ALLOWED_GEOMETRY_TYPES from .scalars import Geometry +logger = logging.getLogger(__name__) + GeoJSONFeature = swapper.load_model("baseapp_geo", "GeoJSONFeature") app_label = GeoJSONFeature._meta.app_label ObjectType = GeoJSONFeature.get_graphql_object_type() +def _resolve_relay_id(info: graphene.ResolveInfo, relay_id): + """Resolve a relay ID, treating any failure as "not found". + + Never let the resolver's own exception reach the client: it leaks which IDs exist. + """ + try: + return get_obj_from_relay_id(info, relay_id) + except Exception: + logger.exception("Could not resolve relay id in a geo mutation") + return None + + +def _get_feature(info: graphene.ResolveInfo, relay_id) -> "GeoJSONFeature": + """Resolve a relay ID to a GeoJSONFeature, or raise `not_found`. + + `get_obj_from_relay_id` resolves a public ID through DocumentId and returns the object + of ANY registered model, so this type check is what stops these mutations from writing + to (or deleting) another block's rows. + """ + obj = _resolve_relay_id(info, relay_id) + if not isinstance(obj, GeoJSONFeature): + raise GraphQLError( + str(_("GeoJSON feature not found")), + extensions={"code": "not_found"}, + ) + return obj + + class GeoJSONFeatureForm(forms.ModelForm): class Meta: model = GeoJSONFeature @@ -68,7 +100,12 @@ def mutate_and_get_payload( extensions={"code": "permission_required"}, ) - target = get_obj_from_relay_id(info, input.get("target_object_id")) + target = _resolve_relay_id(info, input.get("target_object_id")) + if target is None: + raise GraphQLError( + str(_("Target object not found")), + extensions={"code": "not_found"}, + ) instance = GeoJSONFeature(target=target) form = GeoJSONFeatureForm(instance=instance, data=input) @@ -121,7 +158,7 @@ def mutate_and_get_payload( extensions={"code": "permission_required"}, ) - instance = get_obj_from_relay_id(info, input.get("id")) + instance = _get_feature(info, input.get("id")) data = { field: input[field] if field in input else getattr(instance, field) @@ -169,7 +206,7 @@ def mutate_and_get_payload( ) relay_id = input.get("id") - obj = get_obj_from_relay_id(info, relay_id) + obj = _get_feature(info, relay_id) target = obj.target diff --git a/baseapp/geo/rest_framework/views.py b/baseapp/geo/rest_framework/views.py index 19c72d00..e4adf3e8 100644 --- a/baseapp/geo/rest_framework/views.py +++ b/baseapp/geo/rest_framework/views.py @@ -1,4 +1,5 @@ import swapper +from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ValidationError from django.db.models import CharField, Expression, F, QuerySet from django.db.models.functions import Cast @@ -61,6 +62,18 @@ def get_feature_properties(self) -> dict[str, Expression]: "feature_type": F("feature_type"), } + def get_cache_control(self) -> str: + """`public` only when anonymous users may view features. + + The permission is overridable, and under a backend that hides features from + anonymous users a shared cache would otherwise hand one user's tile to the next + requester of the same z/x/y. + """ + GeoJSONFeature = swapper.load_model("baseapp_geo", "GeoJSONFeature") + perm = f"{GeoJSONFeature._meta.app_label}.view_geojsonfeature" + visibility = "public" if AnonymousUser().has_perm(perm) else "private" + return f"{visibility}, max-age={self.cache_max_age}" + def get(self, request: Request, z: int, x: int, y: int) -> HttpResponse: tile = Tile(z, x, y) if not tile.is_valid: @@ -92,5 +105,7 @@ def get(self, request: Request, z: int, x: int, y: int) -> HttpResponse: content_type=MVT_CONTENT_TYPE, status=status.HTTP_200_OK if content else status.HTTP_204_NO_CONTENT, ) - response["Cache-Control"] = f"public, max-age={self.cache_max_age}" + response["Cache-Control"] = self.get_cache_control() + # Token auth never touches the session, so nothing else would mark these apart. + response["Vary"] = "Authorization" return response diff --git a/baseapp/geo/tests/test_graphql_mutations.py b/baseapp/geo/tests/test_graphql_mutations.py index 3b477f73..cfe61dc8 100644 --- a/baseapp/geo/tests/test_graphql_mutations.py +++ b/baseapp/geo/tests/test_graphql_mutations.py @@ -1,3 +1,5 @@ +import uuid + import pytest import swapper from django.contrib.auth.models import Permission @@ -294,6 +296,79 @@ def test_perm_granted_user_deletes_feature(self, django_user_client, graphql_use assert GeoJSONFeature.objects.count() == 0 +class TestCrossModelRelayIds: + """A relay ID of another model must not be writable through these mutations. + + `get_obj_from_relay_id` resolves a public ID through DocumentId and returns the object + of any registered model, so without a type check a user holding only the geo model + permissions could edit or delete rows belonging to other blocks. + """ + + def test_update_with_another_models_id_is_not_found_and_changes_nothing( + self, django_user_client, graphql_user_client + ): + victim = UserFactory(first_name="Untouched") + grant_model_perm(django_user_client.user, "change_geojsonfeature") + + response = graphql_user_client( + UPDATE_MUTATION, + variables={ + "input": { + "id": victim.relay_id, + "name": "Pwned", + "description": "Pwned", + "featureType": "pwned", + "geometry": POINT_GEOJSON, + } + }, + ) + content = response.json() + + assert content["errors"][0]["extensions"]["code"] == "not_found" + victim.refresh_from_db() + assert victim.first_name == "Untouched" + + def test_delete_with_another_models_id_is_not_found_and_deletes_nothing( + self, django_user_client, graphql_user_client + ): + victim = UserFactory() + grant_model_perm(django_user_client.user, "delete_geojsonfeature") + + response = graphql_user_client( + DELETE_MUTATION, variables={"input": {"id": victim.relay_id}} + ) + content = response.json() + + assert content["errors"][0]["extensions"]["code"] == "not_found" + assert type(victim).objects.filter(pk=victim.pk).exists() + + def test_create_with_unknown_target_is_not_found_not_a_server_error( + self, django_user_client, graphql_user_client + ): + response = graphql_user_client( + CREATE_MUTATION, + variables={ + "input": { + "targetObjectId": str(uuid.uuid4()), + "geometry": POINT_GEOJSON, + } + }, + ) + content = response.json() + + assert content["errors"][0]["extensions"]["code"] == "not_found" + assert GeoJSONFeature.objects.count() == 0 + + def test_update_with_a_malformed_id_is_not_found(self, django_user_client, graphql_user_client): + grant_model_perm(django_user_client.user, "change_geojsonfeature") + + response = graphql_user_client( + UPDATE_MUTATION, variables={"input": {"id": "not-a-relay-id", "name": "x"}} + ) + + assert response.json()["errors"][0]["extensions"]["code"] == "not_found" + + class TestWritePermissions: """Write-permission matrix through real mutations (AC-9.5, AC-9.6, AC-10.3).""" diff --git a/baseapp/geo/tests/test_rest_tiles.py b/baseapp/geo/tests/test_rest_tiles.py index 7bb25ac5..11b77aa2 100644 --- a/baseapp/geo/tests/test_rest_tiles.py +++ b/baseapp/geo/tests/test_rest_tiles.py @@ -202,6 +202,29 @@ def test_cache_headers(self, client) -> None: response = client.get(tile_url(tile_for(POINT_NYC, FEATURES_ZOOM))) assert response["Cache-Control"] == "public, max-age=300" + # Other Vary values are appended by DRF/middleware; only ours has to be present. + assert "Authorization" in response["Vary"] + + def test_cache_is_private_when_features_are_not_public( + self, django_user_client, monkeypatch + ) -> None: + """Under a backend that hides features from anonymous users, a shared cache must + not be allowed to serve one user's tile to the next requester.""" + from baseapp.geo import permissions + + monkeypatch.setattr( + permissions.GeoPermissionsBackend, + "has_perm", + lambda self, user_obj, perm, obj=None: bool( + getattr(user_obj, "is_authenticated", False) + ), + ) + GeoJSONFeatureFactory(geometry=POINT_NYC) + + response = django_user_client.get(tile_url(tile_for(POINT_NYC, FEATURES_ZOOM))) + + assert response.status_code == 200, response.content + assert response["Cache-Control"] == "private, max-age=300" def test_filters_are_the_graphql_ones(self, client) -> None: tree = GeoJSONFeatureFactory(geometry=POINT_NYC, feature_type="tree") From c0e15a8727fcc2e527fece6be5ca44b04608d8c1 Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Fri, 9 Oct 2026 01:27:37 +0000 Subject: [PATCH 2/2] feat(geo): record created_by / profile and authorize the owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AbstractGeoJSONFeature had no owner column, so object-level authorization was impossible: a single group grant of change/delete_geojsonfeature let a user edit or delete every feature in the system. Modelled on baseapp.files. - `created_by` (FK to AUTH_USER_MODEL, null/blank) and, when baseapp_profiles is installed, `profile` (FK to the swappable Profile, SET_NULL) via the same conditional mixin baseapp.files uses. geoFeatureCreate records both — the profile from the request's current_profile, so the Current-Profile header is honoured like everywhere else. - GeoPermissionsBackend grows `_is_owner_or_has_global` for change/delete: the creator may act on their own feature, anyone else still needs the model permission, so moderators are unaffected. With no obj it keeps returning False, leaving ModelBackend to decide the global grant. - geoFeatureUpdate / geoFeatureDelete now resolve the feature BEFORE authorizing, so the check gets the object instead of None. - Admin shows and autocompletes the new columns, and select_relates created_by. `created_by` is nullable and `AnonymousUser.pk` is also None, so a plain `created_by_id == user.pk` makes every anonymous user the owner of every unowned feature. The backend requires `is_authenticated` before the comparison; a test pins it. Migration: testproject/geo/0002. Consuming projects with their own concrete model need their own AddField migration. baseapp/geo/tests: 121 passed. makemigrations --check clean. black, isort, flake8 clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015fczpSCpN3kCRs3UBjbity --- baseapp/geo/admin.py | 7 +- baseapp/geo/graphql/mutations.py | 18 +-- baseapp/geo/models.py | 35 +++++- baseapp/geo/permissions.py | 28 ++++- baseapp/geo/tests/test_graphql_mutations.py | 106 ++++++++++++++++++ ...ature_created_by_geojsonfeature_profile.py | 43 +++++++ 6 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 testproject/geo/migrations/0002_geojsonfeature_created_by_geojsonfeature_profile.py diff --git a/baseapp/geo/admin.py b/baseapp/geo/admin.py index ff9c23c3..f3dbec0a 100644 --- a/baseapp/geo/admin.py +++ b/baseapp/geo/admin.py @@ -3,16 +3,19 @@ from django.utils.translation import gettext_lazy as _ from baseapp_core.admin_helpers import ModelAdmin +from baseapp_core.plugins.helpers import apply_if_installed GeoJSONFeatureModel = swapper.load_model("baseapp_geo", "GeoJSONFeature") @admin.register(GeoJSONFeatureModel) class GeoJSONFeatureAdmin(ModelAdmin): - list_display = ("name", "feature_type", "geometry_type", "target", "created") + list_display = ("name", "feature_type", "geometry_type", "target", "created_by", "created") list_filter = ("feature_type",) search_fields = ("name", "description") raw_id_fields = ("target_document",) + # `profile` only exists when baseapp_profiles is installed. + autocomplete_fields = ("created_by", *apply_if_installed("baseapp_profiles", ["profile"])) @admin.display(description=_("geometry type")) def geometry_type(self, obj) -> str: @@ -22,6 +25,6 @@ def get_queryset(self, request): return ( super() .get_queryset(request) - .select_related("target_document__content_type") + .select_related("target_document__content_type", "created_by") .prefetch_related("target_document__content_object") ) diff --git a/baseapp/geo/graphql/mutations.py b/baseapp/geo/graphql/mutations.py index fd7ea8f0..76dc1bbf 100644 --- a/baseapp/geo/graphql/mutations.py +++ b/baseapp/geo/graphql/mutations.py @@ -3,6 +3,7 @@ import graphene import swapper from django import forms +from django.apps import apps from django.contrib.gis.geos import GEOSGeometry from django.utils.translation import gettext_lazy as _ from graphene_django.forms.mutation import _set_errors_flag_to_context @@ -106,7 +107,10 @@ def mutate_and_get_payload( str(_("Target object not found")), extensions={"code": "not_found"}, ) - instance = GeoJSONFeature(target=target) + + instance = GeoJSONFeature(target=target, created_by=info.context.user) + if apps.is_installed("baseapp_profiles"): + instance.profile = getattr(info.context.user, "current_profile", None) form = GeoJSONFeatureForm(instance=instance, data=input) if form.is_valid(): @@ -152,14 +156,13 @@ def mutate_and_get_payload( cls, root, info: graphene.ResolveInfo, **input ) -> "GeoJSONFeatureUpdate": """Permission-check, overlay input onto instance values, validate and save.""" - if not info.context.user.has_perm(f"{app_label}.change_geojsonfeature"): + instance = _get_feature(info, input.get("id")) + if not info.context.user.has_perm(f"{app_label}.change_geojsonfeature", instance): raise GraphQLError( str(_("You don't have permission to perform this action")), extensions={"code": "permission_required"}, ) - instance = _get_feature(info, input.get("id")) - data = { field: input[field] if field in input else getattr(instance, field) for field in GeoJSONFeatureForm.Meta.fields @@ -199,15 +202,14 @@ def mutate_and_get_payload( cls, root, info: graphene.ResolveInfo, **input ) -> "GeoJSONFeatureDelete": """Permission-check, capture the target, and delete the feature.""" - if not info.context.user.has_perm(f"{app_label}.delete_geojsonfeature"): + relay_id = input.get("id") + obj = _get_feature(info, relay_id) + if not info.context.user.has_perm(f"{app_label}.delete_geojsonfeature", obj): raise GraphQLError( str(_("You don't have permission to perform this action")), extensions={"code": "permission_required"}, ) - relay_id = input.get("id") - obj = _get_feature(info, relay_id) - target = obj.target obj.delete() diff --git a/baseapp/geo/models.py b/baseapp/geo/models.py index ad368e74..62ef2427 100644 --- a/baseapp/geo/models.py +++ b/baseapp/geo/models.py @@ -1,4 +1,6 @@ import swapper +from django.apps import apps +from django.conf import settings from django.contrib.gis.db import models as gis_models from django.core.exceptions import ValidationError from django.db import models @@ -10,14 +12,45 @@ ALLOWED_GEOMETRY_TYPES = ("Point", "Polygon") +feature_inheritances = [] -class AbstractGeoJSONFeature(DocumentIdTargetMixin, TimeStampedModel, DocumentIdMixin, RelayModel): +if apps.is_installed("baseapp_profiles"): + + class ProfileMixin(models.Model): + profile = models.ForeignKey( + swapper.get_model_name("baseapp_profiles", "Profile"), + verbose_name=_("profile"), + related_name="geo_features", + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text=_("The profile the feature was created under"), + ) + + class Meta: + abstract = True + + feature_inheritances.append(ProfileMixin) + + +class AbstractGeoJSONFeature( + *feature_inheritances, DocumentIdTargetMixin, TimeStampedModel, DocumentIdMixin, RelayModel +): name = models.CharField(_("name"), max_length=255, blank=True, default="") description = models.TextField(_("description"), blank=True, default="") feature_type = models.CharField( _("feature type"), max_length=100, blank=True, default="", db_index=True ) geometry = gis_models.GeometryField(_("geometry"), geography=True, srid=4326) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="geo_features_created", + on_delete=models.CASCADE, + null=True, + blank=True, + verbose_name=_("created by"), + help_text=_("The user who created the feature"), + ) class Meta: abstract = True diff --git a/baseapp/geo/permissions.py b/baseapp/geo/permissions.py index 80f30606..2ab98cfe 100644 --- a/baseapp/geo/permissions.py +++ b/baseapp/geo/permissions.py @@ -1,9 +1,27 @@ +from typing import Any, Optional + import swapper from django.contrib.auth.backends import BaseBackend +from django.contrib.auth.models import AbstractBaseUser, AnonymousUser + +UserType = AbstractBaseUser | AnonymousUser class GeoPermissionsBackend(BaseBackend): - def has_perm(self, user_obj, perm, obj=None) -> bool: + def authenticate(self, request, **kwargs) -> None: + return None + + @staticmethod + def _is_owner_or_has_global(user_obj: UserType, perm: str, obj: Optional[Any]) -> bool: + """Owner acts on their own feature; otherwise fall back to the global perm + (ModelBackend doesn't grant global perms when an obj is passed).""" + if not obj or not user_obj.is_authenticated: + return False + # created_by is nullable and AnonymousUser.pk is None, so the authentication + # guard above is what stops an unowned feature from matching every anonymous user. + return obj.created_by_id == user_obj.pk or user_obj.has_perm(perm) + + def has_perm(self, user_obj: UserType, perm: str, obj: Optional[Any] = None) -> bool: GeoJSONFeature = swapper.load_model("baseapp_geo", "GeoJSONFeature") app_label = GeoJSONFeature._meta.app_label @@ -13,4 +31,12 @@ def has_perm(self, user_obj, perm, obj=None) -> bool: if perm == f"{app_label}.add_geojsonfeature": return user_obj.is_authenticated + if perm in ( + f"{app_label}.change_geojsonfeature", + f"{app_label}.delete_geojsonfeature", + ): + # With no obj this returns False so ModelBackend still decides the global + # grant; with an obj the creator is allowed and everyone else needs it. + return self._is_owner_or_has_global(user_obj, perm, obj) + return False diff --git a/baseapp/geo/tests/test_graphql_mutations.py b/baseapp/geo/tests/test_graphql_mutations.py index cfe61dc8..e3853616 100644 --- a/baseapp/geo/tests/test_graphql_mutations.py +++ b/baseapp/geo/tests/test_graphql_mutations.py @@ -296,6 +296,112 @@ def test_perm_granted_user_deletes_feature(self, django_user_client, graphql_use assert GeoJSONFeature.objects.count() == 0 +class TestOwnership: + """`created_by` / `profile`, and the object-level permissions they enable.""" + + def test_create_records_the_acting_user_and_their_profile( + self, django_user_client, graphql_user_client + ): + graphql_user_client( + CREATE_MUTATION, + variables={ + "input": { + "targetObjectId": django_user_client.user.relay_id, + "geometry": POINT_GEOJSON, + } + }, + ) + + feature = GeoJSONFeature.objects.get() + assert feature.created_by == django_user_client.user + # No Current-Profile header, so the middleware falls back to the user's own profile. + assert feature.profile == django_user_client.user.profile + + def test_owner_updates_own_feature_without_the_model_permission( + self, django_user_client, graphql_user_client + ): + feature = GeoJSONFeatureFactory(created_by=django_user_client.user, name="Mine") + + response = graphql_user_client( + UPDATE_MUTATION, + variables={"input": {"id": feature.relay_id, "name": "Renamed"}}, + ) + + assert "errors" not in response.json(), response.json() + feature.refresh_from_db() + assert feature.name == "Renamed" + + def test_owner_deletes_own_feature_without_the_model_permission( + self, django_user_client, graphql_user_client + ): + feature = GeoJSONFeatureFactory(created_by=django_user_client.user) + + response = graphql_user_client( + DELETE_MUTATION, variables={"input": {"id": feature.relay_id}} + ) + + assert "errors" not in response.json(), response.json() + assert not GeoJSONFeature.objects.filter(pk=feature.pk).exists() + + def test_anonymous_is_not_the_owner_of_an_unowned_feature(self, graphql_client): + """`created_by` is nullable and AnonymousUser.pk is None, so a naive owner + comparison makes every anonymous user the owner of every unowned feature.""" + feature = GeoJSONFeatureFactory(created_by=None, name="Unowned") + + response = graphql_client( + UPDATE_MUTATION, + variables={"input": {"id": feature.relay_id, "name": "Hijacked"}}, + ) + content = response.json() + + assert content["errors"][0]["extensions"]["code"] == "permission_required" + feature.refresh_from_db() + assert feature.name == "Unowned" + + def test_non_owner_cannot_update_someone_elses_feature( + self, django_user_client, graphql_user_client + ): + feature = GeoJSONFeatureFactory(created_by=UserFactory(), name="Theirs") + + response = graphql_user_client( + UPDATE_MUTATION, + variables={"input": {"id": feature.relay_id, "name": "Hijacked"}}, + ) + content = response.json() + + assert content["errors"][0]["extensions"]["code"] == "permission_required" + feature.refresh_from_db() + assert feature.name == "Theirs" + + def test_non_owner_cannot_delete_someone_elses_feature( + self, django_user_client, graphql_user_client + ): + feature = GeoJSONFeatureFactory(created_by=UserFactory()) + + response = graphql_user_client( + DELETE_MUTATION, variables={"input": {"id": feature.relay_id}} + ) + + assert response.json()["errors"][0]["extensions"]["code"] == "permission_required" + assert GeoJSONFeature.objects.filter(pk=feature.pk).exists() + + def test_global_permission_still_reaches_other_users_features( + self, django_user_client, graphql_user_client + ): + """A moderator holding the model permission is not restricted to their own rows.""" + feature = GeoJSONFeatureFactory(created_by=UserFactory(), name="Theirs") + grant_model_perm(django_user_client.user, "change_geojsonfeature") + + response = graphql_user_client( + UPDATE_MUTATION, + variables={"input": {"id": feature.relay_id, "name": "Moderated"}}, + ) + + assert "errors" not in response.json(), response.json() + feature.refresh_from_db() + assert feature.name == "Moderated" + + class TestCrossModelRelayIds: """A relay ID of another model must not be writable through these mutations. diff --git a/testproject/geo/migrations/0002_geojsonfeature_created_by_geojsonfeature_profile.py b/testproject/geo/migrations/0002_geojsonfeature_created_by_geojsonfeature_profile.py new file mode 100644 index 00000000..1757b9a3 --- /dev/null +++ b/testproject/geo/migrations/0002_geojsonfeature_created_by_geojsonfeature_profile.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.17 on 2026-10-09 01:23 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("geo", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.BASEAPP_PROFILES_PROFILE_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="geojsonfeature", + name="created_by", + field=models.ForeignKey( + blank=True, + help_text="The user who created the feature", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="geo_features_created", + to=settings.AUTH_USER_MODEL, + verbose_name="created by", + ), + ), + migrations.AddField( + model_name="geojsonfeature", + name="profile", + field=models.ForeignKey( + blank=True, + help_text="The profile the feature was created under", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="geo_features", + to=settings.BASEAPP_PROFILES_PROFILE_MODEL, + verbose_name="profile", + ), + ), + ]