0002-commands-per-OU-unused-user-accounts-cleaning-policy.patch
src/authentic2/a2_rbac/migrations/0022_auto_20200318_1004.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2020-03-18 09:04 |
|
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 |
('a2_rbac', '0021_auto_20200317_1514'), |
|
12 |
] |
|
13 | ||
14 |
operations = [ |
|
15 |
migrations.AddField( |
|
16 |
model_name='organizationalunit', |
|
17 |
name='clean_unused_accounts_alert', |
|
18 |
field=models.IntegerField(blank=True, null=True, verbose_name='Days after which the user receives an account deletion alert'), |
|
19 |
), |
|
20 |
migrations.AddField( |
|
21 |
model_name='organizationalunit', |
|
22 |
name='clean_unused_accounts_deletion', |
|
23 |
field=models.IntegerField(blank=True, null=True, verbose_name='Delay in days before cleaning unused accounts'), |
|
24 |
), |
|
25 |
] |
src/authentic2/a2_rbac/models.py | ||
---|---|---|
95 | 95 |
choices=USER_ADD_PASSWD_POLICY_CHOICES, |
96 | 96 |
default=0) |
97 | 97 | |
98 |
clean_unused_accounts_alert = models.IntegerField( |
|
99 |
verbose_name=_('Days after which the user receives an account deletion alert'), |
|
100 |
null=True, |
|
101 |
blank=True) |
|
102 | ||
103 |
clean_unused_accounts_deletion = models.IntegerField( |
|
104 |
verbose_name=_('Delay in days before cleaning unused accounts'), |
|
105 |
null=True, |
|
106 |
blank=True) |
|
107 | ||
98 | 108 |
objects = managers.OrganizationalUnitManager() |
99 | 109 | |
100 | 110 |
class Meta: |
... | ... | |
119 | 129 |
raise ValidationError(_('You cannot unset this organizational ' |
120 | 130 |
'unit as the default, but you can set ' |
121 | 131 |
'another one as the default.')) |
132 |
if self.clean_unused_accounts_alert and \ |
|
133 |
self.clean_unused_accounts_alert >= self.clean_unused_accounts_deletion: |
|
134 |
raise ValidationError(_('Deletion alert delay must be less than actual deletion delay.')) |
|
122 | 135 |
super(OrganizationalUnit, self).clean() |
123 | 136 | |
124 | 137 |
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 |
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 |
def test_unused_account_settings_validation(ou1): |
|
499 |
ou1.clean_unused_accounts_alert = 4 |
|
500 |
ou1.clean_unused_accounts_deletion = 3 |
|
501 |
with pytest.raises(ValidationError): |
|
502 |
ou1.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 | ||
---|---|---|
47 | 47 |
assert old_pass != simple_user.password |
48 | 48 | |
49 | 49 | |
50 |
def test_clean_unused_account(simple_user): |
|
50 |
def test_clean_unused_account(db, simple_user, mailoutbox, freezer): |
|
51 |
freezer.move_to('2018-01-01') |
|
52 |
simple_user.ou.clean_unused_accounts_alert = 2 |
|
53 |
simple_user.ou.clean_unused_accounts_deletion = 3 |
|
54 |
simple_user.ou.save() |
|
55 | ||
56 |
simple_user.last_login = now() - datetime.timedelta(days=2) |
|
57 |
simple_user.save() |
|
58 | ||
59 |
management.call_command('clean-unused-accounts') |
|
60 |
assert not DeletedUser.objects.filter(user=simple_user).exists() |
|
61 |
assert len(mailoutbox) == 1 |
|
62 | ||
63 |
freezer.move_to('2018-01-01 12:00:00') |
|
64 |
# no new mail, no deletion |
|
65 |
management.call_command('clean-unused-accounts') |
|
66 |
assert not DeletedUser.objects.filter(user=simple_user).exists() |
|
67 |
assert len(mailoutbox) == 1 |
|
68 | ||
69 |
freezer.move_to('2018-01-02') |
|
70 |
management.call_command('clean-unused-accounts') |
|
71 |
assert DeletedUser.objects.filter(user=simple_user).exists() |
|
72 |
assert len(mailoutbox) == 2 |
|
73 | ||
74 | ||
75 |
def test_clean_unused_account_disabled_by_default(db, simple_user, mailoutbox): |
|
51 | 76 |
simple_user.last_login = now() - datetime.timedelta(days=2) |
52 | 77 |
simple_user.save() |
53 |
management.call_command('clean-unused-accounts', '1') |
|
54 |
assert DeletedUser.objects.get(user=simple_user) |
|
78 | ||
79 |
management.call_command('clean-unused-accounts') |
|
80 |
assert not DeletedUser.objects.filter(user=simple_user).exists() |
|
81 |
assert len(mailoutbox) == 0 |
|
82 | ||
83 | ||
84 |
def test_clean_unused_account_always_alert(db, simple_user, mailoutbox, freezer): |
|
85 |
simple_user.ou.clean_unused_accounts_alert = 2 |
|
86 |
simple_user.ou.clean_unused_accounts_deletion = 3 # one day between alert and actual deletion |
|
87 |
simple_user.ou.save() |
|
88 | ||
89 |
simple_user.last_login = now() - datetime.timedelta(days=4) |
|
90 |
simple_user.save() |
|
91 | ||
92 |
# even if account last login in past deletion delay, an alert is always sent first |
|
93 |
management.call_command('clean-unused-accounts') |
|
94 |
assert not len(DeletedUser.objects.filter(user=simple_user)) |
|
95 |
assert len(mailoutbox) == 1 |
|
96 | ||
97 |
# and calling again as no effect, since one day must pass before account is deleted |
|
98 |
management.call_command('clean-unused-accounts') |
|
99 |
assert not len(DeletedUser.objects.filter(user=simple_user)) |
|
100 |
assert len(mailoutbox) == 1 |
|
55 | 101 | |
56 | 102 | |
57 | 103 |
def test_cleanupauthentic(db): |
58 |
- |