users.py 12.9 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 168

        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

    def __str__(self):
Lukas Burgey's avatar
Lukas Burgey committed
169 170
        name = ''

Lukas Burgey's avatar
Lukas Burgey committed
171
        if self.is_superuser:
Lukas Burgey's avatar
Lukas Burgey committed
172 173
            name += 'ADMIN '
        if self.user_type == 'oidcuser':
174
            if not self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
175 176
                name += 'DEACTIVATED '
            name += 'USER '
177
        elif self.user_type == 'apiclient':
Lukas Burgey's avatar
Lukas Burgey committed
178 179 180 181 182
            name += 'APICLIENT '

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

Lukas Burgey's avatar
Lukas Burgey committed
185
        return name
186 187 188 189 190 191 192 193 194 195

    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
196
            # the deletion cascades down to DeploymentState and DeploymentState
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212
            # 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':
213
            # for dep in self.deployments.all():
214 215
            #    dep.activate()
            pass
216 217 218 219 220 221 222 223 224

    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()
225 226

        LOGGER.debug(self.msg('deactivating'))
227 228 229

        # oidcuser: remove all credentials
        if self.user_type == 'oidcuser':
230 231 232
            self.deployments_remove_all()

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

234 235 236 237 238 239 240 241 242 243
    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():
            for state_item in dep.state_items.all():
                if state_item.state != 'not_deployed':
                    sites.append(state_item.site)
244

245 246 247
    def update_userinfo_entitlements(self, userinfo):
        if self.idp.userinfo_field_entitlements is None:
            return
Lukas Burgey's avatar
Lukas Burgey committed
248

249
        local_entitlements = self.vos.instance_of(Entitlement)
Lukas Burgey's avatar
Lukas Burgey committed
250 251 252 253 254
        remote_entitlements = [
            Entitlement.extract_name(name)
            for name in
            userinfo.get(self.idp.userinfo_field_entitlements, [])
        ]
255 256 257 258

        # 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
259

260
                self.vos.remove(loc_ent)
Lukas Burgey's avatar
Lukas Burgey committed
261
                self.user_changed_vo_removed(loc_ent)
262

263 264
        for rem_ent_name in remote_entitlements:
            ent = Entitlement.get_entitlement(name=rem_ent_name, idp=self.idp)
265

266 267 268
            # check if user needs to be in this entitlement
            if not self.vos.filter(name=rem_ent_name, idp=self.idp).exists():
                LOGGER.info(self.msg('New: %s'), ent)
Lukas Burgey's avatar
Lukas Burgey committed
269

Lukas Burgey's avatar
Lukas Burgey committed
270
                self.vos.add(ent)
271
                self.user_changed_vo_added(ent)
Lukas Burgey's avatar
Lukas Burgey committed
272

273 274 275
    def update_userinfo_groups(self, userinfo):
        if self.idp.userinfo_field_groups is None:
            return
276

277 278
        local_groups = self.vos.instance_of(Group)
        remote_groups = userinfo.get(self.idp.userinfo_field_groups, [])
279

280
        # check if groups were removed
281 282
        for group in local_groups:
            if group.name not in remote_groups:
Lukas Burgey's avatar
Lukas Burgey committed
283

284
                self.vos.remove(group)
Lukas Burgey's avatar
Lukas Burgey committed
285
                self.user_changed_vo_removed(group)
286

287
        # check if groups were added
288
        for group_name in remote_groups:
289 290 291 292 293 294
            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():
                LOGGER.info(self.msg('New: %s'), group)

Lukas Burgey's avatar
Lukas Burgey committed
295 296
                self.user_changed_vo_added(group)
                self.vos.add(group)
297

298
    def update_userinfo_ssh_key(self, userinfo):
299 300

        idp_key_name = 'ssh_key'
301 302
        unity_key_name = 'unity_key'

303 304
        unity_key_value = userinfo.get(idp_key_name, '')

305 306
        try:
            key = self._ssh_keys.get(name=unity_key_name)
307

308
            # is the idp key still present?
309
            if idp_key_name not in userinfo:
310
                self.user_remove_key(key)
311 312 313

                return True

314
            # is the idp key changed?
315
            if key.key != unity_key_value:
316
                self.user_remove_key(key)
317 318

                new_key = SSHPublicKey(
319 320 321 322
                    name=unity_key_name,
                    key=unity_key_value,
                    user=self,
                )
323 324
                new_key.save()
                self.user_changed_key_added(new_key)
325

326 327 328
                return True

            return False
329 330

        except SSHPublicKey.DoesNotExist:
331 332 333
            if idp_key_name not in userinfo:
                return False

334 335 336 337 338 339
            key = SSHPublicKey(
                name=unity_key_name,
                key=unity_key_value,
                user=self,
            )
            key.save()
Lukas Burgey's avatar
Lukas Burgey committed
340

341 342
            self.user_changed_key_added(key)

343 344
            return True

345
    def update_userinfo(self, userinfo):
346

347 348 349 350 351 352 353 354 355 356 357
        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()
358 359
        changed = False

360 361
        self.update_userinfo_groups(userinfo)
        self.update_userinfo_entitlements(userinfo)
362

363 364 365 366 367
        if self.update_userinfo_ssh_key(userinfo):
            changed = True

        if changed:
            self.user_changed()
368 369

    def user_changed(self):
370
        LOGGER.info('user_changed')
371

372 373 374 375
    def user_changed_key_added(self, key):
        LOGGER.debug('user_changed_key_added: %s %s', self, key)

        for dep in self.deployments.all():
376
            dep.user_credential_added(key)
377

378 379 380 381
    def user_remove_key(self, key):
        if key.delete_key():
            return

382 383
        LOGGER.debug('user_changed_key_removed: %s %s', self, key)

384 385
        for dep in self.deployments.all():
            dep.user_credential_removed(key)
386

Lukas Burgey's avatar
Lukas Burgey committed
387 388
    def user_changed_vo_added(self, vo):
        LOGGER.debug('user_changed_vo_added: %s %s', self, vo)
389

Lukas Burgey's avatar
Lukas Burgey committed
390
        # check if the user has deactivated deployments for this exact vo
391
        # if yes: reactivate the deployments
Lukas Burgey's avatar
Lukas Burgey committed
392 393
        for dep in self.deployments.filter(vo=vo):
            LOGGER.debug('user_changed_vo_added: need to activate deployment %s', dep)
394

Lukas Burgey's avatar
Lukas Burgey committed
395 396
    def user_changed_vo_removed(self, vo):
        LOGGER.debug('user_changed_vo_removed: %s %s', self, vo)
397

Lukas Burgey's avatar
Lukas Burgey committed
398
        # check if the user has deployments which need member ship of this vo
399
        # if yes remove them
Lukas Burgey's avatar
Lukas Burgey committed
400 401
        for dep in self.deployments.filter(vo=vo):
            LOGGER.debug('user_changed_vo_removed: need to deactivate deployment %s', dep)
402

Lukas Burgey's avatar
Lukas Burgey committed
403

Lukas Burgey's avatar
Lukas Burgey committed
404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424
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,
    )

425 426 427 428
    @property
    def value(self):
        return self.key

429
    # returns true if the deletion is final
Lukas Burgey's avatar
Lukas Burgey committed
430
    def delete_key(self):
431 432 433 434
        if self.try_delete_key():
            return True

        LOGGER.debug(self.msg('Deletion started'))
Lukas Burgey's avatar
Lukas Burgey committed
435 436 437 438
        self.user = None
        self.key = ''
        self.deleted = True
        self.save()
439 440 441 442 443 444 445 446
        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
447

448
        return False
Lukas Burgey's avatar
Lukas Burgey committed
449 450 451 452 453 454 455 456

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

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

458

Lukas Burgey's avatar
Lukas Burgey committed
459 460 461 462 463 464 465 466
@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()

467

Lukas Burgey's avatar
Lukas Burgey committed
468 469 470 471 472 473 474
@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()