From a19be462bbb7572e028980c7f369a97ea4df7c8e Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Tue, 7 Jun 2022 16:05:03 +0200 Subject: [PATCH] authenticators: log modifications to journal (#65358) --- .../authenticators/journal_event_types.py | 108 ++++++++++++++++++ .../apps/authenticators/manager_urls.py | 5 + .../authenticators/authenticator_detail.html | 3 +- .../authenticators/authenticator_journal.html | 7 ++ src/authentic2/apps/authenticators/views.py | 37 ++++++ tests/test_manager_authenticators.py | 20 +++- tests/test_manager_journal.py | 42 +++++++ 7 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 src/authentic2/apps/authenticators/journal_event_types.py create mode 100644 src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_journal.html diff --git a/src/authentic2/apps/authenticators/journal_event_types.py b/src/authentic2/apps/authenticators/journal_event_types.py new file mode 100644 index 000000000..e2e7de2d4 --- /dev/null +++ b/src/authentic2/apps/authenticators/journal_event_types.py @@ -0,0 +1,108 @@ +# 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 BaseAuthenticator + + +class AuthenticatorCreation(EventTypeDefinition): + name = 'authenticator.creation' + label = _('authenticator creation') + + @classmethod + def record(cls, *, user, session, authenticator): + super().record(user=user, session=session, references=[authenticator]) + + @classmethod + def get_message(cls, event, context): + (authenticator,) = event.get_typed_references(BaseAuthenticator) + if context != authenticator: + return _('creation of authenticator "%s"') % authenticator + else: + return _('creation') + + +class AuthenticatorEdit(EventTypeDefinition): + name = 'authenticator.edit' + label = _('authenticator edit') + + @classmethod + def record(cls, *, user, session, form): + super().record(user=user, session=session, references=[form.instance], data=form_to_old_new(form)) + + @classmethod + def get_message(cls, event, context): + (authenticator,) = event.get_typed_references(BaseAuthenticator) + new = event.get_data('new') or {} + edited_attributes = ', '.join(new) or '' + if context != authenticator: + return _('edit of authenticator "{authenticator}" ({change})').format( + authenticator=authenticator, change=edited_attributes + ) + else: + return _('edit ({change})').format(change=edited_attributes) + + +class AuthenticatorEnable(EventTypeDefinition): + name = 'authenticator.enable' + label = _('authenticator enable') + + @classmethod + def record(cls, *, user, session, authenticator): + super().record(user=user, session=session, references=[authenticator]) + + @classmethod + def get_message(cls, event, context): + (authenticator,) = event.get_typed_references(BaseAuthenticator) + if context != authenticator: + return _('enable of authenticator "%s"') % authenticator + else: + return _('enable') + + +class AuthenticatorDisable(EventTypeDefinition): + name = 'authenticator.disable' + label = _('authenticator disable') + + @classmethod + def record(cls, *, user, session, authenticator): + super().record(user=user, session=session, references=[authenticator]) + + @classmethod + def get_message(cls, event, context): + (authenticator,) = event.get_typed_references(BaseAuthenticator) + if context != authenticator: + return _('disable of authenticator "%s"') % authenticator + else: + return _('disable') + + +class AuthenticatorDeletion(EventTypeDefinition): + name = 'authenticator.deletion' + label = _('authenticator deletion') + + @classmethod + def record(cls, *, user, session, authenticator): + super().record(user=user, session=session, references=[authenticator]) + + @classmethod + def get_message(cls, event, context): + (authenticator,) = event.get_typed_references(BaseAuthenticator) + return _('deletion of authenticator "%s"') % authenticator diff --git a/src/authentic2/apps/authenticators/manager_urls.py b/src/authentic2/apps/authenticators/manager_urls.py index 09aceb2b5..c25c9bf30 100644 --- a/src/authentic2/apps/authenticators/manager_urls.py +++ b/src/authentic2/apps/authenticators/manager_urls.py @@ -71,5 +71,10 @@ urlpatterns = required( views.toggle, name='a2-manager-authenticator-toggle', ), + path( + 'authenticators//journal/', + views.journal, + name='a2-manager-authenticator-journal', + ), ], ) 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 6cd413a74..647fea06d 100644 --- a/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html +++ b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html @@ -4,15 +4,14 @@ {% block appbar %} {{ block.super }} - {% if not object.internal %} - {% endif %} {% if object.has_valid_configuration and not object.internal %} {{ object.enabled|yesno:_("Disable,Enable") }} {% endif %} {% trans "Edit" %}
    +
  • {% trans "Journal" %}
  • {% if not object.internal %}
  • {% trans "Delete" %}
  • {% endif %} diff --git a/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_journal.html b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_journal.html new file mode 100644 index 000000000..fb9d729ef --- /dev/null +++ b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_journal.html @@ -0,0 +1,7 @@ +{% extends "authentic2/manager/journal.html" %} +{% load i18n %} + +{% block breadcrumb-before-title %} + {% trans "Authenticators" %} + {{ object }} +{% endblock %} diff --git a/src/authentic2/apps/authenticators/views.py b/src/authentic2/apps/authenticators/views.py index cf942db45..8f4a0811f 100644 --- a/src/authentic2/apps/authenticators/views.py +++ b/src/authentic2/apps/authenticators/views.py @@ -17,13 +17,17 @@ from django.contrib import messages from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 from django.urls import reverse, reverse_lazy +from django.utils.functional import cached_property from django.utils.translation import ugettext as _ from django.views.generic import CreateView, DeleteView, DetailView, UpdateView from django.views.generic.list import ListView from authentic2.apps.authenticators import forms from authentic2.apps.authenticators.models import BaseAuthenticator +from authentic2.apps.journal.views import JournalViewWithContext +from authentic2.manager.journal_views import BaseJournalView from authentic2.manager.views import MediaMixin, TitleMixin @@ -49,6 +53,11 @@ class AuthenticatorAddView(AuthenticatorsMixin, CreateView): def get_success_url(self): return reverse('a2-manager-authenticator-edit', kwargs={'pk': self.object.pk}) + def form_valid(self, form): + resp = super().form_valid(form) + self.request.journal.record('authenticator.creation', authenticator=form.instance) + return resp + add = AuthenticatorAddView.as_view() @@ -73,6 +82,10 @@ class AuthenticatorEditView(AuthenticatorsMixin, UpdateView): def get_form_class(self): return self.object.manager_form_class + def form_valid(self, form): + self.request.journal.record('authenticator.edit', form=form) + return super().form_valid(form) + edit = AuthenticatorEditView.as_view() @@ -88,6 +101,10 @@ class AuthenticatorDeleteView(AuthenticatorsMixin, DeleteView): raise PermissionDenied return super().dispatch(*args, **kwargs) + def delete(self, *args, **kwargs): + self.request.journal.record('authenticator.deletion', authenticator=self.get_object()) + return super().delete(*args, **kwargs) + delete = AuthenticatorDeleteView.as_view() @@ -105,10 +122,12 @@ class AuthenticatorToggleView(AuthenticatorsMixin, DetailView): if self.authenticator.enabled: self.authenticator.enabled = False self.authenticator.save() + self.request.journal.record('authenticator.disable', authenticator=self.authenticator) message = _('Authenticator has been disabled.') else: self.authenticator.enabled = True self.authenticator.save() + self.request.journal.record('authenticator.enable', authenticator=self.authenticator) message = _('Authenticator has been enabled.') messages.info(self.request, message) @@ -116,3 +135,21 @@ class AuthenticatorToggleView(AuthenticatorsMixin, DetailView): toggle = AuthenticatorToggleView.as_view() + + +class AuthenticatorJournal(JournalViewWithContext, BaseJournalView): + template_name = 'authentic2/authenticators/authenticator_journal.html' + title = _('Journal') + + @cached_property + def context(self): + return get_object_or_404(BaseAuthenticator.authenticators.all(), pk=self.kwargs['pk']) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['object'] = self.context + ctx['object_name'] = str(self.context) + return ctx + + +journal = AuthenticatorJournal.as_view() diff --git a/tests/test_manager_authenticators.py b/tests/test_manager_authenticators.py index 006b514f2..86299a7bf 100644 --- a/tests/test_manager_authenticators.py +++ b/tests/test_manager_authenticators.py @@ -20,7 +20,7 @@ from django import VERSION as DJ_VERSION from authentic2.apps.authenticators.models import LoginPasswordAuthenticator from authentic2_auth_oidc.models import OIDCProvider -from .utils import login, logout +from .utils import assert_event, login, logout def test_authenticators_authorization(app, simple_user, superuser): @@ -45,7 +45,6 @@ def test_authenticators_password(app, superuser): assert 'Click "Edit" to change configuration.' in resp.text # cannot delete password authenticator assert 'Delete' not in resp.text - assert 'extra-actions-menu-opener' not in resp.text assert 'configuration is not complete' not in resp.text app.get('/manage/authenticators/%s/delete/' % authenticator.pk, status=403) @@ -77,6 +76,7 @@ def test_authenticators_password(app, superuser): "Show condition: 'backoffice' in login_hint or remotre_addr == '1.2.3.4'" in resp.text ) + assert_event('authenticator.edit', user=superuser, session=app.session) resp = resp.click('Edit') resp.form['show_condition'] = "remote_addr in dnsbl('ddns.entrouvert.org')" @@ -87,6 +87,9 @@ def test_authenticators_password(app, superuser): assert 'Disable' not in resp.text app.get('/manage/authenticators/%s/toggle/' % authenticator.pk, status=403) + resp = resp.click('Journal') + assert resp.text.count('edit (show_condition)') == 2 + # cannot add another password authenticator resp = app.get('/manage/authenticators/add/') assert 'Password' not in resp.text @@ -102,6 +105,7 @@ def test_authenticators_oidc(app, superuser, ou1, ou2): resp.form['ou'] = ou1.pk resp = resp.form.submit() assert '/edit/' in resp.location + assert_event('authenticator.creation', user=superuser, session=app.session) provider = OIDCProvider.objects.filter(slug='test').get() resp = app.get(provider.get_absolute_url()) @@ -124,6 +128,7 @@ def test_authenticators_oidc(app, superuser, ou1, ou2): resp.form['userinfo_endpoint'] = 'https://oidc.example.com/user_info' resp.form['idtoken_algo'] = 2 resp = resp.form.submit().follow() + assert_event('authenticator.edit', user=superuser, session=app.session) assert 'Issuer: https://oidc.example.com' in resp.text assert 'Scopes: profile email' in resp.text @@ -136,6 +141,15 @@ def test_authenticators_oidc(app, superuser, ou1, ou2): resp = resp.click('Configure', index=1) resp = resp.click('Enable').follow() assert 'Authenticator has been enabled.' in resp.text + assert_event('authenticator.enable', user=superuser, session=app.session) + + resp = resp.click('Journal') + assert 'enable' in resp.text + assert ( + 'edit (issuer, scopes, strategy, idtoken_algo, token_endpoint, userinfo_endpoint, authorization_endpoint)' + in resp.text + ) + assert 'creation' in resp.text resp = app.get('/manage/authenticators/') assert 'class="section disabled"' not in resp.text @@ -162,6 +176,7 @@ def test_authenticators_oidc(app, superuser, ou1, ou2): resp = resp.click('Configure', index=1) resp = resp.click('Disable').follow() assert 'Authenticator has been disabled.' in resp.text + assert_event('authenticator.disable', user=superuser, session=app.session) resp = app.get('/manage/authenticators/') assert 'class="section disabled"' in resp.text @@ -170,3 +185,4 @@ def test_authenticators_oidc(app, superuser, ou1, ou2): resp = resp.click('Delete') resp = resp.form.submit().follow() assert not OIDCProvider.objects.filter(slug='test').exists() + assert_event('authenticator.deletion', user=superuser, session=app.session) diff --git a/tests/test_manager_journal.py b/tests/test_manager_journal.py index cb535daac..4f46e5622 100644 --- a/tests/test_manager_journal.py +++ b/tests/test_manager_journal.py @@ -23,6 +23,7 @@ from django.utils.timezone import make_aware from authentic2.a2_rbac.models import Role from authentic2.a2_rbac.utils import get_default_ou +from authentic2.apps.authenticators.models import LoginPasswordAuthenticator from authentic2.apps.journal.models import Event, _registry from authentic2.custom_user.models import Profile, ProfileType, User from authentic2.journal import journal @@ -56,6 +57,7 @@ def events(db, freezer): role_user = Role.objects.create(name="role1", ou=ou) role_agent = Role.objects.create(name="role2", ou=ou) service = Service.objects.create(name="service") + authenticator = LoginPasswordAuthenticator.objects.create(slug='test') class EventFactory: date = make_aware(datetime.datetime(2020, 1, 1)) @@ -289,6 +291,16 @@ def events(db, freezer): ) make('user.notification.inactivity', user=user, days_of_inactivity=120, days_to_deletion=20) make('user.deletion.inactivity', user=user, days_of_inactivity=140) + make('authenticator.creation', user=agent, session=session2, authenticator=authenticator) + authenticator_edit_form = mock.Mock(spec=['instance', 'initial', 'changed_data', 'cleaned_data']) + authenticator_edit_form.instance = authenticator + authenticator_edit_form.initial = {'name': 'old'} + authenticator_edit_form.changed_data = ['name'] + authenticator_edit_form.cleaned_data = {'name': 'new'} + make('authenticator.edit', user=agent, session=session2, form=authenticator_edit_form) + 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) # verify we created at least one event for each type assert set(Event.objects.values_list("type__name", flat=True)) == set(_registry) @@ -650,6 +662,36 @@ def test_global_journal(app, superuser, events): 'type': 'user.deletion.inactivity', 'user': 'Johnny doe', }, + { + 'message': 'creation of authenticator "Password"', + 'timestamp': 'Jan. 3, 2020, 3 a.m.', + 'type': 'authenticator.creation', + 'user': 'agent', + }, + { + 'message': 'edit of authenticator "Password" (name)', + 'timestamp': 'Jan. 3, 2020, 4 a.m.', + 'type': 'authenticator.edit', + 'user': 'agent', + }, + { + 'message': 'enable of authenticator "Password"', + 'timestamp': 'Jan. 3, 2020, 5 a.m.', + 'type': 'authenticator.enable', + 'user': 'agent', + }, + { + 'message': 'disable of authenticator "Password"', + 'timestamp': 'Jan. 3, 2020, 6 a.m.', + 'type': 'authenticator.disable', + 'user': 'agent', + }, + { + 'message': 'deletion of authenticator "Password"', + 'timestamp': 'Jan. 3, 2020, 7 a.m.', + 'type': 'authenticator.deletion', + 'user': 'agent', + }, ] agent_page = response.click('agent', index=1) -- 2.30.2