From 6dc0d61030e18fa8df9d91e64dac38bfbc0b63c6 Mon Sep 17 00:00:00 2001 From: Paul Marillonnet Date: Wed, 23 Mar 2022 16:21:58 +0100 Subject: [PATCH] management: cancel sync deletion on abnormally high ratio (#62849) --- .../management/commands/sync-cut.py | 32 ++++---- tests/__init__.py | 0 tests/conftest.py | 9 ++- tests/settings.py | 3 + tests/test_commands.py | 80 ++++++++++++++++++- tox.ini | 1 + 6 files changed, 106 insertions(+), 19 deletions(-) create mode 100644 tests/__init__.py diff --git a/src/authentic2_gnm/management/commands/sync-cut.py b/src/authentic2_gnm/management/commands/sync-cut.py index 048640d..ab4dfc0 100644 --- a/src/authentic2_gnm/management/commands/sync-cut.py +++ b/src/authentic2_gnm/management/commands/sync-cut.py @@ -17,14 +17,13 @@ from __future__ import print_function import datetime + import requests - -from django.conf import settings -from django.core.management.base import BaseCommand -from django.core.exceptions import MultipleObjectsReturned - from authentic2.utils.template import Template -from authentic2_auth_oidc.models import OIDCProvider, OIDCAccount +from authentic2_auth_oidc.models import OIDCAccount, OIDCProvider +from django.conf import settings +from django.core.exceptions import MultipleObjectsReturned +from django.core.management.base import BaseCommand class Command(BaseCommand): @@ -51,14 +50,19 @@ class Command(BaseCommand): resp.raise_for_status() unknown_uuids.extend(resp.json().get('unknown_uuids')) - for account in OIDCAccount.objects.filter(sub__in=unknown_uuids): - if verbose: - print('disabling', account.user.email, account.user.ou) - account.user.email = account.user.email + '.%s.invalid' % ( - datetime.datetime.now().strftime('%Y-%m-%dT%H-%M-%S') - ) - account.user.save() - OIDCAccount.objects.filter(sub__in=unknown_uuids).delete() + deletion_ratio = len(unknown_uuids) / OIDCAccount.objects.filter(provider=cut_users).count() + if deletion_ratio > 0.05: # higher than 5%, something definitely went wrong + print(f'deletion ratio is abnormally high ({deletion_ratio}), aborting unkwown users deletion') + + else: + for account in OIDCAccount.objects.filter(sub__in=unknown_uuids): + if verbose: + print('disabling', account.user.email, account.user.ou) + account.user.email = account.user.email + '.%s.invalid' % ( + datetime.datetime.now().strftime('%Y-%m-%dT%H-%M-%S') + ) + account.user.save() + OIDCAccount.objects.filter(sub__in=unknown_uuids).delete() # update recently modified users url = settings.CUT_API_BASE_URL + 'users/?modified__gt=%s' % ( diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index b4630f6..80afed5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,8 @@ try: except ImportError: import pathlib2 as pathlib +from authentic2.a2_rbac.utils import get_default_ou +from authentic2_auth_oidc.models import OIDCProvider from django.contrib.auth import get_user_model from django.core.management import call_command from django_rbac.utils import get_ou_model @@ -64,6 +66,11 @@ def user(db): return user +@pytest.fixture +def oidc_provider(db): + return OIDCProvider.objects.create(name='CUT', slug='cut', ou=get_default_ou()) + + @pytest.fixture def hooks(settings): if hasattr(settings, 'A2_HOOKS'): @@ -79,7 +86,7 @@ def hooks(settings): @pytest.fixture def admin(db): user = User(username='admin', email='admin@example.net', is_superuser=True, is_staff=True) - user.ou = OU.objects.get(slug='territoire') + user.ou = get_default_ou() user.set_password('admin') user.save() return user diff --git a/tests/settings.py b/tests/settings.py index 03dc63d..a470ff1 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -14,6 +14,9 @@ if 'postgres' in DATABASES['default']['ENGINE']: if key in os.environ: DATABASES['default'][key[2:]] = os.environ[key] +CUT_API_BASE_URL = 'https://cut.base.provider/' +CUT_API_CREDENTIALS = ('abc', 'xyz') + LANGUAGE_CODE = 'en' A2_FC_CLIENT_ID = '' A2_FC_CLIENT_SECRET = '' diff --git a/tests/test_commands.py b/tests/test_commands.py index fc1895a..2ee4004 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,7 +1,79 @@ +import random +import uuid + +import httmock import pytest +import requests +from authentic2.a2_rbac.utils import get_default_ou +from authentic2.utils import crypto +from authentic2_auth_oidc.models import OIDCAccount +from django.contrib.auth import get_user_model +from django.core.management import call_command -def test_dummy(db, app): - assert 1 -def test_another_dummy(db, app): - assert 1 +@pytest.mark.parametrize('deletion_number_and_validity', [(2, True), (5, True), (10, False)]) +def test_user_synchronization_deletion_threshold( + db, app, admin, settings, capsys, oidc_provider, deletion_number_and_validity +): + User = get_user_model() + deletion_number = deletion_number_and_validity[0] + deletion_valid = deletion_number_and_validity[1] + for i in range(100): + user = User.objects.create( + first_name='John%s' % i, + last_name='Doe%s' % i, + username='john.doe.%s' % i, + email='john.doe.%s@ad.dre.ss', + ou=get_default_ou(), + ) + identifier = uuid.UUID(user.uuid).bytes + sector_identifier = 'cut' + cipher_args = [ + settings.SECRET_KEY.encode('utf-8'), + identifier, + sector_identifier, + ] + sub = crypto.aes_base64url_deterministic_encrypt(*cipher_args).decode('utf-8') + OIDCAccount.objects.create(user=user, provider=oidc_provider, sub=sub) + + def synchronization_post_deletion_response(url, request): + headers = {'content-type': 'application/json'} + content = { + 'unknown_uuids': [ + account.sub for account in random.sample(list(OIDCAccount.objects.all()), deletion_number) + ] + } + return httmock.response(status_code=200, headers=headers, content=content, request=request) + + def synchronization_get_modified_response(url, request): + headers = {'content-type': 'application/json'} + content = {'results': [user.to_json() for user in random.sample(list(User.objects.all()), 20)]} + return httmock.response(status_code=200, headers=headers, content=content, request=request) + + with httmock.HTTMock( + httmock.urlmatch( + netloc=r'cut\.base\.provider', + path=r'^/users/synchronization/$', + method='POST', + )(synchronization_post_deletion_response) + ): + + with httmock.HTTMock( + httmock.urlmatch( + netloc=r'cut\.base\.provider', + path=r'^/users/*', + method='GET', + )(synchronization_get_modified_response) + ): + call_command('sync-cut', '--delta', '300', '-v1') + out, err = capsys.readouterr() + assert not err + if deletion_valid: + # existing users check + assert out.count('disabling') == deletion_number + assert OIDCAccount.objects.count() == 100 - deletion_number + else: + assert 'deletion ratio is abnormally high' in out + assert OIDCAccount.objects.count() == 100 + # users update + assert 'got 20 users' in out # fixme: further testing in dedicated unit test diff --git a/tox.ini b/tox.ini index 50ef024..c902357 100644 --- a/tox.ini +++ b/tox.ini @@ -49,6 +49,7 @@ deps = pytest-random django-webtest pyquery + httmock commands = py3: ./getlasso3.sh