Skip to content

Commit

Permalink
🗃️ [#4246] Expand AuthInfo model to capture mandate context
Browse files Browse the repository at this point in the history
* Added fields for the (optional) legal subject (authorizee)
* Added fields for the (optional) acting subject (authorizee)
* Added check constraints to enforce data integrity
* Organized the admin in logical groups

The authentication context data model identifies two parties:
the representee and the authorizee. There is always an
authorizee - it *can* be the same actor as the representee
if no mandate is in involved.

An authorizee has two aspects: legal subject and acting
subject. There is always a legal subject. If no acting
subject is provided, it is inferred from the legal subject.

So, a simple DigiD login in this model is represented by
a legal subject authorizee of type BSN. We still keep
storing this data in the attribute + value fields, but
in the event of a mandate, we will store the additional
information for the legal/acting subject.
  • Loading branch information
sergei-maertens committed May 31, 2024
1 parent 3e8f3cb commit ff451e3
Show file tree
Hide file tree
Showing 4 changed files with 285 additions and 11 deletions.
42 changes: 41 additions & 1 deletion src/openforms/authentication/admin.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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):
Expand Down
10 changes: 10 additions & 0 deletions src/openforms/authentication/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
141 changes: 131 additions & 10 deletions src/openforms/authentication/models.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -61,41 +68,155 @@ 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"),
on_delete=models.CASCADE,
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):
Expand Down

0 comments on commit ff451e3

Please sign in to comment.