From 9cc6c1fc49dfd6383bc7d257c7c4251d16a0c44f 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 + src/authentic2/forms/__init__.py | 6 + .../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 | 19 ++ src/authentic2/views.py | 41 +++- src/authentic2_journal/__init__.py | 21 ++ src/authentic2_journal/admin.py | 0 src/authentic2_journal/app_settings.py | 38 ++++ src/authentic2_journal/apps.py | 25 +++ src/authentic2_journal/kinds.py | 106 +++++++++ src/authentic2_journal/logger.py | 21 ++ src/authentic2_journal/migrations/__init__.py | 0 src/authentic2_journal/models.py | 74 +++++++ src/authentic2_journal/tables.py | 35 +++ .../templates/authentic2_journal/create.html | 2 + .../templates/authentic2_journal/delete.html | 2 + .../templates/authentic2_journal/login.html | 2 + .../templates/authentic2_journal/logout.html | 2 + .../authentic2_journal/modify_members.html | 2 + .../authentic2_journal/register.html | 2 + .../templates/authentic2_journal/sso.html | 2 + .../templates/authentic2_journal/update.html | 2 + .../templatetags/__init__.py | 0 .../templatetags/authentic2_journal.py | 27 +++ src/authentic2_journal/views.py | 34 +++ tests/conftest.py | 6 + tests/test_journal.py | 206 ++++++++++++++++++ 33 files changed, 707 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/kinds.py create mode 100644 src/authentic2_journal/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/templates/authentic2_journal/create.html create mode 100644 src/authentic2_journal/templates/authentic2_journal/delete.html create mode 100644 src/authentic2_journal/templates/authentic2_journal/login.html create mode 100644 src/authentic2_journal/templates/authentic2_journal/logout.html create mode 100644 src/authentic2_journal/templates/authentic2_journal/modify_members.html create mode 100644 src/authentic2_journal/templates/authentic2_journal/register.html create mode 100644 src/authentic2_journal/templates/authentic2_journal/sso.html create mode 100644 src/authentic2_journal/templates/authentic2_journal/update.html 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/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/forms/__init__.py b/src/authentic2/forms/__init__.py index ee887e14..9d8a4434 100644 --- a/src/authentic2/forms/__init__.py +++ b/src/authentic2/forms/__init__.py @@ -8,6 +8,7 @@ from django.utils import html from authentic2.compat import get_user_model from authentic2.forms.fields import PasswordField +from authentic2.forms.widgets import DateTimeWidget from .. import app_settings from ..exponential_retry_timeout import ExponentialRetryTimeout @@ -220,3 +221,8 @@ class AuthenticationForm(auth_forms.AuthenticationForm): class SiteImportForm(forms.Form): site_json = forms.FileField(label=_('Site Export File')) + + +class EventTimewindowForm(forms.Form): + after = forms.DateTimeField(label=_('After'), widget=DateTimeWidget) + before = forms.DateTimeField(label=_('Before'), widget=DateTimeWidget) 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..fbd00c5a --- /dev/null +++ b/src/authentic2/templates/authentic2/user_profile_journal.html @@ -0,0 +1,19 @@ +{% extends "authentic2/base-page.html" %} +{% load i18n %} +{% load authentic2_journal %} + +{% block content %} +

{% trans "User journal" %}

+
    + {% for line in object_list %} +
  • {{ line.timestamp }} - {{ line|to_representation }}
  • + {% empty %} +
  • {% trans "No journal entry yet." %}
  • + {% endfor %} +
+
+ {% csrf_token %} + {{ form }} + +
+{% endblock %} diff --git a/src/authentic2/views.py b/src/authentic2/views.py index 13c96d0f..6051bf39 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -7,12 +7,14 @@ import re import collections +from datetime import datetime from django.conf import settings from django.shortcuts import render_to_response, render 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 +23,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 +40,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, Line logger = logging.getLogger(__name__) @@ -612,6 +616,41 @@ class LoggedInView(View): logged_in = never_cache(LoggedInView.as_view()) + +class UserProfileJournal(ListView, FormView): + model = Line + form_class = forms.EventTimewindowForm + + def get_queryset(self): + timewindow = {} + for qs_param, qs_suffix in {'after': 'gte', 'before': 'lte'}.items(): + delimiter = self.request.GET.get(qs_param) + if delimiter: + try: + dt = datetime.strptime(delimiter, '%d/%m/%Y %H:%M:%S') + except: + logger.info('UserProfileJournal wrong %s timestamp query ' + 'string parameter: %s' % (qs_param, delimiter)) + else: + timewindow.update({'timestamp__%s' % qs_suffix: dt}) + """ + Timestamp consistency allows for time window filtering on Reference + objects directly + """ + return [ref.line for ref in Reference.objects.filter( + target_id=self.request.user.pk, + target_ct=ContentType.objects.get_for_model(get_user_model()), + **timewindow)] + + 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..ef8590ba --- /dev/null +++ b/src/authentic2_journal/apps.py @@ -0,0 +1,25 @@ +# 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' diff --git a/src/authentic2_journal/kinds.py b/src/authentic2_journal/kinds.py new file mode 100644 index 00000000..f1398cd5 --- /dev/null +++ b/src/authentic2_journal/kinds.py @@ -0,0 +1,106 @@ +# 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 json + +from django.contrib.contenttypes.models import ContentType +from django.template.loader import render_to_string, select_template + + +_kind_registry = {} + + +class KindMetaclass(type): + def __init__(self, name, bases, dct): + super(KindMetaclass, self).__init__(name, bases, dct) + if name != "BaseKind": + assert set(dct.keys()) >= set(['name', 'template_names']) + assert dct['name'] not in _kind_registry + _kind_registry[dct['name']] = self + + +class BaseKind(object): + __metaclass__ = KindMetaclass + long_template_names = [] + template_names = [] + + def format(self, line): + return select_template(self.template_names).render(context=line.extra_json) + + def long_format(self, line): + if not self.long_template_names: + return '' + return select_template(self.long_template_names).render(context=line.extra_json) + + def log_real(self, **kwargs): + from authentic2_journal.models import Line, get_kind + kind = get_kind(self.name) + if isinstance(kind, tuple): + kind = kind[0] + return Line.objects.create(kind=kind, extra_json=kwargs) + + def create_reference(self, line, obj, **kwargs): + from authentic2_journal.models import Reference + reference = Reference.objects.create( + line=line, + target_ct=ContentType.objects.get_for_model(obj), + target_id=obj.pk) + + def log(self, user, obj): + line = self.log_real(user_pk=user.pk, obj_pk=obj.pk, user='%s' % user, obj='%s' % obj) + if line: + self.create_reference(line, user) + self.create_reference(line, obj) + + +class SSOKind(BaseKind): + name = 'sso' + template_names = ['authentic2_journal/sso.html'] + + +class LoginKind(BaseKind): + name = 'login' + template_names = ['authentic2_journal/login.html'] + + +class LogoutKind(BaseKind): + name = 'logout' + template_names = ['authentic2_journal/logout.html'] + + +class RegisterKind(BaseKind): + name = 'register' + template_names = ['authentic2_journal/register.html'] + + +class CreateKind(BaseKind): + name = 'create' + template_names = ['authentic2_journal/create.html'] + + +class UpdateKind(BaseKind): + name = 'update' + template_names = ['authentic2_journal/update.html'] + + +class ModifyMembersKind(BaseKind): + name = 'modify_members' + template_names = ['authentic2_journal/modify_members.html'] + + +class DeleteKind(BaseKind): + name = 'delete' + template_names = ['authentic2_journal/delete.html'] diff --git a/src/authentic2_journal/logger.py b/src/authentic2_journal/logger.py new file mode 100644 index 00000000..6f585c32 --- /dev/null +++ b/src/authentic2_journal/logger.py @@ -0,0 +1,21 @@ +from authentic2_journal import kinds, models + + +class Logger(object): + def __init__(self, kind): + self.kind = kinds._kind_registry[kind] + + def __call__(self, *args, **kwargs): + kind_model = models.get_kind(self.kind.name) + if isinstance(kind_model, tuple): + kind_model = kind_model[0] + kind = kind_model.concrete() + return self.kind.log(kind, *args, **kwargs) + + +class LoggerHub(object): + def __getattr__(self, kind_name): + return Logger(kind_name) + + +logger = LoggerHub() 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..cae6f00f --- /dev/null +++ b/src/authentic2_journal/models.py @@ -0,0 +1,74 @@ +# 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 authentic2.decorators import GlobalCache +from authentic2_journal import kinds +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 _ + + +class Kind(models.Model): + name = models.CharField(max_length=128, null=True, verbose_name='name') + + @property + def concrete(self): + return kinds._kind_registry[self.name] + + +# get_kind() should be cached, like ContentType are, because they never vary +@GlobalCache +def get_kind(name): + return Kind.objects.get_or_create(name=name) + + +class Line(models.Model): + timestamp = models.DateTimeField(auto_now=True, verbose_name=_('timestamp')) + kind = models.ForeignKey(Kind) + extra_json = JSONField(blank=True, null=True, verbose_name=_('extra JSON')) + + def format(self): + return self.kind.concrete().format(self) + + def long_format(self): + return self.kind.concrete().long_format(self) + + +class Reference(models.Model): + timestamp = models.DateTimeField(verbose_name=_('timestamp')) + line = models.ForeignKey(Line) + extra_json = JSONField(blank=False, null=False, verbose_name=_('additional description')) + 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, + 'extra_json': line.extra_json + }) + 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..a81baac6 --- /dev/null +++ b/src/authentic2_journal/tables.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 . + +import django_tables2 as tables + +from authentic2_journal.models import Line +from django.utils.translation import ugettext_lazy as _ + + +class UserEventsTable(tables.Table): + def render_kind(self, value): + return value.name + + def render_extra_json(self, value, record): + return record.format() or value + + class Meta: + attrs = {'class': 'main'} + model = Line + order_by = ('-timestamp', 'id') + per_page = 10 + empty_text = _('No journal entry yet.') diff --git a/src/authentic2_journal/templates/authentic2_journal/create.html b/src/authentic2_journal/templates/authentic2_journal/create.html new file mode 100644 index 00000000..ffbf405e --- /dev/null +++ b/src/authentic2_journal/templates/authentic2_journal/create.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}{{user}} performed create on {{obj}}{% endblocktrans %} diff --git a/src/authentic2_journal/templates/authentic2_journal/delete.html b/src/authentic2_journal/templates/authentic2_journal/delete.html new file mode 100644 index 00000000..003f7e2d --- /dev/null +++ b/src/authentic2_journal/templates/authentic2_journal/delete.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}{{user}} performed delete on {{obj}}{% endblocktrans %} diff --git a/src/authentic2_journal/templates/authentic2_journal/login.html b/src/authentic2_journal/templates/authentic2_journal/login.html new file mode 100644 index 00000000..0e4321df --- /dev/null +++ b/src/authentic2_journal/templates/authentic2_journal/login.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}{{user}} performed login on {{provider}}{% endblocktrans %} diff --git a/src/authentic2_journal/templates/authentic2_journal/logout.html b/src/authentic2_journal/templates/authentic2_journal/logout.html new file mode 100644 index 00000000..8b4343ed --- /dev/null +++ b/src/authentic2_journal/templates/authentic2_journal/logout.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}{{user}} performed logout on {{provider}}{% endblocktrans %} diff --git a/src/authentic2_journal/templates/authentic2_journal/modify_members.html b/src/authentic2_journal/templates/authentic2_journal/modify_members.html new file mode 100644 index 00000000..c3490ea9 --- /dev/null +++ b/src/authentic2_journal/templates/authentic2_journal/modify_members.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}{{user}} performed modify members on {{role}}{% endblocktrans %} diff --git a/src/authentic2_journal/templates/authentic2_journal/register.html b/src/authentic2_journal/templates/authentic2_journal/register.html new file mode 100644 index 00000000..77125b52 --- /dev/null +++ b/src/authentic2_journal/templates/authentic2_journal/register.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}{{user}} registered on {{ou}}{% endblocktrans %} diff --git a/src/authentic2_journal/templates/authentic2_journal/sso.html b/src/authentic2_journal/templates/authentic2_journal/sso.html new file mode 100644 index 00000000..2754bab0 --- /dev/null +++ b/src/authentic2_journal/templates/authentic2_journal/sso.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}{{user}} performed sso on {{service}}{% endblocktrans %} diff --git a/src/authentic2_journal/templates/authentic2_journal/update.html b/src/authentic2_journal/templates/authentic2_journal/update.html new file mode 100644 index 00000000..5e785b74 --- /dev/null +++ b/src/authentic2_journal/templates/authentic2_journal/update.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}{{user}} performed update on {{obj}}{% endblocktrans %} 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..11e5bb82 --- /dev/null +++ b/src/authentic2_journal/templatetags/authentic2_journal.py @@ -0,0 +1,27 @@ +# 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 django import template + +register = template.Library() + +@register.filter +def to_representation(line): + return line.format() + +@register.filter +def to_long_representation(line): + return line.long_format() diff --git a/src/authentic2_journal/views.py b/src/authentic2_journal/views.py new file mode 100644 index 00000000..6b824bd7 --- /dev/null +++ b/src/authentic2_journal/views.py @@ -0,0 +1,34 @@ +# 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 + table_pagination = True + + def get_table_queryset(self): + return [ref.line for ref in 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..11477b98 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -344,3 +344,9 @@ 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.logger import LoggerHub + return LoggerHub() diff --git a/tests/test_journal.py b/tests/test_journal.py new file mode 100644 index 00000000..f4de4df5 --- /dev/null +++ b/tests/test_journal.py @@ -0,0 +1,206 @@ +# -*- 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 = u'toto@nowhere.null' + role_name = role_slug = u'r1' + + assert not len(Line.objects.all()) + + 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 json.loads(line.extra_json)['obj']: + if role_name in line.extra_json['obj']: + entries_role.append(line) + if line.extra_json['user'].startswith(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' + + assert not(Reference.objects.all()) + + 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.extra_json['obj']: + entries_role.append(ref) + if ref.extra_json.get('user', '').startswith(email): + 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' in line.text + assert '%s' % admin in line.text + assert '%s' % simple_user in line.text or '%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' in entry.text + assert '%s' % admin in entry.text + assert '%s' % simple_user in entry.text or '%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) + + +def test_log_event_escalate_error(db, event_logger, app, admin, simple_user, ou1): + try: + event_logger.spoke_to(admin, simple_user) + except: + pass + else: + assert 0 -- 2.20.0.rc2