users.py 14.1 KB
Newer Older
Lukas Burgey's avatar
Lukas Burgey committed
1 2
# for receivers:
# pylint: disable=unused-argument
3 4 5

import logging

6
from django.contrib.auth.models import AbstractUser
7
from django.db import models
Lukas Burgey's avatar
Lukas Burgey committed
8
from django.db.models import Q
Lukas Burgey's avatar
Lukas Burgey committed
9 10
from django.db.models.signals import post_save
from django.dispatch import receiver
11 12
from django_mysql.models import JSONField

Lukas Burgey's avatar
Lukas Burgey committed
13
from ..auth.v1.models import OIDCConfig
14
from ..auth.v1.models.vo import VO, Group, Entitlement
15 16 17

LOGGER = logging.getLogger(__name__)

18

19 20 21
def user_info_default():
    return {}

Lukas Burgey's avatar
Lukas Burgey committed
22

23
class User(AbstractUser):
24
    USER_ALREADY_EXISTS = Exception('The user does already exist. This usually implies that the IdP changed the sub. Only possible fix: delete the old user')
25

26 27 28 29 30 31 32 33
    TYPE_CHOICES = (
        ('apiclient', 'API-Client'),
        ('oidcuser', 'OIDC User'),
        ('admin', 'Admin'),
    )
    user_type = models.CharField(
        max_length=20,
        choices=TYPE_CHOICES,
Lukas Burgey's avatar
Lukas Burgey committed
34
        default='apiclient',
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
    )
    sub = models.CharField(
        max_length=150,
        blank=True,
        null=True,
        editable=False,
    )
    password = models.CharField(
        max_length=150,
        blank=True,
        null=True,
    )
    # the real state of the user
    # (self.is_active is the supposed state of the user)
    _is_active = models.BooleanField(
        default=True,
        editable=False,
    )
    # the idp which authenticated the user
    idp = models.ForeignKey(
        OIDCConfig,
        related_name='users',
        on_delete=models.CASCADE,
        blank=True,
        null=True,
        editable=False,
    )
    userinfo = JSONField(
        default=user_info_default,
        null=True,
        blank=True,
        editable=False,
    )

69 70 71 72 73
    vos = models.ManyToManyField(
        VO,
        blank=True,
    )

74 75 76 77
    @property
    def profile_name(self):
        if 'email' in self.userinfo:
            return self.userinfo['email']
78 79

        if 'name' in self.userinfo:
80 81
            return self.userinfo['name']

82 83 84 85
        if 'sub' in self.userinfo:
            return self.userinfo['sub']

        return self.username
86

Lukas Burgey's avatar
Lukas Burgey committed
87 88 89 90 91 92
    # we hide deleted keys here
    # the full list of ssh keys is self._ssh_keys
    @property
    def ssh_keys(self):
        return self._ssh_keys.filter(deleted=False)

93 94 95
    @property
    def credentials(self):
        return {
96
            'ssh_key': self.ssh_keys.all(),
97 98
        }

Lukas Burgey's avatar
Lukas Burgey committed
99 100 101 102 103 104
    @property
    def services(self):
        from ..models import Service
        return (
            Service.objects
            .filter(
105
                Q(vos__user=self)
Lukas Burgey's avatar
Lukas Burgey committed
106 107 108
            ).distinct()
        )

Lukas Burgey's avatar
Lukas Burgey committed
109 110 111 112
    @property
    def is_active_at_clients(self):
        return self._is_active

113 114 115 116 117 118 119 120 121 122 123 124 125
    # returns the user as identified by userinfo and idp
    # if the user does not exists
    @classmethod
    def get_user(cls, userinfo, idp):
        if 'sub' not in userinfo:
            raise ValueError('get_user needs a userinfo which contains the users subject')

        try:
            user = cls.objects.get(
                sub=userinfo['sub'],
                idp=idp,
            )
            user.save()
Lukas Burgey's avatar
Lukas Burgey committed
126
            user.update_userinfo(userinfo)
127 128 129 130 131 132 133 134
            return user

        except cls.DoesNotExist:
            return cls.construct_from_userinfo(userinfo, idp)

    @classmethod
    def construct_from_userinfo(cls, userinfo, idp):
        if 'sub' not in userinfo:
135 136
            raise ValueError('Missing attribute in userinfo: sub')

137
        username = '{}@{}'.format(userinfo['sub'], idp.id)
138

139 140 141 142 143 144
        if cls.objects.filter(
                username=username,
                idp=idp,
        ).exists():
            LOGGER.error('User already exists: %s', username)
            raise cls.USER_ALREADY_EXISTS
145 146 147 148

        user = cls(
            user_type='oidcuser',
            username=username,
149
            sub=userinfo['sub'],
150 151 152
            idp=idp,
        )
        user.save()
Lukas Burgey's avatar
Lukas Burgey committed
153
        user.update_userinfo(userinfo)
154
        LOGGER.info('construct_from_userinfo: new user: %s', user)
155 156 157 158 159 160 161 162 163 164 165 166 167

        return user

    @classmethod
    def construct_client(cls, username, password):
        LOGGER.debug('APICLIENT: new client %s', username)
        client = cls(
            username=username,
            user_type='apiclient',
        )
        client.set_password(password)
        return client

Lukas Burgey's avatar
Lukas Burgey committed
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192
    def delete(self, *args, **kwargs):
        LOGGER.info('Deleting User %s', self)

        from . import deployments

        # TODO these deletions are a hack. django should (TM) be able to delete them itself
        # but there seems to be a bug
        for dep in deployments.ServiceDeployment.objects.filter(user=self):
            LOGGER.debug('Deleting users service deployment %s', dep)
            dep.delete()

        for dep in deployments.VODeployment.objects.filter(user=self):
            LOGGER.debug('Deleting users vo deployment %s', dep)
            dep.delete()

        for dep in deployments.Deployment.objects.filter(user=self):
            LOGGER.debug('Deleting users deployment %s', dep)
            dep.delete()

        LOGGER.info('User has: %s', self.deployments.all())
        for dep in self.deployments.all():
            dep.delete()

        super(User, self).delete(*args, **kwargs)

193
    def __str__(self):
Lukas Burgey's avatar
Lukas Burgey committed
194 195
        name = ''

Lukas Burgey's avatar
Lukas Burgey committed
196
        if self.is_superuser:
Lukas Burgey's avatar
Lukas Burgey committed
197 198
            name += 'ADMIN '
        if self.user_type == 'oidcuser':
199
            if not self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
200 201
                name += 'DEACTIVATED '
            name += 'USER '
202
        elif self.user_type == 'apiclient':
Lukas Burgey's avatar
Lukas Burgey committed
203 204 205 206 207
            name += 'APICLIENT '

        if self.user_type == 'oidcuser':
            name += self.profile_name
        else:
Lukas Burgey's avatar
Lukas Burgey committed
208
            name += self.username
209

Lukas Burgey's avatar
Lukas Burgey committed
210
        return name
211 212 213 214 215 216 217 218 219 220

    def msg(self, msg):
        return '[{}] {}'.format(self, msg)

    # oidcuser: remove and delete all credentials and delete the user
    def remove(self):
        if self.user_type == 'oidcuser':
            self.deactivate()

            # FIXME: deleting the user brings problems:
Lukas Burgey's avatar
Lukas Burgey committed
221
            # the deletion cascades down to DeploymentState and DeploymentState
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237
            # but these need to be conserved so all clients removals can be tracked
            LOGGER.info(self.msg('Deleting'))
            self.delete()

    def activate(self):
        if self._is_active:
            LOGGER.error(self.msg('already activated'))
            return

        self.is_active = True
        self._is_active = True
        self.save()
        LOGGER.info(self.msg('activated'))

        # oidcuser: deploy the according credentials
        if self.user_type == 'oidcuser':
238
            # for dep in self.deployments.all():
239 240
            #    dep.activate()
            pass
241 242 243 244 245 246 247 248 249

    def deactivate(self):
        if not self._is_active:
            LOGGER.error(self.msg('already deactivated'))
            return

        self.is_active = False
        self._is_active = False
        self.save()
250 251

        LOGGER.debug(self.msg('deactivating'))
252 253 254

        # oidcuser: remove all credentials
        if self.user_type == 'oidcuser':
255 256 257
            self.deployments_remove_all()

        LOGGER.info(self.msg('deactivated'))
258

259 260 261 262 263 264 265
    def deployments_remove_all(self):
        LOGGER.debug('Removing all deployments of user %s', self)

        sites = []

        # find which sites need to be notified
        for dep in self.deployments.all():
266
            for state_item in dep.states.all():
267 268
                if state_item.state != 'not_deployed':
                    sites.append(state_item.site)
269

Lukas Burgey's avatar
Lukas Burgey committed
270
    # return True if the userinfo contains entitlements (we ignore groups then)
271 272
    def update_userinfo_entitlements(self, userinfo):
        local_entitlements = self.vos.instance_of(Entitlement)
273 274 275 276 277 278 279 280 281 282 283 284 285 286
        remote_entitlements = []

        # determine upstream entitlements
        if self.idp.userinfo_field_entitlements in userinfo:
            field = userinfo[self.idp.userinfo_field_entitlements]
            if isinstance(field, list):
                remote_entitlements = [
                    Entitlement.extract_name(name)
                    for name in field
                ]
            elif isinstance(field, str):
                remote_entitlements = [Entitlement.extract_name(field)]
            else:
                LOGGER.error('Userinfo field %s is neither str nor list', self.idp.userinfo_field_entitlements)
287 288 289 290

        # check if local_entitlements were removed
        for loc_ent in local_entitlements:
            if loc_ent.name not in remote_entitlements:
Lukas Burgey's avatar
Lukas Burgey committed
291
                self._remove_vo(loc_ent)
292

293 294
        for rem_ent_name in remote_entitlements:
            ent = Entitlement.get_entitlement(name=rem_ent_name, idp=self.idp)
295

296
            # check if the user needs to be in this entitlement
297
            if not self.vos.filter(name=rem_ent_name, idp=self.idp).exists():
Lukas Burgey's avatar
Lukas Burgey committed
298
                self._add_vo(ent)
Lukas Burgey's avatar
Lukas Burgey committed
299

Lukas Burgey's avatar
Lukas Burgey committed
300
        return len(remote_entitlements) > 0
301

Lukas Burgey's avatar
Lukas Burgey committed
302
    def update_userinfo_groups(self, userinfo, ignore_groups=False):
303
        local_groups = self.vos.instance_of(Group)
Lukas Burgey's avatar
Lukas Burgey committed
304 305
        remote_groups = []

306 307 308 309 310 311 312 313
        if not ignore_groups and self.idp.userinfo_field_groups in userinfo:
            field = userinfo[self.idp.userinfo_field_entitlements]
            if isinstance(field, list):
                remote_groups = field
            elif isinstance(field, str):
                remote_groups = [field]
            else:
                LOGGER.error('Userinfo field %s is neither str nor list', self.idp.userinfo_field_entitlements)
314

315
        # check if groups were removed
316 317
        for group in local_groups:
            if group.name not in remote_groups:
Lukas Burgey's avatar
Lukas Burgey committed
318
                self._remove_vo(group)
319

320
        # check if groups were added
321
        for group_name in remote_groups:
322 323 324 325
            group = Group.get_group(name=group_name, idp=self.idp)

            # check if user needs to be in this group
            if not self.vos.filter(name=group_name, idp=self.idp).exists():
Lukas Burgey's avatar
Lukas Burgey committed
326
                self._add_vo(group)
327

328
    def update_userinfo_ssh_key(self, userinfo):
329 330

        idp_key_name = 'ssh_key'
331 332
        unity_key_name = 'unity_key'

333 334
        unity_key_value = userinfo.get(idp_key_name, '')

335 336
        try:
            key = self._ssh_keys.get(name=unity_key_name)
337

338
            # is the idp key still present?
339
            if idp_key_name not in userinfo:
Lukas Burgey's avatar
Lukas Burgey committed
340
                self.remove_key(key)
341

Lukas Burgey's avatar
Lukas Burgey committed
342
                return
343

344
            # is the idp key changed?
345
            if key.key != unity_key_value:
Lukas Burgey's avatar
Lukas Burgey committed
346
                self.remove_key(key)
347

Lukas Burgey's avatar
Lukas Burgey committed
348 349
                # causes creation of a new key
                raise SSHPublicKey.DoesNotExist
350 351

        except SSHPublicKey.DoesNotExist:
352
            if idp_key_name not in userinfo:
Lukas Burgey's avatar
Lukas Burgey committed
353
                return
354

355 356 357 358 359 360
            key = SSHPublicKey(
                name=unity_key_name,
                key=unity_key_value,
                user=self,
            )
            key.save()
Lukas Burgey's avatar
Lukas Burgey committed
361

Lukas Burgey's avatar
Lukas Burgey committed
362
            self.add_key(key)
363

364
    def update_userinfo(self, userinfo):
365

366 367 368 369 370 371 372 373 374 375 376
        if 'email' in userinfo:
            self.email = userinfo['email']

        if 'family_name' in userinfo:
            self.last_name = userinfo['family_name']

        if 'given_name' in userinfo:
            self.first_name = userinfo['given_name']

        self.userinfo = userinfo
        self.save()
377

Lukas Burgey's avatar
Lukas Burgey committed
378 379
        ignore_groups = self.update_userinfo_entitlements(userinfo)
        self.update_userinfo_groups(userinfo, ignore_groups=ignore_groups)
380

Lukas Burgey's avatar
Lukas Burgey committed
381
        self.update_userinfo_ssh_key(userinfo)
382

Lukas Burgey's avatar
Lukas Burgey committed
383 384
    def add_key(self, key):
        LOGGER.debug(self.msg('Adding key: {}'.format(key)))
385

386 387
        from . import deployments
        for dep in deployments.get_deployment(self):
388
            dep.user_credential_added(key)
389

Lukas Burgey's avatar
Lukas Burgey committed
390 391
    def remove_key(self, key):
        LOGGER.debug(self.msg('Removing key: {}'.format(key)))
392

393 394 395
        if key.delete_key():
            return

396 397
        from . import deployments
        for dep in deployments.get_deployment(self):
398
            dep.user_credential_removed(key)
399

Lukas Burgey's avatar
Lukas Burgey committed
400 401
    def _add_vo(self, vo):
        self.vos.add(vo)
Lukas Burgey's avatar
Lukas Burgey committed
402
        LOGGER.debug(self.msg('Adding VO: {}'.format(vo)))
403

Lukas Burgey's avatar
Lukas Burgey committed
404
        # check if the user has deactivated deployments for this exact vo
405
        # if yes: reactivate the deployments
Lukas Burgey's avatar
Lukas Burgey committed
406 407 408

        # TODO this does nothing for ServiceDeployments
        for dep in self.deployments.filter(vodeployment__vo=vo):
Lukas Burgey's avatar
Lukas Burgey committed
409
            LOGGER.debug('_add_vo: need to activate deployment %s', dep)
410

Lukas Burgey's avatar
Lukas Burgey committed
411 412
    def _remove_vo(self, vo):
        self.vos.remove(vo)
Lukas Burgey's avatar
Lukas Burgey committed
413
        LOGGER.debug(self.msg('Removing VO: {}'.format(vo)))
414

Lukas Burgey's avatar
Lukas Burgey committed
415 416
        # TODO this does nothing for ServiceDeployments

Lukas Burgey's avatar
Lukas Burgey committed
417
        # check if the user has deployments which need member ship of this vo
418
        # if yes remove them
Lukas Burgey's avatar
Lukas Burgey committed
419
        for dep in self.deployments.filter(vodeployment__vo=vo):
Lukas Burgey's avatar
Lukas Burgey committed
420
            LOGGER.debug('_remove_vo: need to deactivate deployment %s', dep)
421

Lukas Burgey's avatar
Lukas Burgey committed
422

Lukas Burgey's avatar
Lukas Burgey committed
423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443
class SSHPublicKey(models.Model):
    name = models.CharField(
        max_length=150,
    )
    key = models.TextField(
        max_length=1000
    )
    # hidden field at the user
    user = models.ForeignKey(
        User,
        related_name='_ssh_keys',
        on_delete=models.SET_NULL,
        null=True,
    )

    # has the user triggered the deletion of this key?
    deleted = models.BooleanField(
        default=False,
        editable=False,
    )

444 445 446 447
    @property
    def value(self):
        return self.key

448
    # returns true if the deletion is final
Lukas Burgey's avatar
Lukas Burgey committed
449
    def delete_key(self):
450 451 452 453
        if self.try_delete_key():
            return True

        LOGGER.debug(self.msg('Deletion started'))
Lukas Burgey's avatar
Lukas Burgey committed
454 455 456 457
        self.user = None
        self.key = ''
        self.deleted = True
        self.save()
458 459 460 461 462 463 464 465
        return False

    # if this key has no credential states anymore we _really_ delete it
    def try_delete_key(self):
        if not self.credential_states.filter(state='deployed').exists():
            LOGGER.info(self.msg('Final deletion'))
            self.delete()
            return True
Lukas Burgey's avatar
Lukas Burgey committed
466

467
        return False
Lukas Burgey's avatar
Lukas Burgey committed
468 469 470 471 472 473 474 475

    def __str__(self):
        if self.deleted:
            return 'DELETED: {}'.format(self.name)
        return self.name

    def msg(self, msg):
        return '[SSHKey:{}] {}'.format(self, msg)
476

477

Lukas Burgey's avatar
Lukas Burgey committed
478 479 480 481 482 483 484 485
@receiver(post_save, sender=User)
def deactivate_user(sender, instance=None, created=False, **kwargs):
    if created:
        return

    if not instance.is_active and instance.is_active_at_clients:
        instance.deactivate()

486

Lukas Burgey's avatar
Lukas Burgey committed
487 488 489 490 491 492 493
@receiver(post_save, sender=User)
def activate_user(sender, instance=None, created=False, **kwargs):
    if created:
        return

    if instance.is_active and not instance.is_active_at_clients:
        instance.activate()