456 lines
16 KiB
Python
456 lines
16 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 datetime
|
|
import threading
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db import connection, models
|
|
from django.db.models import query
|
|
from django.db.models.query import Prefetch, Q
|
|
from django.db.transaction import atomic
|
|
|
|
from . import models as a2_models
|
|
from . import signals
|
|
from .utils import get_operation
|
|
|
|
|
|
class AbstractBaseManager(models.Manager):
|
|
def get_by_natural_key(self, uuid):
|
|
return self.get(uuid=uuid)
|
|
|
|
|
|
class OperationManager(models.Manager):
|
|
def get_by_natural_key(self, slug):
|
|
return self.get(slug=slug)
|
|
|
|
def has_perm(self, user, operation_slug, object_or_model, ou=None):
|
|
"""Test if an user can do the operation given by operation_slug
|
|
on the given object_or_model eventually scoped by an organizational
|
|
unit given by ou.
|
|
|
|
Returns True or False.
|
|
"""
|
|
ou_query = query.Q(ou__isnull=True)
|
|
if ou:
|
|
ou_query |= query.Q(ou=ou.as_scope())
|
|
ct = ContentType.objects.get_for_model(object_or_model)
|
|
target_query = query.Q(target_ct=ContentType.objects.get_for_model(ContentType), target_id=ct.pk)
|
|
if isinstance(object_or_model, models.Model):
|
|
target_query |= query.Q(target_ct=ct, target_id=object.pk)
|
|
qs = a2_models.Permission.objects.for_user(user)
|
|
qs = qs.filter(operation__slug=operation_slug)
|
|
qs = qs.filter(ou_query & target_query)
|
|
return qs.exists()
|
|
|
|
|
|
class PermissionManagerBase(models.Manager):
|
|
def get_by_natural_key(self, operation_slug, ou_nk, target_ct, target_nk):
|
|
qs = self.filter(operation__slug=operation_slug)
|
|
if ou_nk:
|
|
try:
|
|
ou = a2_models.OrganizationalUnit.objects.get_by_natural_key(*ou_nk)
|
|
except a2_models.OrganizationalUnit.DoesNotExist:
|
|
raise self.model.DoesNotExist
|
|
qs = qs.filter(ou=ou)
|
|
else:
|
|
qs = qs.filter(ou__isnull=True)
|
|
try:
|
|
target_ct = ContentType.objects.get_by_natural_key(*target_ct)
|
|
except ContentType.DoesNotExist:
|
|
raise self.model.DoesNotExist
|
|
target_model = target_ct.model_class()
|
|
try:
|
|
target = target_model.objects.get_by_natural_key(*target_nk)
|
|
except target_model.DoesNotExist:
|
|
raise self.model.DoesNotExist
|
|
return qs.get(target_ct=ContentType.objects.get_for_model(target), target_id=target.pk)
|
|
|
|
|
|
class PermissionQueryset(query.QuerySet):
|
|
def by_target_ct(self, target):
|
|
"""Filter permission whose target content-type matches the content
|
|
type of the target argument
|
|
"""
|
|
target_ct = ContentType.objects.get_for_model(target)
|
|
return self.filter(target_ct=target_ct)
|
|
|
|
def by_target(self, target):
|
|
'''Filter permission whose target matches target'''
|
|
return self.by_target_ct(target).filter(target_id=target.pk)
|
|
|
|
def for_user(self, user):
|
|
"""Retrieve all permissions hold by an user through its role and
|
|
inherited roles.
|
|
"""
|
|
roles = a2_models.Role.objects.for_user(user=user)
|
|
return self.filter(roles__in=roles)
|
|
|
|
def cleanup(self):
|
|
count = 0
|
|
for p in self:
|
|
if not p.target and (p.target_ct_id or p.target_id):
|
|
p.delete()
|
|
count += 1
|
|
return count
|
|
|
|
|
|
PermissionManager = PermissionManagerBase.from_queryset(PermissionQueryset)
|
|
|
|
|
|
class IntCast(models.Func):
|
|
function = 'int'
|
|
template = 'CAST((%(expressions)s) AS %(function)s)'
|
|
|
|
|
|
class RoleQuerySet(query.QuerySet):
|
|
def for_user(self, user):
|
|
if hasattr(user, 'apiclient_roles'):
|
|
queryset = self.filter(apiclients=user)
|
|
else:
|
|
queryset = self.filter(members=user)
|
|
return queryset.parents().distinct()
|
|
|
|
def parents(self, include_self=True, annotate=False, direct=None):
|
|
assert annotate is False or direct is not True, 'annotate=True cannot be used with direct=True'
|
|
if direct is None:
|
|
qs = self.model.objects.filter(
|
|
child_relation__deleted__isnull=True,
|
|
child_relation__child__in=self,
|
|
)
|
|
else:
|
|
qs = self.model.objects.filter(
|
|
child_relation__deleted__isnull=True,
|
|
child_relation__child__in=self,
|
|
child_relation__direct=direct,
|
|
)
|
|
if include_self:
|
|
qs = self | qs
|
|
qs = qs.distinct()
|
|
if annotate:
|
|
qs = qs.annotate(direct=models.Max(IntCast('child_relation__direct')))
|
|
return qs
|
|
|
|
def children(self, include_self=True, annotate=False, direct=None):
|
|
assert annotate is False or direct is not True, 'annotate=True cannot be used with direct=True'
|
|
if direct is None:
|
|
qs = self.model.objects.filter(
|
|
parent_relation__deleted__isnull=True,
|
|
parent_relation__parent__in=self,
|
|
)
|
|
else:
|
|
qs = self.model.objects.filter(
|
|
parent_relation__deleted__isnull=True,
|
|
parent_relation__parent__in=self,
|
|
parent_relation__direct=direct,
|
|
)
|
|
if include_self:
|
|
qs = self | qs
|
|
qs = qs.distinct()
|
|
if annotate:
|
|
qs = qs.annotate(direct=models.Max(IntCast('parent_relation__direct')))
|
|
return qs
|
|
|
|
def all_members(self):
|
|
User = get_user_model()
|
|
prefetch = Prefetch('roles', queryset=self, to_attr='direct')
|
|
return (
|
|
User.objects.filter(
|
|
Q(roles__in=self)
|
|
| Q(roles__parent_relation__parent__in=self, roles__parent_relation__deleted__isnull=True)
|
|
)
|
|
.distinct()
|
|
.prefetch_related(prefetch)
|
|
)
|
|
|
|
def by_admin_scope_ct(self, admin_scope):
|
|
admin_scope_ct = ContentType.objects.get_for_model(admin_scope)
|
|
return self.filter(admin_scope_ct=admin_scope_ct)
|
|
|
|
def cleanup(self):
|
|
count = 0
|
|
for r in self.filter(Q(admin_scope_ct_id__isnull=False) | Q(admin_scope_id__isnull=False)):
|
|
if not r.admin_scope:
|
|
r.delete()
|
|
count += 1
|
|
return count
|
|
|
|
|
|
BaseRoleManager = AbstractBaseManager.from_queryset(RoleQuerySet)
|
|
|
|
|
|
class RoleParentingManager(models.Manager):
|
|
class Local(threading.local):
|
|
DO_UPDATE_CLOSURE = True
|
|
CLOSURE_UPDATED = False
|
|
|
|
tls = Local()
|
|
|
|
def get_by_natural_key(self, parent_nk, child_nk, direct):
|
|
try:
|
|
parent = a2_models.Role.objects.get_by_natural_key(*parent_nk)
|
|
except a2_models.Role.DoesNotExist:
|
|
raise self.model.DoesNotExist
|
|
try:
|
|
child = a2_models.Role.objects.get_by_natural_key(*child_nk)
|
|
except a2_models.Role.DoesNotExist:
|
|
raise self.model.DoesNotExist
|
|
return self.get(parent=parent, child=child, direct=direct)
|
|
|
|
def soft_create(self, parent, child):
|
|
with atomic(savepoint=False):
|
|
rp, created = self.get_or_create(parent=parent, child=child, direct=True)
|
|
new = created or rp.deleted
|
|
if not created and rp.deleted:
|
|
rp.created = datetime.datetime.now()
|
|
rp.deleted = None
|
|
rp.save(update_fields=['created', 'deleted'])
|
|
if new:
|
|
signals.post_soft_create.send(sender=self.model, instance=rp)
|
|
|
|
def soft_delete(self, parent, child):
|
|
qs = self.filter(parent=parent, child=child, deleted__isnull=True, direct=True)
|
|
with atomic(savepoint=False):
|
|
rp = qs.first()
|
|
if rp:
|
|
count = qs.update(deleted=datetime.datetime.now())
|
|
# read-commited, view of tables can change during transaction
|
|
if count:
|
|
signals.post_soft_delete.send(sender=self.model, instance=rp)
|
|
|
|
def update_transitive_closure(self):
|
|
"""Recompute the transitive closure of the inheritance relation
|
|
from scratch. Add missing indirect relations and delete
|
|
obsolete indirect relations.
|
|
"""
|
|
if not self.tls.DO_UPDATE_CLOSURE:
|
|
self.tls.CLOSURE_UPDATED = True
|
|
return
|
|
|
|
with atomic(savepoint=False):
|
|
# existing direct paths
|
|
direct = set(self.filter(direct=True, deleted__isnull=True).values_list('parent_id', 'child_id'))
|
|
old_indirects = set(
|
|
self.filter(direct=False, deleted__isnull=True).values_list('parent_id', 'child_id')
|
|
)
|
|
indirects = set(direct)
|
|
|
|
while True:
|
|
changed = False
|
|
for (i, j) in list(indirects):
|
|
for (k, l) in direct:
|
|
if j == k and i != l and (i, l) not in indirects:
|
|
indirects.add((i, l))
|
|
changed = True
|
|
if not changed:
|
|
break
|
|
|
|
with connection.cursor() as cur:
|
|
# Delete old ones
|
|
obsolete = old_indirects - indirects - direct
|
|
if obsolete:
|
|
sql = '''UPDATE "%s" AS relation \
|
|
SET deleted = now()\
|
|
FROM (VALUES %s) AS dead(parent_id, child_id) \
|
|
WHERE relation.direct = 'false' AND relation.parent_id = dead.parent_id \
|
|
AND relation.child_id = dead.child_id AND deleted IS NULL''' % (
|
|
self.model._meta.db_table,
|
|
', '.join('(%s, %s)' % (a, b) for a, b in obsolete),
|
|
)
|
|
cur.execute(sql)
|
|
# Create new indirect relations
|
|
new = indirects - old_indirects - direct
|
|
if new:
|
|
new_values = ', '.join(
|
|
(
|
|
"(%s, %s, 'false', now(), NULL)" % (parent_id, child_id)
|
|
for parent_id, child_id in new
|
|
)
|
|
)
|
|
sql = '''INSERT INTO "%s" (parent_id, child_id, direct, created, deleted) VALUES %s \
|
|
ON CONFLICT (parent_id, child_id, direct) DO UPDATE SET created = EXCLUDED.created, deleted = NULL''' % (
|
|
self.model._meta.db_table,
|
|
new_values,
|
|
)
|
|
cur.execute(sql)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def defer_update_transitive_closure():
|
|
RoleParentingManager.tls.DO_UPDATE_CLOSURE = False
|
|
try:
|
|
yield
|
|
if RoleParentingManager.tls.CLOSURE_UPDATED:
|
|
a2_models.RoleParenting.objects.update_transitive_closure()
|
|
finally:
|
|
RoleParentingManager.tls.DO_UPDATE_CLOSURE = True
|
|
RoleParentingManager.tls.CLOSURE_UPDATED = False
|
|
|
|
|
|
class OrganizationalUnitManager(AbstractBaseManager):
|
|
def get_by_natural_key(self, uuid):
|
|
return self.get(slug=uuid)
|
|
|
|
|
|
class RoleManager(BaseRoleManager):
|
|
def get_admin_role(
|
|
self,
|
|
instance,
|
|
name,
|
|
slug,
|
|
ou=None,
|
|
operation=None,
|
|
update_name=False,
|
|
update_slug=False,
|
|
permissions=(),
|
|
self_administered=False,
|
|
create=True,
|
|
):
|
|
'''Get or create the role of manager's of this object instance'''
|
|
from .models import ADMIN_OP
|
|
|
|
if operation is None:
|
|
operation = ADMIN_OP
|
|
|
|
kwargs = {}
|
|
assert not ou or isinstance(
|
|
instance, ContentType
|
|
), 'get_admin_role(ou=...) can only be used with ContentType instances: %s %s %s' % (
|
|
name,
|
|
ou,
|
|
instance,
|
|
)
|
|
|
|
# Does the permission need to be scoped by ou ? Yes if the target is a
|
|
# ContentType and ou is given. It's a general administration right upon
|
|
# all instance of a ContentType, eventually scoped to the given ou.
|
|
defaults = {}
|
|
if isinstance(instance, ContentType):
|
|
if ou:
|
|
kwargs['ou'] = ou
|
|
else:
|
|
kwargs['ou__isnull'] = True
|
|
else: # for non ContentType instances, OU must be set to NULL, always.
|
|
defaults['ou'] = None
|
|
# find an operation matching the template
|
|
op = get_operation(operation)
|
|
if create:
|
|
perm, _ = a2_models.Permission.objects.update_or_create(
|
|
operation=op,
|
|
target_ct=ContentType.objects.get_for_model(instance),
|
|
target_id=instance.pk,
|
|
defaults=defaults,
|
|
**kwargs,
|
|
)
|
|
else:
|
|
try:
|
|
perm = a2_models.Permission.objects.get(
|
|
operation=op,
|
|
target_ct=ContentType.objects.get_for_model(instance),
|
|
target_id=instance.pk,
|
|
**kwargs,
|
|
)
|
|
except a2_models.Permission.DoesNotExist:
|
|
return None
|
|
|
|
# in which ou do we put the role ?
|
|
if ou:
|
|
mirror_role_ou = ou
|
|
elif getattr(instance, 'ou', None):
|
|
mirror_role_ou = instance.ou
|
|
else:
|
|
mirror_role_ou = None
|
|
admin_role = self.get_mirror_role(
|
|
perm,
|
|
name,
|
|
slug,
|
|
ou=mirror_role_ou,
|
|
update_name=update_name,
|
|
update_slug=update_slug,
|
|
create=create,
|
|
)
|
|
|
|
if not admin_role:
|
|
return None
|
|
|
|
permissions = set(permissions)
|
|
permissions.add(perm)
|
|
if self_administered:
|
|
self_perm = admin_role.add_self_administration()
|
|
permissions.add(self_perm)
|
|
if set(admin_role.permissions.all()) != permissions:
|
|
admin_role.permissions.set(permissions)
|
|
return admin_role
|
|
|
|
def get_mirror_role(
|
|
self, instance, name, slug, ou=None, update_name=False, update_slug=False, create=True
|
|
):
|
|
"""Get or create a role which mirrors another model, for example a
|
|
permission.
|
|
"""
|
|
ct = ContentType.objects.get_for_model(instance)
|
|
update_fields = {}
|
|
kwargs = {}
|
|
if ou:
|
|
update_fields['ou'] = ou
|
|
else:
|
|
update_fields['ou'] = None
|
|
if update_name:
|
|
update_fields['name'] = name
|
|
if update_slug:
|
|
update_fields['slug'] = slug
|
|
|
|
if create:
|
|
role, _ = self.prefetch_related('permissions').update_or_create(
|
|
admin_scope_ct=ct, admin_scope_id=instance.pk, defaults=update_fields, **kwargs
|
|
)
|
|
else:
|
|
try:
|
|
role = self.prefetch_related('permissions').get(
|
|
admin_scope_ct=ct, admin_scope_id=instance.pk, **kwargs
|
|
)
|
|
except self.model.DoesNotExist:
|
|
return None
|
|
for field, value in update_fields.items():
|
|
setattr(role, field, value)
|
|
role.save(update_fields=update_fields)
|
|
return role
|
|
|
|
def get_by_natural_key(self, slug, ou_natural_key, service_natural_key):
|
|
kwargs = {'slug': slug}
|
|
if ou_natural_key is None:
|
|
kwargs['ou__isnull'] = True
|
|
else:
|
|
try:
|
|
ou = a2_models.OrganizationalUnit.objects.get_by_natural_key(*ou_natural_key)
|
|
except a2_models.OrganizationalUnit.DoesNotExist:
|
|
raise self.model.DoesNotExist
|
|
kwargs['ou'] = ou
|
|
if service_natural_key is None:
|
|
kwargs['service__isnull'] = True
|
|
else:
|
|
# XXX: prevent an import loop
|
|
from authentic2.models import Service
|
|
|
|
try:
|
|
service = Service.objects.get_by_natural_key(*service_natural_key)
|
|
except Service.DoesNotExist:
|
|
raise self.model.DoesNotExist
|
|
kwargs['service'] = service
|
|
return self.get(**kwargs)
|