From 7b077b530914a67688ebd6696f32a9a60551aef0 Mon Sep 17 00:00:00 2001 From: Paul Marillonnet Date: Wed, 31 Oct 2018 16:04:36 +0100 Subject: [PATCH] WIP add journal application (#20695) --- MANIFEST.in | 1 + setup.py | 1 + src/authentic2/app_settings.py | 1 + .../authentic2/manager/user_journal.html | 10 + src/authentic2/manager/urls.py | 3 + src/authentic2/manager/user_views.py | 13 ++ src/authentic2/profile_urls.py | 4 +- src/authentic2/settings.py | 1 + .../authentic2/user_profile_journal.html | 14 ++ src/authentic2/views.py | 23 +- src/authentic2_journal/__init__.py | 21 ++ src/authentic2_journal/admin.py | 0 src/authentic2_journal/app_settings.py | 38 +++ src/authentic2_journal/apps.py | 44 ++++ src/authentic2_journal/event_logger.py | 61 +++++ src/authentic2_journal/migrations/__init__.py | 0 src/authentic2_journal/models.py | 84 +++++++ src/authentic2_journal/tables.py | 32 +++ .../templatetags/__init__.py | 0 .../templatetags/authentic2_journal.py | 26 +++ src/authentic2_journal/utils.py | 88 +++++++ src/authentic2_journal/views.py | 35 +++ tests/conftest.py | 5 + tests/test_journal.py | 216 ++++++++++++++++++ 24 files changed, 719 insertions(+), 2 deletions(-) create mode 100644 src/authentic2/manager/templates/authentic2/manager/user_journal.html create mode 100644 src/authentic2/templates/authentic2/user_profile_journal.html create mode 100644 src/authentic2_journal/__init__.py create mode 100644 src/authentic2_journal/admin.py create mode 100644 src/authentic2_journal/app_settings.py create mode 100644 src/authentic2_journal/apps.py create mode 100644 src/authentic2_journal/event_logger.py create mode 100644 src/authentic2_journal/migrations/__init__.py create mode 100644 src/authentic2_journal/models.py create mode 100644 src/authentic2_journal/tables.py create mode 100644 src/authentic2_journal/templatetags/__init__.py create mode 100644 src/authentic2_journal/templatetags/authentic2_journal.py create mode 100644 src/authentic2_journal/utils.py create mode 100644 src/authentic2_journal/views.py create mode 100644 tests/test_journal.py diff --git a/MANIFEST.in b/MANIFEST.in index b0517243..bb2dd9e5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -41,6 +41,7 @@ recursive-include src/django_rbac/locale *.po *.mo recursive-include src/authentic2_auth_saml/locale *.po *.mo recursive-include src/authentic2_auth_oidc/locale *.po *.mo recursive-include src/authentic2_idp_oidc/locale *.po *.mo +recursive-include src/authentic2_journal/locale *.po *.mo recursive-include src/authentic2 README xrds.xml *.txt yadis.xrdf recursive-include src/authentic2_provisionning_ldap/tests *.ldif diff --git a/setup.py b/setup.py index 27bb3c64..a1027688 100755 --- a/setup.py +++ b/setup.py @@ -165,5 +165,6 @@ setup(name="authentic2", 'authentic2-idp-cas = authentic2_idp_cas:Plugin', 'authentic2-idp-oidc = authentic2_idp_oidc:Plugin', 'authentic2-provisionning-ldap = authentic2_provisionning_ldap:Plugin', + 'authentic2-journal = authentic2_journal:Plugin', ], }) diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index 67ecb553..79de2f35 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -207,6 +207,7 @@ default_settings = dict( A2_ACCOUNTS_URL=Setting(default=None, definition='IdP has no account page, redirect to this one.'), A2_CACHE_ENABLED=Setting(default=True, definition='Disable all cache decorators for testing purpose.'), A2_ACCEPT_EMAIL_AUTHENTICATION=Setting(default=True, definition='Enable authentication by email'), + A2_JOURNAL_EVENT_LOGGER=Setting(default=None, definition='Event logger class for the journal app.'), ) diff --git a/src/authentic2/manager/templates/authentic2/manager/user_journal.html b/src/authentic2/manager/templates/authentic2/manager/user_journal.html new file mode 100644 index 00000000..4af22975 --- /dev/null +++ b/src/authentic2/manager/templates/authentic2/manager/user_journal.html @@ -0,0 +1,10 @@ +{% extends "authentic2/manager/base.html" %} +{% load i18n staticfiles django_tables2 %} + +{% block page_title %} +{% trans "User events journal" %} +{% endblock %} + +{% block content %} + {% render_table table "authentic2/manager/table.html" %} +{% endblock %} diff --git a/src/authentic2/manager/urls.py b/src/authentic2/manager/urls.py index af4caca4..fb89a599 100644 --- a/src/authentic2/manager/urls.py +++ b/src/authentic2/manager/urls.py @@ -52,6 +52,9 @@ urlpatterns = required( url(r'^users/uuid:(?P[a-z0-9]+)/change-email/$', user_views.user_change_email, name='a2-manager-user-by-uuid-change-email'), + url('^users/(?P\d+)/journal/$', + user_views.user_journal, + name='a2-manager-user-journal'), # Authentic2 roles url(r'^roles/$', role_views.listing, diff --git a/src/authentic2/manager/user_views.py b/src/authentic2/manager/user_views.py index 9e07edd9..ebdcec36 100644 --- a/src/authentic2/manager/user_views.py +++ b/src/authentic2/manager/user_views.py @@ -22,6 +22,8 @@ from authentic2.models import Attribute, PasswordReset from authentic2.utils import switch_user, send_password_reset_mail, redirect, send_email_change_email from authentic2.a2_rbac.utils import get_default_ou from authentic2 import hooks +from authentic2_journal.views import ManagerBaseJournal +from authentic2_journal.tables import UserEventsTable from django_rbac.utils import get_role_model, get_role_parenting_model, get_ou_model @@ -510,3 +512,14 @@ class UserDeleteView(BaseDeleteView): user_delete = UserDeleteView.as_view() + + +class UserJournal(ManagerBaseJournal): + model = get_user_model() + table_class = UserEventsTable + template_name = 'authentic2/manager/user_journal.html' + permissions = ['custom_user.view_user'] + filter_table_by_perm = False + + +user_journal = UserJournal.as_view() diff --git a/src/authentic2/profile_urls.py b/src/authentic2/profile_urls.py index 10538fed..85b8dd3e 100644 --- a/src/authentic2/profile_urls.py +++ b/src/authentic2/profile_urls.py @@ -9,7 +9,8 @@ from django.views.decorators.debug import sensitive_post_parameters from authentic2.utils import import_module_or_class, redirect from . import app_settings, decorators, profile_views, hooks -from .views import (logged_in, edit_profile, email_change, email_change_verify, profile) +from .views import (logged_in, edit_profile, email_change, email_change_verify, profile, + user_profile_journal) SET_PASSWORD_FORM_CLASS = import_module_or_class( app_settings.A2_REGISTRATION_SET_PASSWORD_FORM_CLASS) @@ -94,4 +95,5 @@ urlpatterns = [ auth_views.password_reset_done, name='auth_password_reset_done'), url(r'^switch-back/$', profile_views.switch_back, name='a2-switch-back'), + url('^journal/$', user_profile_journal, name='user-profile-journal'), ] diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py index 8c9289a6..0ee122a2 100644 --- a/src/authentic2/settings.py +++ b/src/authentic2/settings.py @@ -126,6 +126,7 @@ INSTALLED_APPS = ( 'authentic2.disco_service', 'authentic2.manager', 'authentic2_provisionning_ldap', + 'authentic2_journal', 'authentic2', 'django_rbac', 'authentic2.a2_rbac', diff --git a/src/authentic2/templates/authentic2/user_profile_journal.html b/src/authentic2/templates/authentic2/user_profile_journal.html new file mode 100644 index 00000000..6b23cb04 --- /dev/null +++ b/src/authentic2/templates/authentic2/user_profile_journal.html @@ -0,0 +1,14 @@ +{% extends "authentic2/base-page.html" %} +{% load i18n %} +{% load authentic2_journal %} + +{% block content %} +

{% trans "User journal" %}

+
    + {% for reference in object_list %} +
  • {{ reference.timestamp }} - {{ reference.content|to_representation }}
  • + {% empty %} +
  • {% trans "No journal entry yet." %}
  • + {% endfor %} +
+{% endblock %} diff --git a/src/authentic2/views.py b/src/authentic2/views.py index 13c96d0f..9d55b277 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -13,6 +13,7 @@ from django.template.loader import render_to_string, select_template from django.views.generic.edit import UpdateView, FormView from django.views.generic import RedirectView, TemplateView from django.views.generic.base import View +from django.views.generic.list import ListView from django.contrib.auth import SESSION_KEY from django import http, shortcuts from django.core import mail, signing @@ -21,7 +22,8 @@ from django.core.exceptions import ValidationError from django.contrib import messages from django.utils.translation import ugettext as _ from django.contrib.auth import logout as auth_logout -from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model +from django.contrib.contenttypes.models import ContentType from django.http import (HttpResponseRedirect, HttpResponseForbidden, HttpResponse) from django.core.exceptions import PermissionDenied @@ -37,6 +39,7 @@ from django.db.models.query import Q from . import (utils, app_settings, forms, compat, decorators, constants, models, cbv, hooks) +from authentic2_journal.models import Reference logger = logging.getLogger(__name__) @@ -612,6 +615,24 @@ class LoggedInView(View): logged_in = never_cache(LoggedInView.as_view()) + +class UserProfileJournal(ListView): + model = Reference + + def get_queryset(self): + return self.model.objects.filter( + target_id=self.request.user.pk, + target_ct=ContentType.objects.get_for_model(get_user_model())) + + def get_template_names(self): + return [ + 'profiles/user_profile_journal.html', + 'authentic2/user_profile_journal.html', + ] + +user_profile_journal = login_required(UserProfileJournal.as_view()) + + def csrf_failure_view(request, reason=""): messages.warning(request, _('The page is out of date, it was reloaded for you')) return HttpResponseRedirect(request.get_full_path()) diff --git a/src/authentic2_journal/__init__.py b/src/authentic2_journal/__init__.py new file mode 100644 index 00000000..7d27c9b3 --- /dev/null +++ b/src/authentic2_journal/__init__.py @@ -0,0 +1,21 @@ +# authentic - Versatile identity management server +# Copyright (C) 2018 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 . + +class Plugin(object): + def get_apps(self): + return [__name__] + +default_app_config = 'authentic2_journal.apps.Authentic2JournalConfig' diff --git a/src/authentic2_journal/admin.py b/src/authentic2_journal/admin.py new file mode 100644 index 00000000..e69de29b diff --git a/src/authentic2_journal/app_settings.py b/src/authentic2_journal/app_settings.py new file mode 100644 index 00000000..495e4e13 --- /dev/null +++ b/src/authentic2_journal/app_settings.py @@ -0,0 +1,38 @@ +# authentic - Versatile identity management server +# Copyright (C) 2018 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 . + +class AppSettings(object): + __DEFAULTS = { + 'ENABLE': False, + } + + def __init__(self, prefix): + self.prefix = prefix + + def _setting(self, name, dflt): + from django.conf import settings + return getattr(settings, self.prefix + name, dflt) + + def __getattr__(self, name): + if name not in self.__DEFAULTS: + raise AttributeError(name) + return self._setting(name, self.__DEFAULTS[name]) + + +import sys +app_settings = AppSettings('A2_JOURNAL_') +app_settings.__name__ = __name__ +sys.modules[__name__] = app_settings diff --git a/src/authentic2_journal/apps.py b/src/authentic2_journal/apps.py new file mode 100644 index 00000000..3f8c2b89 --- /dev/null +++ b/src/authentic2_journal/apps.py @@ -0,0 +1,44 @@ +# authentic - Versatile identity management server +# Copyright (C) 2018 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 __future__ import unicode_literals + +from django.db import DEFAULT_DB_ALIAS, router +from django.apps import AppConfig + + +class Authentic2JournalConfig(AppConfig): + name = 'authentic2_journal' + verbose_name = 'Authentic 2 Journal Application' + + def ready(self): + from django.db.models.signals import post_migrate + + post_migrate.connect( + self.create_event_kinds, + sender=self) + + def create_event_kinds(self, app_config, verbosity=2, interactive=True, + using=DEFAULT_DB_ALIAS, **kwargs): + from authentic2_journal.models import EventKind, KINDS + + if not router.allow_migrate(using, EventKind): + return + if EventKind.objects.filter(name__in=KINDS).count() == 9: + return + + for kind in KINDS: + EventKind.objects.get_or_create(name=kind) diff --git a/src/authentic2_journal/event_logger.py b/src/authentic2_journal/event_logger.py new file mode 100644 index 00000000..696fc722 --- /dev/null +++ b/src/authentic2_journal/event_logger.py @@ -0,0 +1,61 @@ +# authentic - Versatile identity management server +# Copyright (C) 2018 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 authentic2_journal.models import Line +from authentic2_journal.utils import new_line, new_reference, log_event +from django.utils.translation import ugettext_lazy as _ + + +class EventLogger(object): + def sso(self, user, service): + content = ('action_sso', '%s' % user.email, '%s' % service) + log_event(user, service, 'action_sso', content) + + + def login(self, user, provider): + content = ('action_login', '%s' % user.email, '%s' % provider) + log_event(user, provider, 'action_login', content) + + + def logout(self, user, provider): + content = ('action_logout', '%s' % user.email, '%s' % provider) + log_event(user, provider, 'action_logout', content) + + + def register(self, user, ou): + content = ('action_register', '%s' % user.email, '%s' % ou.slug) + log_event(user, ou, 'action_register', content) + + + def create(self, user, obj): + content = ('modification_create', '%s' % user.email, '%s' % obj) + log_event(user, obj, 'modification_create', content) + + + def update(self, user, obj): + content = ('modification_update', '%s' % user.email, '%s' % obj) + log_event(user, obj, 'modification_update', content) + + + def modify_members(self, user, role): + content = ('modification_modify_members', '%s' % user.email, + '%s' % role) + log_event(user, role, 'modification_modify_members', content) + + + def delete(self, user, obj): + content = ('modification_delete', '%s' % user.email, '%s' % obj) + log_event(user, obj, 'modification_delete', content) diff --git a/src/authentic2_journal/migrations/__init__.py b/src/authentic2_journal/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/authentic2_journal/models.py b/src/authentic2_journal/models.py new file mode 100644 index 00000000..8304f5e8 --- /dev/null +++ b/src/authentic2_journal/models.py @@ -0,0 +1,84 @@ +# authentic - Versatile identity management server +# Copyright (C) 2018 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 __future__ import unicode_literals +from django.db import models +try: + from django.contrib.contenttypes.fields import GenericForeignKey +except ImportError: + from django.contrib.contenttypes.generic import GenericForeignKey +try: + from django.contrib.postgres.fields import JSONField # dj1.11 native JSON +except: + from jsonfield import JSONField # django-jsonfield outer module +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import ugettext_lazy as _ + + +KINDS = ( + 'action_register', + 'action_login', + 'action_sso', + 'action_logout', + 'modification_create', + 'modification_update', + 'modification_modify_members', + 'modification_delete', + 'generic_event' +) + + +KIND_REPRESENTATIONS = ( + _('REGISTER: user %s in OU %s'), + _('SSO: user %s on service %s'), + _('LOGIN: user %s on provider %s'), + _('LOGOUT: user %s on provider %s'), + _('CREATE: user %s on %s'), + _('UPDATE: user %s on %s'), + _('MODIFY MEMBERS: user %s on role %s'), + _('DELETE: user %s on %s'), + _('GENERIC EVENT: user %s on %s') +) + + +def get_choices(): + return tuple((kind, kind) for kind in KINDS) + + +class EventKind(models.Model): + name = models.CharField( + verbose_name=_('name'), choices=get_choices(), max_length=256) + + +class Line(models.Model): + timestamp = models.DateTimeField(auto_now=True, verbose_name=_('timestamp')) + kind = models.ForeignKey(EventKind) + extra = JSONField(blank=True, null=True, verbose_name=_('content')) + + +class Reference(models.Model): + timestamp = models.DateTimeField(verbose_name=_('timestamp')) + line = models.ForeignKey(Line) + content = JSONField(blank=False, null=False, verbose_name=_('content')) + target_ct = models.ForeignKey(ContentType) + target_id = models.IntegerField(default=0) + target = GenericForeignKey('target_ct', 'target_id') + + def __init__(self, *args, **kwargs): + line = kwargs.get('line') + if line: + kwargs.update({'timestamp': line.timestamp}) + models.Model.__init__(self, *args, **kwargs) diff --git a/src/authentic2_journal/tables.py b/src/authentic2_journal/tables.py new file mode 100644 index 00000000..aaf7144f --- /dev/null +++ b/src/authentic2_journal/tables.py @@ -0,0 +1,32 @@ +# authentic - Versatile identity management server +# Copyright (C) 2018 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 . + +import django_tables2 as tables +from authentic2_journal.models import Reference, KINDS, KIND_REPRESENTATIONS +from django.utils.translation import ugettext_lazy as _ + + +class UserEventsTable(tables.Table): + def render_content(self, value): + if isinstance(value, list) and len(value) >=1 : + return KIND_REPRESENTATIONS[KINDS.index(value[0])] % \ + tuple(value[1:]) + + class Meta: + attrs = {'class': 'main'} + model = Reference + exclude = ['line', 'target_id', 'target_ct'] + empty_text = _('No journal entry yet.') diff --git a/src/authentic2_journal/templatetags/__init__.py b/src/authentic2_journal/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/authentic2_journal/templatetags/authentic2_journal.py b/src/authentic2_journal/templatetags/authentic2_journal.py new file mode 100644 index 00000000..2566be9f --- /dev/null +++ b/src/authentic2_journal/templatetags/authentic2_journal.py @@ -0,0 +1,26 @@ +# authentic - Versatile identity management server +# Copyright (C) 2018 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 ..models import KINDS, KIND_REPRESENTATIONS +from django import template + +register = template.Library() + +@register.filter +def to_representation(reference_content): + if isinstance(reference_content, list) and len(reference_content) >=1 : + return KIND_REPRESENTATIONS[KINDS.index(reference_content[0])] % \ + tuple(reference_content[1:]) diff --git a/src/authentic2_journal/utils.py b/src/authentic2_journal/utils.py new file mode 100644 index 00000000..00856f83 --- /dev/null +++ b/src/authentic2_journal/utils.py @@ -0,0 +1,88 @@ +# authentic - Versatile identity management server +# Copyright (C) 2018 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 . + +import logging + +from authentic2_journal.models import EventKind, Line, Reference +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist + + +def new_line(user, obj, kind='generic_event', extra={}): + logger = logging.getLogger(__name__) + try: + kind = EventKind.objects.get(name=kind) + except: + logger.error('EventKind retrieval from model failed for event name %s' % + kind) + else: + try: + line = Line.objects.create( + kind=kind, + extra=extra + ) + except: + logger.error("Couldn't create event journal entry for user %s on " + "object %s" % (user, obj)) + else: + return line + + +def new_reference(line, obj, content): + logger = logging.getLogger(__name__) + try: + ct = ContentType.objects.get_for_model(obj) + except ObjectDoesNotExist: + logger.error('ContentType retrieval from model failed for object %s' % + obj) + logger.warning('No reference will be created for line %s' % line) + else: + try: + reference = Reference.objects.create( + line=line, + content=content, + target_ct=ct, + target_id=obj.pk) + except: + logger.error("Couldn't create event reference for line %s and " + "object %s" % (line, obj)) + else: + return reference + + +def log_event(user, obj, kind, content): + extra = { + 'user_email': user.email, + 'user_pk': user.pk, + 'obj_representation': '%s' % obj, + 'kind': kind + } + """ + No atomic DB transaction here: we can afford losing references in spite of + a successful line creation. + However, a line creation failure will prevent any reference creation. + """ + line = new_line(user, obj, kind, extra) + if line: + new_reference(line, user, content) + new_reference(line, obj, content) + + +def get_event_logger_class(): + from authentic2 import app_settings + from authentic2_journal.event_logger import EventLogger + + return app_settings.A2_JOURNAL_EVENT_LOGGER or EventLogger diff --git a/src/authentic2_journal/views.py b/src/authentic2_journal/views.py new file mode 100644 index 00000000..ed2f236b --- /dev/null +++ b/src/authentic2_journal/views.py @@ -0,0 +1,35 @@ +# authentic - Versatile identity management server +# Copyright (C) 2018 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 authentic2.manager.views import SimpleSubTableView +from authentic2_journal.models import Reference +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType + + +class ManagerBaseJournal(SimpleSubTableView): + model = None + table_class = None # TODO generic table class? + template_name = '' # TODO generic template name? + permissions = [] # TODO generic custom_user.view_object permission? + filter_table_by_perm = False + + def get_table_queryset(self): + return Reference.objects.filter( + target_id=self.object.pk, + target_ct=ContentType.objects.get_for_model(self.object)) + + diff --git a/tests/conftest.py b/tests/conftest.py index b5b5e78c..0cc2ca84 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -344,3 +344,8 @@ def french_translation(): @pytest.fixture def media(settings, tmpdir): settings.MEDIA_ROOT = str(tmpdir.mkdir('media')) + +@pytest.fixture +def event_logger(db): + from authentic2_journal.event_logger import EventLogger + return EventLogger() diff --git a/tests/test_journal.py b/tests/test_journal.py new file mode 100644 index 00000000..65a94a02 --- /dev/null +++ b/tests/test_journal.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- + +from authentic2.a2_rbac.models import Role +from authentic2.a2_rbac.utils import get_default_ou +from authentic2.models import Service +from authentic2.saml.models import (LibertyServiceProvider as SP, + LibertyProvider as IdP) +from authentic2_journal.models import Line, Reference +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django_rbac.utils import get_ou_model, get_role_model +from utils import login + + +def test_action_events(db, event_logger, app, admin, simple_user, ou1): + s1 = Service.objects.create(name='s1', slug='s1') + idp1 = IdP.objects.create( + name='idp', + slug='idp', + protocol_conformance=3) + sp1 = SP.objects.create(liberty_provider=idp1, enabled=True) + user_ct = ContentType.objects.get_for_model(get_user_model()) + ou_ct = ContentType.objects.get_for_model(get_ou_model()) + idp_ct = ContentType.objects.get_for_model(IdP) + sp_ct = ContentType.objects.get_for_model(SP) + + assert not len(Line.objects.all()) + assert not len(Reference.objects.all()) + + event_logger.register(admin, ou1) + assert len(Line.objects.all()) == 1 + assert len(Reference.objects.filter( + target_id=admin.pk, target_ct=user_ct)) == 1 + assert len(Reference.objects.filter( + target_id=ou1.pk, target_ct=ou_ct)) == 1 + + event_logger.sso(admin, sp1) + assert len(Line.objects.all()) == 2 + assert len(Reference.objects.filter( + target_id=admin.pk, target_ct=user_ct)) == 2 + assert len(Reference.objects.filter( + target_id=sp1.pk, target_ct=sp_ct)) == 1 + + event_logger.login(admin, idp1) + assert len(Line.objects.all()) == 3 + assert len(Reference.objects.filter( + target_id=admin.pk, target_ct=user_ct)) == 3 + assert len(Reference.objects.filter( + target_id=idp1.pk, target_ct=idp_ct)) == 1 + + event_logger.logout(admin, idp1) + assert len(Line.objects.all()) == 4 + assert len(Reference.objects.filter( + target_id=admin.pk, target_ct=user_ct)) == 4 + assert len(Reference.objects.filter( + target_id=idp1.pk, target_ct=idp_ct)) == 2 + + +def test_modification_events(db, event_logger, admin, simple_user): + r1 = Role.objects.create(name='r1', slug='r1') + user_ct = ContentType.objects.get_for_model(get_user_model()) + ou_ct = ContentType.objects.get_for_model(get_ou_model()) + role_ct = ContentType.objects.get_for_model(get_role_model()) + + assert not len(Line.objects.all()) + assert not len(Reference.objects.all()) + + event_logger.create(admin, simple_user) + assert len(Line.objects.all()) == 1 + assert len(Reference.objects.filter( + target_id=admin.pk, target_ct=user_ct)) == 1 + assert len(Reference.objects.filter( + target_id=simple_user.pk, target_ct=user_ct)) == 1 + + event_logger.update(admin, simple_user) + assert len(Line.objects.all()) == 2 + assert len(Reference.objects.filter( + target_id=admin.pk, target_ct=user_ct)) == 2 + assert len(Reference.objects.filter( + target_id=simple_user.pk, target_ct=user_ct)) == 2 + + event_logger.delete(admin, simple_user) + assert len(Line.objects.all()) == 3 + assert len(Reference.objects.filter( + target_id=admin.pk, target_ct=user_ct)) == 3 + assert len(Reference.objects.filter( + target_id=simple_user.pk, target_ct=user_ct)) == 3 + + event_logger.modify_members(admin, r1) + assert len(Line.objects.all()) == 4 + assert len(Reference.objects.filter( + target_id=admin.pk, target_ct=user_ct)) == 4 + assert len(Reference.objects.filter( + target_id=r1.pk, target_ct=role_ct)) == 1 + + +def test_entries_timestamp_consistency(db, event_logger, admin): + r1 = Role.objects.create(name='r1', slug='r1') + event_logger.modify_members(admin, r1) + line = Line.objects.first() + for ref in Reference.objects.all(): + assert ref.timestamp == line.timestamp + + +def test_line_retrieval_after_object_deletion(db, event_logger): + email = 'toto@nowhere.null' + role_name = role_slug = 'r1' + + entries_role = [] + entries_user = [] + for line in Line.objects.all(): + if role_name in line.extra['obj_representation']: + entries_role.append(line) + if email == line.extra['user_email']: + entries_user.append(line) + assert not len(entries_role) + assert not len(entries_user) + + role = Role.objects.create(name=role_name, slug=role_slug) + user = get_user_model().objects.create(email=email) + event_logger.modify_members(user, role) + user.delete() + role.delete() + + entries_role = [] + entries_user = [] + for line in Line.objects.all(): + if role_name in line.extra['obj_representation']: + entries_role.append(line) + if email == line.extra['user_email']: + entries_user.append(line) + assert len(entries_role) + assert len(entries_user) + + +def test_reference_retrieval_after_objects_deletion(db, event_logger): + email = 'toto@nowhere.null' + role_name = role_slug = 'r1' + + entries_role = [] + entries_user = [] + for ref in Reference.objects.all(): + if role_name in ref.content: + entries_role.append(ref) + if email in ref.content: + entries_user.append(ref) + assert not len(entries_role) + assert not len(entries_user) + + role = Role.objects.create(name=role_name, slug=role_slug) + user = get_user_model().objects.create(email=email) + event_logger.modify_members(user, role) + user.delete() + role.delete() + + entries_role = [] + entries_user = [] + for ref in Reference.objects.all(): + if role_name in ref.content: + entries_role.append(ref) + if email in ref.content: + entries_user.append(ref) + assert len(entries_role) + assert len(entries_user) + + +def test_backoffice_user_journal_view(db, event_logger, app, admin, simple_user, ou1): + login(app, admin, '/manage/') + url = u'/manage/users/%s/journal/' % admin.pk + + event_logger.create(admin, simple_user) + event_logger.create(admin, ou1) + + response = app.get(url, status=200) + table_lines = response.html.find( + 'div', attrs={'class': 'table-container'}).find( + 'table', 'main').find('tbody').find_all('tr') + assert len(table_lines) == 2 + + # assumptions on table content + for line in table_lines: + assert 'CREATE: user %s' % admin.email in line.text + if 'on user' in line.text: + assert 'on %s' % simple_user in line.text + else: + assert 'on %s' % ou1 in line.text + + +def test_accounts_user_journal_view(db, event_logger, app, admin, simple_user, ou1): + login(app, admin, '/accounts/') + url = u'/accounts/journal/' + + event_logger.create(admin, simple_user) + event_logger.create(admin, ou1) + + response = app.get(url, status=200) + ref_list = response.html.find('div', attrs={'id': 'content'}).find('ul') + assert len(ref_list.find_all('li')) == 2 + + # assumptions on table content + for entry in ref_list.find_all('li'): + assert 'CREATE: user %s' % admin.email in entry.text + if 'on user' in entry.text: + assert 'on %s' % simple_user in entry.text + else: + assert 'on %s' % ou1 in entry.text + + +def test_backoffice_unauthorized_access(db, event_logger, app, admin, simple_user, ou1): + event_logger.create(admin, ou1) + event_logger.update(admin, ou1) + + login(app, simple_user, '/') + url = u'/manage/users/%s/journal/' % admin.pk + response = app.get(url, status=403) -- 2.20.0.rc1