0006-api-add-keepalive-option-to-user-syncronization-API-.patch
src/authentic2/api_views.py | ||
---|---|---|
14 | 14 |
# You should have received a copy of the GNU Affero General Public License |
15 | 15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | |
17 |
import datetime |
|
17 | 18 |
import logging |
18 | 19 |
import smtplib |
19 | 20 |
from functools import partial |
... | ... | |
25 | 26 |
from django.contrib.auth.hashers import identify_hasher |
26 | 27 |
from django.contrib.contenttypes.models import ContentType |
27 | 28 |
from django.core.exceptions import MultipleObjectsReturned |
28 |
from django.db import models |
|
29 |
from django.db import models, transaction
|
|
29 | 30 |
from django.shortcuts import get_object_or_404 |
30 | 31 |
from django.utils.dateparse import parse_datetime |
31 | 32 |
from django.utils.encoding import force_str |
32 | 33 |
from django.utils.text import slugify |
34 |
from django.utils.timezone import now |
|
33 | 35 |
from django.utils.translation import gettext_lazy as _ |
34 | 36 |
from django.views.decorators.cache import cache_control |
35 | 37 |
from django.views.decorators.vary import vary_on_headers |
... | ... | |
52 | 54 |
from rest_framework.views import APIView |
53 | 55 |
from rest_framework.viewsets import ModelViewSet, ViewSet |
54 | 56 | |
57 |
from authentic2.apps.journal.journal import journal |
|
55 | 58 |
from authentic2.apps.journal.models import reference_integer |
56 | 59 |
from authentic2.compat.drf import action |
57 | 60 | |
... | ... | |
796 | 799 |
known_uuids = serializers.ListField(child=serializers.CharField()) |
797 | 800 |
full_known_users = serializers.BooleanField(required=False) |
798 | 801 |
timestamp = serializers.DateTimeField(required=False) |
802 |
keepalive = serializers.BooleanField(required=False) |
|
799 | 803 | |
800 | 804 |
def check_unknown_uuids(self, remote_uuids, users): |
801 | 805 |
return set(remote_uuids) - {user.uuid for user in users} |
... | ... | |
843 | 847 |
# reload users to get all fields |
844 | 848 |
known_users = User.objects.filter(pk__in=[user.pk for user in users[:1000]]) |
845 | 849 |
data['known_users'] = [BaseUserSerializer(user).data for user in known_users] |
850 |
# update keepalive if requested and: |
|
851 |
# - user is an administrator of users, |
|
852 |
# - user is a publik service using publik signature. |
|
853 |
# It currently excludes APIClient and OIDCClient |
|
854 |
keepalive = serializer.validated_data.get('keepalive', False) |
|
855 |
if keepalive: |
|
856 |
if not ( |
|
857 |
str(request.user).startswith('Publik Service') |
|
858 |
or (isinstance(request.user, User) and request.user.has_perm('custom_user.admin_user')) |
|
859 |
): |
|
860 |
raise PermissionDenied('keepalive requires the admin_user permission') |
|
861 |
self._update_keep_alive(actor=request.user, targeted_users=users) |
|
846 | 862 |
hooks.call_hooks('api_modify_response', self, 'synchronization', data) |
847 | 863 |
return Response(data) |
848 | 864 | |
865 |
def _update_keep_alive(self, actor, targeted_users): |
|
866 |
# do not write to db uselessly, one keepalive event by month is ok |
|
867 |
start = now() |
|
868 |
threshold = start - datetime.timedelta(days=30) |
|
869 |
users_to_check = User.objects.filter( |
|
870 |
(models.Q(last_login__isnull=True) | models.Q(last_login__lt=threshold)) |
|
871 |
& (models.Q(keepalive__isnull=True) | models.Q(keepalive__lt=threshold)), |
|
872 |
pk__in=targeted_users, |
|
873 |
).select_related('ou') |
|
874 |
with transaction.atomic(savepoint=False): |
|
875 |
users_pks_to_update = {} |
|
876 |
for user in users_to_check: |
|
877 |
if user.ou and user.ou.clean_unused_accounts_alert: |
|
878 |
activity_date = max( |
|
879 |
user.date_joined, |
|
880 |
user.last_login or user.date_joined, |
|
881 |
user.keepalive or user.date_joined, |
|
882 |
) |
|
883 |
time_since_last_activity = now() - activity_date |
|
884 |
if time_since_last_activity.days > (user.ou.clean_unused_accounts_alert / 2): |
|
885 |
users_pks_to_update[user.pk] = user |
|
886 |
User.objects.filter(pk__in=users_pks_to_update.keys()).update(keepalive=start) |
|
887 |
for user in users_pks_to_update.values(): |
|
888 |
journal.record('user.notification.activity', actor=actor, target_user=user, api=True) |
|
889 | ||
849 | 890 |
@action( |
850 | 891 |
detail=True, |
851 | 892 |
methods=['post'], |
src/authentic2/authentication.py | ||
---|---|---|
51 | 51 |
def is_authenticated(self): |
52 | 52 |
return CallableTrue |
53 | 53 | |
54 |
def __str__(self): |
|
55 |
return f'OIDC Client "{self.oidc_client}"' |
|
56 | ||
54 | 57 | |
55 | 58 |
class Authentic2Authentication(BasicAuthentication): |
56 | 59 |
def authenticate_credentials(self, userid, password, request=None): |
src/authentic2/journal_event_types.py | ||
---|---|---|
514 | 514 |
'notification sent to "{email}" after {days_of_inactivity} days of inactivity. ' |
515 | 515 |
'Account will be deleted in {days_to_deletion} days.' |
516 | 516 |
).format(days_of_inactivity=days_of_inactivity, days_to_deletion=days_to_deletion, email=email) |
517 | ||
518 | ||
519 |
class UserNotificationActivity(EventTypeDefinition): |
|
520 |
name = 'user.notification.activity' |
|
521 |
label = _('user activity notification') |
|
522 | ||
523 |
@classmethod |
|
524 |
def record(cls, *, actor, target_user): |
|
525 |
user = actor if getattr(actor, 'pk', None) is not None else None |
|
526 |
service = user.oidc_client if getattr(user, 'oidc_client', None) is not None else None |
|
527 |
data = { |
|
528 |
'actor': str(actor), |
|
529 |
'target_user': str(target_user), |
|
530 |
'target_user_pk': target_user.pk, |
|
531 |
} |
|
532 |
references = [target_user] |
|
533 |
if service is not None: |
|
534 |
references.append(service) |
|
535 |
if user is not None: |
|
536 |
references.append(user) |
|
537 |
super().record(user=user, service=service, data=data, references=references) |
|
538 | ||
539 |
@classmethod |
|
540 |
def get_message(cls, event, context): |
|
541 |
user, actor_service = event.get_typed_references(User, Service) |
|
542 |
user, actor_user = event.get_typed_references(User, User) |
|
543 |
if actor_service: |
|
544 |
actor = _('service "{0}"').format(actor_service) |
|
545 |
elif actor_user: |
|
546 |
actor = _('user "{0}"').format(actor_user) |
|
547 |
else: |
|
548 |
actor = _('unknown actor') |
|
549 |
if context == user: |
|
550 |
return _('user activity notified by {0}').format(actor) |
|
551 |
else: |
|
552 |
return _('user "{0}" activity notified by {1}').format(user, actor) |
tests/api/test_user_synchronization.py | ||
---|---|---|
20 | 20 | |
21 | 21 |
import pytest |
22 | 22 |
from django.contrib.contenttypes.models import ContentType |
23 |
from django.db import models |
|
23 | 24 |
from django.urls import reverse |
25 |
from django.utils.timezone import now |
|
24 | 26 | |
25 |
from authentic2.a2_rbac.models import Role |
|
27 |
from authentic2.a2_rbac.models import Permission, Role |
|
28 |
from authentic2.a2_rbac.utils import get_default_ou |
|
26 | 29 |
from authentic2.apps.journal.models import Event, EventType |
27 | 30 |
from authentic2.custom_user.models import User |
28 |
from django_rbac.models import SEARCH_OP |
|
31 |
from django_rbac.models import ADMIN_OP, SEARCH_OP |
|
32 |
from django_rbac.utils import get_operation |
|
29 | 33 | |
30 | 34 |
URL = '/api/users/synchronization/' |
31 | 35 | |
... | ... | |
180 | 184 |
assert len(response.json['unknown_uuids']) == 3 |
181 | 185 |
for user in users[3:]: |
182 | 186 |
assert user.uuid in response.json['unknown_uuids'] |
187 | ||
188 | ||
189 |
def test_keepalive_false(app, payload, unknown_uuids): |
|
190 |
app.post_json(URL, params=payload) |
|
191 |
assert User.objects.filter(keepalive__isnull=False).count() == 0 |
|
192 | ||
193 |
payload['keepalive'] = False |
|
194 |
app.post_json(URL, params=payload) |
|
195 |
assert User.objects.filter(keepalive__isnull=False).count() == 0 |
|
196 | ||
197 | ||
198 |
def test_keepalive_missing_permission(app, user, payload, freezer): |
|
199 |
payload['keepalive'] = True |
|
200 |
app.post_json(URL, params=payload, status=403) |
|
201 | ||
202 | ||
203 |
class TestWithPermission: |
|
204 |
@pytest.fixture(autouse=True) |
|
205 |
def configure_ou(self, users, db): |
|
206 |
ou = get_default_ou() |
|
207 |
User.objects.all().update(ou=ou) |
|
208 |
User.objects.update(last_login=models.F('date_joined')) |
|
209 |
ou.clean_unused_accounts_alert = 60 |
|
210 |
ou.clean_unused_accounts_deletion = 63 |
|
211 |
ou.save() |
|
212 | ||
213 |
@pytest.fixture |
|
214 |
def user(self, user): |
|
215 |
perm, _ = Permission.objects.get_or_create( |
|
216 |
ou__isnull=True, |
|
217 |
operation=get_operation(ADMIN_OP), |
|
218 |
target_ct=ContentType.objects.get_for_model(ContentType), |
|
219 |
target_id=ContentType.objects.get_for_model(User).pk, |
|
220 |
) |
|
221 |
user.roles.all()[0].permissions.add(perm) |
|
222 |
return user |
|
223 | ||
224 |
@pytest.fixture |
|
225 |
def payload(self, payload): |
|
226 |
payload['keepalive'] = True |
|
227 |
return payload |
|
228 | ||
229 |
def test_keepalive_true(self, app, user, users, payload, freezer): |
|
230 |
freezer.move_to(datetime.timedelta(days=50, hours=1)) |
|
231 |
app.post_json(URL, params=payload) |
|
232 |
assert User.objects.filter(keepalive__isnull=False).count() == len(users) |
|
233 | ||
234 |
def test_keepalive_one_time_by_clean_unused_period_alert(self, app, user, users, payload, freezer): |
|
235 |
# set last keepalive 29 days ago |
|
236 |
User.objects.exclude(pk=user.pk).update(keepalive=now()) |
|
237 |
app.post_json(URL, params=payload) |
|
238 |
freezer.move_to(datetime.timedelta(days=30)) |
|
239 |
# keepalive did not change |
|
240 |
assert User.objects.filter(keepalive__lt=now() - datetime.timedelta(days=1)).count() == 10 |
|
241 | ||
242 |
# move 2 days in the future |
|
243 |
freezer.move_to(datetime.timedelta(days=1)) |
|
244 |
app.post_json(URL, params=payload) |
|
245 |
# keepalive did change |
|
246 |
assert User.objects.filter(keepalive__lt=now() - datetime.timedelta(days=1)).count() == 0 |
|
183 |
- |