misc: integration of journal in manager (#47155)

This commit is contained in:
Benjamin Dauvergne 2020-10-13 19:08:43 +02:00
parent 1cc04e3ad7
commit 8487d33cff
18 changed files with 1759 additions and 32 deletions

View File

@ -78,13 +78,17 @@ class UserQuerySet(models.QuerySet):
self = self.distinct()
return self
def find_duplicates(self, first_name, last_name, birthdate=None):
def find_duplicates(self, first_name=None, last_name=None, fullname=None, birthdate=None):
with connection.cursor() as cursor:
cursor.execute(
"SET pg_trgm.similarity_threshold = %f" % app_settings.A2_DUPLICATES_THRESHOLD
)
name = '%s %s' % (first_name, last_name)
if fullname is not None:
name = fullname
else:
assert first_name is not None and last_name is not None
name = '%s %s' % (first_name, last_name)
name = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode('ascii').lower()
qs = self.filter(deleted__isnull=True)

View File

@ -1,5 +1,5 @@
# authentic2 - versatile identity manager
# Copyright (C) 2010-2019 Entr'ouvert
# Copyright (C) 2010-2020 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

View File

@ -0,0 +1,503 @@
# authentic2 - versatile identity manager
# Copyright (C) 2010-2020 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.contrib.auth import get_user_model
from django.utils.translation import ugettext_lazy as _
from authentic2.journal_event_types import get_attributes_label, EventTypeWithService
from authentic2.apps.journal.models import EventTypeDefinition
from authentic2.apps.journal.utils import form_to_old_new
from django_rbac.utils import get_role_model
User = get_user_model()
Role = get_role_model()
class ManagerUserCreation(EventTypeDefinition):
name = 'manager.user.creation'
label = _('user creation')
@classmethod
def record(cls, user, session, form):
super().record(user=user, session=session, references=[form.instance])
@classmethod
def get_message(cls, event, context):
(user,) = event.get_typed_references(User)
# user journal page
if context and context == user:
return _('creation by administrator')
elif user:
# manager gloabal journal page
return _('creation of user "%s"') % user.get_full_name()
return super().get_message(event, context)
class ManagerUserProfileEdit(EventTypeDefinition):
name = 'manager.user.profile.edit'
label = _('user profile 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):
(user,) = event.get_typed_references(User)
new = event.get_data('new') or {}
edited_attributes = ', '.join(get_attributes_label(new)) or ''
if context and context == user:
return _('edit by administrator (%s)') % edited_attributes
elif user:
user_full_name = user.get_full_name()
return _('edit of user "{0}" ({1})').format(user_full_name, edited_attributes)
return super().get_message(event, context)
class ManagerUserEmailChangeRequest(EventTypeDefinition):
name = 'manager.user.email.change.request'
label = _('email change request')
@classmethod
def record(cls, user, session, form):
data = {
'old_email': form.instance.email,
'email': form.cleaned_data.get('new_email'),
}
super().record(user=user, session=session, references=[form.instance], data=data)
@classmethod
def get_message(cls, event, context):
(user,) = event.get_typed_references(User)
new_email = event.get_data('email')
if context and context == user:
return _('email change for email address "%s" requested by administrator') % new_email
elif user:
user_full_name = user.get_full_name()
return _('email change of user "{0}" for email address "{1}"').format(user_full_name, new_email)
return super().get_message(event, context)
class ManagerUserPasswordChange(EventTypeDefinition):
name = 'manager.user.password.change'
label = _('user password change')
@classmethod
def record(cls, user, session, form):
data = {
'generate_password': form.cleaned_data['generate_password'],
'send_mail': form.cleaned_data['send_mail'],
}
super().record(user=user, session=session, references=[form.instance], data=data)
@classmethod
def get_message(cls, event, context):
(user,) = event.get_typed_references(User)
send_mail = event.get_data('send_mail')
if context and context == user:
if send_mail:
return _('password change by administrator and notification by mail')
else:
return _('password change by administrator')
elif user:
user_full_name = user.get_full_name()
if send_mail:
return _('password change of user "%s" and notification by mail') % user_full_name
else:
return _('password change of user "%s"') % user_full_name
return super().get_message(event, context)
class ManagerUserPasswordResetRequest(EventTypeDefinition):
name = 'manager.user.password.reset.request'
label = _('user password reset request')
@classmethod
def record(cls, user, session, target_user):
super().record(
user=user, session=session, references=[target_user], data={'email': target_user.email}
)
@classmethod
def get_message(cls, event, context):
(user,) = event.get_typed_references(User)
email = event.get_data('email')
if context and context == user:
return _('password reset request by administrator sent to "%s"') % email
elif user:
return _('password reset request of "{0}" sent to "{1}"').format(user.get_full_name(), email)
return super().get_message(event, context)
class ManagerUserPasswordChangeForce(EventTypeDefinition):
name = 'manager.user.password.change.force'
label = _('mandatory password change at next login set')
@classmethod
def record(cls, user, session, target_user):
super().record(user=user, session=session, references=[target_user])
@classmethod
def get_message(cls, event, context):
(user,) = event.get_typed_references(User)
if context and context == user:
return _('mandatory password change at next login set by administrator')
elif user:
return _('mandatory password change at next login set for user "%s"') % user.get_full_name()
return super().get_message(event, context)
class ManagerUserPasswordChangeUnforce(EventTypeDefinition):
name = 'manager.user.password.change.unforce'
label = _('mandatory password change at next login unset')
@classmethod
def record(cls, user, session, target_user):
super().record(user=user, session=session, references=[target_user])
@classmethod
def get_message(cls, event, context):
(user,) = event.get_typed_references(User)
if context and context == user:
return _('mandatory password change at next login unset by administrator')
elif user:
return _('mandatory password change at next login unset for user "%s"') % user.get_full_name()
return super().get_message(event, context)
class ManagerUserActivation(EventTypeDefinition):
name = 'manager.user.activation'
label = _('user activation')
@classmethod
def record(cls, user, session, target_user):
super().record(user=user, session=session, references=[target_user])
@classmethod
def get_message(cls, event, context):
(user,) = event.get_typed_references(User)
if context and context == user:
return _('activation by administrator')
elif user:
return _('activation of user "%s"') % user.get_full_name()
return super().get_message(event, context)
class ManagerUserDeactivation(EventTypeDefinition):
name = 'manager.user.deactivation'
label = _('user deactivation')
@classmethod
def record(cls, user, session, target_user):
super().record(user=user, session=session, references=[target_user])
@classmethod
def get_message(cls, event, context):
(user,) = event.get_typed_references(User)
if context and context == user:
return _('deactivation by administrator')
elif user:
return _('deactivation of user "%s"') % user.get_full_name()
return super().get_message(event, context)
class ManagerUserDeletion(EventTypeDefinition):
name = 'manager.user.deletion'
label = _('user deletion')
@classmethod
def record(cls, user, session, target_user):
super().record(user=user, session=session, references=[target_user])
@classmethod
def get_message(cls, event, context):
(user,) = event.get_typed_references(User)
if context and context == user:
return _('deletion by administrator')
elif user:
return _('deletion of user "%s"') % user.get_full_name()
return super().get_message(event, context)
class ManagerUserSSOAuthorizationDeletion(EventTypeWithService):
name = 'manager.user.sso.authorization.deletion'
label = _('delete authorization')
@classmethod
def record(cls, user, session, service, target_user):
super().record(user=user, session=session, service=service, references=[target_user])
@classmethod
def get_message(cls, event, context):
# first reference is to the service
__, user = event.get_typed_references(None, User)
service_name = cls.get_service_name(event)
if context and context == user:
return _('deletion of authorization of single sign on with "%s" by administrator') % service_name
elif user:
return _('deletion of authorization of single sign on with "%s" of user "%s"') % (
service_name,
user.get_full_name(),
)
return super().get_message(event, context)
class RoleEventsMixin(EventTypeDefinition):
@classmethod
def record(self, user, session, role, references=None, data=None):
references = references or []
references = [role] + references
data = data or {}
data.update(
{'role_name': str(role), 'role_uuid': role.uuid,}
)
super().record(
user=user, session=session, references=references, data=data,
)
class ManagerRoleCreation(RoleEventsMixin):
name = 'manager.role.creation'
label = _('role creation')
@classmethod
def get_message(cls, event, context):
(role,) = event.get_typed_references(Role)
role = role or event.get_data('role_name')
if context != role:
return _('creation of role "%s"') % role
else:
return _('creation')
class ManagerRoleEdit(RoleEventsMixin):
name = 'manager.role.edit'
label = _('role edit')
@classmethod
def record(cls, user, session, role, form):
super().record(user=user, session=session, role=role, data=form_to_old_new(form))
@classmethod
def get_message(cls, event, context):
(role,) = event.get_typed_references(Role)
role = role or event.get_data('role_name')
new = event.get_data('new')
edited_attributes = ', '.join(get_attributes_label(new)) or ''
if context != role:
return _('edit of role "%s" (%s)') % (role, edited_attributes)
else:
return _('edit (%s)') % edited_attributes
class ManagerRoleDeletion(RoleEventsMixin):
name = 'manager.role.deletion'
label = _('role deletion')
@classmethod
def get_message(cls, event, context):
(role,) = event.get_typed_references(Role)
role = role or event.get_data('role_name')
if context != role:
return _('deletion of role "%s"') % role
else:
return _('deletion')
class ManagerRoleMembershipGrant(RoleEventsMixin):
name = 'manager.role.membership.grant'
label = _('role membership grant')
@classmethod
def record(cls, user, session, role, member):
data = {'member_name': member.get_full_name()}
super().record(user=user, session=session, role=role, references=[member], data=data)
@classmethod
def get_message(cls, event, context):
role, member = event.get_typed_references(Role, User)
role = role or event.get_data('role_name')
member = member or event.get_data('member_name')
if context == member:
return _('membership grant in role "%s"') % role
elif context == role:
return _('membership grant to user "%s"') % member
else:
return _('membership grant to user "{member}" in role "{role}"').format(member=member, role=role)
class ManagerRoleMembershipRemoval(RoleEventsMixin):
name = 'manager.role.membership.removal'
label = _('role membership removal')
@classmethod
def record(cls, user, session, role, member):
data = {'member_name': member.get_full_name()}
super().record(user=user, session=session, role=role, references=[member], data=data)
@classmethod
def get_message(cls, event, context):
role, member = event.get_typed_references(Role, User)
role = role or event.get_data('role_name')
member = member or event.get_data('member_name')
if context == member:
return _('membership removal from role "%s"') % role
elif context == role:
return _('membership removal of user "%s"') % member
else:
return _('membership removal of user "{member}" from role "{role}"').format(
member=member, role=role
)
class ManagerRoleInheritanceAddition(RoleEventsMixin):
name = 'manager.role.inheritance.addition'
label = _('role inheritance addition')
@classmethod
def record(cls, user, session, parent, child):
data = {
'child_name': str(child),
'child_uuid': child.uuid,
}
super().record(user=user, session=session, role=parent, references=[child], data=data)
@classmethod
def get_message(cls, event, context):
parent, child = event.get_typed_references(Role, Role)
parent = parent or event.get_data('role_name')
child = child or event.get_data('child_name')
if context == child:
return _('inheritance addition from parent role "%s"') % parent
elif context == parent:
return _('inheritance addition to child role "%s"') % child
else:
return _('inheritance addition from parent role "{parent}" to child role "{child}"').format(
parent=parent, child=child
)
class ManagerRoleInheritanceRemoval(ManagerRoleInheritanceAddition):
name = 'manager.role.inheritance.removal'
label = _('role inheritance removal')
@classmethod
def get_message(cls, event, context):
parent, child = event.get_typed_references(Role, Role)
parent = parent or event.get_data('role_name')
child = child or event.get_data('child_name')
if context == child:
return _('inheritance removal from parent role "%s"') % parent
elif context == parent:
return _('inheritance removal to child role "%s"') % child
else:
return _('inheritance removal from parent role "{parent}" to child role "{child}"').format(
parent=parent, child=child
)
class ManagerRoleAdministratorRoleAddition(RoleEventsMixin):
name = 'manager.role.administrator.role.addition'
label = _('role administrator role addition')
@classmethod
def record(cls, user, session, role, admin_role):
data = {
'admin_role_name': str(admin_role),
'admin_role_uuid': admin_role.uuid,
}
super().record(user=user, session=session, role=role, references=[admin_role], data=data)
@classmethod
def get_message(cls, event, context):
role, admin_role = event.get_typed_references(Role, Role)
role = role or event.get_data('role_name')
admin_role = admin_role or event.get('admin_role_name')
if context == role:
return _('addition of role "%s" as administrator') % admin_role
elif context == admin_role:
return _('addition as administrator of role "%s"') % role
else:
return _('addition of role "{admin_role}" as administrator of role "{role}"').format(
admin_role=admin_role, role=role
)
class ManagerRoleAdministratorRoleRemoval(ManagerRoleAdministratorRoleAddition):
name = 'manager.role.administrator.role.removal'
label = _('role administrator role removal')
@classmethod
def get_message(cls, event, context):
role, admin_role = event.get_typed_references(Role, Role)
role = role or event.get_data('role_name')
admin_role = admin_role or event.get('admin_role_name')
if context == role:
return _('removal of role "%s" as administrator') % admin_role
elif context == admin_role:
return _('removal as administrator of role "%s"') % role
else:
return _('removal of role "{admin_role}" as administrator of role "{role}"').format(
admin_role=admin_role, role=role
)
class ManagerRoleAdministratorUserAddition(RoleEventsMixin):
name = 'manager.role.administrator.user.addition'
label = _('role administrator user addition')
@classmethod
def record(cls, user, session, role, admin_user):
data = {
'admin_user_name': admin_user.get_full_name(),
'admin_user_uuid': admin_user.uuid,
}
super().record(user=user, session=session, role=role, references=[admin_user], data=data)
@classmethod
def get_message(cls, event, context):
role, admin_user = event.get_typed_references(Role, User)
role = role or event.get_data('role_name')
admin_user = admin_user or event.get_data('admin_user_name')
if context == role:
return _('addition of user "%s" as administrator') % admin_user
elif context == admin_user:
return _('addition as administrator of role "%s"') % role
else:
return _('addition of user "{admin_user}" as administrator of role "{role}"').format(
admin_user=admin_user, role=role
)
class ManagerRoleAdministratorUserRemoval(ManagerRoleAdministratorUserAddition):
name = 'manager.role.administrator.user.removal'
label = _('role administrator user removal')
@classmethod
def get_message(cls, event, context):
role, admin_user = event.get_typed_references(Role, User)
role = role or event.get_data('role_name')
admin_user = admin_user or event.get_data('admin_user_name')
if context == role:
return _('removal of user "%s" as administrator') % admin_user
elif context == admin_user:
return _('removal as administrator of role "%s"') % role
else:
return _('removal of user "{admin_user}" as administrator of role "{role}"').format(
admin_user=admin_user, role=role
)

View File

@ -0,0 +1,98 @@
# authentic2 - versatile identity manager
# Copyright (C) 2010-2020 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/>.
import uuid
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.validators import EmailValidator
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from authentic2.apps.journal.forms import JournalForm
from authentic2.apps.journal.search_engine import JournalSearchEngine
from authentic2.apps.journal.views import JournalView
from . import views
User = get_user_model()
class JournalSearchEngine(JournalSearchEngine):
def search_by_uuid(self, lexem):
# by user uuid
try:
user_uuid = uuid.UUID(lexem)
except ValueError:
yield self.q_false
else:
yield Q(user__uuid=user_uuid.hex)
search_by_uuid.documentation = _(
'''\
You can use <tt>uuid:1234</tt> to find all events related \
to user whose UUID is <tt>1234</tt>.'''
)
unmatched = None
def lexem_queries(self, lexem):
queries = list(super().lexem_queries(lexem))
if queries:
yield from queries
elif '@' in lexem:
# fallback for raw email
try:
EmailValidator(lexem)
except ValidationError:
pass
else:
yield from super().lexem_queries('email:' + lexem)
yield from super().lexem_queries('username:' + lexem)
def unmatched_lexems_query(self, lexems):
fullname = ' '.join(lexem.strip() for lexem in lexems if lexem.strip())
if fullname:
users = User.objects.find_duplicates(fullname=fullname)
return self.query_for_users(users)
class JournalForm(JournalForm):
search_engine_class = JournalSearchEngine
class BaseJournalView(views.TitleMixin, views.MediaMixin, views.MultipleOUMixin, JournalView):
template_name = 'authentic2/manager/journal.html'
title = _('Journal')
form_class = JournalForm
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
date_hierarchy = ctx['date_hierarchy']
if date_hierarchy.title:
ctx['title'] = _('Journal of %s') % date_hierarchy.title
return ctx
class GlobalJournalView(BaseJournalView):
template_name = 'authentic2/manager/journal.html'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_superuser:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
journal = GlobalJournalView.as_view()

View File

@ -17,6 +17,7 @@
import json
from django.core.exceptions import PermissionDenied, ValidationError
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from django.views.generic import FormView, TemplateView
@ -27,15 +28,20 @@ from django.db import transaction
from django.db.models.query import Q, Prefetch
from django.db.models import Count, F
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404
from django_rbac.utils import get_role_model, get_permission_model, get_ou_model
from authentic2.forms.profile import modelform_factory
from authentic2.utils import redirect
from authentic2 import hooks, data_transfer
from authentic2.apps.journal.views import JournalViewWithContext
from . import tables, views, resources, forms, app_settings
from .utils import has_show_username
from .journal_views import BaseJournalView
OU = get_ou_model()
class RolesMixin(object):
@ -48,7 +54,7 @@ class RolesMixin(object):
Permission = get_permission_model()
permission_ct = ContentType.objects.get_for_model(Permission)
ct_ct = ContentType.objects.get_for_model(ContentType)
ou_ct = ContentType.objects.get_for_model(get_ou_model())
ou_ct = ContentType.objects.get_for_model(OU)
permission_qs = Permission.objects.filter(target_ct_id__in=[ct_ct.id, ou_ct.id]) \
.values_list('id', flat=True)
# only non role-admin roles, they are accessed through the
@ -61,7 +67,8 @@ class RolesMixin(object):
return qs
class RolesView(views.HideOUColumnMixin, RolesMixin, views.BaseTableView):
class RolesView(views.SearchOUMixin, views.HideOUColumnMixin, RolesMixin,
views.BaseTableView):
template_name = 'authentic2/manager/roles.html'
model = get_role_model()
table_class = tables.RoleTable
@ -105,6 +112,7 @@ class RoleAddView(views.BaseAddView):
response = super(RoleAddView, self).form_valid(form)
hooks.call_hooks('event', name='manager-add-role', user=self.request.user,
instance=form.instance, form=form)
self.request.journal.record('manager.role.creation', role=form.instance)
return response
@ -145,6 +153,7 @@ class RoleEditView(RoleViewMixin, views.BaseEditView):
response = super(RoleEditView, self).form_valid(form)
hooks.call_hooks('event', name='manager-edit-role', user=self.request.user,
instance=form.instance, form=form)
self.request.journal.record('manager.role.edit', role=form.instance, form=form)
return response
edit = RoleEditView.as_view()
@ -186,6 +195,7 @@ class RoleMembersView(views.HideOUColumnMixin, RoleViewMixin, views.BaseSubTable
self.object.members.add(user)
hooks.call_hooks('event', name='manager-add-role-member',
user=self.request.user, role=self.object, member=user)
self.request.journal.record('manager.role.membership.grant', role=self.object, member=user)
elif action == 'remove':
if not self.object.members.filter(pk=user.pk).exists():
messages.warning(self.request, _('User was not in this role.'))
@ -193,6 +203,7 @@ class RoleMembersView(views.HideOUColumnMixin, RoleViewMixin, views.BaseSubTable
self.object.members.remove(user)
hooks.call_hooks('event', name='manager-remove-role-member',
user=self.request.user, role=self.object, member=user)
self.request.journal.record('manager.role.membership.removal', role=self.object, member=user)
else:
messages.warning(self.request, _('You are not authorized'))
return super(RoleMembersView, self).form_valid(form)
@ -210,7 +221,7 @@ class RoleMembersView(views.HideOUColumnMixin, RoleViewMixin, views.BaseSubTable
self.object.children(include_self=False, annotate=True))
ctx['parents'] = views.filter_view(self.request, self.object.parents(
include_self=False, annotate=True).order_by(F('ou').asc(nulls_first=True), 'name'))
ctx['has_multiple_ou'] = get_ou_model().objects.count() > 1
ctx['has_multiple_ou'] = OU.objects.count() > 1
ctx['admin_roles'] = views.filter_view(self.request,
self.object.get_admin_role().children(include_self=False,
annotate=True))
@ -247,11 +258,12 @@ class RoleDeleteView(RoleViewMixin, views.BaseDeleteView):
def get_success_url(self):
return reverse('a2-manager-roles')
def form_valid(self, form):
response = super(RoleDeleteView, self).form_valid(form)
hooks.call_hooks('event', name='manager-delete-role', user=self.request.user,
role=form.instance)
return response
def delete(self, request, *args, **kwargs):
role = self.get_object()
hooks.call_hooks('event', name='manager-delete-role', user=request.user, role=role)
self.request.journal.record('manager.role.deletion', role=role)
return super(RoleDeleteView, self).delete(request, *args, **kwargs)
delete = RoleDeleteView.as_view()
@ -331,6 +343,7 @@ class RoleAddChildView(views.AjaxFormViewMixin, views.TitleMixin,
parent.add_child(role)
hooks.call_hooks('event', name='manager-add-child-role', user=self.request.user,
parent=parent, child=role)
self.request.journal.record('manager.role.inheritance.addition', parent=parent, child=role)
return super(RoleAddChildView, self).form_valid(form)
add_child = RoleAddChildView.as_view()
@ -356,6 +369,7 @@ class RoleAddParentView(views.AjaxFormViewMixin, views.TitleMixin,
child.add_parent(role)
hooks.call_hooks('event', name='manager-add-child-role', user=self.request.user,
parent=role, child=child)
self.request.journal.record('manager.role.inheritance.addition', parent=role, child=child)
return super(RoleAddParentView, self).form_valid(form)
add_parent = RoleAddParentView.as_view()
@ -383,6 +397,7 @@ class RoleRemoveChildView(views.AjaxFormViewMixin, SingleObjectMixin,
self.object.remove_child(self.child)
hooks.call_hooks('event', name='manager-remove-child-role', user=self.request.user,
parent=self.object, child=self.child)
self.request.journal.record('manager.role.inheritance.removal', parent=self.object, child=self.child)
return redirect(self.request, self.success_url)
remove_child = RoleRemoveChildView.as_view()
@ -413,6 +428,7 @@ class RoleRemoveParentView(views.AjaxFormViewMixin, SingleObjectMixin,
self.object.remove_parent(self.parent)
hooks.call_hooks('event', name='manager-remove-child-role', user=self.request.user,
parent=self.parent, child=self.object)
self.request.journal.record('manager.role.inheritance.removal', parent=self.parent, child=self.object)
return redirect(self.request, self.success_url)
remove_parent = RoleRemoveParentView.as_view()
@ -438,6 +454,8 @@ class RoleAddAdminRoleView(views.AjaxFormViewMixin, views.TitleMixin,
administered_role.get_admin_role().add_child(role)
hooks.call_hooks('event', name='manager-add-admin-role', user=self.request.user,
role=administered_role, admin_role=role)
self.request.journal.record('manager.role.administrator.role.addition',
role=administered_role, admin_role=role)
return super(RoleAddAdminRoleView, self).form_valid(form)
add_admin_role = RoleAddAdminRoleView.as_view()
@ -466,6 +484,8 @@ class RoleRemoveAdminRoleView(views.TitleMixin, views.AjaxFormViewMixin,
self.object.get_admin_role().remove_child(self.child)
hooks.call_hooks('event', name='manager-remove-admin-role',
user=self.request.user, role=self.object, admin_role=self.child)
self.request.journal.record('manager.role.administrator.role.removal',
role=self.object, admin_role=self.child)
return redirect(self.request, self.success_url)
remove_admin_role = RoleRemoveAdminRoleView.as_view()
@ -491,6 +511,8 @@ class RoleAddAdminUserView(views.AjaxFormViewMixin, views.TitleMixin,
administered_role.get_admin_role().members.add(user)
hooks.call_hooks('event', name='manager-add-admin-role-user', user=self.request.user,
role=administered_role, admin=user)
self.request.journal.record('manager.role.administrator.user.addition',
role=administered_role, admin_user=user)
return super(RoleAddAdminUserView, self).form_valid(form)
add_admin_user = RoleAddAdminUserView.as_view()
@ -519,6 +541,8 @@ class RoleRemoveAdminUserView(views.TitleMixin, views.AjaxFormViewMixin,
self.object.get_admin_role().members.remove(self.user)
hooks.call_hooks('event', name='remove-remove-admin-role-user', user=self.request.user,
role=self.object, admin=self.user)
self.request.journal.record('manager.role.administrator.user.removal',
role=self.object, admin_user=self.user)
return redirect(self.request, self.success_url)
remove_admin_user = RoleRemoveAdminUserView.as_view()
@ -564,3 +588,33 @@ class RolesImportView(views.PermissionMixin, views.TitleMixin, views.MediaMixin,
roles_import = RolesImportView.as_view()
class RoleJournal(views.PermissionMixin, JournalViewWithContext, BaseJournalView):
template_name = 'authentic2/manager/role_journal.html'
permissions = ['a2_rbac.view_role']
title = _('Journal')
@cached_property
def context(self):
return get_object_or_404(get_role_model(), pk=self.kwargs['pk'])
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['object'] = self.context
return ctx
journal = RoleJournal.as_view()
class RolesJournal(views.SearchOUMixin, views.PermissionMixin, JournalViewWithContext, BaseJournalView):
template_name = 'authentic2/manager/roles_journal.html'
permissions = ['a2_rbac.view_role']
title = _('Journal')
@cached_property
def context(self):
return get_role_model()
roles_journal = RolesJournal.as_view()

View File

@ -219,5 +219,20 @@
window.history.replaceState({'form': '#search-form', 'values': $('#search-form').values()}, window.document.title, window.location.href)
}
$(window.document).trigger('gadjo:content-update');
function FitToContent(id, maxHeight)
{
var text = id && id.style ? id : document.getElementById(id);
if ( !text )
return;
}
$('textarea.js-autoresize').each(function () {
this.setAttribute('style', 'height:' + (this.scrollHeight) + 'px;overflow-y:hidden;');
})
$(document).on('input', 'textarea.js-autoresize', function () {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
return true;
});
});
})(jQuery, window)

View File

@ -0,0 +1,43 @@
{% extends "authentic2/manager/base.html" %}
{% load i18n gadjo %}
{% block page-title %}{{ block.super }} - {% trans "Journal" %}{% endblock %}
{% block breadcrumb %}
{{ block.super }}
{% block breadcrumb-before-title %}
{% endblock %}
<a href="#">{% trans 'Journal' %}</a>
{% for caption, url in date_hierarchy.back_urls %}
<a href="{{ url }}">{{ caption }}</a>
{% endfor %}
{% endblock %}
{% block sidebar %}
<aside id="sidebar">
<h3>{% trans "Search" context "title" %}</h3>
<div class="journal-list--search-form">
<form action="{{ form.url }}">
{{ form|with_template }}
<button>{% trans "Search" %}</button>
</form>
</div>
{% if date_hierarchy.choice_name %}
<h4>{{ date_hierarchy.choice_name }}</h4>
<p>
{% for caption, url in date_hierarchy.choice_urls %}
<a href="{{ url }}">{{ caption }}</a>
{% endfor %}
</p>
{% endif %}
<div class="documentation">
{% for documentation in form.search_engine_class.documentation %}
<p>{{ documentation|safe }}</p>
{% endfor %}
</div>
</aside>
{% endblock %}
{% block main %}
{% include "journal/event_list.html" %}
{% endblock %}

View File

@ -6,4 +6,8 @@
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'a2-manager-roles' %}">{% trans 'Roles' %}</a>
{% firstof ou object.ou as current_ou %}
{% if multiple_ou and current_ou %}
<a href="{% url 'a2-manager-roles' %}?search-ou={{ current_ou.id }}">{{ current_ou }}</a>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "authentic2/manager/journal.html" %}
{% load i18n gadjo %}
{% block breadcrumb-before-title %}
<a href="{% url 'a2-manager-roles' %}">{% trans 'Roles' %}</a>
{% if multiple_ou and object.ou %}
<a href="../?search-ou={{ object.ou.pk }}">{{ object.ou }}</a>
{% endif %}
<a href="../">{{ object }}</a>
{% endblock %}

View File

@ -3,9 +3,6 @@
{% block breadcrumb %}
{{ block.super }}
{% if multiple_ou and object.ou %}
<a href="../?search-ou={{ object.ou.pk }}">{{ object.ou }}</a>
{% endif %}
<a href="#">{{ object }}</a>
{% endblock %}
@ -19,6 +16,7 @@
{% block appbar %}
{{ block.super }}
<span class="actions">
<a class="extra-actions-menu-opener"></a>
{% if not object.is_internal and view.can_delete %}
<a rel="popup" href="{% url "a2-manager-role-delete" pk=object.pk %}">{% trans "Delete" %}</a>
{% else %}
@ -36,6 +34,9 @@
{% if perms.a2_rbac.admin_permission %}
<a href="{% url "a2-manager-role-permissions" pk=object.pk %}">{% trans "Permissions" %}</a>
{% endif %}
<ul class="extra-actions-menu">
<li><a href="{% url "a2-manager-role-journal" pk=object.pk %}">{% trans "Journal" %}</a></li>
</ul>
</span>
{% endblock %}

View File

@ -3,6 +3,8 @@
{% block page-title %}{{ block.super }} - {% trans "Roles" %}{% endblock %}
{% block page_title %}{% if multiple_ou and ou %}{{ ou }}{% else %}{{ block.super }}{% endif %}{% endblock %}
{% block appbar %}
{{ block.super }}
<span class="actions">
@ -13,6 +15,7 @@
<a href="#" class="disabled" rel="popup">{% trans "Add role" %}</a>
{% endif %}
<ul class="extra-actions-menu">
<li><a href="{% url "a2-manager-roles-journal" %}{% if multiple_ou and ou %}?search-ou={{ ou.id }}{% endif %}">{% trans "Journal" %}</a></li>
<li><a download href="{% url 'a2-manager-roles-export' format="json" %}?{{ request.GET.urlencode }}">{% trans 'Export' %}</a></li>
{% if view.can_add %}
<li><a href="{% url 'a2-manager-roles-import' %}?{{ request.GET.urlencode }}" rel="popup">{% trans 'Import' %}</a></li>

View File

@ -0,0 +1,9 @@
{% extends "authentic2/manager/journal.html" %}
{% load i18n gadjo %}
{% block breadcrumb-before-title %}
<a href="{% url 'a2-manager-roles' %}">{% trans 'Roles' %}</a>
{% if multiple_ou and ou %}
<a href="../?search-ou={{ ou.id }}">{{ ou }}</a>
{% endif %}
{% endblock %}

View File

@ -22,6 +22,7 @@
{% if view.is_oidc_services %}
<li><a href="{% url "a2-manager-user-authorizations" pk=object.pk %}">{% trans "Consents" %}</a></li>
{% endif %}
<li><a href="{% url "a2-manager-user-journal" pk=object.pk %}">{% trans "Journal" %}</a></li>
</ul>
</span>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "authentic2/manager/journal.html" %}
{% load i18n gadjo %}
{% block breadcrumb-before-title %}
<a href="{% url 'a2-manager-users' %}">{% trans 'Users' %}</a>
{% if multiple_ou and object.ou %}
<a href="../?search-ou={{ object.ou.pk }}">{{ object.ou }}</a>
{% endif %}
<a href="{% url 'a2-manager-user-detail' pk=object.pk %}">{{ object.get_full_name }}</a>
{% endblock %}

View File

@ -19,7 +19,7 @@ from django.conf.urls import url
from django.views.i18n import JavaScriptCatalog
from django.contrib.auth.decorators import login_required
from django.utils.functional import lazy
from . import views, role_views, ou_views, user_views, service_views
from . import views, role_views, ou_views, user_views, service_views, journal_views
from ..decorators import required
from authentic2 import utils
@ -67,6 +67,12 @@ urlpatterns = required(
name='a2-manager-user-change-email'),
url(r'^users/(?P<pk>\d+)/su/$', user_views.su,
name='a2-manager-user-su'),
url(r'^users/(?P<pk>\d+)/authorizations/$',
user_views.user_authorizations,
name='a2-manager-user-authorizations'),
url(r'^users/(?P<pk>\d+)/journal/$',
user_views.user_journal,
name='a2-manager-user-journal'),
# by uuid
url(r'^users/uuid:(?P<slug>[a-z0-9]+)/$', user_views.user_detail,
name='a2-manager-user-by-uuid-detail'),
@ -81,9 +87,9 @@ urlpatterns = required(
url(r'^users/uuid:(?P<slug>[a-z0-9]+)/change-email/$',
user_views.user_change_email,
name='a2-manager-user-by-uuid-change-email'),
url(r'^users/(?P<pk>\d+)/authorizations/$',
user_views.user_authorizations,
name='a2-manager-user-authorizations'),
url(r'^users/uuid:(?P<slug>[a-z0-9]+)/journal/$',
user_views.user_journal,
name='a2-manager-user-journal'),
# Authentic2 roles
url(r'^roles/$', role_views.listing,
@ -94,6 +100,8 @@ urlpatterns = required(
name='a2-manager-role-add'),
url(r'^roles/export/(?P<format>csv|json)/$',
role_views.export, name='a2-manager-roles-export'),
url(r'^roles/journal/$', role_views.roles_journal,
name='a2-manager-roles-journal'),
url(r'^roles/(?P<pk>\d+)/$', role_views.members,
name='a2-manager-role-members'),
url(r'^roles/(?P<pk>\d+)/add-child/$', role_views.add_child,
@ -124,6 +132,8 @@ urlpatterns = required(
name='a2-manager-role-edit'),
url(r'^roles/(?P<pk>\d+)/permissions/$', role_views.permissions,
name='a2-manager-role-permissions'),
url(r'^roles/(?P<pk>\d+)/journal/$', role_views.journal,
name='a2-manager-role-journal'),
# Authentic2 organizational units
@ -152,6 +162,10 @@ urlpatterns = required(
url(r'^services/(?P<service_pk>\d+)/edit/$', service_views.edit,
name='a2-manager-service-edit'),
# Journal
url(r'^journal/$', journal_views.journal,
name='a2-manager-journal'),
# backoffice menu as json
url(r'^menu.json$', views.menu_json),

View File

@ -20,6 +20,7 @@ import collections
import operator
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _, pgettext_lazy, ugettext
from django.utils.html import format_html
from django.urls import reverse
@ -33,6 +34,7 @@ from django.views.generic import FormView, TemplateView, DetailView
from django.views.generic.edit import BaseFormView
from django.views.generic.detail import SingleObjectMixin
from django.http import Http404, FileResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404
import tablib
@ -41,8 +43,9 @@ from authentic2.utils import send_password_reset_mail, redirect, select_next_url
from authentic2.a2_rbac.utils import get_default_ou
from authentic2 import hooks
from authentic2_idp_oidc.models import OIDCAuthorization, OIDCClient
from django_rbac.utils import get_role_model, get_role_parenting_model, get_ou_model
from authentic2.apps.journal.views import JournalViewWithContext
from django_rbac.utils import get_role_model, get_role_parenting_model, get_ou_model
from .views import (BaseTableView, BaseAddView, BaseEditView, ActionMixin,
OtherActionsMixin, Action, ExportMixin, BaseSubTableView,
@ -55,6 +58,7 @@ from .forms import (UserSearchForm, UserAddForm, UserEditForm,
UserEditImportForm, ChooseUserAuthorizationsForm)
from .resources import UserResource
from .utils import get_ou_count, has_show_username
from .journal_views import BaseJournalView
from . import app_settings
User = get_user_model()
@ -215,6 +219,7 @@ class UserAddView(BaseAddView):
response = super(UserAddView, self).form_valid(form)
hooks.call_hooks('event', name='manager-add-user', user=self.request.user,
instance=form.instance, form=form)
self.request.journal.record('manager.user.creation', form=form)
return response
def get_initial(self, *args, **kwargs):
@ -286,10 +291,12 @@ class UserDetailView(OtherActionsMixin, BaseDetailView):
def action_force_password_change(self, request, *args, **kwargs):
PasswordReset.objects.get_or_create(user=self.object)
request.journal.record('manager.user.password.change.force', target_user=self.object)
def action_activate(self, request, *args, **kwargs):
self.object.is_active = True
self.object.save()
request.journal.record('manager.user.activation', target_user=self.object)
def action_deactivate(self, request, *args, **kwargs):
if request.user == self.object:
@ -298,6 +305,7 @@ class UserDetailView(OtherActionsMixin, BaseDetailView):
else:
self.object.is_active = False
self.object.save()
request.journal.record('manager.user.deactivation', target_user=self.object)
def action_password_reset(self, request, *args, **kwargs):
user = self.object
@ -308,9 +316,11 @@ class UserDetailView(OtherActionsMixin, BaseDetailView):
return
send_password_reset_mail(user, request=request)
messages.info(request, _('A mail was sent to %s') % self.object.email)
request.journal.record('manager.user.password.reset.request', target_user=self.object)
def action_delete_password_reset(self, request, *args, **kwargs):
PasswordReset.objects.filter(user=self.object).delete()
request.journal.record('manager.user.password.change.unforce', target_user=self.object)
def action_su(self, request, *args, **kwargs):
return redirect(request, 'auth_logout',
@ -430,8 +440,10 @@ class UserEditView(OtherActionsMixin, ActionMixin, BaseEditView):
self.object.email_verified = False
self.object.save()
response = super(UserEditView, self).form_valid(form)
hooks.call_hooks('event', name='manager-edit-user', user=self.request.user,
instance=form.instance, form=form)
if form.has_changed():
hooks.call_hooks('event', name='manager-edit-user', user=self.request.user,
instance=form.instance, form=form)
self.request.journal.record('manager.user.profile.edit', form=form)
return response
user_edit = UserEditView.as_view()
@ -518,6 +530,7 @@ class UserChangePasswordView(BaseEditView):
response = super(UserChangePasswordView, self).form_valid(form)
hooks.call_hooks('event', name='manager-change-password', user=self.request.user,
instance=form.instance, form=form)
self.request.journal.record('manager.user.password.change', form=form)
return response
@ -633,10 +646,13 @@ class UserRolesView(HideOUColumnMixin, BaseSubTableView):
user.roles.add(role)
hooks.call_hooks('event', name='manager-add-role-member',
user=self.request.user, role=role, member=user)
self.request.journal.record('manager.role.membership.grant', member=user, role=role)
elif action == 'remove':
user.roles.remove(role)
hooks.call_hooks('event', name='manager-remove-role-member', user=self.request.user,
role=role, member=user)
if user.roles.filter(pk=role.pk).exists():
user.roles.remove(role)
hooks.call_hooks('event', name='manager-remove-role-member', user=self.request.user,
role=role, member=user)
self.request.journal.record('manager.role.membership.removal', member=user, role=role)
return super(UserRolesView, self).form_valid(form)
def get_search_form_kwargs(self):
@ -674,6 +690,7 @@ class UserDeleteView(BaseDeleteView):
self.get_object().mark_as_deleted()
hooks.call_hooks('event', name='manager-delete-user', user=request.user,
instance=self.object)
request.journal.record('manager.user.deletion', target_user=self.object)
return HttpResponseRedirect(self.get_success_url())
@ -898,8 +915,32 @@ class UserAuthorizationsView(FormNeedsRequest, BaseFormView, SingleObjectMixin,
if self.can_manage_authorizations:
qs = OIDCAuthorization.objects.filter(user=self.get_object())
qs = qs.filter(id=auth_id.pk)
qs.delete()
oidc_authorization = qs.first()
count, cascade = qs.delete()
if count:
self.request.journal.record(
'manager.user.sso.authorization.deletion',
service=oidc_authorization.client,
target_user=self.object)
return response
user_authorizations = UserAuthorizationsView.as_view()
class UserJournal(PermissionMixin, JournalViewWithContext, BaseJournalView):
template_name = 'authentic2/manager/user_journal.html'
permissions = ['custom_user.view_user']
title = _('Journal')
@cached_property
def context(self):
return get_object_or_404(User, pk=self.kwargs['pk'])
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['object'] = self.context
return ctx
user_journal = UserJournal.as_view()

View File

@ -16,7 +16,7 @@
import base64
import json
import inspect
import itertools
import pickle
from django.core import signing
@ -30,6 +30,7 @@ from django.views.generic.edit import FormMixin
from django.http import HttpResponse, Http404
from django.utils.encoding import force_text
from django.utils import six
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from django.utils.timezone import now
from django.urls import reverse
@ -45,6 +46,7 @@ from gadjo.templatetags.gadjo import xstatic
from django_rbac.utils import get_ou_model
from authentic2.a2_rbac.models import OrganizationalUnit as OU
from authentic2.data_transfer import export_site, import_site, ImportContext
from authentic2.forms.profile import modelform_factory
from authentic2.utils import redirect, batch_queryset
@ -597,6 +599,14 @@ class HomepageView(TitleMixin, PermissionMixin, MediaMixin, TemplateView):
'permission': 'authentic2.search_service',
'slug': 'services',
},
{
'class': 'icon-journal',
'href': reverse_lazy('a2-manager-journal'),
'label': _('Journal'),
'order': -1,
'permission': 'superuser',
'slug': 'journal',
},
]
def dispatch(self, request, *args, **kwargs):
@ -606,15 +616,16 @@ class HomepageView(TitleMixin, PermissionMixin, MediaMixin, TemplateView):
def get_homepage_entries(self):
entries = []
for entry in self.default_entries:
if 'permission' in entry and not self.request.user.has_perm_any(entry['permission']):
continue
entries.append(entry)
for hook_entries in hooks.call_hooks('manager_homepage_entries', self):
for hook_entries in itertools.chain(
self.default_entries,
hooks.call_hooks('manager_homepage_entries', self)):
if not hasattr(hook_entries, 'append'):
hook_entries = [hook_entries]
for entry in hook_entries:
if 'permission' in entry and not self.request.user.has_perm_any(entry['permission']):
permission = entry.get('permission')
if permission == 'superuser' and not self.request.user.is_superuser:
continue
elif permission and not self.request.user.has_perm_any(permission):
continue
entries.append(entry)
# use possible key order to sort
@ -730,3 +741,17 @@ class SiteImportView(MediaMixin, TitleMixin, FormView):
site_import = SiteImportView.as_view()
class SearchOUMixin:
@cached_property
def ou(self):
try:
ou_id = int(self.request.GET['search-ou'])
except (ValueError, KeyError):
return None
else:
return OU.objects.filter(pk=ou_id).first()
def get_context_data(self, **kwargs):
return super().get_context_data(ou=self.ou, **kwargs)

View File

@ -0,0 +1,892 @@
# authentic2 - versatile identity manager
# Copyright (C) 2010-2020 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/>.
import datetime
import mock
from authentic2.custom_user.models import User
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.a2_rbac.models import Role
from authentic2.models import Service
from authentic2.apps.journal.models import Event, _registry
from authentic2.journal import journal
from django.contrib.sessions.models import Session
from django.utils.timezone import make_aware
import pytest
from .utils import login, text_content
def test_journal_authorization(app, db, admin):
response = login(app, admin, path='/manage/')
assert 'Journal' not in response
app.get('/manage/journal/', status=403)
@pytest.fixture(autouse=True)
def events(db, freezer):
session1 = Session(session_key="1234")
session2 = Session(session_key="abcd")
ou = get_default_ou()
user = User.objects.create(
username="user", email="user@example.com", ou=ou, uuid="1" * 32, first_name='Johnny', last_name='doe'
)
agent = User.objects.create(username="agent", email="agent@example.com", ou=ou, uuid="2" * 32)
role_user = Role.objects.create(name="role1", ou=ou)
role_agent = Role.objects.create(name="role2", ou=ou)
service = Service.objects.create(name="service")
class EventFactory:
date = make_aware(datetime.datetime(2020, 1, 1))
def __call__(self, name, **kwargs):
freezer.move_to(self.date)
journal.record(name, **kwargs)
assert Event.objects.latest("timestamp").type.name == name
self.date += datetime.timedelta(hours=1)
make = EventFactory()
make("user.registration.request", email=user.email)
make(
"user.registration", user=user, session=session1, service=service, how="franceconnect",
)
make("user.logout", user=user, session=session1)
make("user.login.failure", username="user")
make("user.login.failure", username="agent")
make("user.login", user=user, session=session1, how="password")
make("user.password.change", user=user, session=session1)
edit_profile_form = mock.Mock()
edit_profile_form.initial = {'email': "user@example.com", 'first_name': "John"}
edit_profile_form.changed_data = ["first_name"]
edit_profile_form.cleaned_data = {'first_name': "Jane"}
make("user.profile.edit", user=user, session=session1, form=edit_profile_form)
make("user.service.sso.authorization", user=user, session=session1, service=service)
make("user.service.sso", user=user, session=session1, service=service, how="password")
make("user.service.sso.unauthorization", user=user, session=session1, service=service)
make("user.deletion", user=user, session=session1, service=service)
make("user.password.reset.request", email="USER@example.com", user=user)
make("user.password.reset.failure", email="USER@example.com")
make("user.password.reset", user=user)
make("user.login", user=agent, session=session2, how="saml")
create_form = mock.Mock(spec=["instance"])
create_form.instance = user
make("manager.user.creation", user=agent, session=session2, form=create_form)
edit_form = mock.Mock(spec=["instance", "initial", "changed_data", "cleaned_data"])
edit_form.instance = user
edit_form.initial = {'email': "user@example.com", 'first_name': "John"}
edit_form.changed_data = ["first_name"]
edit_form.cleaned_data = {'first_name': "Jane"}
make("manager.user.profile.edit", user=agent, session=session2, form=edit_form)
change_email_form = mock.Mock(spec=["instance", "cleaned_data"])
change_email_form.instance = user
change_email_form.cleaned_data = {'new_email': "jane@example.com"}
make(
"manager.user.email.change.request", user=agent, session=session2, form=change_email_form,
)
password_change_form = mock.Mock(spec=["instance", "cleaned_data"])
password_change_form.instance = user
password_change_form.cleaned_data = {'generate_password': False, 'send_mail': False}
make(
"manager.user.password.change", user=agent, session=session2, form=password_change_form,
)
password_change_form.cleaned_data["send_mail"] = True
make(
"manager.user.password.change", user=agent, session=session2, form=password_change_form,
)
make(
"manager.user.password.reset.request", user=agent, session=session2, target_user=user,
)
make(
"manager.user.password.change.force", user=agent, session=session2, target_user=user,
)
make(
"manager.user.password.change.unforce", user=agent, session=session2, target_user=user,
)
make("manager.user.activation", user=agent, session=session2, target_user=user)
make("manager.user.deactivation", user=agent, session=session2, target_user=user)
make("manager.user.deletion", user=agent, session=session2, target_user=user)
make(
"manager.user.sso.authorization.deletion",
user=agent,
session=session2,
service=service,
target_user=user,
)
make("manager.role.creation", user=agent, session=session2, role=role_user)
role_edit_form = mock.Mock(spec=["instance", "initial", "changed_data", "cleaned_data"])
role_edit_form.instance = role_user
role_edit_form.initial = {'name': role_user.name}
role_edit_form.changed_data = ["name"]
role_edit_form.cleaned_data = {'name': "changed role name"}
make(
"manager.role.edit", user=agent, session=session2, role=role_user, form=role_edit_form,
)
make("manager.role.deletion", user=agent, session=session2, role=role_user)
make(
"manager.role.membership.grant", user=agent, session=session2, role=role_user, member=user,
)
make(
"manager.role.membership.removal", user=agent, session=session2, role=role_user, member=user,
)
make(
"manager.role.inheritance.addition", user=agent, session=session2, parent=role_agent, child=role_user,
)
make(
"manager.role.inheritance.removal", user=agent, session=session2, parent=role_agent, child=role_user,
)
make(
"manager.role.administrator.role.addition",
user=agent,
session=session2,
role=role_user,
admin_role=role_agent,
)
make(
"manager.role.administrator.role.removal",
user=agent,
session=session2,
role=role_user,
admin_role=role_agent,
)
make(
"manager.role.administrator.user.addition",
user=agent,
session=session2,
role=role_user,
admin_user=user,
)
make(
"manager.role.administrator.user.removal",
user=agent,
session=session2,
role=role_user,
admin_user=user,
)
# verify we created at least one event for each type
assert set(Event.objects.values_list("type__name", flat=True)) == set(_registry)
return locals()
def extract_journal(response):
rows = []
seen_event_ids = set()
while True:
for tr in response.pyquery("tr[data-event-type]"):
# page can overlap when they contain less than 20 items (to prevent orphan rows)
event_id = tr.attrib["data-event-id"]
if event_id not in seen_event_ids:
rows.append(response.pyquery(tr))
seen_event_ids.add(event_id)
if "Previous page" not in response:
break
response = response.click("Previous page", index=0)
rows.reverse()
content = [
{
'timestamp': text_content(row.find(".journal-list--timestamp-column")[0]).strip(),
'type': row[0].attrib["data-event-type"],
'user': text_content(row.find(".journal-list--user-column")[0]).strip(),
'message': text_content(row.find(".journal-list--message-column")[0]),
}
for row in rows
]
return content
def test_global_journal(app, superuser, events):
response = login(app, user=superuser, path="/manage/")
# remove event about admin login
Event.objects.filter(user=superuser).delete()
response = response.click(href="journal")
content = extract_journal(response)
assert content == [
{
'message': 'registration request with email "user@example.com"',
'timestamp': 'Jan. 1, 2020, midnight',
'type': 'user.registration.request',
'user': '-',
},
{
'message': 'registration using franceconnect',
'timestamp': 'Jan. 1, 2020, 1 a.m.',
'type': 'user.registration',
'user': 'Johnny doe',
},
{
'message': 'logout',
'timestamp': 'Jan. 1, 2020, 2 a.m.',
'type': 'user.logout',
'user': 'Johnny doe',
},
{
'message': 'login failure with username "user"',
'timestamp': 'Jan. 1, 2020, 3 a.m.',
'type': 'user.login.failure',
'user': '-',
},
{
'message': 'login failure with username "agent"',
'timestamp': 'Jan. 1, 2020, 4 a.m.',
'type': 'user.login.failure',
'user': '-',
},
{
'message': 'login using password',
'timestamp': 'Jan. 1, 2020, 5 a.m.',
'type': 'user.login',
'user': 'Johnny doe',
},
{
'message': 'password change',
'timestamp': 'Jan. 1, 2020, 6 a.m.',
'type': 'user.password.change',
'user': 'Johnny doe',
},
{
'message': 'profile edit (first name)',
'timestamp': 'Jan. 1, 2020, 7 a.m.',
'type': 'user.profile.edit',
'user': 'Johnny doe',
},
{
'message': 'authorization of single sign on with "service"',
'timestamp': 'Jan. 1, 2020, 8 a.m.',
'type': 'user.service.sso.authorization',
'user': 'Johnny doe',
},
{
'message': 'service single sign on with "service"',
'timestamp': 'Jan. 1, 2020, 9 a.m.',
'type': 'user.service.sso',
'user': 'Johnny doe',
},
{
'message': 'unauthorization of single sign on with "service"',
'timestamp': 'Jan. 1, 2020, 10 a.m.',
'type': 'user.service.sso.unauthorization',
'user': 'Johnny doe',
},
{
'message': 'deletion',
'timestamp': 'Jan. 1, 2020, 11 a.m.',
'type': 'user.deletion',
'user': 'Johnny doe',
},
{
'message': 'password reset request with email "user@example.com"',
'timestamp': 'Jan. 1, 2020, noon',
'type': 'user.password.reset.request',
'user': 'Johnny doe',
},
{
'message': 'password reset failure with email "USER@example.com"',
'timestamp': 'Jan. 1, 2020, 1 p.m.',
'type': 'user.password.reset.failure',
'user': '-',
},
{
'message': 'password reset',
'timestamp': 'Jan. 1, 2020, 2 p.m.',
'type': 'user.password.reset',
'user': 'Johnny doe',
},
{
'message': 'login using SAML',
'timestamp': 'Jan. 1, 2020, 3 p.m.',
'type': 'user.login',
'user': 'agent',
},
{
'message': 'creation of user "Johnny doe"',
'timestamp': 'Jan. 1, 2020, 4 p.m.',
'type': 'manager.user.creation',
'user': 'agent',
},
{
'message': 'edit of user "Johnny doe" (first name)',
'timestamp': 'Jan. 1, 2020, 5 p.m.',
'type': 'manager.user.profile.edit',
'user': 'agent',
},
{
'message': 'email change of user "Johnny doe" for email address "jane@example.com"',
'timestamp': 'Jan. 1, 2020, 6 p.m.',
'type': 'manager.user.email.change.request',
'user': 'agent',
},
{
'message': 'password change of user "Johnny doe"',
'timestamp': 'Jan. 1, 2020, 7 p.m.',
'type': 'manager.user.password.change',
'user': 'agent',
},
{
'message': 'password change of user "Johnny doe" and notification by mail',
'timestamp': 'Jan. 1, 2020, 8 p.m.',
'type': 'manager.user.password.change',
'user': 'agent',
},
{
'message': 'password reset request of "Johnny doe" sent to "user@example.com"',
'timestamp': 'Jan. 1, 2020, 9 p.m.',
'type': 'manager.user.password.reset.request',
'user': 'agent',
},
{
'message': 'mandatory password change at next login set for user "Johnny doe"',
'timestamp': 'Jan. 1, 2020, 10 p.m.',
'type': 'manager.user.password.change.force',
'user': 'agent',
},
{
'message': 'mandatory password change at next login unset for user "Johnny doe"',
'timestamp': 'Jan. 1, 2020, 11 p.m.',
'type': 'manager.user.password.change.unforce',
'user': 'agent',
},
{
'message': 'activation of user "Johnny doe"',
'timestamp': 'Jan. 2, 2020, midnight',
'type': 'manager.user.activation',
'user': 'agent',
},
{
'message': 'deactivation of user "Johnny doe"',
'timestamp': 'Jan. 2, 2020, 1 a.m.',
'type': 'manager.user.deactivation',
'user': 'agent',
},
{
'message': 'deletion of user "Johnny doe"',
'timestamp': 'Jan. 2, 2020, 2 a.m.',
'type': 'manager.user.deletion',
'user': 'agent',
},
{
'message': 'deletion of authorization of single sign on with "service" of ' 'user "Johnny doe"',
'timestamp': 'Jan. 2, 2020, 3 a.m.',
'type': 'manager.user.sso.authorization.deletion',
'user': 'agent',
},
{
'message': 'creation of role "role1"',
'timestamp': 'Jan. 2, 2020, 4 a.m.',
'type': 'manager.role.creation',
'user': 'agent',
},
{
'message': 'edit of role "role1" (name)',
'timestamp': 'Jan. 2, 2020, 5 a.m.',
'type': 'manager.role.edit',
'user': 'agent',
},
{
'message': 'deletion of role "role1"',
'timestamp': 'Jan. 2, 2020, 6 a.m.',
'type': 'manager.role.deletion',
'user': 'agent',
},
{
'message': 'membership grant to user "user (111111)" in role "role1"',
'timestamp': 'Jan. 2, 2020, 7 a.m.',
'type': 'manager.role.membership.grant',
'user': 'agent',
},
{
'message': 'membership removal of user "user (111111)" from role "role1"',
'timestamp': 'Jan. 2, 2020, 8 a.m.',
'type': 'manager.role.membership.removal',
'user': 'agent',
},
{
'message': 'inheritance addition from parent role "role2" to child role ' '"role1"',
'timestamp': 'Jan. 2, 2020, 9 a.m.',
'type': 'manager.role.inheritance.addition',
'user': 'agent',
},
{
'message': 'inheritance removal from parent role "role2" to child role ' '"role1"',
'timestamp': 'Jan. 2, 2020, 10 a.m.',
'type': 'manager.role.inheritance.removal',
'user': 'agent',
},
{
'message': 'addition of role "role2" as administrator of role "role1"',
'timestamp': 'Jan. 2, 2020, 11 a.m.',
'type': 'manager.role.administrator.role.addition',
'user': 'agent',
},
{
'message': 'removal of role "role2" as administrator of role "role1"',
'timestamp': 'Jan. 2, 2020, noon',
'type': 'manager.role.administrator.role.removal',
'user': 'agent',
},
{
'message': 'addition of user "user (111111)" as administrator of role ' '"role1"',
'timestamp': 'Jan. 2, 2020, 1 p.m.',
'type': 'manager.role.administrator.user.addition',
'user': 'agent',
},
{
'message': 'removal of user "user (111111)" as administrator of role "role1"',
'timestamp': 'Jan. 2, 2020, 2 p.m.',
'type': 'manager.role.administrator.user.removal',
'user': 'agent',
},
]
def test_user_journal(app, superuser, events):
response = login(app, user=superuser, path="/manage/")
user = User.objects.get(username="user")
response = app.get("/manage/users/%s/journal/" % user.id)
content = extract_journal(response)
assert content == [
{
'message': 'registration using franceconnect',
'timestamp': 'Jan. 1, 2020, 1 a.m.',
'type': 'user.registration',
'user': 'Johnny doe',
},
{
'message': 'logout',
'timestamp': 'Jan. 1, 2020, 2 a.m.',
'type': 'user.logout',
'user': 'Johnny doe',
},
{
'message': 'login using password',
'timestamp': 'Jan. 1, 2020, 5 a.m.',
'type': 'user.login',
'user': 'Johnny doe',
},
{
'message': 'password change',
'timestamp': 'Jan. 1, 2020, 6 a.m.',
'type': 'user.password.change',
'user': 'Johnny doe',
},
{
'message': 'profile edit (first name)',
'timestamp': 'Jan. 1, 2020, 7 a.m.',
'type': 'user.profile.edit',
'user': 'Johnny doe',
},
{
'message': 'authorization of single sign on with "service"',
'timestamp': 'Jan. 1, 2020, 8 a.m.',
'type': 'user.service.sso.authorization',
'user': 'Johnny doe',
},
{
'message': 'service single sign on with "service"',
'timestamp': 'Jan. 1, 2020, 9 a.m.',
'type': 'user.service.sso',
'user': 'Johnny doe',
},
{
'message': 'unauthorization of single sign on with "service"',
'timestamp': 'Jan. 1, 2020, 10 a.m.',
'type': 'user.service.sso.unauthorization',
'user': 'Johnny doe',
},
{
'message': 'deletion',
'timestamp': 'Jan. 1, 2020, 11 a.m.',
'type': 'user.deletion',
'user': 'Johnny doe',
},
{
'message': 'password reset request with email "user@example.com"',
'timestamp': 'Jan. 1, 2020, noon',
'type': 'user.password.reset.request',
'user': 'Johnny doe',
},
{
'message': 'password reset',
'timestamp': 'Jan. 1, 2020, 2 p.m.',
'type': 'user.password.reset',
'user': 'Johnny doe',
},
{
'message': 'creation by administrator',
'timestamp': 'Jan. 1, 2020, 4 p.m.',
'type': 'manager.user.creation',
'user': 'agent',
},
{
'message': 'edit by administrator (first name)',
'timestamp': 'Jan. 1, 2020, 5 p.m.',
'type': 'manager.user.profile.edit',
'user': 'agent',
},
{
'message': 'email change for email address "jane@example.com" requested by administrator',
'timestamp': 'Jan. 1, 2020, 6 p.m.',
'type': 'manager.user.email.change.request',
'user': 'agent',
},
{
'message': 'password change by administrator',
'timestamp': 'Jan. 1, 2020, 7 p.m.',
'type': 'manager.user.password.change',
'user': 'agent',
},
{
'message': 'password change by administrator and notification by mail',
'timestamp': 'Jan. 1, 2020, 8 p.m.',
'type': 'manager.user.password.change',
'user': 'agent',
},
{
'message': "password reset request by administrator sent to " '"user@example.com"',
'timestamp': 'Jan. 1, 2020, 9 p.m.',
'type': 'manager.user.password.reset.request',
'user': 'agent',
},
{
'message': 'mandatory password change at next login set by administrator',
'timestamp': 'Jan. 1, 2020, 10 p.m.',
'type': 'manager.user.password.change.force',
'user': 'agent',
},
{
'message': 'mandatory password change at next login unset by administrator',
'timestamp': 'Jan. 1, 2020, 11 p.m.',
'type': 'manager.user.password.change.unforce',
'user': 'agent',
},
{
'message': 'activation by administrator',
'timestamp': 'Jan. 2, 2020, midnight',
'type': 'manager.user.activation',
'user': 'agent',
},
{
'message': 'deactivation by administrator',
'timestamp': 'Jan. 2, 2020, 1 a.m.',
'type': 'manager.user.deactivation',
'user': 'agent',
},
{
'message': 'deletion by administrator',
'timestamp': 'Jan. 2, 2020, 2 a.m.',
'type': 'manager.user.deletion',
'user': 'agent',
},
{
'message': 'deletion of authorization of single sign on with "service" by ' "administrator",
'timestamp': 'Jan. 2, 2020, 3 a.m.',
'type': 'manager.user.sso.authorization.deletion',
'user': 'agent',
},
{
'message': 'membership grant in role "role1"',
'timestamp': 'Jan. 2, 2020, 7 a.m.',
'type': 'manager.role.membership.grant',
'user': 'agent',
},
{
'message': 'membership removal from role "role1"',
'timestamp': 'Jan. 2, 2020, 8 a.m.',
'type': 'manager.role.membership.removal',
'user': 'agent',
},
{
'message': 'addition as administrator of role "role1"',
'timestamp': 'Jan. 2, 2020, 1 p.m.',
'type': 'manager.role.administrator.user.addition',
'user': 'agent',
},
{
'message': 'removal as administrator of role "role1"',
'timestamp': 'Jan. 2, 2020, 2 p.m.',
'type': 'manager.role.administrator.user.removal',
'user': 'agent',
},
]
def test_role_journal(app, superuser, events):
response = login(app, user=superuser, path="/manage/")
role1 = Role.objects.get(name="role1")
role2 = Role.objects.get(name="role2")
response = app.get("/manage/roles/%s/journal/" % role1.id)
content = extract_journal(response)
assert content == [
{
'message': 'creation',
'timestamp': 'Jan. 2, 2020, 4 a.m.',
'type': 'manager.role.creation',
'user': 'agent',
},
{
'message': 'edit (name)',
'timestamp': 'Jan. 2, 2020, 5 a.m.',
'type': 'manager.role.edit',
'user': 'agent',
},
{
'message': 'deletion',
'timestamp': 'Jan. 2, 2020, 6 a.m.',
'type': 'manager.role.deletion',
'user': 'agent',
},
{
'message': 'membership grant to user "user (111111)"',
'timestamp': 'Jan. 2, 2020, 7 a.m.',
'type': 'manager.role.membership.grant',
'user': 'agent',
},
{
'message': 'membership removal of user "user (111111)"',
'timestamp': 'Jan. 2, 2020, 8 a.m.',
'type': 'manager.role.membership.removal',
'user': 'agent',
},
{
'message': 'inheritance addition from parent role "role2"',
'timestamp': 'Jan. 2, 2020, 9 a.m.',
'type': 'manager.role.inheritance.addition',
'user': 'agent',
},
{
'message': 'inheritance removal from parent role "role2"',
'timestamp': 'Jan. 2, 2020, 10 a.m.',
'type': 'manager.role.inheritance.removal',
'user': 'agent',
},
{
'message': 'addition of role "role2" as administrator',
'timestamp': 'Jan. 2, 2020, 11 a.m.',
'type': 'manager.role.administrator.role.addition',
'user': 'agent',
},
{
'message': 'removal of role "role2" as administrator',
'timestamp': 'Jan. 2, 2020, noon',
'type': 'manager.role.administrator.role.removal',
'user': 'agent',
},
{
'message': 'addition of user "user (111111)" as administrator',
'timestamp': 'Jan. 2, 2020, 1 p.m.',
'type': 'manager.role.administrator.user.addition',
'user': 'agent',
},
{
'message': 'removal of user "user (111111)" as administrator',
'timestamp': 'Jan. 2, 2020, 2 p.m.',
'type': 'manager.role.administrator.user.removal',
'user': 'agent',
},
]
response = app.get("/manage/roles/%s/journal/" % role2.id)
content = extract_journal(response)
assert content == [
{
'message': 'inheritance addition to child role "role1"',
'timestamp': 'Jan. 2, 2020, 9 a.m.',
'type': 'manager.role.inheritance.addition',
'user': 'agent',
},
{
'message': 'inheritance removal to child role "role1"',
'timestamp': 'Jan. 2, 2020, 10 a.m.',
'type': 'manager.role.inheritance.removal',
'user': 'agent',
},
{
'message': 'addition as administrator of role "role1"',
'timestamp': 'Jan. 2, 2020, 11 a.m.',
'type': 'manager.role.administrator.role.addition',
'user': 'agent',
},
{
'message': 'removal as administrator of role "role1"',
'timestamp': 'Jan. 2, 2020, noon',
'type': 'manager.role.administrator.role.removal',
'user': 'agent',
},
]
def test_roles_journal(app, superuser, events):
response = login(app, user=superuser, path='/manage/')
response = response.click('Role')
response = response.click('Journal')
content = extract_journal(response)
assert content == [
{
'message': 'creation of role "role1"',
'timestamp': 'Jan. 2, 2020, 4 a.m.',
'type': 'manager.role.creation',
'user': 'agent',
},
{
'message': 'edit of role "role1" (name)',
'timestamp': 'Jan. 2, 2020, 5 a.m.',
'type': 'manager.role.edit',
'user': 'agent',
},
{
'message': 'deletion of role "role1"',
'timestamp': 'Jan. 2, 2020, 6 a.m.',
'type': 'manager.role.deletion',
'user': 'agent',
},
{
'message': 'membership grant to user "user (111111)" in role "role1"',
'timestamp': 'Jan. 2, 2020, 7 a.m.',
'type': 'manager.role.membership.grant',
'user': 'agent',
},
{
'message': 'membership removal of user "user (111111)" from role "role1"',
'timestamp': 'Jan. 2, 2020, 8 a.m.',
'type': 'manager.role.membership.removal',
'user': 'agent',
},
{
'message': 'inheritance addition from parent role "role2" to child role ' '"role1"',
'timestamp': 'Jan. 2, 2020, 9 a.m.',
'type': 'manager.role.inheritance.addition',
'user': 'agent',
},
{
'message': 'inheritance removal from parent role "role2" to child role ' '"role1"',
'timestamp': 'Jan. 2, 2020, 10 a.m.',
'type': 'manager.role.inheritance.removal',
'user': 'agent',
},
{
'message': 'addition of role "role2" as administrator of role "role1"',
'timestamp': 'Jan. 2, 2020, 11 a.m.',
'type': 'manager.role.administrator.role.addition',
'user': 'agent',
},
{
'message': 'removal of role "role2" as administrator of role "role1"',
'timestamp': 'Jan. 2, 2020, noon',
'type': 'manager.role.administrator.role.removal',
'user': 'agent',
},
{
'message': 'addition of user "user (111111)" as administrator of role ' '"role1"',
'timestamp': 'Jan. 2, 2020, 1 p.m.',
'type': 'manager.role.administrator.user.addition',
'user': 'agent',
},
{
'message': 'removal of user "user (111111)" as administrator of role "role1"',
'timestamp': 'Jan. 2, 2020, 2 p.m.',
'type': 'manager.role.administrator.user.removal',
'user': 'agent',
},
]
def test_date_navigation(app, superuser, events):
response = login(app, user=superuser, path="/manage/journal/")
response = response.click('2020')
response = response.click('January')
response = response.click('1')
response = response.click('January 2020')
response = response.click('2020')
response = response.click('All dates')
def test_search(app, superuser, events):
response = login(app, user=superuser, path="/manage/journal/")
response.form.set('search', 'event:registration')
response = response.form.submit()
assert len(response.pyquery('tbody tr')) == 1
response.form.set('search', 'username:agent event:login')
response = response.form.submit()
assert len(response.pyquery('tbody tr')) == 1
assert all(
'agent' == text_content(node) for node in response.pyquery('tbody tr td.journal-list--user-column')
)
response.form.set('search', 'uuid:%s event:reset' % events['user'].uuid)
response = response.form.submit()
assert len(response.pyquery('tbody tr')) == 1
response.form.set('search', 'session:1234')
response = response.form.submit()
assert len(response.pyquery('tbody tr')) == 9
assert all(
text_content(node) == 'Johnny doe'
for node in response.pyquery('tbody tr td.journal-list--user-column')
)
response.form.set('search', 'email:jane@example.com')
response = response.form.submit()
assert (
text_content(response.pyquery('tbody tr td.journal-list--message-column')[0]).strip()
== 'email change of user "Johnny doe" for email address "jane@example.com"'
)
response.form.set('search', 'jane@example.com')
response = response.form.submit()
assert (
text_content(response.pyquery('tbody tr td.journal-list--message-column')[0]).strip()
== 'email change of user "Johnny doe" for email address "jane@example.com"'
)
response.form.set('search', 'johny doe event:login')
response = response.form.submit()
pq = response.pyquery
assert [
list(map(text_content, p))
for p in zip(pq('tbody td.journal-list--user-column'), pq('tbody td.journal-list--message-column'))
] == [['Johnny doe', 'login using password']]