0001-per-OU-unused-user-accounts-cleaning-policy-26909.patch
src/authentic2/a2_rbac/migrations/0020_auto_20190329_1539.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2019-03-29 14:39 |
|
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', '0019_organizationalunit_show_username'), |
|
12 |
] |
|
13 | ||
14 |
operations = [ |
|
15 |
migrations.AddField( |
|
16 |
model_name='organizationalunit', |
|
17 |
name='clean_unused_accounts_alert', |
|
18 |
field=models.IntegerField(default=0, 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(default=0, verbose_name='Delay in days before cleaning unused accounts'), |
|
24 |
), |
|
25 |
migrations.AddField( |
|
26 |
model_name='organizationalunit', |
|
27 |
name='clean_unused_accounts_filter', |
|
28 |
field=models.CharField(blank=True, default=None, max_length=511, null=True, verbose_name='json dict filter to apply for unused account cleanup'), |
|
29 |
), |
|
30 |
migrations.AddField( |
|
31 |
model_name='organizationalunit', |
|
32 |
name='clean_unused_accounts_from_email', |
|
33 |
field=models.EmailField(blank=True, default=None, max_length=255, null=True, verbose_name='sender email address for account deletion warnings and notifications'), |
|
34 |
), |
|
35 |
] |
src/authentic2/a2_rbac/models.py | ||
---|---|---|
1 |
import os |
|
2 | ||
1 | 3 |
from collections import namedtuple |
2 | 4 |
from django.core.exceptions import ValidationError |
3 | 5 |
from django.utils.translation import ugettext_lazy as _ |
... | ... | |
76 | 78 |
choices=USER_ADD_PASSWD_POLICY_CHOICES, |
77 | 79 |
default=0) |
78 | 80 | |
81 |
clean_unused_accounts_alert = models.IntegerField( |
|
82 |
verbose_name=_('Days after which the user receives an account deletion ' |
|
83 |
'alert'), |
|
84 |
default=0) |
|
85 | ||
86 |
clean_unused_accounts_deletion = models.IntegerField( |
|
87 |
verbose_name=_('Delay in days before cleaning unused accounts'), |
|
88 |
default=0) |
|
89 | ||
90 |
clean_unused_accounts_from_email = models.EmailField( |
|
91 |
verbose_name=_('sender email address for account deletion warnings and notifications'), |
|
92 |
null=True, blank=True, default=None, max_length=255) |
|
93 | ||
94 |
clean_unused_accounts_filter = models.CharField( |
|
95 |
verbose_name=_('json dict filter to apply for unused account cleanup'), |
|
96 |
null=True, blank=True, default=None, max_length=511) |
|
97 | ||
79 | 98 |
objects = managers.OrganizationalUnitManager() |
80 | 99 | |
81 | 100 |
class Meta: |
src/authentic2/custom_user/migrations/0017_user_last_account_deletion_alert.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2019-03-29 14:39 |
|
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', '0016_auto_20180925_1107'), |
|
12 |
] |
|
13 | ||
14 |
operations = [ |
|
15 |
migrations.AddField( |
|
16 |
model_name='user', |
|
17 |
name='last_account_deletion_alert', |
|
18 |
field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='last account deletion alert'), |
|
19 |
), |
|
20 |
] |
src/authentic2/custom_user/models.py | ||
---|---|---|
126 | 126 |
verbose_name=_('Last modification time'), |
127 | 127 |
db_index=True, |
128 | 128 |
auto_now=True) |
129 |
last_account_deletion_alert = models.DateTimeField( |
|
130 |
_('last account deletion alert'), null=True, blank=True, default=None) |
|
129 | 131 | |
130 | 132 |
objects = UserManager.from_queryset(UserQuerySet)() |
131 | 133 |
attributes = AttributesDescriptor() |
src/authentic2/management/commands/clean-unused-accounts.py | ||
---|---|---|
1 | 1 |
from __future__ import print_function |
2 | 2 | |
3 |
import json |
|
3 | 4 |
import logging |
4 |
import datetime |
|
5 | 5 | |
6 |
from datetime import timedelta |
|
6 | 7 |
from django.contrib.auth import get_user_model |
7 | 8 |
from django.core.management.base import BaseCommand, CommandError |
8 |
from django.core.mail import send_mail |
|
9 | 9 |
from django.utils.timezone import now |
10 | 10 |
from django.template.loader import render_to_string |
11 |
from django_rbac.utils import get_ou_model |
|
11 | 12 | |
12 | 13 |
from authentic2.models import DeletedUser |
14 |
from authentic2.utils import send_templated_mail |
|
13 | 15 | |
14 | 16 |
from django.conf import settings |
15 | 17 | |
... | ... | |
24 | 26 |
help = '''Clean unused accounts''' |
25 | 27 | |
26 | 28 |
def add_arguments(self, parser): |
27 |
parser.add_argument('clean_threshold', type=int) |
|
28 |
parser.add_argument( |
|
29 |
"--alert-thresholds", |
|
30 |
help='list of durations before sending an alert ' |
|
31 |
'message for unused account, default is none', |
|
32 |
default=None) |
|
33 |
parser.add_argument( |
|
34 |
"--period", type=int, |
|
35 |
help='period between two calls to ' |
|
36 |
'clean-unused-accounts as days, default is 1', |
|
37 |
default=1 |
|
38 |
) |
|
39 | 29 |
parser.add_argument("--fake", action='store_true', help='do nothing', default=False) |
40 |
parser.add_argument( |
|
41 |
"--filter", help='filter to apply to the user queryset, ' |
|
42 |
'the Django filter key and value are separated by character =', action='append', |
|
43 |
default=[] |
|
44 |
) |
|
45 |
parser.add_argument( |
|
46 |
'--from-email', default=settings.DEFAULT_FROM_EMAIL, |
|
47 |
help='sender address for notifications, default is DEFAULT_FROM_EMAIL from settings' |
|
48 |
) |
|
49 | 30 | |
50 | 31 |
def handle(self, *args, **options): |
51 | 32 |
log = logging.getLogger(__name__) |
... | ... | |
55 | 36 |
log.exception('failure while cleaning unused accounts') |
56 | 37 | |
57 | 38 |
def clean_unused_acccounts(self, *args, **options): |
58 |
if options['period'] < 1: |
|
59 |
raise CommandError('period must be > 0') |
|
60 | ||
61 |
clean_threshold = options['clean_threshold'] |
|
62 |
if clean_threshold < 1: |
|
63 |
raise CommandError('clean_threshold must be an integer > 0') |
|
64 | ||
65 | 39 |
if options['verbosity'] == '0': |
66 | 40 |
logging.basicConfig(level=logging.CRITICAL) |
67 | 41 |
if options['verbosity'] == '1': |
... | ... | |
74 | 48 |
log = logging.getLogger(__name__) |
75 | 49 |
n = now().replace(hour=0, minute=0, second=0, microsecond=0) |
76 | 50 |
self.fake = options['fake'] |
77 |
self.from_email = options['from_email'] |
|
78 | 51 |
if self.fake: |
79 | 52 |
log.info('fake call to clean-unused-accounts') |
80 | 53 |
users = get_user_model().objects.all() |
81 |
if options['filter']: |
|
82 |
for f in options['filter']: |
|
83 |
key, value = f.split('=', 1) |
|
54 |
for ou in get_ou_model().objects.all(): |
|
55 |
if ou.clean_unused_accounts_filter: |
|
84 | 56 |
try: |
85 |
users = users.filter(**{key: value}) |
|
57 |
for key, value in json.loads(ou.clean_unused_accounts_filter): |
|
58 |
users = users.filter(**{key: value}) |
|
86 | 59 |
except: |
87 |
raise CommandError('invalid --filter %s' % f) |
|
88 |
if options['alert_thresholds']: |
|
89 |
alert_thresholds = options['alert_thresholds'] |
|
90 |
alert_thresholds = alert_thresholds.split(',') |
|
91 |
try: |
|
92 |
alert_thresholds = map(int, alert_thresholds) |
|
93 |
except ValueError: |
|
94 |
raise CommandError('alert_thresholds must be a comma ' |
|
95 |
'separated list of integers') |
|
96 |
for threshold in alert_thresholds: |
|
97 |
if not (0 < threshold < clean_threshold): |
|
98 |
raise CommandError('alert-threshold must a positive integer ' |
|
99 |
'inferior to clean-threshold: 0 < %d < %d' % ( |
|
100 |
threshold, clean_threshold)) |
|
101 |
for threshold in alert_thresholds: |
|
102 |
a = n - datetime.timedelta(days=threshold) |
|
103 |
b = n - datetime.timedelta(days=threshold-options['period']) |
|
104 |
for user in users.filter(last_login__lt=b, last_login__gte=a): |
|
105 |
log.info('%s last login %d days ago, sending alert', user, threshold) |
|
106 |
self.send_alert(user, threshold, clean_threshold-threshold) |
|
107 |
threshold = n - datetime.timedelta(days=clean_threshold) |
|
108 |
for user in users.filter(last_login__lt=threshold): |
|
109 |
d = n - user.last_login |
|
110 |
log.info('%s last login %d days ago, deleting user', user, d.days) |
|
111 |
self.delete_user(user, clean_threshold) |
|
112 | ||
113 | ||
114 |
def send_alert(self, user, threshold, clean_threshold): |
|
115 |
ctx = { 'user': user, 'threshold': threshold, |
|
116 |
'clean_threshold': clean_threshold } |
|
117 |
self.send_mail('authentic2/unused_account_alert', user, ctx) |
|
118 | ||
119 | ||
120 |
def send_mail(self, prefix, user, ctx): |
|
60 |
raise CommandError('invalid filter {}: {}'.format( |
|
61 |
key, value)) |
|
62 |
if not ou.clean_unused_accounts_deletion: |
|
63 |
continue |
|
64 | ||
65 |
deletion = timedelta(days=ou.clean_unused_accounts_deletion) |
|
66 |
alert = timedelta(days=ou.clean_unused_accounts_alert) |
|
67 | ||
68 |
for user in users.filter(ou=ou, last_login__lt=n-alert): |
|
69 |
if n - user.last_login >= deletion and \ |
|
70 |
user.last_account_deletion_alert and \ |
|
71 |
n - user.last_account_deletion_alert >= alert: |
|
72 |
log.info( |
|
73 |
'%s last login more than %d days ago, deleting user', |
|
74 |
user, ou.clean_unused_accounts_deletion) |
|
75 |
self.delete_user(user) |
|
76 |
elif not user.last_account_deletion_alert or \ |
|
77 |
n - user.last_account_deletion_alert >= alert: |
|
78 |
log.info('%s last login %d days ago, sending alert', user, |
|
79 |
ou.clean_unused_accounts_alert) |
|
80 |
self.send_alert(user) |
|
81 |
user.last_account_deletion_alert = n |
|
82 |
user.save() |
|
83 | ||
84 | ||
85 |
def send_alert(self, user): |
|
86 |
ctx = { |
|
87 |
'user': user, |
|
88 |
'threshold': user.ou.clean_unused_accounts_alert, |
|
89 |
'clean_threshold': user.ou.clean_unused_accounts_deletion |
|
90 |
} |
|
91 |
self.send_mail( |
|
92 |
[ |
|
93 |
'authentic2/unused_account_alert_{}'.format(user.ou.slug), |
|
94 |
'authentic2/unused_account_alert' #default fallback template |
|
95 |
], |
|
96 |
user, |
|
97 |
ctx) |
|
98 | ||
99 | ||
100 |
def send_mail(self, template_names, user, ctx): |
|
121 | 101 |
log = logging.getLogger(__name__) |
122 | 102 | |
123 | 103 |
if not user.email: |
124 | 104 |
log.debug('%s has no email, no mail sent', user) |
125 |
subject = render_to_string(prefix + '_subject.txt', ctx).strip() |
|
126 |
body = render_to_string(prefix + '_body.txt', ctx) |
|
105 |
from_email = user.ou.clean_unused_accounts_from_email |
|
127 | 106 |
if not self.fake: |
128 | 107 |
try: |
129 | 108 |
log.debug('sending mail to %s', user.email) |
130 |
send_mail(subject, body, self.from_email, [user.email]) |
|
109 |
send_templated_mail( |
|
110 |
user.email, template_names, from_email=from_email) |
|
131 | 111 |
except: |
132 | 112 |
log.exception('email sending failure') |
133 | 113 | |
134 | 114 | |
135 |
def delete_user(self, user, threshold): |
|
136 |
ctx = { 'user': user, 'threshold': threshold } |
|
137 |
self.send_mail('authentic2/unused_account_delete', user, |
|
138 |
ctx) |
|
115 |
def delete_user(self, user): |
|
116 |
ctx = { |
|
117 |
'user': user, |
|
118 |
'threshold': user.ou.clean_unused_accounts_deletion |
|
119 |
} |
|
120 |
self.send_mail( |
|
121 |
[ |
|
122 |
'authentic2/unused_account_delete_{}'.format(user.ou.slug), |
|
123 |
'authentic2/unused_account_delete' #default fallback template |
|
124 |
], |
|
125 |
user, ctx) |
|
139 | 126 |
if not self.fake: |
140 | 127 |
DeletedUser.objects.delete_user(user) |
tests/test_api.py | ||
---|---|---|
369 | 369 |
'first_name', 'first_name_verified', 'last_name', |
370 | 370 |
'last_name_verified', 'date_joined', 'last_login', |
371 | 371 |
'username', 'password', 'email', 'is_active', 'title', |
372 |
'title_verified', 'modified', 'email_verified']) == set(resp.json.keys()) |
|
372 |
'title_verified', 'modified', 'email_verified', |
|
373 |
'last_account_deletion_alert']) == set(resp.json.keys()) |
|
373 | 374 |
assert resp.json['first_name'] == payload['first_name'] |
374 | 375 |
assert resp.json['last_name'] == payload['last_name'] |
375 | 376 |
assert resp.json['email'] == payload['email'] |
... | ... | |
435 | 436 |
'first_name', 'first_name_verified', 'last_name', |
436 | 437 |
'last_name_verified', 'date_joined', 'last_login', |
437 | 438 |
'username', 'password', 'email', 'is_active', 'title', |
438 |
'title_verified', 'modified', 'email_verified']) == set(resp.json.keys()) |
|
439 |
'title_verified', 'modified', 'email_verified', |
|
440 |
'last_account_deletion_alert']) == set(resp.json.keys()) |
|
439 | 441 |
user = get_user_model().objects.get(pk=resp.json['id']) |
440 | 442 |
assert AttributeValue.objects.with_owner(user).filter(verified=True).count() == 3 |
441 | 443 |
assert AttributeValue.objects.with_owner(user).filter(verified=False).count() == 0 |
tests/test_commands.py | ||
---|---|---|
8 | 8 | |
9 | 9 |
from authentic2.models import Attribute, DeletedUser |
10 | 10 |
from authentic2_auth_oidc.models import OIDCProvider |
11 |
from django.contrib.auth import get_user_model |
|
11 | 12 |
from django_rbac.utils import get_ou_model |
12 | 13 | |
13 | 14 | |
... | ... | |
24 | 25 |
assert old_pass != simple_user.password |
25 | 26 | |
26 | 27 | |
27 |
def test_clean_unused_account(simple_user): |
|
28 |
simple_user.last_login = now() - datetime.timedelta(days=2) |
|
28 |
def test_clean_unused_account(db, simple_user, mailoutbox): |
|
29 |
User = get_user_model() |
|
30 | ||
31 |
simple_user.ou.clean_unused_accounts_alert = 2 |
|
32 |
simple_user.ou.clean_unused_accounts_deletion = 3 |
|
33 |
simple_user.ou.save() |
|
34 | ||
35 |
simple_user.last_login = now() - datetime.timedelta(days=4) |
|
36 |
simple_user.save() |
|
37 | ||
38 |
management.call_command('clean-unused-accounts') |
|
39 |
assert not len(DeletedUser.objects.filter(user=simple_user)) |
|
40 |
assert len(mailoutbox) == 1 |
|
41 | ||
42 |
simple_user.last_account_deletion_alert = now() - datetime.timedelta(days=4) |
|
43 |
simple_user.last_login = now() - datetime.timedelta(days=5) |
|
29 | 44 |
simple_user.save() |
30 |
management.call_command('clean-unused-accounts', '1') |
|
45 | ||
46 |
management.call_command('clean-unused-accounts') |
|
31 | 47 |
assert DeletedUser.objects.get(user=simple_user) |
48 |
assert len(mailoutbox) == 2 |
|
32 | 49 | |
33 | 50 | |
34 | 51 |
def test_cleanupauthentic(db): |
35 |
- |