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
7 changes: 5 additions & 2 deletions baseapp/geo/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
)
18 changes: 10 additions & 8 deletions baseapp/geo/graphql/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
35 changes: 34 additions & 1 deletion baseapp/geo/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
28 changes: 27 additions & 1 deletion baseapp/geo/permissions.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
106 changes: 106 additions & 0 deletions baseapp/geo/tests/test_graphql_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
Loading