users.py 15.4 KB
Newer Older
Lukas Burgey's avatar
Lukas Burgey committed
1
2
3
# unused-argument: for receivers
# no-member: polymorphic class not handled correctly
# pylint: disable=unused-argument,no-member
4
5
6

import logging

7
from django.contrib.auth.models import AbstractUser
8
from django.db import models
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

13
14
from feudal.backend.models.auth import OIDCConfig
from feudal.backend.models.auth.vos 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

Lukas Burgey's avatar
Lukas Burgey committed
26
27
28
29
30
    TYPE_CHOICE_DOWNSTREAM = 'apiclient'
    TYPE_CHOICE_USER = 'oidcuser'
    TYPE_CHOICE_ADMIN = 'admin'
    TYPE_CHOICE_UPSTREAM = 'upstream'

31
    TYPE_CHOICES = (
Lukas Burgey's avatar
Lukas Burgey committed
32
33
34
35
        (TYPE_CHOICE_DOWNSTREAM, 'Downstream Client'),  # clients which connect to us via pubsub
        (TYPE_CHOICE_USER, 'Webpage User'),             # normal users which logged in using the webpage
        (TYPE_CHOICE_ADMIN, 'Admin'),                   # admins of the django admin
        (TYPE_CHOICE_UPSTREAM, 'Upstream Client'),      # E.g. an idP that provides us with fresh userinfos or access tokens
36
37
38
39
    )
    user_type = models.CharField(
        max_length=20,
        choices=TYPE_CHOICES,
40
        default=TYPE_CHOICE_DOWNSTREAM,
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
    )
    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,
Lukas Burgey's avatar
Lukas Burgey committed
66
        editable=True,  # editable because when user_type==upstream the idp needs to be configurable in the admin
67
68
69
70
71
72
73
74
    )
    userinfo = JSONField(
        default=user_info_default,
        null=True,
        blank=True,
        editable=False,
    )

75
76
77
78
79
    vos = models.ManyToManyField(
        VO,
        blank=True,
    )

80
81
82
83
    @property
    def profile_name(self):
        if 'email' in self.userinfo:
            return self.userinfo['email']
84
85

        if 'name' in self.userinfo:
86
87
            return self.userinfo['name']

88
89
90
91
        if 'sub' in self.userinfo:
            return self.userinfo['sub']

        return self.username
92

Lukas Burgey's avatar
Lukas Burgey committed
93
94
95
96
97
98
    # 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)

99
100
101
    @property
    def credentials(self):
        return {
102
            'ssh_key': self.ssh_keys.all(),
103
104
        }

Lukas Burgey's avatar
Lukas Burgey committed
105
106
107
108
    @property
    def is_active_at_clients(self):
        return self._is_active

109
110
    # get_user returns the user as identified by userinfo and idp.
    # If the user does not exists it is created.
111
112
113
114
115
116
117
118
119
120
121
    @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
122
            user.update_userinfo(userinfo)
123
124
125
126
127
128
129
130
            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:
131
132
            raise ValueError('Missing attribute in userinfo: sub')

ubedv's avatar
ubedv committed
133
134
135
136
        username = '{}@{}'.format(
            userinfo['sub'],
            idp.name.replace(' ', '_'),
        )
137

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

        user = cls(
146
            user_type=cls.TYPE_CHOICE_USER,
147
            username=username,
148
            sub=userinfo['sub'],
149
150
151
            idp=idp,
        )
        user.save()
Lukas Burgey's avatar
Lukas Burgey committed
152
        user.update_userinfo(userinfo)
153
        LOGGER.info('construct_from_userinfo: new user: %s', user)
154
155
156
157

        return user

    @classmethod
158
159
160
161
162
163
164
165
    def construct_client(cls, username, password, user_type=None, idp=None):
        _user_type = None
        if user_type is None:
            _user_type = cls.TYPE_CHOICE_DOWNSTREAM
        else:
            _user_type = user_type

        LOGGER.debug('Constructing %s %s', _user_type, username)
166
167
        client = cls(
            username=username,
168
            user_type=_user_type,
169
170
        )
        client.set_password(password)
171
172
173
174
175

        if idp is not None:
            client.idp = idp
            client.save()

176
177
        return client

Lukas Burgey's avatar
Lukas Burgey committed
178
179
180
181
182
183
    def _delete_deployments(self):
        for deployment in self.deployments.all():
            LOGGER.debug('Deleting users deployment %s', deployment)
            deployment.delete()

    def delete(self, *args, **kwargs):
184
        if self.user_type == self.TYPE_CHOICE_USER:
Lukas Burgey's avatar
Lukas Burgey committed
185
            LOGGER.info('Deleting User %s', self)
Lukas Burgey's avatar
Lukas Burgey committed
186

Lukas Burgey's avatar
Lukas Burgey committed
187
188
            # TODO these deletions are a hack. django should (TM) be able to delete them itself
            # but there seems to be a bug
Lukas Burgey's avatar
Lukas Burgey committed
189

Lukas Burgey's avatar
Lukas Burgey committed
190
            self._delete_deployments()
Lukas Burgey's avatar
Lukas Burgey committed
191

Lukas Burgey's avatar
Lukas Burgey committed
192
193
194
            # delete SSH Keys
            for key in self.ssh_keys.all():
                key.delete_key()
Lukas Burgey's avatar
Lukas Burgey committed
195

Lukas Burgey's avatar
Lukas Burgey committed
196
197
            for state in self.states.all():
                state.publish_to_client()
Lukas Burgey's avatar
Lukas Burgey committed
198

Lukas Burgey's avatar
Lukas Burgey committed
199
        super().delete(**kwargs)
Lukas Burgey's avatar
Lukas Burgey committed
200

201
    def __str__(self):
Lukas Burgey's avatar
Lukas Burgey committed
202
203
        name = ''

204
        if self.user_type == self.TYPE_CHOICE_ADMIN:
Lukas Burgey's avatar
Lukas Burgey committed
205
            name += 'ADMIN '
206
        elif self.user_type == self.TYPE_CHOICE_USER:
207
            if not self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
208
209
                name += 'DEACTIVATED '
            name += 'USER '
210
211
212
213
        elif self.user_type == self.TYPE_CHOICE_DOWNSTREAM:
            name += 'DOWNSTREAM-CLIENT '
        elif self.user_type == self.TYPE_CHOICE_UPSTREAM:
            name += 'UPSTREAM-CLIENT '
Lukas Burgey's avatar
Lukas Burgey committed
214

215
        if self.user_type == self.TYPE_CHOICE_USER:
Lukas Burgey's avatar
Lukas Burgey committed
216
217
            name += self.profile_name
        else:
Lukas Burgey's avatar
Lukas Burgey committed
218
            name += self.username
219

Lukas Burgey's avatar
Lukas Burgey committed
220
        return name
221
222
223
224

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

Lukas Burgey's avatar
Lukas Burgey committed
225
    # TODO rework deactivation
226
227
228
229
230
231
232
233
234
235
236
    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
237
        if self.user_type == self.TYPE_CHOICE_USER:
238
            # for dep in self.deployments.all():
239
240
            #    dep.activate()
            pass
241

Lukas Burgey's avatar
Lukas Burgey committed
242
    # TODO rework deactivation
243
244
245
246
247
248
249
250
    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()
251
252

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

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

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

260
261
262
263
264
265
266
    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():
267
            for state_item in dep.states.all():
268
269
                if state_item.state != 'not_deployed':
                    sites.append(state_item.site)
270

Lukas Burgey's avatar
Lukas Burgey committed
271
    # return True if the userinfo contains entitlements (we ignore groups then)
272
273
    def update_userinfo_entitlements(self, userinfo):
        local_entitlements = self.vos.instance_of(Entitlement)
274
        remote_entitlements = []
Lukas Burgey's avatar
Lukas Burgey committed
275
        remote_entitlements_raw = []
276
277

        # determine upstream entitlements
278
279
280
281
282
283
284
285
286
287
288
289
290
291
        if self.idp.userinfo_field_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
                    ]
                    remote_entitlements_raw = field
                elif isinstance(field, str):
                    remote_entitlements = [Entitlement.extract_name(field)]
                    remote_entitlements_raw = [field]
                else:
                    LOGGER.error('Userinfo field %s is neither str nor list', self.idp.userinfo_field_entitlements)
292
            else:
293
294
295
296
297
                LOGGER.info(
                    'Userinfo from the idp %s does not contain the configured field %s. Change the OIDC Config',
                    self.idp,
                    self.idp.userinfo_field_entitlements,
                )
298
299
300
301

        # 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
302
                self._remove_vo(loc_ent)
303

Lukas Burgey's avatar
Lukas Burgey committed
304
305
        for rem_ent_name in remote_entitlements_raw:
            ent = Entitlement.get_entitlement(rem_ent_name, idp=self.idp)
306

307
            # check if the user needs to be in this entitlement
308
            if self not in ent.user_set.all():
Lukas Burgey's avatar
Lukas Burgey committed
309
                self._add_vo(ent)
Lukas Burgey's avatar
Lukas Burgey committed
310

Lukas Burgey's avatar
Lukas Burgey committed
311
        return len(remote_entitlements) > 0
312

Lukas Burgey's avatar
Lukas Burgey committed
313
    def update_userinfo_groups(self, userinfo, ignore_groups=False):
314
        local_groups = self.vos.instance_of(Group)
Lukas Burgey's avatar
Lukas Burgey committed
315
316
        remote_groups = []

317
318
319
320
321
322
323
324
325
        if self.idp.userinfo_field_groups != '' and not ignore_groups:
            if self.idp.userinfo_field_groups in userinfo:
                field = userinfo[self.idp.userinfo_field_groups]
                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_groups)
326
            else:
327
328
329
330
331
                LOGGER.info(
                    'Userinfo from the idp %s does not contain the configured field %s. Change the OIDC Config',
                    self.idp,
                    self.idp.userinfo_field_groups,
                )
332

333
        # check if groups were removed
334
335
        for group in local_groups:
            if group.name not in remote_groups:
Lukas Burgey's avatar
Lukas Burgey committed
336
                self._remove_vo(group)
337

338
        # check if groups were added
339
        for group_name in remote_groups:
340
            group = Group.get_group(group_name, self.idp)
341
342

            # check if user needs to be in this group
343
            if self not in group.user_set.all():
Lukas Burgey's avatar
Lukas Burgey committed
344
                self._add_vo(group)
345

346
    def update_userinfo_ssh_key(self, userinfo):
347
348

        idp_key_name = 'ssh_key'
349
350
        unity_key_name = 'unity_key'

351
352
        unity_key_value = userinfo.get(idp_key_name, '')

353
354
        try:
            key = self._ssh_keys.get(name=unity_key_name)
355

356
            # is the idp key still present?
357
            if idp_key_name not in userinfo:
Lukas Burgey's avatar
Lukas Burgey committed
358
                self.remove_key(key)
359

Lukas Burgey's avatar
Lukas Burgey committed
360
                return
361

362
            # is the idp key changed?
363
            if key.key != unity_key_value:
Lukas Burgey's avatar
Lukas Burgey committed
364
                self.remove_key(key)
365

Lukas Burgey's avatar
Lukas Burgey committed
366
367
                # causes creation of a new key
                raise SSHPublicKey.DoesNotExist
368
369

        except SSHPublicKey.DoesNotExist:
370
            if idp_key_name not in userinfo:
Lukas Burgey's avatar
Lukas Burgey committed
371
                return
372

373
374
375
376
377
378
            key = SSHPublicKey(
                name=unity_key_name,
                key=unity_key_value,
                user=self,
            )
            key.save()
Lukas Burgey's avatar
Lukas Burgey committed
379

Lukas Burgey's avatar
Lukas Burgey committed
380
            self.add_key(key)
381

382
    def update_userinfo(self, userinfo):
383

384
385
386
387
388
389
390
391
392
        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']

393
394
395
396
        # make sure the userinfo always contains the issuer uri
        if 'iss' not in userinfo:
            userinfo['iss'] = self.idp.issuer_uri

397
398
        self.userinfo = userinfo
        self.save()
399

400
        # if one or more entitlements exist, ignore the groups
401
        ignore_groups = False
Lukas Burgey's avatar
Lukas Burgey committed
402
403
        ignore_groups = self.update_userinfo_entitlements(userinfo)
        self.update_userinfo_groups(userinfo, ignore_groups=ignore_groups)
404

Lukas Burgey's avatar
Lukas Burgey committed
405
        self.update_userinfo_ssh_key(userinfo)
406

Lukas Burgey's avatar
Lukas Burgey committed
407
408
    def add_key(self, key):
        LOGGER.debug(self.msg('Adding key: {}'.format(key)))
409

410
        for dep in self.deployments.all():
411
            dep.user_credential_added(key)
412

413

Lukas Burgey's avatar
Lukas Burgey committed
414
415
    def remove_key(self, key):
        LOGGER.debug(self.msg('Removing key: {}'.format(key)))
416

417
418
419
        if key.delete_key():
            return

420
        for dep in self.deployments.all():
421
            dep.user_credential_removed(key)
422

Lukas Burgey's avatar
Lukas Burgey committed
423
424
    def _add_vo(self, vo):
        self.vos.add(vo)
Lukas Burgey's avatar
Lukas Burgey committed
425
        LOGGER.debug(self.msg('Adding VO: {}'.format(vo)))
426

Lukas Burgey's avatar
Lukas Burgey committed
427
        # check if the user has deactivated deployments for this exact vo
428
        # if yes: reactivate the deployments
Lukas Burgey's avatar
Lukas Burgey committed
429
430
431

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

Lukas Burgey's avatar
Lukas Burgey committed
434
435
    def _remove_vo(self, vo):
        self.vos.remove(vo)
Lukas Burgey's avatar
Lukas Burgey committed
436
        LOGGER.debug(self.msg('Removing VO: {}'.format(vo)))
437

Lukas Burgey's avatar
Lukas Burgey committed
438
439
        # TODO this does nothing for ServiceDeployments

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

Lukas Burgey's avatar
Lukas Burgey committed
445

Lukas Burgey's avatar
Lukas Burgey committed
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
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,
    )

467
468
469
470
    @property
    def value(self):
        return self.key

471
    # returns true if the deletion is final
Lukas Burgey's avatar
Lukas Burgey committed
472
    def delete_key(self):
473
474
475
476
        if self.try_delete_key():
            return True

        LOGGER.debug(self.msg('Deletion started'))
Lukas Burgey's avatar
Lukas Burgey committed
477
478
479
480
        self.user = None
        self.key = ''
        self.deleted = True
        self.save()
481
482
483
484
485
486
487
488
        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
489

490
        return False
Lukas Burgey's avatar
Lukas Burgey committed
491
492
493
494
495
496
497
498

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

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

500

Lukas Burgey's avatar
Lukas Burgey committed
501
502
503
504
505
506
507
508
@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()

509

Lukas Burgey's avatar
Lukas Burgey committed
510
511
512
513
514
515
516
@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()