Skip to content
Snippets Groups Projects
Commit 3ff518fa authored by Konrad Mohrfeldt's avatar Konrad Mohrfeldt :koala:
Browse files

feat: add re-usable validator mixin for write permissions

We have introduced a system for custom field write permissions that
allows administrators to control which users can write specific fields
in steering.

This system is based on specific model permissions in the form of:
	{app_label}.edit__{model_name}__{field_name}

This mixin makes it easy to check said permissions simply by including
it as part of the serializer.
parent dc8469ae
No related branches found
No related tags found
No related merge requests found
......@@ -19,6 +19,7 @@
#
import re
from collections.abc import Iterable
from datetime import datetime
from functools import cached_property
from zoneinfo import ZoneInfo
......@@ -98,6 +99,78 @@ SOLUTION_CHOICES = {
}
class UpdateFieldPermissionValidatorMixin:
"""
Validates that fields can only be saved if the appropriate permissions have been given.
The required field permissions are determined based the following strategy:
1. If Serializer.Meta.custom_field_write_permission_map[{field_name}] has been set
the permissions defined there will be used.
2. If permission in the style of
{app_label}.edit__{model_name}__{field_name}
exists, that permission will be used.
3. If none of the above are available, no permissions will be used for the field.
The permissions for each field, for which specific write permissions have been defined,
will be checked on update and create operations. If the user does not have any one of the
required field permissions for the provided fields the request will be rejected with a
403 error including a list of fields that the user is not allowed to set.
"""
def get_validators(self):
def validate_field_permissions(data, *args, **kwargs):
user = self.context.get("request").user
self._check_field_write_permissions(user, data, self.instance)
return [*super().get_validators(), validate_field_permissions]
def _generate_field_permission_name_candidates(self, model_name: str, field_name: str):
prefix = f"edit__{model_name}__"
yield prefix + field_name
if field_name.endswith("_id"):
yield prefix + field_name[:-3]
if field_name.endswith("_ids"):
yield prefix + field_name[:-4] + "s"
def _get_field_write_permission_map(self, field_names: set[str]):
custom_map: dict[str, str | Iterable[str]] = getattr(
self.Meta, "custom_field_write_permission_map", {}
)
model = self.Meta.model
custom_model_permissions = set(dict(model._meta.permissions).keys())
app_label = model._meta.app_label
model_name = model._meta.model_name
result = {}
for field_name in field_names:
if perm := custom_map.get(field_name):
if isinstance(perm, str):
result[field_name] = {perm}
else:
result[field_name] = set(perm)
continue
for perm in self._generate_field_permission_name_candidates(model_name, field_name):
if perm in custom_model_permissions:
result[field_name] = {f"{app_label}.{perm}"}
return result
def _check_field_write_permissions(self, user, validated_data, instance=None):
field_error_map = {}
field_write_permission_map = self._get_field_write_permission_map(validated_data.keys())
client_name_map = {field.source: field.field_name for field in self._writable_fields}
for field_name, required_permissions in field_write_permission_map.items():
if not user.has_perms(required_permissions):
# The name of the field in validated_data is not necessarily
# the same as the field name the client sent.
# The error mapping should reflect the name the client sent.
client_field_name = client_name_map.get(field_name, field_name)
field_error_map[client_field_name] = exceptions.ErrorDetail(
"You do not have permission to set this field.",
code="missing-field-permission",
)
if field_error_map:
raise exceptions.PermissionDenied(field_error_map)
class ErrorSerializer(serializers.Serializer):
message = serializers.CharField()
code = serializers.CharField(allow_null=True)
......
from collections.abc import Iterable
from rest_framework import serializers
from program.serializers import UpdateFieldPermissionValidatorMixin
class DummyUser:
def __init__(self, permissions: Iterable[str] | None = None):
self.username = "dummy user"
self.permissions = set(permissions or [])
def get_all_permissions(self):
return set(self.permissions)
def has_perm(self, perm, obj=None):
return perm in self.permissions
def has_perms(self, perms, obj=None):
return all(perm in self.permissions for perm in perms)
@classmethod
def make_context(cls, permissions: Iterable[str] | None = None):
class Request:
user = cls(permissions=permissions)
return {"request": Request()}
class _Episode:
def __init__(self):
self.title = "my title"
self.subtitle = "my subtitle"
self.content = "my content"
self.created_by = ""
self.updated_by = ""
class _meta:
app_label = "program"
model_name = "episode"
permissions = [
("edit__episode__title", "Can edit title field"),
("edit__episode__image", "Can edit image field"),
("edit__episode__contributors", "Can edit contributors field"),
]
def save(self, *args, **kwargs):
pass
class SimpleAssignmentWriter:
def update(self, instance, validated_data):
for key, value in validated_data.items():
setattr(instance, key, value)
return instance
def create(self, validated_data):
return self.update(self.Meta.model(), validated_data)
class SerializerMeta:
model = _Episode
fields = ["__all__"]
custom_field_write_permission_map = {"subtitle": {"program.modify_episode_subtitle"}}
class _UpdateFieldPermissionValidatorSerializer(
UpdateFieldPermissionValidatorMixin, SimpleAssignmentWriter, serializers.Serializer
):
title = serializers.CharField()
subtitle = serializers.CharField()
content = serializers.CharField()
class Meta(SerializerMeta):
pass
class TestUpdateFieldPermissionValidatorMixin:
def test_permissions_are_resolved_according_to_specification(self):
serializer = _UpdateFieldPermissionValidatorSerializer()
field_write_permission_map = serializer._get_field_write_permission_map(
{"title", "subtitle", "content", "image_id", "contributor_ids"}
)
assert field_write_permission_map == {
"title": {"program.edit__episode__title"},
"subtitle": {"program.modify_episode_subtitle"},
"image_id": {"program.edit__episode__image"},
"contributor_ids": {"program.edit__episode__contributors"},
}
def test_serializer_raises_if_permissions_are_missing(self):
instance = _Episode()
update_data = {
"title": "my new title",
"subtitle": "my new subtitle",
"content": "my new content",
}
serializer = _UpdateFieldPermissionValidatorSerializer(
instance=instance, data=update_data, context=DummyUser.make_context()
)
try:
serializer.is_valid(raise_exception=True)
except PermissionDenied as exc:
write_exception = exc
else:
assert False, "is_valid should raise"
details = write_exception.get_full_details()
assert details["title"]["code"] == "missing-field-permission"
assert details["subtitle"]["code"] == "missing-field-permission"
assert "content" not in details
def test_serializer_succeeds_if_user_has_permissions(self):
serializer = _UpdateFieldPermissionValidatorSerializer(
context=DummyUser.make_context(
["program.edit__episode__title", "program.modify_episode_subtitle"]
)
)
update_data = {
"title": "my new title",
"subtitle": "my new subtitle",
"content": "my new content",
}
instance: _Episode = serializer.update(_Episode(), update_data)
for key, value in update_data.items():
assert getattr(instance, key) == value
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment