From f79f0eb3b9f5f8ce6ae818f116b6a91a562674b3 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 11 Aug 2022 15:14:28 +0200 Subject: [PATCH 7/7] auth_saml: add views to configure related objects (#67025) --- src/authentic2/apps/authenticators/models.py | 1 + .../authenticators/authenticator_detail.html | 24 ++++- src/authentic2/apps/authenticators/views.py | 3 +- src/authentic2_auth_saml/forms.py | 9 ++ .../journal_event_types.py | 93 +++++++++++++++++++ src/authentic2_auth_saml/models.py | 68 +++++++++++--- .../authenticator_detail.html | 27 ++++++ .../related_object_list.html | 11 +++ src/authentic2_auth_saml/urls.py | 27 ++++++ src/authentic2_auth_saml/views.py | 91 +++++++++++++++++- tests/test_manager_authenticators.py | 77 +++++++++++++++ tests/test_manager_journal.py | 43 +++++++++ 12 files changed, 455 insertions(+), 19 deletions(-) create mode 100644 src/authentic2_auth_saml/journal_event_types.py create mode 100644 src/authentic2_auth_saml/templates/authentic2_auth_saml/authenticator_detail.html create mode 100644 src/authentic2_auth_saml/templates/authentic2_auth_saml/related_object_list.html diff --git a/src/authentic2/apps/authenticators/models.py b/src/authentic2/apps/authenticators/models.py index 0bbd1219..857334c1 100644 --- a/src/authentic2/apps/authenticators/models.py +++ b/src/authentic2/apps/authenticators/models.py @@ -75,6 +75,7 @@ class BaseAuthenticator(models.Model): type = '' manager_form_class = None + manager_view_template_name = 'authentic2/authenticators/authenticator_detail.html' unique = False protected = False description_fields = ['show_condition'] diff --git a/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html index 42c72f1c..a0c8cd7c 100644 --- a/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html +++ b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html @@ -31,9 +31,25 @@ {% endif %} -
- {% for line in object.get_full_description %} -

{{ line }}

- {% endfor %} +
+
+
+ + {% block extra-tab-buttons %} + {% endblock %} +
+ +
+
+
    + {% for line in object.get_full_description %} +
  • {{ line }}
  • + {% endfor %} +
+
+ {% block extra-tab-list %} + {% endblock %} +
+
{% endblock %} diff --git a/src/authentic2/apps/authenticators/views.py b/src/authentic2/apps/authenticators/views.py index 23f7040f..bb50fa56 100644 --- a/src/authentic2/apps/authenticators/views.py +++ b/src/authentic2/apps/authenticators/views.py @@ -64,7 +64,8 @@ add = AuthenticatorAddView.as_view() class AuthenticatorDetailView(AuthenticatorsMixin, DetailView): - template_name = 'authentic2/authenticators/authenticator_detail.html' + def get_template_names(self): + return self.object.manager_view_template_name @property def title(self): diff --git a/src/authentic2_auth_saml/forms.py b/src/authentic2_auth_saml/forms.py index 5831d33c..75bc7e4c 100644 --- a/src/authentic2_auth_saml/forms.py +++ b/src/authentic2_auth_saml/forms.py @@ -16,6 +16,8 @@ from django import forms +from authentic2.a2_rbac.models import Role + from .models import SAMLAuthenticator @@ -23,3 +25,10 @@ class SAMLAuthenticatorForm(forms.ModelForm): class Meta: model = SAMLAuthenticator exclude = ('ou',) + + +class RelatedObjectForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if 'role' in self.fields: + self.fields['role'].queryset = Role.objects.exclude(slug__startswith='_') diff --git a/src/authentic2_auth_saml/journal_event_types.py b/src/authentic2_auth_saml/journal_event_types.py new file mode 100644 index 00000000..dd6826e7 --- /dev/null +++ b/src/authentic2_auth_saml/journal_event_types.py @@ -0,0 +1,93 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2022 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.utils.translation import gettext_lazy as _ + +from authentic2.apps.journal.models import EventTypeDefinition +from authentic2.apps.journal.utils import form_to_old_new + +from .models import SAMLAuthenticator + + +class SAMLAuthenticatorEventsMixin(EventTypeDefinition): + @classmethod + def record(cls, *, user, session, related_object, data=None): + data = data or {} + data.update({'related_object': repr(related_object)}) + super().record(user=user, session=session, references=[related_object.authenticator], data=data) + + +class SAMLAuthenticatorRelatedObjectCreation(SAMLAuthenticatorEventsMixin): + name = 'authenticator.saml.related_object.creation' + label = _('SAML authenticator related object creation') + + @classmethod + def get_message(cls, event, context): + (authenticator,) = event.get_typed_references(SAMLAuthenticator) + related_object = event.get_data('related_object') + if context != authenticator: + return _('creation of {related_object} in authenticator "{authenticator}"').format( + related_object=related_object, authenticator=authenticator + ) + else: + return _('creation of %s') % related_object + + +class SAMLAuthenticatorRelatedObjectEdit(SAMLAuthenticatorEventsMixin): + name = 'authenticator.saml.related_object.edit' + label = _('SAML authenticator related object edit') + + @classmethod + def record(cls, *, user, session, form): + super().record( + user=user, + session=session, + related_object=form.instance, + data=form_to_old_new(form), + ) + + @classmethod + def get_message(cls, event, context): + (authenticator,) = event.get_typed_references(SAMLAuthenticator) + related_object = event.get_data('related_object') + new = event.get_data('new') or {} + edited_attributes = ', '.join(new) or '' + if context != authenticator: + return _('edit {related_object} in authenticator "{authenticator}" ({change})').format( + related_object=related_object, + authenticator=authenticator, + change=edited_attributes, + ) + else: + return _('edit {related_object} ({change})').format( + related_object=related_object, change=edited_attributes + ) + + +class SAMLAuthenticatorRelatedObjectDeletion(SAMLAuthenticatorEventsMixin): + name = 'authenticator.saml.related_object.deletion' + label = _('SAML authenticator related object deletion') + + @classmethod + def get_message(cls, event, context): + (authenticator,) = event.get_typed_references(SAMLAuthenticator) + related_object = event.get_data('related_object') + if context != authenticator: + return _('deletion of {related_object} in authenticator "{authenticator}"').format( + related_object=related_object, authenticator=authenticator + ) + else: + return _('deletion of %s') % related_object diff --git a/src/authentic2_auth_saml/models.py b/src/authentic2_auth_saml/models.py index 2606365a..0e167f6b 100644 --- a/src/authentic2_auth_saml/models.py +++ b/src/authentic2_auth_saml/models.py @@ -21,10 +21,9 @@ from django.utils.translation import gettext_lazy as _ from authentic2.a2_rbac.models import Role from authentic2.apps.authenticators.models import BaseAuthenticator +from authentic2.utils.evaluate import condition_validator from authentic2.utils.misc import redirect_to_login -from . import views - class SAMLAuthenticator(BaseAuthenticator): metadata_url = models.URLField(_('Metadata URL'), max_length=300, blank=True) @@ -147,6 +146,7 @@ class SAMLAuthenticator(BaseAuthenticator): type = 'saml' how = ['saml'] + manager_view_template_name = 'authentic2_auth_saml/authenticator_detail.html' description_fields = [ 'show_condition', 'metadata_url', @@ -195,23 +195,44 @@ class SAMLAuthenticator(BaseAuthenticator): ) def login(self, request, *args, **kwargs): + from . import views + return views.login(request, self, *args, **kwargs) def profile(self, request, *args, **kwargs): + from . import views + return views.profile(request, *args, **kwargs) -class SAMLAttributeLookup(models.Model): - authenticator = models.ForeignKey( - SAMLAuthenticator, on_delete=models.CASCADE, related_name='attribute_lookups' - ) +class SAMLRelatedObjectBase(models.Model): + authenticator = models.ForeignKey(SAMLAuthenticator, on_delete=models.CASCADE) + + def __repr__(self): + return '%s (%s)' % (self._meta.object_name, self.pk) + + class Meta: + abstract = True + + +class SAMLAttributeLookup(SAMLRelatedObjectBase): user_field = models.CharField(_('User field'), max_length=32) saml_attribute = models.CharField(_('SAML attribute'), max_length=128) ignore_case = models.BooleanField(_('Ignore case'), default=False) class Meta: + default_related_name = 'attribute_lookups' verbose_name = _('Attribute lookup') + def __str__(self): + label = _('"%(saml_attribute)s" (from "%(user_field)s")') % { + 'saml_attribute': self.saml_attribute, + 'user_field': self.user_field, + } + if self.ignore_case: + label = '%s, %s' % (label, _('case insensitive')) + return label + def as_dict(self): return { 'user_field': self.user_field, @@ -219,8 +240,8 @@ class SAMLAttributeLookup(models.Model): 'ignore-case': self.ignore_case, } -class RenameAttributeAction(models.Model): - authenticator = models.ForeignKey(SAMLAuthenticator, on_delete=models.CASCADE) + +class RenameAttributeAction(SAMLRelatedObjectBase): from_name = models.CharField(_('From'), max_length=128) to_name = models.CharField(_('To'), max_length=32) @@ -228,9 +249,14 @@ class RenameAttributeAction(models.Model): default_related_name = 'rename_attribute_actions' verbose_name = _('Rename an attribute') + def __str__(self): + return '%s → %s' % (self.from_name, self.to_name) -class SetAttributeAction(models.Model): - authenticator = models.ForeignKey(SAMLAuthenticator, on_delete=models.CASCADE) + def __repr__(self): + return '%s (%s)' % (self._meta.object_name, self.pk) + + +class SetAttributeAction(SAMLRelatedObjectBase): attribute = models.CharField(_('User attribute name'), max_length=32) saml_attribute = models.CharField(_('SAML attribute name'), max_length=128) mandatory = models.BooleanField(_('Mandatory'), default=False, help_text=_('Deny login if action fails.')) @@ -239,13 +265,29 @@ class SetAttributeAction(models.Model): default_related_name = 'set_attribute_actions' verbose_name = _('Set an attribute') + def __str__(self): + label = _('"%(attribute)s" from "%(saml_attribute)s"') % { + 'attribute': self.attribute, + 'saml_attribute': self.saml_attribute, + } + if self.mandatory: + label = '%s (%s)' % (label, _('mandatory')) + return label + -class AddRoleAction(models.Model): - authenticator = models.ForeignKey(SAMLAuthenticator, on_delete=models.CASCADE) +class AddRoleAction(SAMLRelatedObjectBase): role = models.ForeignKey(Role, verbose_name=_('Role'), on_delete=models.CASCADE) - condition = models.CharField(_('Condition'), max_length=256, blank=True) + condition = models.CharField(_('Condition'), max_length=256, blank=True, validators=[condition_validator]) mandatory = models.BooleanField(_('Mandatory'), default=False, help_text=_('Deny login if action fails.')) class Meta: default_related_name = 'add_role_actions' verbose_name = _('Add a role') + + def __str__(self): + label = str(self.role) + if self.condition: + label = '%s, %s' % (label, _('with condition')) + if self.mandatory: + label = '%s (%s)' % (label, _('mandatory')) + return label diff --git a/src/authentic2_auth_saml/templates/authentic2_auth_saml/authenticator_detail.html b/src/authentic2_auth_saml/templates/authentic2_auth_saml/authenticator_detail.html new file mode 100644 index 00000000..ef8b35ca --- /dev/null +++ b/src/authentic2_auth_saml/templates/authentic2_auth_saml/authenticator_detail.html @@ -0,0 +1,27 @@ +{% extends 'authentic2/authenticators/authenticator_detail.html' %} +{% load i18n %} + +{% block extra-tab-buttons %} + + + + +{% endblock %} + +{% block extra-tab-list %} + + + + + + + +{% endblock %} diff --git a/src/authentic2_auth_saml/templates/authentic2_auth_saml/related_object_list.html b/src/authentic2_auth_saml/templates/authentic2_auth_saml/related_object_list.html new file mode 100644 index 00000000..7186030c --- /dev/null +++ b/src/authentic2_auth_saml/templates/authentic2_auth_saml/related_object_list.html @@ -0,0 +1,11 @@ +{% load i18n %} + + diff --git a/src/authentic2_auth_saml/urls.py b/src/authentic2_auth_saml/urls.py index 8c2f7b85..f2473f40 100644 --- a/src/authentic2_auth_saml/urls.py +++ b/src/authentic2_auth_saml/urls.py @@ -15,7 +15,34 @@ # along with this program. If not, see . from django.conf.urls import include, url +from django.urls import path + +from authentic2.apps.authenticators.manager_urls import superuser_login_required +from authentic2.decorators import required + +from . import views urlpatterns = [ url(r'^accounts/saml/', include('mellon.urls'), kwargs={'template_base': 'authentic2/base.html'}) ] + +urlpatterns += required( + superuser_login_required, + [ + path( + 'authenticators///add/', + views.add_related_object, + name='a2-manager-saml-add-related-object', + ), + path( + 'authenticators////edit/', + views.edit_related_object, + name='a2-manager-saml-edit-related-object', + ), + path( + 'authenticators////delete/', + views.delete_related_object, + name='a2-manager-saml-delete-related-object', + ), + ], +) diff --git a/src/authentic2_auth_saml/views.py b/src/authentic2_auth_saml/views.py index 1cb44cf3..10775920 100644 --- a/src/authentic2_auth_saml/views.py +++ b/src/authentic2_auth_saml/views.py @@ -1,9 +1,24 @@ -from django.shortcuts import render +from django.apps import apps +from django.forms.models import modelform_factory +from django.http import Http404 +from django.shortcuts import get_object_or_404, render from django.template.loader import render_to_string +from django.urls import reverse +from django.views.generic import CreateView, DeleteView, UpdateView from mellon.utils import get_idp +from authentic2.manager.views import MediaMixin, TitleMixin from authentic2.utils.misc import redirect_to_login +from .forms import RelatedObjectForm +from .models import ( + AddRoleAction, + RenameAttributeAction, + SAMLAttributeLookup, + SAMLAuthenticator, + SetAttributeAction, +) + def login(request, authenticator, *args, **kwargs): context = kwargs.pop('context', {}).copy() @@ -34,3 +49,77 @@ def profile(request, *args, **kwargs): user_saml_identifier.idp = get_idp(user_saml_identifier.issuer.entity_id) context['user_saml_identifiers'] = user_saml_identifiers return render_to_string('authentic2_auth_saml/profile.html', context, request=request) + + +class SAMLAuthenticatorMixin(MediaMixin, TitleMixin): + allowed_models = (SAMLAttributeLookup, RenameAttributeAction, SetAttributeAction, AddRoleAction) + + def dispatch(self, request, *args, **kwargs): + self.authenticator = get_object_or_404(SAMLAuthenticator, pk=kwargs.get('authenticator_pk')) + + model_name = kwargs.get('model_name') + if model_name not in (x._meta.model_name for x in self.allowed_models): + raise Http404() + self.model = apps.get_model('authentic2_auth_saml', model_name) + + return super().dispatch(request, *args, **kwargs) + + def get_form_class(self): + return modelform_factory(self.model, exclude=('authenticator',), form=RelatedObjectForm) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + if not kwargs.get('instance'): + kwargs['instance'] = self.model() + kwargs['instance'].authenticator = self.authenticator + return kwargs + + def get_success_url(self): + return ( + reverse('a2-manager-authenticator-detail', kwargs={'pk': self.authenticator.pk}) + + '#open:%s' % self.model._meta.model_name + ) + + @property + def title(self): + return self.model._meta.verbose_name + + +class RelatedObjectAddView(SAMLAuthenticatorMixin, CreateView): + template_name = 'authentic2/manager/form.html' + + def form_valid(self, form): + resp = super().form_valid(form) + self.request.journal.record( + 'authenticator.saml.related_object.creation', related_object=form.instance + ) + return resp + + +add_related_object = RelatedObjectAddView.as_view() + + +class RelatedObjectEditView(SAMLAuthenticatorMixin, UpdateView): + template_name = 'authentic2/manager/form.html' + + def form_valid(self, form): + resp = super().form_valid(form) + self.request.journal.record('authenticator.saml.related_object.edit', form=form) + return resp + + +edit_related_object = RelatedObjectEditView.as_view() + + +class RelatedObjectDeleteView(SAMLAuthenticatorMixin, DeleteView): + template_name = 'authentic2/authenticators/authenticator_delete_form.html' + title = '' + + def delete(self, *args, **kwargs): + self.request.journal.record( + 'authenticator.saml.related_object.deletion', related_object=self.get_object() + ) + return super().delete(*args, **kwargs) + + +delete_related_object = RelatedObjectDeleteView.as_view() diff --git a/tests/test_manager_authenticators.py b/tests/test_manager_authenticators.py index 74ee5c56..85441c9b 100644 --- a/tests/test_manager_authenticators.py +++ b/tests/test_manager_authenticators.py @@ -16,6 +16,7 @@ import pytest from django import VERSION as DJ_VERSION +from django.utils.html import escape from authentic2.a2_rbac.utils import get_default_ou from authentic2.apps.authenticators.models import BaseAuthenticator, LoginPasswordAuthenticator @@ -279,6 +280,82 @@ def test_authenticators_saml(app, superuser, ou1, ou2): assert 'Authenticator has been enabled.' in resp.text +def test_authenticators_saml_attribute_lookup(app, superuser): + authenticator = SAMLAuthenticator.objects.create(metadata='meta1.xml', slug='idp1') + resp = login(app, superuser, path=authenticator.get_absolute_url()) + + resp = resp.click('Add', href='samlattributelookup') + resp.form['user_field'] = 'email' + resp.form['saml_attribute'] = 'mail' + resp = resp.form.submit() + assert_event('authenticator.saml.related_object.creation', user=superuser, session=app.session) + assert '#open:samlattributelookup' in resp.location + + resp = resp.follow() + assert escape('"mail" (from "email")') in resp.text + + resp = resp.click('mail') + resp.form['ignore_case'] = True + resp = resp.form.submit().follow() + assert escape('"mail" (from "email"), case insensitive') in resp.text + assert_event('authenticator.saml.related_object.edit', user=superuser, session=app.session) + + resp = resp.click('Remove', href='samlattributelookup') + resp = resp.form.submit().follow() + assert 'mail' not in resp.text + assert_event('authenticator.saml.related_object.deletion', user=superuser, session=app.session) + + +def test_authenticators_saml_rename_attribute(app, superuser): + authenticator = SAMLAuthenticator.objects.create(metadata='meta1.xml', slug='idp1') + resp = login(app, superuser, path=authenticator.get_absolute_url()) + + resp = resp.click('Add', href='renameattributeaction') + resp.form['from_name'] = 'a' + resp.form['to_name'] = 'b' + resp = resp.form.submit().follow() + assert 'a → b' in resp.text + + +def test_authenticators_saml_set_attribute(app, superuser): + authenticator = SAMLAuthenticator.objects.create(metadata='meta1.xml', slug='idp1') + resp = login(app, superuser, path=authenticator.get_absolute_url()) + + resp = resp.click('Add', href='setattributeaction') + resp.form['attribute'] = 'email' + resp.form['saml_attribute'] = 'mail' + resp = resp.form.submit().follow() + assert escape('"email" from "mail"') in resp.text + + resp = resp.click('mail') + resp.form['mandatory'] = True + resp = resp.form.submit().follow() + assert escape('"email" from "mail" (mandatory)') in resp.text + + +def test_authenticators_saml_add_role(app, superuser, role_ou1): + authenticator = SAMLAuthenticator.objects.create(metadata='meta1.xml', slug='idp1') + resp = login(app, superuser, path=authenticator.get_absolute_url()) + + resp = resp.click('Add', href='addroleaction') + assert len(resp.form['role'].options) == 2 # blank choice and role_ou1, no internal roles + + resp.form['role'] = role_ou1.pk + resp = resp.form.submit().follow() + assert 'role_ou1' in resp.text + + resp = resp.click('role_ou1') + resp.form['condition'] = 'abc' + resp.form['mandatory'] = True + resp = resp.form.submit().follow() + assert 'role_ou1, with condition (mandatory)' in resp.text + + resp = resp.click('role_ou1') + resp.form['condition'] = '{' + resp = resp.form.submit() + assert "could not parse expression" in resp.text + + def test_authenticators_order(app, superuser): resp = login(app, superuser, path='/manage/authenticators/') diff --git a/tests/test_manager_journal.py b/tests/test_manager_journal.py index 069a4247..66990dc4 100644 --- a/tests/test_manager_journal.py +++ b/tests/test_manager_journal.py @@ -28,6 +28,7 @@ from authentic2.apps.journal.models import Event, _registry from authentic2.custom_user.models import Profile, ProfileType, User from authentic2.journal import journal from authentic2.models import Service +from authentic2_auth_saml.models import RenameAttributeAction, SAMLAuthenticator from .utils import login, logout, text_content @@ -58,6 +59,8 @@ def events(db, freezer): role_agent = Role.objects.create(name="role2", ou=ou) service = Service.objects.create(name="service") authenticator = LoginPasswordAuthenticator.objects.create(slug='test') + saml_authenticator = SAMLAuthenticator.objects.create(slug='saml') + rename_attribute_action = RenameAttributeAction.objects.create(authenticator=saml_authenticator) class EventFactory: date = make_aware(datetime.datetime(2020, 1, 1)) @@ -301,6 +304,24 @@ def events(db, freezer): make('authenticator.enable', user=agent, session=session2, authenticator=authenticator) make('authenticator.disable', user=agent, session=session2, authenticator=authenticator) make('authenticator.deletion', user=agent, session=session2, authenticator=authenticator) + make( + 'authenticator.saml.related_object.creation', + user=agent, + session=session2, + related_object=rename_attribute_action, + ) + action_edit_form = mock.Mock(spec=['instance', 'initial', 'changed_data', 'cleaned_data']) + action_edit_form.instance = rename_attribute_action + action_edit_form.initial = {'from_name': 'old'} + action_edit_form.changed_data = ['from_name'] + action_edit_form.cleaned_data = {'from_name': 'new'} + make('authenticator.saml.related_object.edit', user=agent, session=session2, form=action_edit_form) + make( + 'authenticator.saml.related_object.deletion', + user=agent, + session=session2, + related_object=rename_attribute_action, + ) # verify we created at least one event for each type assert set(Event.objects.values_list("type__name", flat=True)) == set(_registry) @@ -337,6 +358,7 @@ def extract_journal(response): def test_global_journal(app, superuser, events): response = login(app, user=superuser, path="/manage/") + rename_attribute_action = RenameAttributeAction.objects.get() # remove event about admin login Event.objects.filter(user=superuser).delete() @@ -692,6 +714,27 @@ def test_global_journal(app, superuser, events): 'type': 'authenticator.deletion', 'user': 'agent', }, + { + 'message': 'creation of RenameAttributeAction (%s) in authenticator "SAML"' + % rename_attribute_action.pk, + 'timestamp': 'Jan. 3, 2020, 8 a.m.', + 'type': 'authenticator.saml.related_object.creation', + 'user': 'agent', + }, + { + 'message': 'edit RenameAttributeAction (%s) in authenticator "SAML" (from_name)' + % rename_attribute_action.pk, + 'timestamp': 'Jan. 3, 2020, 9 a.m.', + 'type': 'authenticator.saml.related_object.edit', + 'user': 'agent', + }, + { + 'message': 'deletion of RenameAttributeAction (%s) in authenticator "SAML"' + % rename_attribute_action.pk, + 'timestamp': 'Jan. 3, 2020, 10 a.m.', + 'type': 'authenticator.saml.related_object.deletion', + 'user': 'agent', + }, ] agent_page = response.click('agent', index=1) -- 2.30.2