268 lines
11 KiB
Python
268 lines
11 KiB
Python
# hobo - portal to configure and deploy applications
|
|
# Copyright (C) 2015-2020 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 hashlib
|
|
import logging
|
|
|
|
from django.contrib.auth.models import Group
|
|
from django.db import IntegrityError
|
|
from django.db.models.query import Q
|
|
from django.db.transaction import atomic
|
|
|
|
from hobo.agent.common.models import Role, UserExtraAttributes
|
|
from hobo.multitenant.utils import provision_user_groups
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TryAgain(Exception):
|
|
pass
|
|
|
|
|
|
def user_str(user):
|
|
'''Compute a string representation of user'''
|
|
s = ''
|
|
if user.first_name or user.last_name:
|
|
s += '"'
|
|
if user.first_name:
|
|
s += user.first_name
|
|
if user.first_name and user.last_name:
|
|
s += ' '
|
|
if user.last_name:
|
|
s += user.last_name
|
|
s += '" '
|
|
if user.email:
|
|
s += user.email + ' '
|
|
s += user.username
|
|
return s
|
|
|
|
|
|
class NotificationProcessing:
|
|
@classmethod
|
|
def check_valid_notification(cls, notification):
|
|
return (
|
|
isinstance(notification, dict)
|
|
and '@type' in notification
|
|
and notification['@type'] in ['provision', 'deprovision']
|
|
and 'objects' in notification
|
|
and 'audience' in notification
|
|
and isinstance(notification['audience'], list)
|
|
and isinstance(notification['objects'], dict)
|
|
)
|
|
|
|
@classmethod
|
|
def check_valid_role(cls, o):
|
|
return 'uuid' in o and 'name' in o and 'description' in o
|
|
|
|
@classmethod
|
|
def check_valid_user(cls, o):
|
|
return (
|
|
'uuid' in o
|
|
and 'is_superuser' in o
|
|
and 'email' in o
|
|
and 'first_name' in o
|
|
and 'last_name' in o
|
|
and 'roles' in o
|
|
)
|
|
|
|
@classmethod
|
|
def provision_user(cls, issuer, action, data, full=False):
|
|
from django.contrib.auth import get_user_model
|
|
from mellon.models import UserSAMLIdentifier
|
|
from mellon.models_utils import get_issuer
|
|
|
|
User = get_user_model()
|
|
|
|
assert not full # provisionning all users is dangerous, we prefer deprovision
|
|
uuids = set()
|
|
for o in data:
|
|
try:
|
|
with atomic():
|
|
if action == 'provision':
|
|
new = False
|
|
updated = set()
|
|
attributes = {
|
|
'first_name': o['first_name'][:30],
|
|
'last_name': o['last_name'][:150],
|
|
'email': o['email'][:254],
|
|
'username': o['uuid'][:150],
|
|
'is_superuser': o['is_superuser'],
|
|
'is_staff': o['is_superuser'],
|
|
'is_active': o.get('is_active', True),
|
|
}
|
|
assert cls.check_valid_user(o)
|
|
try:
|
|
mellon_user = UserSAMLIdentifier.objects.get(
|
|
issuer__entity_id=issuer, name_id=o['uuid']
|
|
)
|
|
user = mellon_user.user
|
|
except UserSAMLIdentifier.DoesNotExist:
|
|
try:
|
|
user = User.objects.get(
|
|
Q(username=o['uuid'][:30]) | Q(username=o['uuid'][:150])
|
|
)
|
|
except User.DoesNotExist:
|
|
# temp user object
|
|
user = User.objects.create(**attributes)
|
|
new = True
|
|
saml_issuer = get_issuer(issuer)
|
|
mellon_user = UserSAMLIdentifier.objects.create(
|
|
user=user, issuer=saml_issuer, name_id=o['uuid']
|
|
)
|
|
excluded_attrs = ['roles', 'password']
|
|
|
|
extra_attributes = UserExtraAttributes.objects.update_or_create(
|
|
user=user,
|
|
defaults={'data': {k: v for k, v in o.items() if k not in excluded_attrs}},
|
|
)
|
|
if new:
|
|
logger.info('provisionned new user %s', user_str(user))
|
|
else:
|
|
for key in attributes:
|
|
if getattr(user, key) != attributes[key]:
|
|
setattr(user, key, attributes[key])
|
|
updated.add(key)
|
|
if updated:
|
|
user.save()
|
|
logger.info('updated user %s(%s)', user_str(user), updated)
|
|
role_uuids = [role['uuid'] for role in o.get('roles', [])]
|
|
provision_user_groups(user, role_uuids)
|
|
elif action == 'deprovision':
|
|
assert 'uuid' in o
|
|
uuids.add(o['uuid'])
|
|
except IntegrityError:
|
|
raise TryAgain
|
|
if (full and action == 'provision') or (action == 'deprovision'):
|
|
if action == 'deprovision':
|
|
qs = User.objects.filter(saml_identifiers__name_id__in=uuids)
|
|
else:
|
|
qs = User.objects.exclude(saml_identifiers__name_id__in=uuids)
|
|
# retrieve users before deleting them
|
|
qs = qs[:]
|
|
qs.delete()
|
|
for user in qs:
|
|
logger.info('deprovisionning user %s', user_str(user))
|
|
|
|
group_name_max_length = Group._meta.get_field('name').max_length
|
|
|
|
@classmethod
|
|
def truncate_role_name(cls, name):
|
|
"""Truncate name to 150 characters by adding a 4-chars partial-MD5 hex
|
|
digest for disambiguation."""
|
|
if len(name) <= cls.group_name_max_length: # Group.name has max_length=150 since Django 2.2
|
|
return name
|
|
else:
|
|
return (
|
|
name[: cls.group_name_max_length - 7] + ' (%4s)' % hashlib.md5(name.encode()).hexdigest()[:4]
|
|
)
|
|
|
|
@classmethod
|
|
def provision_role(cls, issuer, action, data, full=False):
|
|
uuids = set()
|
|
roles_by_uuid = dict()
|
|
|
|
if action == 'provision':
|
|
# first pass to gather existing roles by uuid
|
|
target_uuids = set()
|
|
for o in data:
|
|
assert 'uuid' in o
|
|
target_uuids.add(o['uuid'])
|
|
for role in Role.objects.filter(uuid__in=target_uuids):
|
|
roles_by_uuid[role.uuid] = role
|
|
|
|
for o in data:
|
|
assert 'uuid' in o
|
|
uuids.add(o['uuid'])
|
|
if action == 'provision':
|
|
created = False
|
|
save = False
|
|
assert cls.check_valid_role(o)
|
|
role_name = cls.truncate_role_name(o['name'])
|
|
try:
|
|
role = roles_by_uuid[o['uuid']]
|
|
created = False
|
|
except KeyError:
|
|
try:
|
|
with atomic():
|
|
role, created = Role.objects.get_or_create(
|
|
name=role_name,
|
|
defaults={
|
|
'uuid': o['uuid'],
|
|
'description': o['description'],
|
|
'details': o.get('details', ''),
|
|
'emails': o.get('emails', []),
|
|
'emails_to_members': o.get('emails_to_members', True),
|
|
},
|
|
)
|
|
except IntegrityError:
|
|
# Can happen if uuid and name already exist
|
|
logger.error('cannot provision role "%s" (%s)', o['name'], o['uuid'])
|
|
continue
|
|
if not created:
|
|
if role.name != role_name:
|
|
role.name = role_name
|
|
save = True
|
|
if role.uuid != o['uuid']:
|
|
role.uuid = o['uuid']
|
|
save = True
|
|
if role.description != o['description']:
|
|
role.description = o['description']
|
|
save = True
|
|
if role.details != o.get('details', ''):
|
|
role.details = o.get('details', '')
|
|
save = True
|
|
if role.emails != o.get('emails', []):
|
|
role.emails = o.get('emails', [])
|
|
save = True
|
|
if role.emails_to_members != o.get('emails_to_members', True):
|
|
role.emails_to_members = o.get('emails_to_members', True)
|
|
save = True
|
|
if save:
|
|
try:
|
|
with atomic():
|
|
role.save()
|
|
except IntegrityError:
|
|
# Can happen if uuid and name already exist
|
|
logger.error('cannot provision role "%s" (%s)', o['name'], o['uuid'])
|
|
continue
|
|
if created:
|
|
logger.info('provisionned new role %s (%s)', o['name'], o['uuid'])
|
|
if save:
|
|
logger.info('updated role %s (%s)', o['name'], o['uuid'])
|
|
if full and action == 'provision':
|
|
qs = Role.objects.exclude(uuid__in=uuids)
|
|
logger.info(
|
|
'deprovisionning roles %s',
|
|
', '.join('%s (%s)' % (name, uuid) for name, uuid in qs.values_list('name', 'uuid')),
|
|
)
|
|
qs.delete()
|
|
elif action == 'deprovision':
|
|
qs = Role.objects.filter(uuid__in=uuids)
|
|
logger.info(
|
|
'deprovisionning roles %s',
|
|
', '.join('%s (%s)' % (name, uuid) for name, uuid in qs.values_list('name', 'uuid')),
|
|
)
|
|
qs.delete()
|
|
|
|
@classmethod
|
|
def provision(cls, object_type, issuer, action, data, full):
|
|
for i in range(20):
|
|
try:
|
|
getattr(cls, 'provision_' + object_type)(issuer=issuer, action=action, data=data, full=full)
|
|
except TryAgain:
|
|
continue
|
|
break
|