authentic/src/authentic2/management/commands/check-and-repair.py

576 lines
25 KiB
Python

# authentic2 - versatile identity manager
# Copyright (C) 2010-2019 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# 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 contextlib
import traceback
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.core.management.base import BaseCommand
from django.db import connection
from django.db.models import Count, Q
from django.db.models.functions import Lower
from django.db.transaction import atomic
from django.utils.timezone import localtime
from authentic2 import app_settings
from authentic2.a2_rbac.models import ADMIN_OP
from authentic2.a2_rbac.models import OrganizationalUnit as OU
from authentic2.a2_rbac.models import Permission, Role
from authentic2.a2_rbac.utils import get_operation
from authentic2.custom_user.models import User
try:
from authentic2.a2_rbac.models import MANAGE_MEMBERS_OP # pylint: disable=C0412
except ImportError:
MANAGE_MEMBERS_OP = None
MULTITENANT = 'hobo.multitenant' in settings.INSTALLED_APPS
if MULTITENANT:
from hobo.multitenant.middleware import TenantMiddleware # pylint: disable=import-error
from tenant_schemas.utils import tenant_context # pylint: disable=import-error
class FakeState:
class FakeException(Exception):
pass
def __init__(self, faked=True):
self.__faked = faked
def dofake(self):
self.__faked = True
def dontfake(self):
self.__faked = False
def __bool__(self):
return self.__faked
def __nonzero__(self):
return self.__faked
@contextlib.contextmanager
def fake_atomic(faked=True):
faked = FakeState(faked=faked)
try:
with atomic():
yield faked
if faked:
raise faked.FakeException
except faked.FakeException:
pass
class Command(BaseCommand):
help = 'Check and repair authentic2 datas'
def add_arguments(self, parser):
parser.add_argument('--repair', action='store_true', help='repair what\'s broken', default=False)
parser.add_argument('--noinput', action='store_true', help='do not ask questions', default=False)
parser.add_argument('--fake', action='store_true', help='fake repair', default=False)
if MULTITENANT:
parser.add_argument('-d', '--domain', dest='domain_name', help='specify tenant domain name')
for key in dir(self):
if not key.startswith('check_'):
continue
if not hasattr(self.__class__, key):
continue
method = getattr(self, key)
if callable(method) and method.__name__.startswith('check_'):
slug = method.__name__.replace('_', '-')
parser.add_argument(
'--no-%s' % slug,
action='store_false',
default=True,
dest=method.__name__,
help='disable check %s' % slug,
)
def handle(self, verbosity=0, repair=False, noinput=False, fake=False, domain_name=None, **options):
self.verbosity = verbosity
self.repair = repair
self.noinput = noinput
self.tenant = None
self.tenant_shown = False
self.fake = fake
self.output = False
if MULTITENANT and not getattr(getattr(connection, 'tenant', None), 'domain_url', None):
for tenant in TenantMiddleware.get_tenants():
if domain_name and tenant.domain_url != domain_name:
self.info('Skipping %s', tenant.domain_url)
continue
self.tenant = tenant
with tenant_context(tenant):
self.tenant_shown = False
self.output = False
with fake_atomic(faked=self.fake):
self.check_and_repair(options)
if self.fake and self.output:
self.success('Faked!')
else: # called in monotenant situation or with a tenant already set
self.check_and_repair(options)
def log(self, message, style, *args, **kwargs):
self.output = True
ending = kwargs.pop('ending', None)
if self.tenant and not self.tenant_shown:
self.tenant_shown = True
self.stdout.write('== %s' % self.tenant.domain_url)
message = message % (args or kwargs)
if style:
message = style(message)
self.stdout.write(message, ending=ending)
def error(self, message='', *args, **kwargs):
self.log(message, self.style.ERROR, *args, **kwargs)
def warning(self, message='', *args, **kwargs):
if self.verbosity > 0:
self.log(message, self.style.WARNING, *args, **kwargs)
def notice(self, message='', *args, **kwargs):
self.log(message, None, *args, **kwargs)
def success(self, message='', *args, **kwargs):
self.log(message, self.style.SUCCESS, *args, **kwargs)
def info(self, message='', *args, **kwargs):
if self.verbosity > 1:
self.log(message, None, *args, **kwargs)
@atomic
def check_and_repair(self, options):
for method in [
self.check_roles_with_lost_admin_scope,
self.check_duplicate_manage_members_permissions,
self.check_duplicate_permissions,
self.check_unused_permissions,
self.check_instance_permission_ou,
self.check_admin_roles_ou,
self.check_manager_of_roles,
self.check_identifiers_uniqueness,
]:
if not options[method.__name__]:
continue
try:
method()
except Exception as e:
self.error('Error during %s: %s', method, e)
self.error('%s', traceback.format_exc(5))
def do_repair(self):
if self.repair:
if self.noinput:
return True
self.notice('Repair (Y/N) [default=N] ?', ending=' ')
while True:
answer = input()
if answer.lower() == 'y':
return True
if not answer or answer.lower() == 'n':
return False
def check_roles_with_lost_admin_scope(self):
for role in Role.objects.filter(admin_scope_ct__isnull=False):
try:
role.admin_scope
except ObjectDoesNotExist:
self.warning('Role %s has lost its admin_scope', role)
def check_unused_permissions(self):
permission_ct = ContentType.objects.get_for_model(Permission)
used_permission_ids = set()
used_permission_ids.update(
Role.objects.filter(admin_scope_ct=permission_ct).values_list('admin_scope_id', flat=True)
)
used_permission_ids.update(Role.permissions.through.objects.values_list('permission_id', flat=True))
qs = Permission.objects.exclude(id__in=used_permission_ids)
count = qs.count()
if not count:
return
self.warning('Found %d unused permissions', count)
if self.repair:
for permission in qs:
self.notice('- %s', permission)
if self.do_repair():
self.notice('Deleting unused permissions...', ending=' ')
qs.delete()
self.success('DONE!')
def check_duplicate_manage_members_permissions(self):
ct_ct = ContentType.objects.get_for_model(ContentType)
permission_ct = ContentType.objects.get_for_model(Permission)
manage_members_op = get_operation(MANAGE_MEMBERS_OP)
permissions = Permission.objects.exclude(target_ct=ct_ct).filter(operation=manage_members_op)
targets_with_duplicates = set(
permissions.values_list('operation_id', 'target_ct_id', 'target_id')
.annotate(count=Count(('operation_id', 'target_ct_id', 'target_id')))
.filter(count__gt=1)
.values_list('operation_id', 'target_ct_id', 'target_id')
)
if targets_with_duplicates:
self.warning('Found %d manage members permissions with duplicates', len(targets_with_duplicates))
if self.repair:
for dummy_operation_id, target_ct_id, target_id in targets_with_duplicates:
qs = Permission.objects.filter(target_ct_id=target_ct_id, target_id=target_id)
for perm in qs:
linked_admin_role = Role.objects.get(
admin_scope_ct=permission_ct, admin_scope_id=perm.id
)
target = (
ContentType.objects.get_for_id(target_ct_id).model_class().objects.get(pk=target_id)
)
self.notice(' - %s: [%s]', target, '; '.join(map(str, qs)))
if self.do_repair():
self.notice('Deleting duplicate manage members permissions...', ending=' ')
for dummy_operation_id, target_ct_id, target_id in targets_with_duplicates:
qs = Permission.objects.filter(target_ct_id=target_ct_id, target_id=target_id).order_by(
'id'
)
role_perms = []
for perm in qs:
linked_admin_role = Role.objects.filter(
admin_scope_ct=permission_ct, admin_scope_id=perm.id
).first()
if linked_admin_role:
role_perms.append((perm, linked_admin_role))
else:
perm.delete()
if role_perms:
first_role = role_perms[0][1]
user_ids = set()
user_ids.update(first_role.all_members().values_list('id', flat=True))
for perm, role in role_perms[1:]:
user_ids.update(role.all_members().values_list('id', flat=True))
first_role.members.add(*role.members.all())
for child_role in role.children(include_self=False):
first_role.add_child(child_role)
role.delete()
perm.delete()
assert first_role.all_members().distinct().count() == len(user_ids)
self.success('DONE!')
def check_duplicate_permissions(self):
ct_ct = ContentType.objects.get_for_model(ContentType)
permissions = Permission.objects.exclude(target_ct=ct_ct)
targets_with_duplicates = set(
permissions.values_list('operation_id', 'target_ct_id', 'target_id')
.annotate(count=Count(('operation_id', 'target_ct_id', 'target_id')))
.filter(count__gt=1)
.values_list('operation_id', 'target_ct_id', 'target_id')
)
if targets_with_duplicates:
self.warning('Found %d instance permissions with duplicates', len(targets_with_duplicates))
if self.repair:
for operation_id, target_ct_id, target_id in targets_with_duplicates:
qs = Permission.objects.filter(
operation_id=operation_id, target_ct_id=target_ct_id, target_id=target_id
)
target = (
ContentType.objects.get_for_id(target_ct_id).model_class().objects.get(pk=target_id)
)
self.notice(' - %s: [%s]', target, '; '.join(map(str, qs)))
if self.do_repair():
self.notice('Deleting duplicate permissions...', ending=' ')
for operation_id, target_ct_id, target_id in targets_with_duplicates:
qs = list(
Permission.objects.filter(
operation_id=operation_id, target_ct_id=target_ct_id, target_id=target_id
).order_by('id')
)
first_perm = qs[0]
for perm in qs[1:]:
perm.delete()
if first_perm.ou:
first_perm.ou = None
first_perm.save()
self.success('DONE!')
def check_instance_permission_ou(self):
ct_ct = ContentType.objects.get_for_model(ContentType)
permissions = Permission.objects.exclude(target_ct=ct_ct).filter(ou__isnull=False)
count = permissions.count()
if count:
self.warning('Found %d instance permissions with an ou.', count)
if self.do_repair():
self.notice('Changing ou of instance permissions...', ending=' ')
permissions.update(ou=None)
self.success('DONE!')
def check_admin_roles_ou(self):
permission_ct = ContentType.objects.get_for_model(Permission)
role_ct = ContentType.objects.get_for_model(Role)
manage_members_op = get_operation(MANAGE_MEMBERS_OP)
roles_to_fix = {}
for ou in OU.objects.all():
roles = Role.objects.exclude(admin_scope_ct=permission_ct).filter(ou=ou)
permissions = Permission.objects.filter(
operation=manage_members_op, target_ct=role_ct, target_id__in=roles.values_list('id')
)
wrong_ou_roles = Role.objects.filter(
admin_scope_ct=permission_ct, admin_scope_id__in=permissions.values_list('id')
).exclude(ou=ou)
if wrong_ou_roles:
self.warning('Found %4d admin role with wrong ou in ou %s', wrong_ou_roles.count(), ou)
roles_to_fix[ou] = wrong_ou_roles
if roles_to_fix and self.do_repair():
self.notice('Changing ou of admin roles...', ending=' ')
for ou, role in roles_to_fix.items():
role.update(ou=ou)
self.success('DONE!')
def check_manager_of_roles(self):
permission_ct = ContentType.objects.get_for_model(Permission)
role_ct = ContentType.objects.get_for_model(Role)
admin_op = get_operation(ADMIN_OP)
operations = [admin_op]
if MANAGE_MEMBERS_OP:
manage_members_op = get_operation(MANAGE_MEMBERS_OP)
operations.append(manage_members_op)
roles = Role.objects.exclude(slug__startswith='_a2-managers-of-role-')
for role in roles:
manager_perms = Permission.objects.filter(
operation__in=operations, target_ct=role_ct, target_id=role.id
)
manager_perms_ids = manager_perms.values_list('id', flat=True)
manager_roles = Role.objects.filter(
slug__startswith='_a2-managers-of-role-',
admin_scope_ct=permission_ct,
admin_scope_id__in=manager_perms_ids,
)
role_shown = False
to_delete = []
to_change_ou = []
with fake_atomic() as fake_state:
admin_role = role.get_admin_role()
ok = set(manager_roles) <= {admin_role}
if ok and manager_roles:
if list(manager_roles)[0].ou != role.ou:
self.warning(
'- "%s" detected wrong ou, should be "%s" and is "%s"',
admin_role,
role.ou,
admin_role.ou,
)
to_change_ou.append((admin_role, role.ou))
continue
add_members = set()
add_children = set()
for manager_role in manager_roles:
if manager_role == admin_role:
continue
members_count = manager_role.all_members().count()
direct_members = manager_role.members.all()
direct_members_count = direct_members.count()
direct_children = Role.objects.filter(
parent_relation__deleted__isnull=True,
parent_relation__parent=manager_role,
parent_relation__direct=True,
)
direct_children_count = direct_children.count()
show = members_count or self.verbosity > 1
if show:
if not role_shown:
role_shown = True
self.notice('- "%s" has problematic manager roles', role)
self.warning(' - %s', manager_role, ending=' ')
direct_parents = Role.objects.filter(
child_relation__deleted__isnull=True,
child_relation__child=manager_role,
child_relation__direct=True,
)
if show:
self.warning('DELETE', ending=' ')
to_delete.append(manager_role)
if manager_role.ou != role.ou:
if show:
self.warning('WRONG_OU', ending=' ')
if manager_role.admin_scope.ou != role.ou:
if show:
self.warning('WRONG_PERMISSION_OU', ending=' ')
if MANAGE_MEMBERS_OP and manager_role.admin_scope.operation == admin_op:
if show:
self.warning('WRONG_PERMISSION_OPERATION', ending=' ')
if direct_members_count:
if show:
self.warning('MEMBERS(%d)', direct_members_count, ending=' ')
add_members.update(direct_members)
if (members_count - direct_children_count) and show:
self.warning('INDIRECT_MEMBERS(%d)', members_count - direct_members_count, ending=' ')
if direct_children_count:
if show:
self.warning('CHILDREN(%d)', direct_children_count, ending=' ')
add_children.update(direct_children)
if show:
self.notice('')
if direct_parents.exists() and show:
self.error(' SHOULD NOT HAVE PARENTS')
for parent in direct_parents:
self.error(' - %s(id=%s)', parent, parent.id)
repair = (self.repair and self.noinput) or (self.repair and self.do_repair())
if repair:
if add_children or add_members:
fake_state.dontfake()
if add_members:
admin_role.members.add(*add_members)
add_members = None
if add_children:
for child in add_children:
admin_role.add_child(child)
add_children = None
if repair:
for role_to_delete in to_delete:
role_to_delete.delete()
for admin_role, ou in to_change_ou:
admin_role.ou = ou
admin_role.save(update_fields=['ou'])
if role_shown:
self.notice('')
self.stdout.flush()
for admin_role in Role.objects.filter(slug__startswith='_a2-managers-of-role-'):
if not admin_role.admin_scope:
self.warning('invalid admin role "%s": no admin scope', admin_role)
admin_permissions = (
admin_role.permissions.filter(operation__in=operations, target_ct=role_ct)
.select_related('ou')
.prefetch_related('target__ou')
)
count = admin_permissions.count()
if not count:
self.warning('invalid admin role "%s" no admin permission', admin_role)
elif count != 2:
self.warning('invalid admin role "%s" too few or too many admin permissions', admin_role)
for admin_permission in admin_permissions:
self.notice(' - %s', admin_permission)
for admin_permission in admin_permissions:
if MANAGE_MEMBERS_OP and admin_permission.operation != manage_members_op:
self.warning(
'invalid admin role "%s" invalid permission "%s": not manage_members operation',
admin_role,
admin_permission,
)
if not (
(admin_permission.target != admin_role and admin_permission == admin_role.admin_scope)
or (admin_permission.target == admin_role)
):
self.warning(
'invalid admin role "%s" invalid permission "%s": not admin_scope and not self manage'
' permission',
admin_role,
admin_permission,
)
if admin_permission.ou is not None:
self.warning(
'invalid admin role "%s" invalid permission "%s": wrong ou "%s"',
admin_role,
admin_permission,
admin_permission.ou,
)
admin_permission.target.get_admin_role()
if admin_permission.target.ou != admin_role.ou:
self.warning(
'invalid admin role "%s" wrong ou, should be "%s" is "%s"',
admin_role,
admin_permission.target.ou,
admin_role.ou,
)
def duplicate_emails(self, user_qs):
return (
user_qs.order_by()
.values(iemail=Lower('email'))
.annotate(count_id=Count('id'))
.filter(count_id__gt=1)
.values_list('iemail', flat=True)
.exclude(iemail='')
)
def duplicate_username(self, user_qs):
return (
user_qs.order_by()
.values('username')
.exclude(Q(username__isnull=True) | Q(username=''))
.annotate(count_id=Count('id'))
.filter(count_id__gt=1)
.values_list('username', flat=True)
)
def check_identifiers_uniqueness(self):
users = User.objects.prefetch_related('userexternalid_set')
if app_settings.A2_USERNAME_IS_UNIQUE:
self._check_username_uniqueness(users, 'Username should be globally unique')
for ou in OU.objects.all():
ou_users = users.filter(ou=ou)
if ou.email_is_unique:
self._check_email_uniqueness(ou_users, 'Email should be unique in ou "%s"' % ou)
if ou.username_is_unique:
self._check_username_uniqueness(ou_users, 'Username should be unique in ou "%s"' % ou)
def _check_email_uniqueness(self, qs, msg):
emails = self.duplicate_emails(qs)
if not emails:
return
users = qs.annotate(iemail=Lower('email'))
count = users.filter(iemail__in=emails).count()
self.warning('%s, found %%d user accounts with same email:' % msg, count)
for email in emails:
self.notice('- %s :', email)
self.show_users(users.filter(iemail=email))
def show_users(self, users):
for user in users:
self.notice(' * "%s" %s ', user, user, ending=' ')
self.notice('(created %s', localtime(user.date_joined).strftime('%Y-%m-%dT%H:%M:%S'), ending=' ')
if user.last_login:
self.notice(
', last login %s', localtime(user.last_login).strftime('%Y-%m-%dT%H:%M:%S'), ending=''
)
else:
self.notice(', never logged in', ending='')
external_ids = list(user.userexternalid_set.all())
if external_ids:
self.notice(
', external-ids: %s',
', '.join(
external_id.source + '#' + external_id.external_id for external_id in external_ids
),
ending='',
)
self.notice(')')
def _check_username_uniqueness(self, qs, msg):
usernames = self.duplicate_username(qs)
if not usernames:
return
users = qs.filter(username__in=usernames)
self.warning('%s, found %%d user accounts with same username:' % msg, users.count())
for username in usernames:
self.notice('- %s :', username)
self.show_users(qs.filter(username=username))