From d5dcf055aaf3849ba445cc31bc983474b8f45745 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Fri, 7 Oct 2022 10:45:14 +0200 Subject: [PATCH 6/6] api: add keepalive option to user syncronization API (#67901) --- src/authentic2/api_views.py | 43 +++++++++++++++- src/authentic2/authentication.py | 3 ++ src/authentic2/journal_event_types.py | 36 ++++++++++++++ tests/api/test_user_synchronization.py | 68 +++++++++++++++++++++++++- 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py index fc23fff9..a497dbad 100644 --- a/src/authentic2/api_views.py +++ b/src/authentic2/api_views.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import datetime import logging import smtplib from functools import partial @@ -25,11 +26,12 @@ from django.contrib.auth import get_user_model from django.contrib.auth.hashers import identify_hasher from django.contrib.contenttypes.models import ContentType from django.core.exceptions import MultipleObjectsReturned -from django.db import models +from django.db import models, transaction from django.shortcuts import get_object_or_404 from django.utils.dateparse import parse_datetime from django.utils.encoding import force_str from django.utils.text import slugify +from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django.views.decorators.cache import cache_control from django.views.decorators.vary import vary_on_headers @@ -52,6 +54,7 @@ from rest_framework.validators import UniqueTogetherValidator from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet, ViewSet +from authentic2.apps.journal.journal import journal from authentic2.apps.journal.models import reference_integer from authentic2.compat.drf import action @@ -796,6 +799,7 @@ class UsersAPI(api_mixins.GetOrCreateMixinView, HookMixin, ExceptionHandlerMixin known_uuids = serializers.ListField(child=serializers.CharField()) full_known_users = serializers.BooleanField(required=False) timestamp = serializers.DateTimeField(required=False) + keepalive = serializers.BooleanField(required=False) def check_unknown_uuids(self, remote_uuids, users): return set(remote_uuids) - {user.uuid for user in users} @@ -843,9 +847,46 @@ class UsersAPI(api_mixins.GetOrCreateMixinView, HookMixin, ExceptionHandlerMixin # reload users to get all fields known_users = User.objects.filter(pk__in=[user.pk for user in users[:1000]]) data['known_users'] = [BaseUserSerializer(user).data for user in known_users] + # update keepalive if requested and: + # - user is an administrator of users, + # - user is a publik service using publik signature. + # It currently excludes APIClient and OIDCClient + keepalive = serializer.validated_data.get('keepalive', False) + if keepalive: + if not ( + str(request.user).startswith('Publik Service') + or (isinstance(request.user, User) and request.user.has_perm('custom_user.admin_user')) + ): + raise PermissionDenied('keepalive requires the admin_user permission') + self._update_keep_alive(actor=request.user, targeted_users=users) hooks.call_hooks('api_modify_response', self, 'synchronization', data) return Response(data) + def _update_keep_alive(self, actor, targeted_users): + # do not write to db uselessly, one keepalive event by month is ok + start = now() + threshold = start - datetime.timedelta(days=30) + users_to_check = User.objects.filter( + (models.Q(last_login__isnull=True) | models.Q(last_login__lt=threshold)) + & (models.Q(keepalive__isnull=True) | models.Q(keepalive__lt=threshold)), + pk__in=targeted_users, + ).select_related('ou') + with transaction.atomic(savepoint=False): + users_pks_to_update = {} + for user in users_to_check: + if user.ou and user.ou.clean_unused_accounts_alert: + activity_date = max( + user.date_joined, + user.last_login or user.date_joined, + user.keepalive or user.date_joined, + ) + time_since_last_activity = now() - activity_date + if time_since_last_activity.days > (user.ou.clean_unused_accounts_alert / 2): + users_pks_to_update[user.pk] = user + User.objects.filter(pk__in=users_pks_to_update.keys()).update(keepalive=start) + for user in users_pks_to_update.values(): + journal.record('user.notification.activity', actor=actor, target_user=user, api=True) + @action( detail=True, methods=['post'], diff --git a/src/authentic2/authentication.py b/src/authentic2/authentication.py index 6b415788..46b5f7fd 100644 --- a/src/authentic2/authentication.py +++ b/src/authentic2/authentication.py @@ -51,6 +51,9 @@ class OIDCUser: def is_authenticated(self): return CallableTrue + def __str__(self): + return f'OIDC Client "{self.oidc_client}"' + class Authentic2Authentication(BasicAuthentication): def authenticate_credentials(self, userid, password, request=None): diff --git a/src/authentic2/journal_event_types.py b/src/authentic2/journal_event_types.py index aa7b593d..1d6f970d 100644 --- a/src/authentic2/journal_event_types.py +++ b/src/authentic2/journal_event_types.py @@ -514,3 +514,39 @@ class UserNotificationInactivity(EventTypeDefinition): 'notification sent to "{email}" after {days_of_inactivity} days of inactivity. ' 'Account will be deleted in {days_to_deletion} days.' ).format(days_of_inactivity=days_of_inactivity, days_to_deletion=days_to_deletion, email=email) + + +class UserNotificationActivity(EventTypeDefinition): + name = 'user.notification.activity' + label = _('user activity notification') + + @classmethod + def record(cls, *, actor, target_user): + user = actor if getattr(actor, 'pk', None) is not None else None + service = user.oidc_client if getattr(user, 'oidc_client', None) is not None else None + data = { + 'actor': str(actor), + 'target_user': str(target_user), + 'target_user_pk': target_user.pk, + } + references = [target_user] + if service is not None: + references.append(service) + if user is not None: + references.append(user) + super().record(user=user, service=service, data=data, references=references) + + @classmethod + def get_message(cls, event, context): + user, actor_service = event.get_typed_references(User, Service) + user, actor_user = event.get_typed_references(User, User) + if actor_service: + actor = _('service "{0}"').format(actor_service) + elif actor_user: + actor = _('user "{0}"').format(actor_user) + else: + actor = _('unknown actor') + if context == user: + return _('user activity notified by {0}').format(actor) + else: + return _('user "{0}" activity notified by {1}').format(user, actor) diff --git a/tests/api/test_user_synchronization.py b/tests/api/test_user_synchronization.py index fe5364c2..1160c7a7 100644 --- a/tests/api/test_user_synchronization.py +++ b/tests/api/test_user_synchronization.py @@ -20,12 +20,16 @@ import uuid import pytest from django.contrib.contenttypes.models import ContentType +from django.db import models from django.urls import reverse +from django.utils.timezone import now -from authentic2.a2_rbac.models import Role +from authentic2.a2_rbac.models import Permission, Role +from authentic2.a2_rbac.utils import get_default_ou from authentic2.apps.journal.models import Event, EventType from authentic2.custom_user.models import User -from django_rbac.models import SEARCH_OP +from django_rbac.models import ADMIN_OP, SEARCH_OP +from django_rbac.utils import get_operation URL = '/api/users/synchronization/' @@ -180,3 +184,63 @@ def test_timestamp(app, users): assert len(response.json['unknown_uuids']) == 3 for user in users[3:]: assert user.uuid in response.json['unknown_uuids'] + + +def test_keepalive_false(app, payload, unknown_uuids): + app.post_json(URL, params=payload) + assert User.objects.filter(keepalive__isnull=False).count() == 0 + + payload['keepalive'] = False + app.post_json(URL, params=payload) + assert User.objects.filter(keepalive__isnull=False).count() == 0 + + +def test_keepalive_missing_permission(app, user, payload, freezer): + payload['keepalive'] = True + app.post_json(URL, params=payload, status=403) + + +class TestWithPermission: + @pytest.fixture(autouse=True) + def configure_ou(self, users, db): + ou = get_default_ou() + User.objects.all().update(ou=ou) + User.objects.update(last_login=models.F('date_joined')) + ou.clean_unused_accounts_alert = 60 + ou.clean_unused_accounts_deletion = 63 + ou.save() + + @pytest.fixture + def user(self, user): + perm, _ = Permission.objects.get_or_create( + ou__isnull=True, + operation=get_operation(ADMIN_OP), + target_ct=ContentType.objects.get_for_model(ContentType), + target_id=ContentType.objects.get_for_model(User).pk, + ) + user.roles.all()[0].permissions.add(perm) + return user + + @pytest.fixture + def payload(self, payload): + payload['keepalive'] = True + return payload + + def test_keepalive_true(self, app, user, users, payload, freezer): + freezer.move_to(datetime.timedelta(days=50, hours=1)) + app.post_json(URL, params=payload) + assert User.objects.filter(keepalive__isnull=False).count() == len(users) + + def test_keepalive_one_time_by_clean_unused_period_alert(self, app, user, users, payload, freezer): + # set last keepalive 29 days ago + User.objects.exclude(pk=user.pk).update(keepalive=now()) + app.post_json(URL, params=payload) + freezer.move_to(datetime.timedelta(days=30)) + # keepalive did not change + assert User.objects.filter(keepalive__lt=now() - datetime.timedelta(days=1)).count() == 10 + + # move 2 days in the future + freezer.move_to(datetime.timedelta(days=1)) + app.post_json(URL, params=payload) + # keepalive did change + assert User.objects.filter(keepalive__lt=now() - datetime.timedelta(days=1)).count() == 0 -- 2.37.2