api: add keepalive option to user syncronization API (#67901)
This commit is contained in:
parent
23956e98dd
commit
01190b740a
|
@ -14,6 +14,7 @@
|
|||
# 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 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
|
||||
|
||||
|
@ -797,6 +800,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}
|
||||
|
@ -844,9 +848,36 @@ 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 (
|
||||
getattr(request.user, 'is_publik_service', False)
|
||||
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, period_in_days=30):
|
||||
# do not write to db uselessly, one keepalive event by month is ok
|
||||
start = now()
|
||||
threshold = start - datetime.timedelta(days=period_in_days)
|
||||
users_to_update = User.objects.filter(pk__in=targeted_users).exclude(
|
||||
models.Q(date_joined__gt=threshold)
|
||||
| models.Q(last_login__gt=threshold)
|
||||
| models.Q(keepalive__gt=threshold)
|
||||
)
|
||||
with transaction.atomic(savepoint=False):
|
||||
users_to_update.update(keepalive=start)
|
||||
actor = actor if isinstance(actor, User) else getattr(actor, 'oidc_client', None)
|
||||
for user in users_to_update.only('id'):
|
||||
journal.record('user.notification.activity', actor=actor, target_user=user, api=True)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=['post'],
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -514,3 +514,36 @@ 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(EventTypeWithService):
|
||||
name = 'user.notification.activity'
|
||||
label = _('user activity notification')
|
||||
|
||||
@classmethod
|
||||
def record(cls, *, actor, target_user):
|
||||
user = actor if isinstance(actor, User) else None
|
||||
service = actor if isinstance(actor, Service) else None
|
||||
data = {
|
||||
'target_user': str(target_user),
|
||||
'target_user_pk': target_user.pk,
|
||||
}
|
||||
references = [target_user]
|
||||
super().record(user=user, service=service, data=data, references=references)
|
||||
|
||||
@classmethod
|
||||
def get_message(cls, event, context):
|
||||
actor_user = event.user
|
||||
actor_service, target_user = event.get_typed_references(Service, User)
|
||||
if actor_service is None:
|
||||
(target_user,) = event.get_typed_references(User)
|
||||
if actor_user is not None:
|
||||
actor = _('user "{0}"').format(actor_user)
|
||||
elif actor_service:
|
||||
actor = _('service "{0}"').format(actor_service)
|
||||
else:
|
||||
actor = _('unknown actor')
|
||||
if context == target_user:
|
||||
return _('user activity notified by {0}').format(actor)
|
||||
else:
|
||||
return _('user "{0}" activity notified by {1}').format(target_user, actor)
|
||||
|
|
|
@ -20,9 +20,12 @@ 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 SEARCH_OP, Role
|
||||
from authentic2.a2_rbac.models import ADMIN_OP, SEARCH_OP, Permission, Role
|
||||
from authentic2.a2_rbac.utils import get_default_ou, get_operation
|
||||
from authentic2.apps.journal.models import Event, EventType
|
||||
from authentic2.custom_user.models import User
|
||||
|
||||
|
@ -179,3 +182,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
|
||||
|
|
|
@ -44,7 +44,7 @@ def test_journal_authorization(app, db, simple_user, admin):
|
|||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def events(db, freezer):
|
||||
def events(db, superuser, freezer):
|
||||
session1 = Session(session_key="1234")
|
||||
session2 = Session(session_key="abcd")
|
||||
|
||||
|
@ -322,6 +322,8 @@ def events(db, freezer):
|
|||
session=session2,
|
||||
related_object=set_attribute_action,
|
||||
)
|
||||
make('user.notification.activity', actor=service, target_user=user)
|
||||
make('user.notification.activity', actor=superuser, target_user=user)
|
||||
|
||||
# verify we created at least one event for each type
|
||||
assert set(Event.objects.values_list("type__name", flat=True)) == set(_registry)
|
||||
|
@ -361,7 +363,7 @@ def test_global_journal(app, superuser, events):
|
|||
set_attribute_action = SetAttributeAction.objects.get()
|
||||
|
||||
# remove event about admin login
|
||||
Event.objects.filter(user=superuser).delete()
|
||||
Event.objects.order_by('-id').filter(type__name='user.login', user=superuser)[0].delete()
|
||||
|
||||
response = response.click("Global journal")
|
||||
|
||||
|
@ -735,6 +737,18 @@ def test_global_journal(app, superuser, events):
|
|||
'type': 'authenticator.related_object.deletion',
|
||||
'user': 'agent',
|
||||
},
|
||||
{
|
||||
'message': 'user "Johnny doe" activity notified by service "service"',
|
||||
'timestamp': 'Jan. 3, 2020, 11 a.m.',
|
||||
'type': 'user.notification.activity',
|
||||
'user': '-',
|
||||
},
|
||||
{
|
||||
'message': 'user "Johnny doe" activity notified by user "super user"',
|
||||
'timestamp': 'Jan. 3, 2020, noon',
|
||||
'type': 'user.notification.activity',
|
||||
'user': 'super user',
|
||||
},
|
||||
]
|
||||
|
||||
agent_page = response.click('agent', index=1)
|
||||
|
@ -969,6 +983,18 @@ def test_user_journal(app, superuser, events):
|
|||
'type': 'user.deletion.inactivity',
|
||||
'user': 'Johnny doe',
|
||||
},
|
||||
{
|
||||
'message': 'user activity notified by service "service"',
|
||||
'timestamp': 'Jan. 3, 2020, 11 a.m.',
|
||||
'type': 'user.notification.activity',
|
||||
'user': '-',
|
||||
},
|
||||
{
|
||||
'message': 'user activity notified by user "super user"',
|
||||
'timestamp': 'Jan. 3, 2020, noon',
|
||||
'type': 'user.notification.activity',
|
||||
'user': 'super user',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue