diff --git a/docs/changelog.rst b/docs/changelog.rst index 77f3668..f9dd798 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,8 @@ Changelog ========= * :feature:`-` add mysqltasks and pgsqltasks +* :feature:`-` add :py:mod:`userdbs` app to allow management of user databases + via :py:mod:`mysqltasks` and :py:mod:`pgsqltasks` * :feature:`-` add new app :py:mod:`taskresults` that takes care of handling asynchronous `Celery`_ results * :feature:`-` add new task :py:func:`osusers.tasks.delete_ldap_group` (needs diff --git a/docs/code.rst b/docs/code.rst index 22f2a53..33978fe 100644 --- a/docs/code.rst +++ b/docs/code.rst @@ -114,6 +114,7 @@ provides some functionality that is common to all gnuviechadmin subprojects. ---------------------------------- .. automodule:: mysqltasks.tasks + :members: :py:mod:`osusers` app @@ -167,6 +168,7 @@ provides some functionality that is common to all gnuviechadmin subprojects. ======================== .. automodule:: pgsqltasks + :members: :py:mod:`tasks ` @@ -199,3 +201,23 @@ provides some functionality that is common to all gnuviechadmin subprojects. ------------------------------------- .. automodule:: taskresults.models + + +:py:mod:`userdbs` app +===================== + +.. automodule:: userdbs + + +:py:mod:`admin ` +------------------------------- + +.. automodule:: userdbs.admin + :members: + + +:py:mod:`models ` +--------------------------------- + +.. automodule:: userdbs.models + :members: diff --git a/gnuviechadmin/gnuviechadmin/settings/base.py b/gnuviechadmin/gnuviechadmin/settings/base.py index cfd72f7..33f5b85 100644 --- a/gnuviechadmin/gnuviechadmin/settings/base.py +++ b/gnuviechadmin/gnuviechadmin/settings/base.py @@ -230,6 +230,7 @@ LOCAL_APPS = ( 'domains', 'osusers', 'managemails', + 'userdbs', ) # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps diff --git a/gnuviechadmin/userdbs/__init__.py b/gnuviechadmin/userdbs/__init__.py new file mode 100644 index 0000000..6f1b8ad --- /dev/null +++ b/gnuviechadmin/userdbs/__init__.py @@ -0,0 +1,5 @@ +""" +This app is for managing database users and user databases. + +""" +default_app_config = 'userdbs.apps.UserdbsAppConfig' diff --git a/gnuviechadmin/userdbs/admin.py b/gnuviechadmin/userdbs/admin.py new file mode 100644 index 0000000..7a498c8 --- /dev/null +++ b/gnuviechadmin/userdbs/admin.py @@ -0,0 +1,279 @@ +""" +Admin functionality for the :py:mod:`userdbs.models` models. + +""" +from __future__ import absolute_import + +from django import forms +from django.contrib import admin +from django.utils.translation import ugettext_lazy as _ + +from .models import ( + DatabaseUser, + UserDatabase, +) + + +class DatabaseUserCreationForm(forms.ModelForm): + """ + A form for creating :py:class:`database users + ` + + """ + + class Meta: + model = DatabaseUser + fields = ['osuser', 'db_type'] + + def save(self, commit=True): + """ + Save the database user. + + :param boolean commit: whether to save the created database user + :return: database user instance + :rtype: :py:class:`userdbs.models.DatabaseUser` + + """ + dbuser = DatabaseUser.objects.create_database_user( + osuser=self.cleaned_data['osuser'], + db_type=self.cleaned_data['db_type'], commit=commit) + return dbuser + + def save_m2m(self): + """ + Noop. + + """ + + +class UserDatabaseCreationForm(forms.ModelForm): + """ + A form for creating :py:class:`user databases + ` + + """ + + class Meta: + model = UserDatabase + fields = ['db_user'] + + def save(self, commit=True): + """ + Save the user database. + + :param boolean commit: whether to save the created user database + :return: user database instance + :rtype: :py:class:`userdbs.models.UserDatabase` + + """ + database = UserDatabase.objects.create_userdatabase( + db_user=self.cleaned_data['db_user'], commit=commit) + return database + + def save_m2m(self): + """ + Noop. + + """ + + +class DatabaseUserAdmin(admin.ModelAdmin): + """ + Admin class for working with :py:class:`database users + ` + + """ + actions = ['perform_delete_selected'] + add_form = DatabaseUserCreationForm + + def get_form(self, request, obj=None, **kwargs): + """ + Use special form for database user creation. + + :param request: the current HTTP request + :param obj: either a :py:class:`Database user + ` instance or None for a new database + user + :param kwargs: keyword arguments to be passed to + :py:meth:`django.contrib.admin.ModelAdmin.get_form` + :return: form instance + + """ + defaults = {} + if obj is None: + defaults.update({ + 'form': self.add_form, + }) + defaults.update(kwargs) + return super(DatabaseUserAdmin, self).get_form( + request, obj, **defaults) + + def get_readonly_fields(self, request, obj=None): + """ + Make sure that osuser, name and db_type are not editable for existing + database users. + + :param request: the current HTTP request + :param obj: either a :py:class:`Database user + ` instance or None for a new database + user + :return: a list of fields + :rtype: list + + """ + if obj: + return ['osuser', 'name', 'db_type'] + return [] + + def save_model(self, request, obj, form, change): + """ + Make sure that the user is created in the target database. + + :param request: the current HTTP request + :param obj: a :py:class:`Database user ` + instance + :param form: the form instance + :param boolean change: whether this is a change operation or not + + """ + if not change: + obj.create_in_database() + super(DatabaseUserAdmin, self).save_model(request, obj, form, change) + + def perform_delete_selected(self, request, queryset): + """ + Action to delete a list of selected database users. + + This action calls the delete method of each selected database user in + contrast to the default `delete_selected` + + :param request: the current HTTP request + :param queryset: Django ORM queryset representing the selected database + users + + """ + for dbuser in queryset.all(): + dbuser.delete() + perform_delete_selected.short_description = _( + 'Delete selected database users') + + def get_actions(self, request): + """ + Get the available actions for database users. + + This overrides the default behavior to remove the default + `delete_selected` action. + + :param request: the current HTTP request + :return: list of actions + :rtype: list + + """ + actions = super(DatabaseUserAdmin, self).get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions + + +class UserDatabaseAdmin(admin.ModelAdmin): + """ + Admin class for working with :py:class:`user databases + ` + + """ + actions = ['perform_delete_selected'] + add_form = UserDatabaseCreationForm + + def get_form(self, request, obj=None, **kwargs): + """ + Use special form for user database creation. + + :param request: the current HTTP request + :param obj: either a :py:class:`User database + ` instance or None for a new user + database + :param kwargs: keyword arguments to be passed to + :py:meth:`django.contrib.admin.ModelAdmin.get_form` + :return: form instance + + """ + defaults = {} + if obj is None: + defaults.update({ + 'form': self.add_form, + }) + defaults.update(kwargs) + return super(UserDatabaseAdmin, self).get_form( + request, obj, **defaults) + + def get_readonly_fields(self, request, obj=None): + """ + Make sure that db_name and db_user are not editable for existing user + databases. + + :param request: the current HTTP request + :param obj: either a :py:class:`User database + ` instance or None for a new user + database + :return: a list of fields + :rtype: list + + """ + if obj: + return ['db_name', 'db_user'] + return [] + + def save_model(self, request, obj, form, change): + """ + Make sure that the database is created in the target database server. + + :param request: the current HTTP request + :param obj: a :py:class:`Database user ` + instance + :param form: the form instance + :param boolean change: whether this is a change operation or not + + """ + if not change: + obj.create_in_database() + super(UserDatabaseAdmin, self).save_model(request, obj, form, change) + + def perform_delete_selected(self, request, queryset): + """ + Action to delete a list of selected user databases. + + This action calls the delete method of each selected user database in + contrast to the default `delete_selected` + + :param request: the current HTTP request + :param queryset: Django ORM queryset representing the selected user + databases + + """ + for dbuser in queryset.all(): + dbuser.delete() + for database in queryset.all(): + database.delete() + perform_delete_selected.short_description = _( + 'Delete selected user databases') + + def get_actions(self, request): + """ + Get the available actions for user databases. + + This overrides the default behavior to remove the default + `delete_selected` action. + + :param request: the current HTTP request + :return: list of actions + :rtype: list + + """ + actions = super(UserDatabaseAdmin, self).get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions + + +admin.site.register(DatabaseUser, DatabaseUserAdmin) +admin.site.register(UserDatabase, UserDatabaseAdmin) diff --git a/gnuviechadmin/userdbs/apps.py b/gnuviechadmin/userdbs/apps.py new file mode 100644 index 0000000..40fd5ec --- /dev/null +++ b/gnuviechadmin/userdbs/apps.py @@ -0,0 +1,18 @@ +""" +This module contains the :py:class:`django.apps.AppConfig` instance for the +:py:mod:`userdbs` app. + +""" +from __future__ import unicode_literals + +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class UserdbsAppConfig(AppConfig): + """ + AppConfig for the :py:mod:`userdbs` app. + + """ + name = 'userdbs' + verbose_name = _('Database Users and their Databases') diff --git a/gnuviechadmin/userdbs/migrations/0001_initial.py b/gnuviechadmin/userdbs/migrations/0001_initial.py new file mode 100644 index 0000000..7a81c3c --- /dev/null +++ b/gnuviechadmin/userdbs/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('osusers', '0004_auto_20150104_1751'), + ] + + operations = [ + migrations.CreateModel( + name='DatabaseUser', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), + ('name', models.CharField(max_length=63, verbose_name='username')), + ('db_type', models.PositiveSmallIntegerField(verbose_name='database type', choices=[(0, 'PostgreSQL'), (1, 'MySQL')])), + ('osuser', models.ForeignKey(to='osusers.User')), + ], + options={ + 'verbose_name': 'database user', + 'verbose_name_plural': 'database users', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='UserDatabase', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), + ('db_name', models.CharField(max_length=63, verbose_name='database name')), + ('db_user', models.ForeignKey(verbose_name='database user', to='userdbs.DatabaseUser')), + ], + options={ + 'verbose_name': 'user database', + 'verbose_name_plural': 'user specific database', + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='userdatabase', + unique_together=set([('db_name', 'db_user')]), + ), + migrations.AlterUniqueTogether( + name='databaseuser', + unique_together=set([('name', 'db_type')]), + ), + ] diff --git a/gnuviechadmin/userdbs/migrations/__init__.py b/gnuviechadmin/userdbs/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gnuviechadmin/userdbs/models.py b/gnuviechadmin/userdbs/models.py new file mode 100644 index 0000000..899ed58 --- /dev/null +++ b/gnuviechadmin/userdbs/models.py @@ -0,0 +1,276 @@ +from __future__ import unicode_literals + +from django.db import models +from django.db import transaction +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext as _ + +from model_utils import Choices +from model_utils.models import TimeStampedModel + +from passlib.utils import generate_password + +from osusers.models import User as OsUser + +from mysqltasks.tasks import ( + create_mysql_database, + create_mysql_user, + delete_mysql_database, + delete_mysql_user, + set_mysql_userpassword, +) +from pgsqltasks.tasks import ( + create_pgsql_database, + create_pgsql_user, + delete_pgsql_database, + delete_pgsql_user, + set_pgsql_userpassword, +) + + +DB_TYPES = Choices( + (0, 'pgsql', _('PostgreSQL')), + (1, 'mysql', _('MySQL')), +) +""" +Database type choice enumeration. +""" + + +class DatabaseUserManager(models.Manager): + """ + Default Manager for :py:class:`userdbs.models.DatabaseUser`. + + """ + + def _get_next_dbuser_name(self, osuser, db_type): + """ + Get the next available database user name. + + :param osuser: :py:class:`osusers.models.User` instance + :param db_type: value from :py:data:`DB_TYPES` + :return: database user name + :rtype: str + """ + count = 1 + dbuser_name_format = "{0}db{{0:02d}}".format(osuser.username) + nextname = dbuser_name_format.format(count) + + for user in self.values('name').filter( + osuser=osuser, db_type=db_type + ).order_by('name'): + if user['name'] == nextname: + count += 1 + nextname = dbuser_name_format.format(count) + else: + break + return nextname + + @transaction.atomic + def create_database_user( + self, osuser, db_type, username=None, password=None, commit=True + ): + """ + Create a database user of the given type for the given OS user. + + If username or password are not specified they are generated. + + :param osuser: the :py:class:`osusers.models.User` instance + :param db_type: value from :py:data:`DB_TYPES` + :param str username: database user name + :param str password: initial password or None + :param boolean commit: whether the user should be persisted + :return: :py:class:`userdbs.models.DatabaseUser` instance + + .. note:: + + The password is not persisted it is only used to set the password + on the database side. + + """ + if username is None: + username = self._get_next_dbuser_name(osuser, db_type) + db_user = DatabaseUser( + osuser=osuser, db_type=db_type, name=username, password=password) + if commit: + db_user.create_in_database() + db_user.save() + return db_user + + + +@python_2_unicode_compatible +class DatabaseUser(TimeStampedModel, models.Model): + osuser = models.ForeignKey(OsUser) + name = models.CharField( + _('username'), max_length=63) + db_type = models.PositiveSmallIntegerField( + _('database type'), choices=DB_TYPES) + + objects = DatabaseUserManager() + + class Meta: + unique_together = ['name', 'db_type'] + verbose_name = _('database user') + verbose_name_plural = _('database users') + + def __str__(self): + return "%(name)s (%(db_type)s for %(osuser)s)" % { + 'name': self.name, + 'db_type': self.get_db_type_display(), + 'osuser': self.osuser.username, + } + + def create_in_database(self, password=None): + """ + Create this user in the target database. + + :param str password: initial password for the database user + """ + if password is None: + password = generate_password() + # TODO: send GPG encrypted mail with this information + if self.db_type == DB_TYPES.pgsql: + create_pgsql_user.delay(self.name, password).get() + elif self.db_type == DB_TYPES.mysql: + create_mysql_user.delay(self.name, password).get() + else: + raise ValueError('Unknown database type %d' % self.db_type) + + def set_password(self, password): + """ + Set an existing user's password. + + :param str password: new password for the database user + """ + if self.db_type == DB_TYPES.pgsql: + set_pgsql_userpassword.delay(self.name, password).get(timeout=5) + elif self.db_type == DB_TYPES.mysql: + set_mysql_userpassword.delay(self.name, password).get(timeout=5) + else: + raise ValueError('Unknown database type %d' % self.db_type) + + @transaction.atomic + def delete(self, *args, **kwargs): + """ + Delete the database user from the target database and the Django + database. + + :param args: positional arguments for + :py:meth:`django.db.models.Model.delete` + :param kwargs: keyword arguments for + :py:meth:`django.db.models.Model.delete` + + """ + for database in self.userdatabase_set.all(): + database.delete() + if self.db_type == DB_TYPES.pgsql: + delete_pgsql_user.delay(self.name).get(propagate=False, timeout=5) + elif self.db_type == DB_TYPES.mysql: + delete_mysql_user.delay(self.name).get(propagate=False, timeout=5) + else: + raise ValueError('Unknown database type %d' % self.db_type) + super(DatabaseUser, self).delete(*args, **kwargs) + + +class UserDatabaseManager(models.Manager): + """ + Default manager for :py:class:`userdbs.models.UserDatabase` instances. + + """ + + def _get_next_dbname(self, db_user): + """ + Get the next available database name for the given database user. + + :param db_user: :py:class:`userdbs.models.DatabaseUser` instance + :return: database name + :rtype: str + + """ + count = 1 + db_name_format = "{0}_{{0:02d}}".format(db_user.name) + # first db is named the same as the user + nextname = db_user.name + for name in self.values('db_name').filter(db_user=db_user).order_by( + 'db_name' + ): + if name['db_name'] == nextname: + count += 1 + nextname = db_name_format.format(count) + else: + break + return nextname + + @transaction.atomic + def create_userdatabase(self, db_user, db_name=None, commit=True): + """ + Creates a new user database. + + :param db_user: :py:class:`userdbs.models.DatabaseUser` instance + :param str db_name: database name + :param boolean commit: whether the database should be persisted + :return: :py:class:`userdbs.models.UserDatabase` instance + + """ + if db_name is None: + db_name = self._get_next_dbname(db_user) + database = UserDatabase(db_user=db_user, db_name=db_name) + if commit: + database.create_in_database() + database.save() + return database + + +@python_2_unicode_compatible +class UserDatabase(TimeStampedModel, models.Model): + # MySQL limits to 64, PostgreSQL to 63 characters + db_name = models.CharField( + _('database name'), max_length=63) + db_user = models.ForeignKey(DatabaseUser, verbose_name=_('database user')) + + objects = UserDatabaseManager() + + class Meta: + unique_together = ['db_name', 'db_user'] + verbose_name = _('user database') + verbose_name_plural = _('user specific database') + + def __str__(self): + return "%(db_name)s (%(db_user)s)" % { + 'db_name': self.db_name, + 'db_user': self.db_user, + } + + def create_in_database(self): + """ + Create this database (schema) in the target database. + + """ + # TODO: send GPG encrypted mail with this information + if self.db_user.db_type == DB_TYPES.pgsql: + create_pgsql_database.delay(self.db_name, self.db_user.name).get() + elif self.db_user.db_type == DB_TYPES.mysql: + create_mysql_database.delay(self.db_name, self.db_user.name).get() + else: + raise ValueError('Unknown database type %d' % self.db_type) + + @transaction.atomic + def delete(self, *args, **kwargs): + """ + Delete the database (schema) from the target database and the Django + database. + + :param args: positional arguments for + :py:meth:`django.db.models.Model.delete` + :param kwargs: keyword arguments for + :py:meth:`django.db.models.Model.delete` + + """ + if self.db_user.db_type == DB_TYPES.pgsql: + delete_pgsql_database.delay(self.db_name, self.db_user.name).get() + elif self.db_user.db_type == DB_TYPES.mysql: + delete_mysql_database.delay(self.db_name, self.db_user.name).get() + else: + raise ValueError('Unknown database type %d' % self.db_type) + super(UserDatabase, self).delete(*args, **kwargs) diff --git a/gnuviechadmin/userdbs/tests.py b/gnuviechadmin/userdbs/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/gnuviechadmin/userdbs/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/gnuviechadmin/userdbs/views.py b/gnuviechadmin/userdbs/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/gnuviechadmin/userdbs/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.