auth_saml: add views to configure related objects (#67025)

This commit is contained in:
Valentin Deniaud 2022-08-16 17:11:14 +02:00
parent 18ea92a76b
commit 742e955dcc
12 changed files with 430 additions and 8 deletions

View File

@ -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']

View File

@ -31,9 +31,25 @@
</div>
{% endif %}
<div class='placeholder'>
{% for line in object.get_full_description %}
<p>{{ line }}</p>
{% endfor %}
<div class='section authenticator-detail'>
<div class='pk-tabs'>
<div class="pk-tabs--tab-list" role="tablist">
<button aria-controls="panel-description" aria-selected="true" id="tab-description" role="tab" tabindex="0">{% trans "Description" %}</button>
{% block extra-tab-buttons %}
{% endblock %}
</div>
<div class="pk-tabs--container">
<div aria-labelledby="tab-description" id="panel-description" role="tabpanel" tabindex="0">
<ul>
{% for line in object.get_full_description %}
<li>{{ line }}</li>
{% endfor %}
</ul>
</div>
{% block extra-tab-list %}
{% endblock %}
</div>
</div>
</div>
{% endblock %}

View File

@ -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):

View File

@ -16,6 +16,9 @@
from django import forms
from authentic2.a2_rbac.models import Role
from authentic2.manager.utils import label_from_role
from .models import SAMLAuthenticator
@ -23,3 +26,12 @@ class SAMLAuthenticatorForm(forms.ModelForm):
class Meta:
model = SAMLAuthenticator
exclude = ('ou',)
class RoleChoiceField(forms.ModelChoiceField):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.queryset = Role.objects.exclude(slug__startswith='_')
def label_from_instance(self, obj):
return label_from_role(obj)

View File

@ -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 <http://www.gnu.org/licenses/>.
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 SAMLAuthenticatorEvents(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(SAMLAuthenticatorEvents):
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(SAMLAuthenticatorEvents):
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(SAMLAuthenticatorEvents):
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

View File

@ -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.manager.utils import label_from_role
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)
@ -144,6 +143,7 @@ class SAMLAuthenticator(BaseAuthenticator):
type = 'saml'
how = ['saml']
manager_view_template_name = 'authentic2_auth_saml/authenticator_detail.html'
description_fields = [
'show_condition',
'metadata_url',
@ -191,9 +191,13 @@ 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)
@ -215,6 +219,9 @@ class RenameAttributeAction(SAMLRelatedObjectBase):
default_related_name = 'rename_attribute_actions'
verbose_name = _('Rename an attribute')
def __str__(self):
return '%s%s' % (self.from_name, self.to_name)
class SAMLAttributeLookup(SAMLRelatedObjectBase):
user_field = models.CharField(_('User field'), max_length=256)
@ -225,6 +232,15 @@ class SAMLAttributeLookup(SAMLRelatedObjectBase):
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,
@ -242,6 +258,15 @@ class SetAttributeAction(SAMLRelatedObjectBase):
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(SAMLRelatedObjectBase):
role = models.ForeignKey(Role, verbose_name=_('Role'), on_delete=models.CASCADE)
@ -251,3 +276,6 @@ class AddRoleAction(SAMLRelatedObjectBase):
class Meta:
default_related_name = 'add_role_actions'
verbose_name = _('Add a role')
def __str__(self):
return label_from_role(self.role)

View File

@ -0,0 +1,27 @@
{% extends 'authentic2/authenticators/authenticator_detail.html' %}
{% load i18n %}
{% block extra-tab-buttons %}
<button aria-controls="panel-samlattributelookup" aria-selected="false" id="tab-samlattributelookup" role="tab" tabindex="-1">{% trans "Lookup by attributes" %}</button>
<button aria-controls="panel-renameattributeaction" aria-selected="false" id="tab-renameattributeaction" role="tab" tabindex="-1">{% trans "Rename attributes" %}</button>
<button aria-controls="panel-setattributeaction" aria-selected="false" id="tab-setattributeaction" role="tab" tabindex="-1">{% trans "Set attributes" %}</button>
<button aria-controls="panel-addroleaction" aria-selected="false" id="tab-addroleaction" role="tab" tabindex="-1">{% trans "Add roles" %}</button>
{% endblock %}
{% block extra-tab-list %}
<div aria-labelledby="tab-samlattributelookup" hidden="" id="panel-samlattributelookup" role="tabpanel" tabindex="0">
{% include 'authentic2_auth_saml/related_object_list.html' with object_list=object.attribute_lookups.all model_name='samlattributelookup' %}
</div>
<div aria-labelledby="tab-renameattributeaction" hidden="" id="panel-renameattributeaction" role="tabpanel" tabindex="0">
{% include 'authentic2_auth_saml/related_object_list.html' with object_list=object.rename_attribute_actions.all model_name='renameattributeaction' %}
</div>
<div aria-labelledby="tab-setattributeaction" hidden="" id="panel-setattributeaction" role="tabpanel" tabindex="0">
{% include 'authentic2_auth_saml/related_object_list.html' with object_list=object.set_attribute_actions.all model_name='setattributeaction' %}
</div>
<div aria-labelledby="tab-addroleaction" hidden="" id="panel-addroleaction" role="tabpanel" tabindex="0">
{% include 'authentic2_auth_saml/related_object_list.html' with object_list=object.add_role_actions.all model_name='addroleaction' %}
</div>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% load i18n %}
<ul class="objects-list single-links">
{% for related_object in object_list %}
<li>
<a rel="popup" href="{% url 'a2-manager-saml-edit-related-object' authenticator_pk=object.pk model_name=model_name pk=related_object.pk %}">{{ related_object }}</a>
<a rel="popup" class="delete" href="{% url 'a2-manager-saml-delete-related-object' authenticator_pk=object.pk model_name=model_name pk=related_object.pk %}">{% trans "Remove" %}</a>
</li>
{% endfor %}
<li><a class="add" rel="popup" href="{% url 'a2-manager-saml-add-related-object' authenticator_pk=object.pk model_name=model_name %}">{% trans 'Add' %}</a></li>
</ul>

View File

@ -15,7 +15,34 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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/<int:authenticator_pk>/<slug:model_name>/add/',
views.add_related_object,
name='a2-manager-saml-add-related-object',
),
path(
'authenticators/<int:authenticator_pk>/<slug:model_name>/<int:pk>/edit/',
views.edit_related_object,
name='a2-manager-saml-edit-related-object',
),
path(
'authenticators/<int:authenticator_pk>/<slug:model_name>/<int:pk>/delete/',
views.delete_related_object,
name='a2-manager-saml-delete-related-object',
),
],
)

View File

@ -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 RoleChoiceField
from .models import (
AddRoleAction,
RenameAttributeAction,
SAMLAttributeLookup,
SAMLAuthenticator,
SetAttributeAction,
)
def login(request, authenticator, *args, **kwargs):
context = kwargs.pop('context', {}).copy()
@ -34,3 +49,79 @@ 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',), field_classes={'role': RoleChoiceField}
)
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()

View File

@ -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,77 @@ 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, role_ou2):
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 [x[2] for x in resp.form['role'].options] == ['---------', 'OU1 - role_ou1', 'OU2 - role_ou2']
resp.form['role'] = role_ou1.pk
resp = resp.form.submit().follow()
assert 'role_ou1' in resp.text
resp = resp.click('role_ou1')
resp.form['role'] = role_ou2.pk
resp = resp.form.submit().follow()
assert 'role_ou1' not in resp.text
assert 'role_ou2' in resp.text
def test_authenticators_order(app, superuser):
resp = login(app, superuser, path='/manage/authenticators/')

View File

@ -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)