diff --git a/src/openforms/authentication/admin.py b/src/openforms/authentication/admin.py index a253d0a090..5d8c877e9d 100644 --- a/src/openforms/authentication/admin.py +++ b/src/openforms/authentication/admin.py @@ -1,5 +1,6 @@ from django import forms from django.contrib import admin +from django.utils.translation import gettext_lazy as _ from .models import AuthInfo, RegistratorInfo @@ -36,10 +37,49 @@ def has_delete_permission(self, request, obj=None): @admin.register(AuthInfo) class AuthInfoAdmin(admin.ModelAdmin): - list_display = ("submission", "plugin", "attribute") + list_display = ("submission", "plugin", "attribute", "loa") list_filter = ("plugin", "attribute") search_fields = ("submission__pk",) raw_id_fields = ("submission",) + fieldsets = ( + ( + None, + { + "fields": ( + "submission", + "attribute", + "value", + ) + }, + ), + ( + _("Means"), + {"fields": ("plugin", "loa")}, + ), + ( + _("Acting subject"), + { + "fields": ( + "acting_subject_identifier_type", + "acting_subject_identifier_value", + ) + }, + ), + ( + _("Legal subject"), + { + "fields": ( + "legal_subject_identifier_type", + "legal_subject_identifier_value", + ) + }, + ), + (_("Mandate"), {"fields": ("mandate_context", "machtigen")}), + ( + _("Misc"), + {"fields": ("attribute_hashed",), "classes": ("collapse in",)}, + ), + ) class RegistratorInfoAdminForm(forms.ModelForm): diff --git a/src/openforms/authentication/constants.py b/src/openforms/authentication/constants.py index 332ebe543d..8f41b19d5c 100644 --- a/src/openforms/authentication/constants.py +++ b/src/openforms/authentication/constants.py @@ -27,3 +27,13 @@ class ModeChoices(models.TextChoices): citizen = "citizen", _("Citizen") company = "company", _("Company") employee = "employee", _("Employee") + + +class ActingSubjectIdentifierType(models.TextChoices): + opaque = "opaque", _("Opaque") + + +class LegalSubjectIdentifierType(models.TextChoices): + bsn = "bsn", _("BSN") + kvk = "kvk", _("KvK number") + rsin = "rsin", _("RSIN") diff --git a/src/openforms/authentication/migrations/0002_add_authentication_context_mandate_fields.py b/src/openforms/authentication/migrations/0002_add_authentication_context_mandate_fields.py new file mode 100644 index 0000000000..7cde5cc41d --- /dev/null +++ b/src/openforms/authentication/migrations/0002_add_authentication_context_mandate_fields.py @@ -0,0 +1,103 @@ +# Generated by Django 4.2.11 on 2024-05-31 07:13 + +from django.db import migrations, models +import openforms.authentication.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("of_authentication", "0001_initial_to_openforms_v230"), + ] + + operations = [ + migrations.AddField( + model_name="authinfo", + name="acting_subject_identifier_type", + field=models.CharField( + blank=True, + choices=[("opaque", "Opaque")], + help_text="The identifier type determines how to interpret the identifier value.", + max_length=50, + verbose_name="acting subject identifier type", + ), + ), + migrations.AddField( + model_name="authinfo", + name="acting_subject_identifier_value", + field=models.CharField( + blank=True, + help_text="(Contextually) unique identifier for the acting subject.", + max_length=250, + verbose_name="acting subject identifier", + ), + ), + migrations.AddField( + model_name="authinfo", + name="legal_subject_identifier_type", + field=models.CharField( + blank=True, + choices=[("bsn", "BSN"), ("kvk", "KvK number"), ("rsin", "RSIN")], + help_text="The identifier type determines how to interpret the identifier value.", + max_length=50, + verbose_name="legal subject identifier type", + ), + ), + migrations.AddField( + model_name="authinfo", + name="legal_subject_identifier_value", + field=models.CharField( + blank=True, + help_text="(Contextually) unique identifier for the legal subject.", + max_length=250, + verbose_name="legal subject identifier", + ), + ), + migrations.AddField( + model_name="authinfo", + name="mandate_context", + field=models.JSONField( + blank=True, + help_text="If a mandate is in play, then the mandate context must be provided. The details are tracked here, in line with the authentication context data JSON schema definition.", + null=True, + verbose_name="mandate context", + ), + ), + migrations.AddConstraint( + model_name="authinfo", + constraint=openforms.authentication.models.ConjointConstraint( + fields=( + "acting_subject_identifier_type", + "acting_subject_identifier_value", + ), + name="acting_subject_integrity", + ), + ), + migrations.AddConstraint( + model_name="authinfo", + constraint=openforms.authentication.models.ConjointConstraint( + fields=( + "legal_subject_identifier_type", + "legal_subject_identifier_value", + ), + name="legal_subject_integrity", + ), + ), + migrations.AddConstraint( + model_name="authinfo", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("legal_subject_identifier_value", ""), + ("mandate_context__isnull", True), + ), + models.Q( + models.Q(("legal_subject_identifier_value", ""), _negated=True), + ("mandate_context__isnull", False), + ), + _connector="OR", + ), + name="mandate_context_not_null", + ), + ), + ] diff --git a/src/openforms/authentication/models.py b/src/openforms/authentication/models.py index 8f34b2c1c9..a71cd7f8fc 100644 --- a/src/openforms/authentication/models.py +++ b/src/openforms/authentication/models.py @@ -1,11 +1,18 @@ +from collections.abc import Collection + from django.contrib.auth.hashers import make_password as get_salted_hash from django.db import models +from django.db.models import Q from django.utils.translation import gettext_lazy as _ from openforms.contrib.kvk.validators import validate_kvk from openforms.utils.validators import validate_bsn -from .constants import AuthAttribute +from .constants import ( + ActingSubjectIdentifierType, + AuthAttribute, + LegalSubjectIdentifierType, +) from .tasks import hash_identifying_attributes as hash_identifying_attributes_task @@ -61,14 +68,42 @@ def hash_identifying_attributes(self, delay=False): self.save() def clean(self): - if self.attribute == AuthAttribute.bsn: + if self.attribute == AuthAttribute.bsn and not self.attribute_hashed: validate_bsn(self.value) - elif self.attribute == AuthAttribute.kvk: + elif self.attribute == AuthAttribute.kvk and not self.attribute_hashed: validate_kvk(self.value) +class ConjointConstraint(models.CheckConstraint): + """ + The provided (string) fields must all or none have a non-empty value. + """ + + def __init__( + self, *, fields: Collection[str], name: str, violation_error_message=None + ): + self.fields = fields + check = self._build_check(fields) + super().__init__( + name=name, check=check, violation_error_message=violation_error_message + ) + + def _build_check(self, fields: Collection[str]): + # This is the opposite of mutual exclusivity + all_empty = [Q(**{f"{field}__exact": ""}) for field in fields] + none_empty = [~q for q in all_empty] + return Q(*all_empty) | Q(*none_empty) + + def deconstruct(self): + path, args, kwargs = super().deconstruct() + kwargs.pop("check") + kwargs["fields"] = self.fields + return path, args, kwargs + + # TODO: what about co-sign data? class AuthInfo(BaseAuthInfo): + # Relation to submission - there can only be a single auth info for a submission. submission = models.OneToOneField( to="submissions.Submission", verbose_name=_("Submission"), @@ -76,26 +111,112 @@ class AuthInfo(BaseAuthInfo): help_text=_("Submission related to this authentication information"), related_name="auth_info", ) - machtigen = models.JSONField( - verbose_name=_("machtigen"), + + # authentication details of the representee (without mandate/machtigen, this is the + # logged in actor themselves) are stored in the fields `plugin`, `attribute` and `value`. + # They match the (inferred) `representee.identifierType` and + # `representee.identifier`. + + # together with the value of `plugin`, this tracks the way authentication was + # performed. + loa = models.TextField( + verbose_name=_("Level of assurance"), help_text=_( - "Data related to any 'machtiging' (authorising someone else to perform actions on your behalf)." + "How certain is the identity provider that this identity belongs to this user." + ), + default="", # not all plugins support this concept + blank=True, + ) + + # track information about the mandate/machtigen. Note that this also captures an + # employee authenticating for their company, they are then the acting subject and + # the company is the legal subject. + acting_subject_identifier_type = models.CharField( + verbose_name=_("acting subject identifier type"), + help_text=_( + "The identifier type determines how to interpret the identifier value." + ), + max_length=50, + blank=True, + choices=ActingSubjectIdentifierType.choices, + ) + acting_subject_identifier_value = models.CharField( + verbose_name=_("acting subject identifier"), + help_text=_("(Contextually) unique identifier for the acting subject."), + max_length=250, + blank=True, + ) + + legal_subject_identifier_type = models.CharField( + verbose_name=_("legal subject identifier type"), + help_text=_( + "The identifier type determines how to interpret the identifier value." + ), + max_length=50, + blank=True, + choices=LegalSubjectIdentifierType.choices, + ) + legal_subject_identifier_value = models.CharField( + verbose_name=_("legal subject identifier"), + help_text=_("(Contextually) unique identifier for the legal subject."), + max_length=250, + blank=True, + ) + + mandate_context = models.JSONField( + verbose_name=_("mandate context"), + help_text=_( + "If a mandate is in play, then the mandate context must be provided. The " + "details are tracked here, in line with the authentication context data " + "JSON schema definition." ), blank=True, null=True, ) - loa = models.TextField( - verbose_name=_("Level of assurance"), + + # deprecated! + machtigen = models.JSONField( + verbose_name=_("machtigen"), help_text=_( - "How certain is the identity provider that this identity belongs to this user." + "Data related to any 'machtiging' (authorising someone else to perform actions on your behalf)." ), - default="", # not all plugins support this concept blank=True, + null=True, ) class Meta: verbose_name = _("Authentication details") verbose_name_plural = _("Authentication details") + constraints = [ + # if a type or value for action/legal subject is given, then the other + # property must be provided too. + ConjointConstraint( + name="acting_subject_integrity", + fields=( + "acting_subject_identifier_type", + "acting_subject_identifier_value", + ), + ), + ConjointConstraint( + name="legal_subject_integrity", + fields=( + "legal_subject_identifier_type", + "legal_subject_identifier_value", + ), + ), + # presence of a legal subject implies a mandata context + models.CheckConstraint( + name="mandate_context_not_null", + check=( + Q(legal_subject_identifier_value="", mandate_context__isnull=True) + | Q( + ~Q(legal_subject_identifier_value=""), + mandate_context__isnull=False, + ) + ), + ), + # TODO: add constraints matching the json schema for the identifier types + ] class RegistratorInfo(BaseAuthInfo):