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, period_in_days=30): |
|
866 |
# do not write to db uselessly, one keepalive event by month is ok |
|
867 |
start = now() |
|
868 |
threshold = start - datetime.timedelta(days=period_in_days) |
|
869 |
users_to_update = User.objects.filter(pk__in=targeted_users).exclude( |
|
870 |
models.Q(date_joined__gt=threshold) |
|
871 |
| models.Q(last_login__gt=threshold) |
|
872 |
| models.Q(keepalive__gt=threshold) |
|
873 |
) |
|
874 |
with transaction.atomic(savepoint=False): |
|
875 |
users_to_update.update(keepalive=start) |
|
876 |
actor = actor if isinstance(actor, User) else getattr(actor, 'oidc_client', None) |
|
877 |
for user in users_to_update.only('id'): |
|
878 |
journal.record('user.notification.activity', actor=actor, target_user=user, api=True) |
|
879 | ||
849 | 880 |
@action( |
850 | 881 |
detail=True, |
851 | 882 |
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(EventTypeWithService): |
|
520 |
name = 'user.notification.activity' |
|
521 |
label = _('user activity notification') |
|
522 | ||
523 |
@classmethod |
|
524 |
def record(cls, *, actor, target_user): |
|
525 |
user = actor if isinstance(actor, User) else None |
|
526 |
service = actor if isinstance(actor, Service) else None |
|
527 |
data = { |
|
528 |
'target_user': str(target_user), |
|
529 |
'target_user_pk': target_user.pk, |
|
530 |
} |
|
531 |
references = [target_user] |
|
532 |
super().record(user=user, service=service, data=data, references=references) |
|
533 | ||
534 |
@classmethod |
|
535 |
def get_message(cls, event, context): |
|
536 |
actor_user = event.user |
|
537 |
actor_service, target_user = event.get_typed_references(Service, User) |
|
538 |
if actor_service is None: |
|
539 |
(target_user,) = event.get_typed_references(User) |
|
540 |
if actor_user is not None: |
|
541 |
actor = _('user "{0}"').format(actor_user) |
|
542 |
elif actor_service: |
|
543 |
actor = _('service "{0}"').format(actor_service) |
|
544 |
else: |
|
545 |
actor = _('unknown actor') |
|
546 |
if context == target_user: |
|
547 |
return _('user activity notified by {0}').format(actor) |
|
548 |
else: |
|
549 |
return _('user "{0}" activity notified by {1}').format(target_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 |
tests/test_manager_journal.py | ||
---|---|---|
44 | 44 | |
45 | 45 | |
46 | 46 |
@pytest.fixture(autouse=True) |
47 |
def events(db, freezer): |
|
47 |
def events(db, superuser, freezer):
|
|
48 | 48 |
session1 = Session(session_key="1234") |
49 | 49 |
session2 = Session(session_key="abcd") |
50 | 50 | |
... | ... | |
322 | 322 |
session=session2, |
323 | 323 |
related_object=set_attribute_action, |
324 | 324 |
) |
325 |
make('user.notification.activity', actor=service, target_user=user) |
|
326 |
make('user.notification.activity', actor=superuser, target_user=user) |
|
325 | 327 | |
326 | 328 |
# verify we created at least one event for each type |
327 | 329 |
assert set(Event.objects.values_list("type__name", flat=True)) == set(_registry) |
... | ... | |
361 | 363 |
set_attribute_action = SetAttributeAction.objects.get() |
362 | 364 | |
363 | 365 |
# remove event about admin login |
364 |
Event.objects.filter(user=superuser).delete()
|
|
366 |
Event.objects.order_by('-id').filter(type__name='user.login', user=superuser)[0].delete()
|
|
365 | 367 | |
366 | 368 |
response = response.click("Global journal") |
367 | 369 | |
... | ... | |
735 | 737 |
'type': 'authenticator.related_object.deletion', |
736 | 738 |
'user': 'agent', |
737 | 739 |
}, |
740 |
{ |
|
741 |
'message': 'user "Johnny doe" activity notified by service "service"', |
|
742 |
'timestamp': 'Jan. 3, 2020, 11 a.m.', |
|
743 |
'type': 'user.notification.activity', |
|
744 |
'user': '-', |
|
745 |
}, |
|
746 |
{ |
|
747 |
'message': 'user "Johnny doe" activity notified by user "super user"', |
|
748 |
'timestamp': 'Jan. 3, 2020, noon', |
|
749 |
'type': 'user.notification.activity', |
|
750 |
'user': 'super user', |
|
751 |
}, |
|
738 | 752 |
] |
739 | 753 | |
740 | 754 |
agent_page = response.click('agent', index=1) |
... | ... | |
969 | 983 |
'type': 'user.deletion.inactivity', |
970 | 984 |
'user': 'Johnny doe', |
971 | 985 |
}, |
986 |
{ |
|
987 |
'message': 'user activity notified by service "service"', |
|
988 |
'timestamp': 'Jan. 3, 2020, 11 a.m.', |
|
989 |
'type': 'user.notification.activity', |
|
990 |
'user': '-', |
|
991 |
}, |
|
992 |
{ |
|
993 |
'message': 'user activity notified by user "super user"', |
|
994 |
'timestamp': 'Jan. 3, 2020, noon', |
|
995 |
'type': 'user.notification.activity', |
|
996 |
'user': 'super user', |
|
997 |
}, |
|
972 | 998 |
] |
973 | 999 | |
974 | 1000 | |
975 |
- |