# django senders need their arguments # pylint: disable=unused-argument import json import logging from django.contrib.auth.models import AbstractUser, Group from django.core.cache import cache from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django_mysql.models import JSONField from pika.exceptions import ConnectionClosed import pika from requests.auth import HTTPBasicAuth from .auth.v1.models import OIDCConfig LOGGER = logging.getLogger(__name__) RABBITMQ_CONNECTION = None STATE_CHOICES = ( ('deployment_pending', 'Deployment Pending'), ('removal_pending', 'Removal Pending'), ('deployed', 'Deployed'), ('not_deployed', 'Not Deployed'), ('questionnaire', 'Questionnaire'), ) # singleton for simple configs # https://steelkiwi.com/blog/practical-application-singleton-design-pattern/ class SingletonModel(models.Model): class Meta: abstract = True def set_cache(self): cache.set(self.__class__.__name__, self) # pylint: disable=invalid-name, arguments-differ def save(self, *args, **kwargs): self.pk = 1 super(SingletonModel, self).save(*args, **kwargs) self.set_cache() @classmethod def load(cls): if cache.get(cls.__name__) is None: obj, created = cls.objects.get_or_create(pk=1) if not created: obj.set_cache() return cache.get(cls.__name__) def exchanges_default(): return [] # takes a list of states # return '' if all states are equal to '' # else it returns 'mixed' def analyze_states(states): _state = '' for state in states: if _state == '': _state = state elif _state != state: return 'mixed' return _state # clients are registerred at rabbitmq, when they are assigned to a site # (because we only then know what services they provide) class RabbitMQInstance(SingletonModel): host = models.CharField( max_length=150, default='localhost', ) vhost = models.CharField( max_length=150, default='%2f', ) exchanges = JSONField( default=exchanges_default, null=True, blank=True, ) port = models.IntegerField( default=15672, ) username = models.CharField( max_length=150, default='guest', ) password = models.CharField( max_length=150, default='guest', ) def __str__(self): return self.host def msg(self, msg): return '[RabbitMQ:{}] {}'.format(self.host, msg) @property def auth(self): return HTTPBasicAuth( self.username, self.password, ) def _init_exchanges(self, channel): channel.exchange_declare( exchange='deployments', durable=True, auto_delete=False, exchange_type='topic', ) channel.exchange_declare( exchange='sites', durable=True, auto_delete=False, exchange_type='topic', ) channel.exchange_declare( exchange='update', durable=True, auto_delete=False, exchange_type='topic', ) def _init_connection(self): global RABBITMQ_CONNECTION #LOGGER.debug('Opening new BlockingConnection') RABBITMQ_CONNECTION = pika.BlockingConnection( pika.ConnectionParameters( host=self.host, ssl=True, heartbeat_interval=60, ) ) return RABBITMQ_CONNECTION @property def _connection(self): global RABBITMQ_CONNECTION if RABBITMQ_CONNECTION is not None: if RABBITMQ_CONNECTION.is_open: return RABBITMQ_CONNECTION elif RABBITMQ_CONNECTION.is_closing: RABBITMQ_CONNECTION.close() connection = self._init_connection() channel = connection.channel() self._init_exchanges(connection.channel()) channel.close() RABBITMQ_CONNECTION = connection return connection @property def _channel(self): try: channel = self._connection.channel() channel.confirm_delivery() return channel except ConnectionClosed as exception: LOGGER.error(self.msg('ConnectionClosed: ' + str(exception))) self._init_connection() return self._channel def _publish(self, exchange, routing_key, body): channel = self._channel channel.basic_publish( exchange=exchange, routing_key=routing_key, body=body, properties=pika.BasicProperties( delivery_mode=1, ), ) channel.close() # PUBLIC API def publish_by_service(self, service, msg): self._publish( 'deployments', service.routing_key, msg, ) def publish_by_site(self, site, msg): self._publish( 'sites', site.name, msg, ) def publish_to_user(self, user, msg): self._publish( 'update', str(user.id), json.dumps(msg), ) def user_info_default(): return {} def questionnaire_default(): return {} def credential_default(): return {} class User(AbstractUser): TYPE_CHOICES = ( ('apiclient', 'API-Client'), ('oidcuser', 'OIDC User'), ('admin', 'Admin'), ) user_type = models.CharField( max_length=20, choices=TYPE_CHOICES, default='apiclient', ) 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, ) @property def deployment_states(self): states = [] for deployment in self.deployments.all(): for state in deployment.states.all(): states.append(state) return states @property def deployment_state_items(self): items = [] for state in self.deployment_states: for item in state.state_items.all(): items.append(item) return items # 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 Exception('get_user needs a userinfo which contains the users subject') query_result = cls.objects.filter( sub=userinfo['sub'], idp=idp, ) if not query_result.exists(): return cls.construct_from_userinfo(userinfo, idp) if len(query_result) > 1: return Exception('Two user instances with same subject from the same idp') # TODO update the users userinfo when it changes # TODO update the users groupinfo when it changes return query_result.first() @classmethod def construct_from_userinfo(cls, userinfo, idp): LOGGER.debug('Constructing User from:\n%s', userinfo) if 'sub' not in userinfo: raise Exception('Missing attribute in userinfo: sub') sub = userinfo['sub'] if 'email' not in userinfo: username = sub else: username = userinfo['email'] email = userinfo['email'] user = cls( user_type='oidcuser', username=username, sub=sub, email=email, idp=idp, userinfo=userinfo, ) user.save() for group in idp.get_user_groupinformation( userinfo, ).all(): group.users.add(user) 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 # 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) @property def is_active_at_clients(self): return self._is_active def __str__(self): if self.user_type == 'admin': return 'ADMIN {}'.format(self.username) elif self.user_type == 'oidcuser': if not self.is_active: return 'DEACTIVATED USER {}'.format(self.username) return 'USER {}'.format(self.username) elif self.user_type == 'apiclient': try: return 'APICLIENT {}@{}'.format(self.username, self.site) except: return 'APICLIENT {}'.format(self.username) else: raise Exception() def msg(self, msg): return '[{}] {}'.format(self, msg) # oidcuser: withdraw and delete all credentials and delete the user def remove(self): if self.user_type == 'oidcuser': self.deactivate() # FIXME: deleting the user brings problems: # the deletion cascades down to DeploymentState and DeploymentStateItem # 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': for dep in self.deployments.all(): dep.activate() 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() LOGGER.info(self.msg('deactivated')) # oidcuser: withdraw all credentials if self.user_type == 'oidcuser': for dep in self.deployments.all(): dep.deactivate() # authorisation groups class AuthGroup(models.Model): name = models.CharField( max_length=200, ) users = models.ManyToManyField( User, related_name='auth_groups', blank=True, ) class Site(models.Model): client = models.OneToOneField( User, related_name='site', on_delete=models.SET_NULL, null=True, blank=True, ) name = models.CharField( max_length=150, unique=True, ) description = models.TextField( max_length=300, blank=True, ) @property def pending_tasks(self): return [item.parent for item in self.state_items.all()] def __str__(self): return self.name class Service(models.Model): name = models.CharField( max_length=150, unique=True, ) description = models.TextField( max_length=300, blank=True, ) site = models.ManyToManyField( Site, related_name='services', ) groups = models.ManyToManyField( Group, related_name='services', blank=True, ) @property def routing_key(self): return 'service.{}'.format(self.name) def __str__(self): return self.name class SSHPublicKey(models.Model): name = models.CharField( max_length=150, ) key = models.TextField( max_length=1000 ) # hidden field at the user # TODO checks: if the user is null 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, ) @property def deployed_anywhere(self): for state in self.states.all(): for item in state.state_items.all(): if item.state == 'deployed' or item.state == 'removal_pending': return True return False # does not directly delete the key if the key is deployed or withdrawn # somewhere # the receiver 'delete_withdrawn_ssh_key' does the actual deletion def delete_key(self): # if this key is not deployed anywhere we delete it now if not self.deployed_anywhere: LOGGER.info(self.msg('Direct deletion of key')) self.delete() return LOGGER.info(self.msg('Deletion of key started')) self.deleted = True self.save() # delete implies withdrawing the key from all clients for deployment in self.deployments.all(): deployment.remove_key(self) # when a key is withdrawn by a client we try to finally delete it def try_final_deletion(self): if self.deleted: if not self.deployed_anywhere: LOGGER.info(self.msg('All clients have withdrawn this key. Final deletion')) self._final_deletion() def _final_deletion(self): _self = self for state in self.states.all(): #for item in state.state_items.all(): # item.delete() state.delete() _self.delete() def __str__(self): if self.deleted: return "DELETED: {}".format(self.name) return self.name def msg(self, msg): return '[SSHKey:{}] {}'.format(self, msg) # Deployment describes the credential state per user (and site) as it is supposed to be # # (exception: if is_active=False the ssh_keys contain the keys to be deployed # if the deployment is reactivated) # # DeploymentState is what is sent to the clients via rabbitmq # The DeploymentStateItem track the acknowledgements from the clients class Deployment(models.Model): user = models.ForeignKey( User, related_name='deployments', on_delete=models.SET_NULL, null=True, ) service = models.ForeignKey( Service, related_name='deployments', on_delete=models.CASCADE, ) ssh_keys = models.ManyToManyField( SSHPublicKey, related_name='deployments', blank=True, ) ssh_keys_to_withdraw = models.ManyToManyField( SSHPublicKey, related_name='withdrawn_deployments', blank=True, ) is_active = models.BooleanField( default=True, ) # get a deployment for a user/service. # if it does not exist it is created @classmethod def get_deployment(cls, user, service): query = cls.objects.filter( user=user, service=service, ) if query.exists(): return query.first() deployment = cls( user=user, service=service, ) deployment.save() LOGGER.debug(deployment.msg('created')) return deployment # deploy credentials which were deployed prior to deactivation def activate(self): if self.is_active: LOGGER.error(self.msg('already active')) return for key in self.ssh_keys.all(): self._deploy_key(key) self.is_active = True self.save() LOGGER.info(self.msg('activated')) # withdraw all credentials def deactivate(self): if not self.is_active: LOGGER.error(self.msg('already deactivated')) return self.is_active = False self.save() for key in self.ssh_keys.all(): self._remove_key(key) LOGGER.info(self.msg('deactivated')) # deploy key and track changes in the key lists def deploy_key(self, key): if not self.is_active: LOGGER.error(self.msg('cannot deploy while deactivated')) raise Exception('deployment deactivated') self.ssh_keys.add(key) if key in self.ssh_keys_to_withdraw.all(): self.ssh_keys_to_withdraw.remove(key) self.save() self._deploy_key(key) # withdraw key and track changes in the key lists def remove_key(self, key): if not self.is_active: LOGGER.error(self.msg('cannot withdraw while deactivated')) raise Exception('deployment deactivated') self.ssh_keys.remove(key) # keys which are to be withdrawn by the clients self.ssh_keys_to_withdraw.add(key) self.save() self._remove_key(key) # only deploy the key def _deploy_key(self, key): state = DeploymentState.get_state( deployment=self, key=key, ) state.save() state.deploy() def _remove_key(self, key): state = DeploymentState.get_state( deployment=self, key=key, ) state.save() state.remove() def __str__(self): return '{}:{}'.format(self.service, self.user) def msg(self, msg): return '[Depl.m:{}] {}'.format(self, msg) # DeploymentState: knows: # user, service, key, state_target class DeploymentState(models.Model): key = models.ForeignKey( SSHPublicKey, related_name='states', # deleting the key leaves us without references about its deployments # we _HAVE_ to remove all deployments prior to deleting key on_delete=models.CASCADE, ) deployment = models.ForeignKey( Deployment, related_name='states', on_delete=models.CASCADE, ) # which state do we currently want to reach? state_target = models.CharField( max_length=50, choices=STATE_CHOICES, default='deployed', ) @property def user(self): return self.deployment.user @property def states(self): return [item.state for item in self.state_items.all()] @property def state(self): return analyze_states(self.states) @property def service(self): return self.deployment.service @property def target_reached(self): return self.state_target == self.state # get a deployment for a user/service. # if it does not exist it is created @classmethod def get_state(cls, deployment, key): # check if a state does already exist query = cls.objects.filter( deployment=deployment, key=key, ) if query.exists(): return query.first() # create new state if not state = cls( deployment=deployment, key=key, ) state.save() LOGGER.debug(state.msg('created')) # generate state items for site in deployment.service.site.all(): deploy = DeploymentStateItem( parent=state, site=site, ) deploy.save() return state def deploy(self): self._set_target('deployed') for item in self.state_items.all(): item.user_deploy() self.publish_to_client() # each state item publishes its state to the user def remove(self): self._set_target('not_deployed') for item in self.state_items.all(): item.user_remove() self.publish_to_client() # each state item publishes its state to the user def publish_to_client(self): # mitigating circular dependencies here from .clientapi.serializers import DeploymentStateSerializer msg = json.dumps(DeploymentStateSerializer(self).data) RabbitMQInstance.load().publish_by_service( self.service, msg, ) # update the state of the remote webpage def publish_to_user(self): from .frontend.views import user_state content = { 'user_state': user_state(self.user), } RabbitMQInstance.load().publish_to_user( self.user, content, ) def msg(self, msg): return '[DState:{}] {}'.format(self, msg) def _set_target(self, target): self.state_target = target LOGGER.debug(self.msg('target: '+target)) self.save() def __str__(self): return "{}:{}#{}".format( self.deployment.service, self.key, self.id, ) # DeploymentStateItem: knows: # user, service, key, state_target, _and_ site class DeploymentStateItem(models.Model): parent = models.ForeignKey( DeploymentState, related_name='state_items', on_delete=models.CASCADE, ) site = models.ForeignKey( Site, related_name='state_items', on_delete=models.CASCADE, ) state = models.CharField( max_length=50, choices=STATE_CHOICES, default='deployment_pending', ) # questions for the user (needed for deployment questionnaire = JSONField( default=questionnaire_default, ) # credentials for the service # only valid when state == deployed credentials = JSONField( default=credential_default, ) @property def user(self): return self.parent.user @property def service(self): return self.parent.service @property def key(self): return self.parent.key # STATE transitions # user: deployment requested def user_deploy(self): if self.state == 'removal_pending': self._set_state('deployed') return if self.state == 'deployed': LOGGER.info(self.msg('ignoring invalid state transition user_deploy')) return self._set_state('deployment_pending') # user: removal requested def user_remove(self): if ( self.state == 'deployment_pending' or self.state == 'questionnaire' ): self._set_state('not_deployed') return if self.state == 'not_deployed': LOGGER.info(self.msg('ignoring invalid state transition user_remove')) return self._set_state('removal_pending') # user: questionnaire answered def user_answers(self, answers=None): self.questionnaire = answers self._set_state('deployment_pending') self.parent.publish_to_client() # client: deployed def client_deployed(self, credentials=None): self.credentials = credentials self._set_state('deployed') # client: removed def client_removed(self): # TODO check if all values are reset correctly self.credentials = credential_default() self.questionnaire = questionnaire_default() self._set_state('not_deployed') # this removal maybe was the last of out ssh key self.key.try_final_deletion() # client: questionnaire def client_questionnaire(self, questionnaire=None): self.questionnaire = questionnaire self._set_state('questionnaire') def msg(self, msg): return '[DSItem:{}] {}'.format(self, msg) def _set_state(self, state): self.state = state self.save() LOGGER.debug(self.msg('state: '+self.state)) self.parent.publish_to_user() def __str__(self): return "{}:{}@{}#{}".format( self.parent.service, self.parent.key, self.site, self.id, ) # # RECEIVERS # @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() @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()