Projet

Général

Profil

0002-commands-per-OU-unused-user-accounts-cleaning-policy.patch

Valentin Deniaud, 02 avril 2020 11:03

Télécharger (21,8 ko)

Voir les différences:

Subject: [PATCH 2/3] commands: per-OU unused user accounts cleaning policy
 (#26909)

 .../migrations/0022_auto_20200402_1101.py     |  26 ++++
 src/authentic2/a2_rbac/models.py              |  22 +++
 .../0018_user_last_account_deletion_alert.py  |  20 +++
 src/authentic2/custom_user/models.py          |   4 +
 .../commands/clean-unused-accounts.py         | 130 +++++++-----------
 src/authentic2/manager/forms.py               |   2 +-
 src/authentic2/utils/__init__.py              |   2 +
 tests/test_a2_rbac.py                         |  10 ++
 tests/test_all.py                             |   1 +
 tests/test_api.py                             |   6 +-
 tests/test_commands.py                        |  80 ++++++++++-
 11 files changed, 218 insertions(+), 85 deletions(-)
 create mode 100644 src/authentic2/a2_rbac/migrations/0022_auto_20200402_1101.py
 create mode 100644 src/authentic2/custom_user/migrations/0018_user_last_account_deletion_alert.py
src/authentic2/a2_rbac/migrations/0022_auto_20200402_1101.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2020-04-02 09:01
3
from __future__ import unicode_literals
4

  
5
import django.core.validators
6
from django.db import migrations, models
7

  
8

  
9
class Migration(migrations.Migration):
10

  
11
    dependencies = [
12
        ('a2_rbac', '0021_auto_20200317_1514'),
13
    ]
14

  
15
    operations = [
16
        migrations.AddField(
17
            model_name='organizationalunit',
18
            name='clean_unused_accounts_alert',
19
            field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(30, 'Ensure that this value is greater than 30 days, or leave blank for deactivating.')], verbose_name='Days after which the user receives an account deletion alert'),
20
        ),
21
        migrations.AddField(
22
            model_name='organizationalunit',
23
            name='clean_unused_accounts_deletion',
24
            field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(30, 'Ensure that this value is greater than 30 days, or leave blank for deactivating.')], verbose_name='Delay in days before cleaning unused accounts'),
25
        ),
26
    ]
src/authentic2/a2_rbac/models.py
16 16

  
17 17
from collections import namedtuple
18 18
from django.core.exceptions import ValidationError
19
from django.core.validators import MinValueValidator
19 20
from django.utils import six
20 21
from django.utils.translation import ugettext_lazy as _
21 22
from django.utils.text import slugify
......
95 96
        choices=USER_ADD_PASSWD_POLICY_CHOICES,
96 97
        default=0)
97 98

  
99
    clean_unused_accounts_alert = models.PositiveIntegerField(
100
        verbose_name=_('Days after which the user receives an account deletion alert'),
101
        validators=[MinValueValidator(
102
            30, _('Ensure that this value is greater than 30 days, or leave blank for deactivating.')
103
        )],
104
        null=True,
105
        blank=True)
106

  
107
    clean_unused_accounts_deletion = models.PositiveIntegerField(
108
        verbose_name=_('Delay in days before cleaning unused accounts'),
109
        validators=[MinValueValidator(
110
            30, _('Ensure that this value is greater than 30 days, or leave blank for deactivating.')
111
        )],
112
        null=True,
113
        blank=True)
114

  
98 115
    objects = managers.OrganizationalUnitManager()
99 116

  
100 117
    class Meta:
......
119 136
            raise ValidationError(_('You cannot unset this organizational '
120 137
                                    'unit as the default, but you can set '
121 138
                                    'another one as the default.'))
139
        if bool(self.clean_unused_accounts_alert) ^ bool(self.clean_unused_accounts_deletion):
140
            raise ValidationError(_('Deletion and alert delays must be set together.'))
141
        if self.clean_unused_accounts_alert and \
142
                self.clean_unused_accounts_alert >= self.clean_unused_accounts_deletion:
143
            raise ValidationError(_('Deletion alert delay must be less than actual deletion delay.'))
122 144
        super(OrganizationalUnit, self).clean()
123 145

  
124 146
    def get_admin_role(self):
src/authentic2/custom_user/migrations/0018_user_last_account_deletion_alert.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2020-03-17 14:16
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6

  
7

  
8
class Migration(migrations.Migration):
9

  
10
    dependencies = [
11
        ('custom_user', '0017_auto_20200305_1645'),
12
    ]
13

  
14
    operations = [
15
        migrations.AddField(
16
            model_name='user',
17
            name='last_account_deletion_alert',
18
            field=models.DateTimeField(blank=True, null=True, verbose_name='Last account deletion alert'),
19
        ),
20
    ]
src/authentic2/custom_user/models.py
166 166
        verbose_name=_('Last modification time'),
167 167
        db_index=True,
168 168
        auto_now=True)
169
    last_account_deletion_alert = models.DateTimeField(
170
        verbose_name=_('Last account deletion alert'),
171
        null=True,
172
        blank=True)
169 173

  
170 174
    objects = UserManager.from_queryset(UserQuerySet)()
171 175
    attributes = AttributesDescriptor()
src/authentic2/management/commands/clean-unused-accounts.py
16 16

  
17 17
from __future__ import print_function
18 18

  
19
import json
19 20
import logging
20
import datetime
21
import smtplib
21 22

  
23
from datetime import timedelta
22 24
from django.contrib.auth import get_user_model
23 25
from django.core.management.base import BaseCommand, CommandError
24
from django.core.mail import send_mail
25
from django.utils.timezone import now
26
from django.utils import timezone
26 27
from django.template.loader import render_to_string
28
from django_rbac.utils import get_ou_model
27 29

  
28 30
from authentic2.models import DeletedUser
31
from authentic2.utils import send_templated_mail
29 32

  
30 33
from django.conf import settings
31 34

  
32 35
logger = logging.getLogger(__name__)
33 36

  
37
User = get_user_model()
38

  
34 39

  
35 40
def print_table(table):
36 41
    col_width = [max(len(x) for x in col) for col in zip(*table)]
......
43 48
    help = '''Clean unused accounts'''
44 49

  
45 50
    def add_arguments(self, parser):
46
        parser.add_argument('clean_threshold', type=int)
47
        parser.add_argument(
48
            "--alert-thresholds",
49
            help='list of durations before sending an alert '
50
                 'message for unused account, default is none',
51
            default=None)
52
        parser.add_argument(
53
            "--period", type=int,
54
            help='period between two calls to '
55
                 'clean-unused-accounts as days, default is 1',
56
            default=1
57
        )
58 51
        parser.add_argument("--fake", action='store_true', help='do nothing', default=False)
59
        parser.add_argument(
60
            "--filter", help='filter to apply to the user queryset, '
61
            'the Django filter key and value are separated by character =', action='append',
62
            default=[]
63
        )
64
        parser.add_argument(
65
            '--from-email', default=settings.DEFAULT_FROM_EMAIL,
66
            help='sender address for notifications, default is DEFAULT_FROM_EMAIL from settings'
67
        )
68 52

  
69 53
    def handle(self, *args, **options):
70 54
        try:
......
73 57
            logger.exception('failure while cleaning unused accounts')
74 58

  
75 59
    def clean_unused_acccounts(self, *args, **options):
76
        if options['period'] < 1:
77
            raise CommandError('period must be > 0')
78

  
79
        clean_threshold = options['clean_threshold']
80
        if clean_threshold < 1:
81
            raise CommandError('clean_threshold must be an integer > 0')
82

  
83 60
        if options['verbosity'] == '0':
84 61
            logging.basicConfig(level=logging.CRITICAL)
85 62
        if options['verbosity'] == '1':
......
89 66
        elif options['verbosity'] == '3':
90 67
            logging.basicConfig(level=logging.DEBUG)
91 68

  
92
        n = now().replace(hour=0, minute=0, second=0, microsecond=0)
69
        now = timezone.now()
93 70
        self.fake = options['fake']
94
        self.from_email = options['from_email']
95 71
        if self.fake:
96 72
            logger.info('fake call to clean-unused-accounts')
97
        users = get_user_model().objects.all()
98
        if options['filter']:
99
            for f in options['filter']:
100
                key, value = f.split('=', 1)
101
                try:
102
                    users = users.filter(**{key: value})
103
                except Exception:
104
                    raise CommandError('invalid --filter %s' % f)
105
        if options['alert_thresholds']:
106
            alert_thresholds = options['alert_thresholds']
107
            alert_thresholds = alert_thresholds.split(',')
108
            try:
109
                alert_thresholds = map(int, alert_thresholds)
110
            except ValueError:
111
                raise CommandError('alert_thresholds must be a comma separated list of integers')
112
            for threshold in alert_thresholds:
113
                if not (0 < threshold < clean_threshold):
114
                    raise CommandError(
115
                        'alert-threshold must a positive integer inferior to clean-threshold: 0 < %d < %d' % (
116
                            threshold, clean_threshold))
117
            for threshold in alert_thresholds:
118
                a = n - datetime.timedelta(days=threshold)
119
                b = n - datetime.timedelta(days=threshold - options['period'])
120
                for user in users.filter(last_login__lt=b, last_login__gte=a):
121
                    logger.info('%s last login %d days ago, sending alert', user, threshold)
122
                    self.send_alert(user, threshold, clean_threshold - threshold)
123
        threshold = n - datetime.timedelta(days=clean_threshold)
124
        for user in users.filter(last_login__lt=threshold):
125
            d = n - user.last_login
126
            logger.info('%s last login %d days ago, deleting user', user, d.days)
127
            self.delete_user(user, clean_threshold)
128

  
129
    def send_alert(self, user, threshold, clean_threshold):
73

  
74
        for ou in get_ou_model().objects.filter(clean_unused_accounts_alert__isnull=False):
75
            alert_delay = timedelta(days=ou.clean_unused_accounts_alert)
76
            deletion_delay = timedelta(days=ou.clean_unused_accounts_deletion)
77
            users = User.objects.filter(ou=ou, last_login__lte=now-alert_delay)
78

  
79
            for user in users.filter(last_account_deletion_alert__isnull=True):
80
                logger.info('%s last login %d days ago, sending alert', user,
81
                         ou.clean_unused_accounts_alert)
82
                self.send_alert(user)
83

  
84
            to_delete = users.filter(
85
                last_login__lte=now - deletion_delay,
86
                # ensure respect of alert delay before deletion
87
                last_account_deletion_alert__lte=now - (deletion_delay - alert_delay)
88
            )
89
            for user in to_delete:
90
                logger.info('%s last login more than %d days ago, deleting user',
91
                         user, ou.clean_unused_accounts_deletion)
92
                self.delete_user(user)
93

  
94
    def send_alert(self, user):
95
        alert_delay = user.ou.clean_unused_accounts_alert
96
        days_to_deletion = user.ou.clean_unused_accounts_deletion - alert_delay
130 97
        ctx = {
131 98
            'user': user,
132
            'threshold': threshold,
133
            'clean_threshold': clean_threshold
99
            'threshold': alert_delay,
100
            'clean_threshold': days_to_deletion,
134 101
        }
135
        self.send_mail('authentic2/unused_account_alert', user, ctx)
102
        try:
103
            self.send_mail('authentic2/unused_account_alert', user, ctx)
104
        except smtplib.SMTPException as e:
105
            logger.exception('email sending failure: %s', e)
106
        else:
107
            if not self.fake:
108
                user.last_account_deletion_alert = timezone.now()
109
                user.save()
136 110

  
137 111
    def send_mail(self, prefix, user, ctx):
138 112
        if not user.email:
139 113
            logger.debug('%s has no email, no mail sent', user)
140
        subject = render_to_string(prefix + '_subject.txt', ctx).strip()
141
        body = render_to_string(prefix + '_body.txt', ctx)
142 114
        if not self.fake:
143
            try:
144
                logger.debug('sending mail to %s', user.email)
145
                send_mail(subject, body, self.from_email, [user.email])
146
            except Exception:
147
                logger.exception('email sending failure')
115
            logger.debug('sending mail to %s', user.email)
116
            send_templated_mail(user.email, prefix, ctx)
148 117

  
149
    def delete_user(self, user, threshold):
118
    def delete_user(self, user):
150 119
        ctx = {
151 120
            'user': user,
152
            'threshold': threshold
121
            'threshold': user.ou.clean_unused_accounts_deletion
153 122
        }
154
        self.send_mail('authentic2/unused_account_delete', user, ctx)
123
        try:
124
            self.send_mail('authentic2/unused_account_delete', user, ctx)
125
        except smtplib.SMTPException as e:
126
            logger.exception('email sending failure: %s', e)
155 127
        if not self.fake:
156 128
            DeletedUser.objects.delete_user(user)
src/authentic2/manager/forms.py
665 665
        model = get_ou_model()
666 666
        fields = (
667 667
            'name', 'default', 'username_is_unique', 'email_is_unique', 'validate_emails',
668
            'show_username'
668
            'show_username', 'clean_unused_accounts_alert', 'clean_unused_accounts_deletion'
669 669
        )
670 670

  
671 671

  
src/authentic2/utils/__init__.py
437 437
    if constants.LAST_LOGIN_SESSION_KEY not in request.session:
438 438
        request.session[constants.LAST_LOGIN_SESSION_KEY] = \
439 439
            localize(to_current_timezone(last_login), True)
440
    user.last_account_deletion_alert = None
441
    user.save()
440 442
    record_authentication_event(request, how, nonce=nonce)
441 443
    hooks.call_hooks('event', name='login', user=user, how=how, service=service_slug)
442 444
    return continue_to_next_url(request, **kwargs)
tests/test_a2_rbac.py
493 493

  
494 494
    # 5 global roles and 4 ou roles for both ous
495 495
    assert Role.objects.count() == 5 + 4 + 4
496

  
497

  
498
@pytest.mark.parametrize(
499
    'alert,deletion', [(-1, 31), (31, -1), (0, 31), (31, 0), (None, 31), (31, None), (32, 31)]
500
)
501
def test_unused_account_settings_validation(ou1, alert, deletion):
502
    ou1.clean_unused_accounts_alert = alert
503
    ou1.clean_unused_accounts_deletion = deletion
504
    with pytest.raises(ValidationError):
505
        ou1.full_clean()
tests/test_all.py
83 83
                    'is_staff': False,
84 84
                    'is_superuser': False,
85 85
                    'last_login': u.last_login,
86
                    'last_account_deletion_alert': None,
86 87
                    'date_joined': u.date_joined,
87 88
                    'modified': u.modified,
88 89
                    'groups': [],
tests/test_api.py
416 416
                    'first_name', 'first_name_verified', 'last_name',
417 417
                    'last_name_verified', 'date_joined', 'last_login',
418 418
                    'username', 'password', 'email', 'is_active', 'title',
419
                    'title_verified', 'modified', 'email_verified']) == set(resp.json.keys())
419
                    'title_verified', 'modified', 'email_verified',
420
                    'last_account_deletion_alert']) == set(resp.json.keys())
420 421
        assert resp.json['first_name'] == payload['first_name']
421 422
        assert resp.json['last_name'] == payload['last_name']
422 423
        assert resp.json['email'] == payload['email']
......
482 483
                    'first_name', 'first_name_verified', 'last_name',
483 484
                    'last_name_verified', 'date_joined', 'last_login',
484 485
                    'username', 'password', 'email', 'is_active', 'title',
485
                    'title_verified', 'modified', 'email_verified']) == set(resp.json.keys())
486
                    'title_verified', 'modified', 'email_verified',
487
                    'last_account_deletion_alert']) == set(resp.json.keys())
486 488
        user = get_user_model().objects.get(pk=resp.json['id'])
487 489
        assert AttributeValue.objects.with_owner(user).filter(verified=True).count() == 3
488 490
        assert AttributeValue.objects.with_owner(user).filter(verified=False).count() == 0
tests/test_commands.py
27 27
from authentic2_auth_oidc.models import OIDCProvider
28 28
from django_rbac.utils import get_ou_model
29 29

  
30
from utils import login
31

  
30 32
if six.PY2:
31 33
    FileType = file
32 34
else:
......
47 49
    assert old_pass != simple_user.password
48 50

  
49 51

  
50
def test_clean_unused_account(simple_user):
52
def test_clean_unused_account(db, simple_user, mailoutbox, freezer):
53
    freezer.move_to('2018-01-01')
54
    simple_user.ou.clean_unused_accounts_alert = 2
55
    simple_user.ou.clean_unused_accounts_deletion = 3
56
    simple_user.ou.save()
57

  
51 58
    simple_user.last_login = now() - datetime.timedelta(days=2)
52 59
    simple_user.save()
53
    management.call_command('clean-unused-accounts', '1')
54
    assert DeletedUser.objects.get(user=simple_user)
60

  
61
    management.call_command('clean-unused-accounts')
62
    assert not DeletedUser.objects.filter(user=simple_user).exists()
63
    assert len(mailoutbox) == 1
64

  
65
    freezer.move_to('2018-01-01 12:00:00')
66
    # no new mail, no deletion
67
    management.call_command('clean-unused-accounts')
68
    assert not DeletedUser.objects.filter(user=simple_user).exists()
69
    assert len(mailoutbox) == 1
70

  
71
    freezer.move_to('2018-01-02')
72
    management.call_command('clean-unused-accounts')
73
    assert DeletedUser.objects.filter(user=simple_user).exists()
74
    assert len(mailoutbox) == 2
75

  
76

  
77
def test_clean_unused_account_user_logs_in(app, db, simple_user, mailoutbox, freezer):
78
    freezer.move_to('2018-01-01')
79
    simple_user.ou.clean_unused_accounts_alert = 2
80
    simple_user.ou.clean_unused_accounts_deletion = 3
81
    simple_user.ou.save()
82

  
83
    simple_user.last_login = now() - datetime.timedelta(days=2)
84
    simple_user.save()
85

  
86
    management.call_command('clean-unused-accounts')
87
    assert len(mailoutbox) == 1
88

  
89
    login(app, simple_user)
90

  
91
    # the day of deletion, nothing happens
92
    freezer.move_to('2018-01-02')
93
    assert not DeletedUser.objects.filter(user=simple_user).exists()
94
    assert len(mailoutbox) == 1
95

  
96
    # when new alert delay is reached, user gets alerted again
97
    freezer.move_to('2018-01-04')
98
    management.call_command('clean-unused-accounts')
99
    assert not DeletedUser.objects.filter(user=simple_user).exists()
100
    assert len(mailoutbox) == 2
101

  
102

  
103
def test_clean_unused_account_disabled_by_default(db, simple_user, mailoutbox):
104
    simple_user.last_login = now() - datetime.timedelta(days=2)
105
    simple_user.save()
106

  
107
    management.call_command('clean-unused-accounts')
108
    assert not DeletedUser.objects.filter(user=simple_user).exists()
109
    assert len(mailoutbox) == 0
110

  
111

  
112
def test_clean_unused_account_always_alert(db, simple_user, mailoutbox, freezer):
113
    simple_user.ou.clean_unused_accounts_alert = 2
114
    simple_user.ou.clean_unused_accounts_deletion = 3  # one day between alert and actual deletion
115
    simple_user.ou.save()
116

  
117
    simple_user.last_login = now() - datetime.timedelta(days=4)
118
    simple_user.save()
119

  
120
    # even if account last login in past deletion delay, an alert is always sent first
121
    management.call_command('clean-unused-accounts')
122
    assert not len(DeletedUser.objects.filter(user=simple_user))
123
    assert len(mailoutbox) == 1
124

  
125
    # and calling again as no effect, since one day must pass before account is deleted
126
    management.call_command('clean-unused-accounts')
127
    assert not len(DeletedUser.objects.filter(user=simple_user))
128
    assert len(mailoutbox) == 1
55 129

  
56 130

  
57 131
def test_cleanupauthentic(db):
58
-