document osusers code

This commit is contained in:
Jan Dittberner 2014-12-27 22:44:27 +01:00
parent 18e47d73b4
commit 0df67e7154
5 changed files with 430 additions and 27 deletions

View file

@ -0,0 +1,5 @@
"""
This app is for managing operating system users and groups.
"""
default_app_config = 'osusers.apps.OsusersAppConfig'

View file

@ -1,3 +1,7 @@
"""
This module contains the Django admin classes of the :py:mod:`osusers` app.
"""
from django import forms from django import forms
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.contrib import admin from django.contrib import admin
@ -10,13 +14,24 @@ from .models import (
) )
PASSWORD_MISMATCH_ERROR = _("Passwords don't match") PASSWORD_MISMATCH_ERROR = _("Passwords don't match")
"""
Error message for non matching passwords.
"""
class AdditionalGroupInline(admin.TabularInline): class AdditionalGroupInline(admin.TabularInline):
"""
Inline for :py:class:`osusers.models.AdditionalGroup` instances.
"""
model = AdditionalGroup model = AdditionalGroup
class ShadowInline(admin.TabularInline): class ShadowInline(admin.TabularInline):
"""
Inline for :py:class:`osusers.models.ShadowInline` instances.
"""
model = Shadow model = Shadow
readonly_fields = ['passwd'] readonly_fields = ['passwd']
can_delete = False can_delete = False
@ -24,7 +39,8 @@ class ShadowInline(admin.TabularInline):
class UserCreationForm(forms.ModelForm): class UserCreationForm(forms.ModelForm):
""" """
A form for creating system users. A form for creating :py:class:`operating system users
<osusers.models.User>`.
""" """
password1 = forms.CharField( password1 = forms.CharField(
@ -44,6 +60,9 @@ class UserCreationForm(forms.ModelForm):
""" """
Check that the two password entries match. Check that the two password entries match.
:return: the validated password
:rtype: str or None
""" """
password1 = self.cleaned_data.get('password1') password1 = self.cleaned_data.get('password1')
password2 = self.cleaned_data.get('password2') password2 = self.cleaned_data.get('password2')
@ -55,6 +74,10 @@ class UserCreationForm(forms.ModelForm):
""" """
Save the provided password in hashed format. Save the provided password in hashed format.
:param boolean commit: whether to save the created user
:return: user instance
:rtype: :py:class:`osusers.models.User`
""" """
user = User.objects.create_user( user = User.objects.create_user(
customer=self.cleaned_data['customer'], customer=self.cleaned_data['customer'],
@ -65,10 +88,16 @@ class UserCreationForm(forms.ModelForm):
""" """
No additional groups are created when this form is saved, so this No additional groups are created when this form is saved, so this
method just does nothing. method just does nothing.
""" """
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
"""
Admin class for working with :py:class:`operating system users
<osusers.models.User>`.
"""
actions = ['perform_delete_selected'] actions = ['perform_delete_selected']
add_form = UserCreationForm add_form = UserCreationForm
inlines = [AdditionalGroupInline, ShadowInline] inlines = [AdditionalGroupInline, ShadowInline]
@ -83,6 +112,13 @@ class UserAdmin(admin.ModelAdmin):
""" """
Use special form during user creation. Use special form during user creation.
:param request: the current HTTP request
:param obj: either a :py:class:`User <osusers.models.User>` instance or
None for a new user
:param kwargs: keyword arguments to be passed to
:py:meth:`django.contrib.admin.ModelAdmin.get_form`
:return: form instance
""" """
defaults = {} defaults = {}
if obj is None: if obj is None:
@ -94,16 +130,47 @@ class UserAdmin(admin.ModelAdmin):
return super(UserAdmin, self).get_form(request, obj, **defaults) return super(UserAdmin, self).get_form(request, obj, **defaults)
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
"""
Make sure that uid is not editable for existing users.
:param request: the current HTTP request
:param obj: either a :py:class:`User <osusers.models.User>` instance or
None for a new user
:return: a list of fields
:rtype: list
"""
if obj: if obj:
return ['uid'] return ['uid']
return [] return []
def perform_delete_selected(self, request, queryset): def perform_delete_selected(self, request, queryset):
"""
Action to delete a list of selected users.
This action calls the delete method of each selected user in contrast
to the default `delete_selected`.
:param request: the current HTTP request
:param queryset: Django ORM queryset representing the selected users
"""
for user in queryset.all(): for user in queryset.all():
user.delete() user.delete()
perform_delete_selected.short_description = _('Delete selected users') perform_delete_selected.short_description = _('Delete selected users')
def get_actions(self, request): def get_actions(self, request):
"""
Get the available actions for 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(UserAdmin, self).get_actions(request) actions = super(UserAdmin, self).get_actions(request)
if 'delete_selected' in actions: if 'delete_selected' in actions:
del actions['delete_selected'] del actions['delete_selected']
@ -111,19 +178,40 @@ class UserAdmin(admin.ModelAdmin):
class GroupAdmin(admin.ModelAdmin): class GroupAdmin(admin.ModelAdmin):
"""
Admin class for workint with :py:class:`operating system groups
<osusers.models.Group>`.
"""
actions = ['perform_delete_selected'] actions = ['perform_delete_selected']
def get_inline_instances(self, request, obj=None):
if obj is None:
return []
return super(GroupAdmin, self).get_inline_instances(request, obj)
def perform_delete_selected(self, request, queryset): def perform_delete_selected(self, request, queryset):
"""
Action to delete a list of selected groups.
This action calls the delete method of each selected group in contrast
to the default `delete_selected`.
:param request: the current HTTP request
:param queryset: Django ORM queryset representing the selected groups
"""
for group in queryset.all(): for group in queryset.all():
group.delete() group.delete()
perform_delete_selected.short_description = _('Delete selected groups') perform_delete_selected.short_description = _('Delete selected groups')
def get_actions(self, request): def get_actions(self, request):
"""
Get the available actions for groups.
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(GroupAdmin, self).get_actions(request) actions = super(GroupAdmin, self).get_actions(request)
if 'delete_selected' in actions: if 'delete_selected' in actions:
del actions['delete_selected'] del actions['delete_selected']

View file

@ -0,0 +1,17 @@
"""
This module contains the :py:class:`django.apps.AppConfig` instance for the
:py:module:`osusers` app.
"""
from __future__ import unicode_literals
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
class OsusersAppConfig(AppConfig):
"""
AppConfig for the :py:mod:`osusers` app.
"""
name = 'osusers'
verbose_name = _('Operating System Users and Groups')

View file

@ -1,3 +1,7 @@
"""
This module defines the database models of operating system users.
"""
from __future__ import unicode_literals from __future__ import unicode_literals
from datetime import date from datetime import date
@ -30,7 +34,7 @@ from .tasks import (
) )
logger = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CANNOT_USE_PRIMARY_GROUP_AS_ADDITIONAL = _( CANNOT_USE_PRIMARY_GROUP_AS_ADDITIONAL = _(
@ -38,8 +42,19 @@ CANNOT_USE_PRIMARY_GROUP_AS_ADDITIONAL = _(
class GroupManager(models.Manager): class GroupManager(models.Manager):
"""
Manager class for :py:class:`osusers.models.Group`.
"""
def get_next_gid(self): def get_next_gid(self):
"""
Get the next available group id.
:returns: group id
:rtype: int
"""
q = self.aggregate(models.Max('gid')) q = self.aggregate(models.Max('gid'))
if q['gid__max'] is None: if q['gid__max'] is None:
return settings.OSUSER_MINGID return settings.OSUSER_MINGID
@ -48,6 +63,10 @@ class GroupManager(models.Manager):
@python_2_unicode_compatible @python_2_unicode_compatible
class Group(TimeStampedModel, models.Model): class Group(TimeStampedModel, models.Model):
"""
This entity class corresponds to an operating system group.
"""
groupname = models.CharField( groupname = models.CharField(
_('Group name'), max_length=16, unique=True) _('Group name'), max_length=16, unique=True)
gid = models.PositiveSmallIntegerField( gid = models.PositiveSmallIntegerField(
@ -67,34 +86,73 @@ class Group(TimeStampedModel, models.Model):
@transaction.atomic @transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""
Save the group to the database and synchronizes group information to
LDAP.
:param args: positional arguments to be passed on to
:py:meth:`django.db.Model.save`
:param kwargs: keyword arguments to be passed on to
:py:meth:`django.db.Model.save`
:return: self
:rtype: :py:class:`osusers.models.Group`
"""
super(Group, self).save(*args, **kwargs) super(Group, self).save(*args, **kwargs)
dn = create_ldap_group.delay( dn = create_ldap_group.delay(
self.groupname, self.gid, self.descr).get() self.groupname, self.gid, self.descr).get()
logger.info("created LDAP group with dn %s", dn) _LOGGER.info("created LDAP group with dn %s", dn)
return self return self
@transaction.atomic @transaction.atomic
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
"""
Delete the group from LDAP and the database.
:param args: positional arguments to be passed on to
:py:meth:`django.db.Model.delete`
:param kwargs: keyword arguments to be passed on to
:py:meth:`django.db.Model.delete`
"""
delete_ldap_group_if_empty.delay(self.groupname).get() delete_ldap_group_if_empty.delay(self.groupname).get()
super(Group, self).delete(*args, **kwargs) super(Group, self).delete(*args, **kwargs)
class UserManager(models.Manager): class UserManager(models.Manager):
"""
Manager class for :py:class:`osusers.models.User`.
"""
def get_next_uid(self): def get_next_uid(self):
"""
Get the next available user id.
:return: user id
:rtype: int
"""
q = self.aggregate(models.Max('uid')) q = self.aggregate(models.Max('uid'))
if q['uid__max'] is None: if q['uid__max'] is None:
return settings.OSUSER_MINUID return settings.OSUSER_MINUID
return max(settings.OSUSER_MINUID, q['uid__max'] + 1) return max(settings.OSUSER_MINUID, q['uid__max'] + 1)
def get_next_username(self): def get_next_username(self):
"""
Get the next available user name.
:return: user name
:rtype: str
"""
count = 1 count = 1
usernameformat = "{0}{1:02d}" usernameformat = "{0}{1:02d}"
nextuser = usernameformat.format(settings.OSUSER_USERNAME_PREFIX, nextuser = usernameformat.format(settings.OSUSER_USERNAME_PREFIX,
count) count)
for user in self.values('username').filter( for user in self.values('username').filter(
username__startswith=settings.OSUSER_USERNAME_PREFIX).order_by( username__startswith=settings.OSUSER_USERNAME_PREFIX
'username'): ).order_by('username'):
if user['username'] == nextuser: if user['username'] == nextuser:
count += 1 count += 1
nextuser = usernameformat.format( nextuser = usernameformat.format(
@ -107,6 +165,23 @@ class UserManager(models.Manager):
def create_user( def create_user(
self, customer, username=None, password=None, commit=False self, customer, username=None, password=None, commit=False
): ):
"""
Create a new user with a primary group named the same as the user and
an initial password.
If username is None the result of :py:meth:`get_next_username` is used.
If password is None a new password will be generated using passlib's
:py:func:`generate_password`.
:param customer: Django User instance this user is associated to
:param str username: the username or None
:param str password: the password or None
:param boolean commit: whether to commit the user data to the database
or not
:return: new user
:rtype: :py:class:`osusers.models.User`
"""
uid = self.get_next_uid() uid = self.get_next_uid()
gid = Group.objects.get_next_gid() gid = Group.objects.get_next_gid()
if username is None: if username is None:
@ -126,6 +201,10 @@ class UserManager(models.Manager):
@python_2_unicode_compatible @python_2_unicode_compatible
class User(TimeStampedModel, models.Model): class User(TimeStampedModel, models.Model):
"""
This entity class corresponds to an operating system user.
"""
username = models.CharField( username = models.CharField(
_('User name'), max_length=64, unique=True) _('User name'), max_length=64, unique=True)
uid = models.PositiveSmallIntegerField( uid = models.PositiveSmallIntegerField(
@ -147,6 +226,15 @@ class User(TimeStampedModel, models.Model):
@transaction.atomic @transaction.atomic
def set_password(self, password): def set_password(self, password):
"""
Set the password of the user.
The password is set to the user's
:py:class:`Shadow <osusers.models.Shadow>` instance and to LDAP.
:param str password: the new password
"""
if hasattr(self, 'shadow'): if hasattr(self, 'shadow'):
self.shadow.set_password(password) self.shadow.set_password(password)
else: else:
@ -161,12 +249,24 @@ class User(TimeStampedModel, models.Model):
@transaction.atomic @transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""
Save the user to the database, create user directories and synchronize
user information to LDAP.
:param args: positional arguments to be passed on to
:py:meth:`django.db.Model.save`
:param kwargs: keyword arguments to be passed on to
:py:meth:`django.db.Model.save`
:return: self
:rtype: :py:class:`osusers.models.User`
"""
dn = create_ldap_user.delay( dn = create_ldap_user.delay(
self.username, self.uid, self.group.gid, self.gecos, self.username, self.uid, self.group.gid, self.gecos,
self.homedir, self.shell, password=None).get() self.homedir, self.shell, password=None).get()
sftp_dir = setup_file_sftp_userdir.delay(self.username).get() sftp_dir = setup_file_sftp_userdir.delay(self.username).get()
mail_dir = setup_file_mail_userdir.delay(self.username).get() mail_dir = setup_file_mail_userdir.delay(self.username).get()
logger.info( _LOGGER.info(
"created user %(user)s with LDAP dn %(dn)s, home directory " "created user %(user)s with LDAP dn %(dn)s, home directory "
"%(homedir)s and mail base directory %(maildir)s.", { "%(homedir)s and mail base directory %(maildir)s.", {
'user': self, 'dn': dn, 'user': self, 'dn': dn,
@ -176,6 +276,16 @@ class User(TimeStampedModel, models.Model):
@transaction.atomic @transaction.atomic
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
"""
Delete the user and its groups from LDAP and the database and remove
the user's directories.
:param args: positional arguments to be passed on to
:py:meth:`django.db.Model.delete`
:param kwargs: keyword arguments to be passed on to
:py:meth:`django.db.Model.delete`
"""
delete_file_mail_userdir.delay(self.username).get() delete_file_mail_userdir.delay(self.username).get()
delete_file_sftp_userdir.delay(self.username).get() delete_file_sftp_userdir.delay(self.username).get()
for group in [ag.group for ag in self.additionalgroup_set.all()]: for group in [ag.group for ag in self.additionalgroup_set.all()]:
@ -187,9 +297,23 @@ class User(TimeStampedModel, models.Model):
class ShadowManager(models.Manager): class ShadowManager(models.Manager):
"""
Manager class for :py:class:`osusers.models.Shadow`.
"""
@transaction.atomic @transaction.atomic
def create_shadow(self, user, password): def create_shadow(self, user, password):
"""
Create a new shadow instance with typical Linux settings for the given
user with the given password.
:param user: :py:class:`User <osusers.models.User>` instance
:param str password: the password
:return: new Shadow instance
:rtype: :py:class:`osusers.models.Shadow` instance
"""
changedays = (timezone.now().date() - date(1970, 1, 1)).days changedays = (timezone.now().date() - date(1970, 1, 1)).days
shadow = self.create( shadow = self.create(
user=user, changedays=changedays, user=user, changedays=changedays,
@ -203,6 +327,11 @@ class ShadowManager(models.Manager):
@python_2_unicode_compatible @python_2_unicode_compatible
class Shadow(TimeStampedModel, models.Model): class Shadow(TimeStampedModel, models.Model):
"""
This entity class corresponds to an operating system user's shadow file
entry.
"""
user = models.OneToOneField(User, primary_key=True, verbose_name=_('User')) user = models.OneToOneField(User, primary_key=True, verbose_name=_('User'))
passwd = models.CharField(_('Encrypted password'), max_length=128) passwd = models.CharField(_('Encrypted password'), max_length=128)
changedays = models.PositiveSmallIntegerField( changedays = models.PositiveSmallIntegerField(
@ -245,11 +374,21 @@ class Shadow(TimeStampedModel, models.Model):
return 'for user {0}'.format(self.user) return 'for user {0}'.format(self.user)
def set_password(self, password): def set_password(self, password):
"""
Set and encrypt the password.
:param str password: the password
"""
self.passwd = sha512_crypt.encrypt(password) self.passwd = sha512_crypt.encrypt(password)
@python_2_unicode_compatible @python_2_unicode_compatible
class AdditionalGroup(TimeStampedModel, models.Model): class AdditionalGroup(TimeStampedModel, models.Model):
"""
This entity class corresponds to additional group assignments for an
:py:class:`operating system user <osusers.models.User>`.
"""
user = models.ForeignKey(User) user = models.ForeignKey(User)
group = models.ForeignKey(Group) group = models.ForeignKey(Group)
@ -258,21 +397,45 @@ class AdditionalGroup(TimeStampedModel, models.Model):
verbose_name = _('Additional group') verbose_name = _('Additional group')
verbose_name_plural = _('Additional groups') verbose_name_plural = _('Additional groups')
def __str__(self):
return '{0} in {1}'.format(self.user, self.group)
def clean(self): def clean(self):
"""
Ensure that the assigned group is different from the user's primary
group.
"""
if self.user.group == self.group: if self.user.group == self.group:
raise ValidationError(CANNOT_USE_PRIMARY_GROUP_AS_ADDITIONAL) raise ValidationError(CANNOT_USE_PRIMARY_GROUP_AS_ADDITIONAL)
@transaction.atomic @transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""
Persists the group assignment to LDAP and the database.
:param args: positional arguments to be passed on to
:py:meth:`django.db.Model.save`
:param kwargs: keyword arguments to be passed on to
:py:meth:`django.db.Model.save`
:return: this instance
:rtype: :py:class:`AdditionalGroup <osusers.models.AdditionalGroup>`
"""
add_ldap_user_to_group.delay( add_ldap_user_to_group.delay(
self.user.username, self.group.groupname).get() self.user.username, self.group.groupname).get()
super(AdditionalGroup, self).save(*args, **kwargs) return super(AdditionalGroup, self).save(*args, **kwargs)
@transaction.atomic @transaction.atomic
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
"""
Delete the group assignment from LDAP and the database.
:param args: positional arguments to be passed on to
:py:meth:`django.db.Model.delete`
:param kwargs: keyword arguments to be passed on to
:py:meth:`django.db.Model.delete`
"""
remove_ldap_user_from_group.delay( remove_ldap_user_from_group.delay(
self.user.username, self.group.groupname).get() self.user.username, self.group.groupname).get()
super(AdditionalGroup, self).delete(*args, **kwargs) super(AdditionalGroup, self).delete(*args, **kwargs)
def __str__(self):
return '{0} in {1}'.format(self.user, self.group)

View file

@ -1,3 +1,10 @@
"""
This module defines task stubs for the tasks implemented on the `Celery`_
workers.
.. _Celery: http://www.celeryproject.org/
"""
from __future__ import absolute_import from __future__ import absolute_import
from celery import shared_task from celery import shared_task
@ -5,59 +12,182 @@ from celery import shared_task
@shared_task @shared_task
def create_ldap_group(groupname, gid, descr): def create_ldap_group(groupname, gid, descr):
pass """
This task creates an :py:class:`LDAP group <ldapentities.models.LdapGroup>`
if it does not exist yet.
If a group with the given name exists its group id and description
attributes are updated.
:param str groupname: the group name
:param int gid: the group id
:param str descr: description text for the group
:return: the distinguished name of the group
:rtype: str
"""
@shared_task @shared_task
def create_ldap_user(username, uid, gid, gecos, homedir, shell, password): def create_ldap_user(username, uid, gid, gecos, homedir, shell, password):
pass """
This task creates an :py:class:`LDAP user <ldapentities.models.LdapUser>`
if it does not exist yet.
The task is rejected if the primary group of the user is not defined.
The user's fields are updated if the user already exists.
:param str username: the user name
:param int uid: the user id
:param int gid: the user's primary group's id
:param str gecos: the text for the GECOS field
:param str homedir: the user's home directory
:param str shell: the user's login shell
:param str or None password: the clear text password, if :py:const:`None`
is passed the password is not touched
:raises celery.exceptions.Reject: if the specified primary group does not
exist
:return: the distinguished name of the user
:rtype: str
"""
@shared_task @shared_task
def add_ldap_user_to_group(username, groupname): def add_ldap_user_to_group(username, groupname):
pass """
This task adds the specified user to the given group.
This task does nothing if the user is already member of the group.
:param str username: the user name
:param str groupname: the group name
:raises celery.exceptions.Retry: if the user does not exist yet,
:py:func:`create_ldap_user` should be called before
:return: True if the user has been added to the group otherwise False
:rtype: boolean
"""
@shared_task @shared_task
def remove_ldap_user_from_group(username, groupname): def remove_ldap_user_from_group(username, groupname):
pass """
This task removes the given user from the given group.
:param str username: the user name
:param str groupname: the group name
:return: True if the user has been removed, False otherwise
:rtype: boolean
"""
@shared_task @shared_task
def delete_ldap_user(username): def delete_ldap_user(username):
pass """
This task deletes the given user.
:param str username: the user name
:return: True if the user has been deleted, False otherwise
:rtype: boolean
"""
@shared_task @shared_task
def delete_ldap_group_if_empty(groupname): def delete_ldap_group_if_empty(groupname):
pass """
This task deletes the given group.
:param str groupname: the group name
:return: True if the user has been deleted, False otherwise
:rtype: boolean
"""
@shared_task @shared_task
def setup_file_sftp_userdir(username): def setup_file_sftp_userdir(username):
pass """
This task creates the home directory for an SFTP user if it does not exist
yet.
:param str username: the user name
:raises Exception: if the SFTP directory of the user cannot be created
:return: the created directory name
:rtype: str
"""
@shared_task @shared_task
def delete_file_sftp_userdir(username): def delete_file_sftp_userdir(username):
pass """
This task recursively deletes the home directory of an SFTP user if it
does not exist yet.
:param str username: the user name
:raises Exception: if the SFTP directory of the user cannot be removed
:return: the removed directory name
:rtype: str
"""
@shared_task @shared_task
def setup_file_mail_userdir(username): def setup_file_mail_userdir(username):
pass """
This task creates the mail base directory for a user if it does not exist
yet.
:param str username: the user name
:raises Exception: if the mail base directory for the user cannot be
created
:return: the created directory name
:rtype: str
"""
@shared_task @shared_task
def delete_file_mail_userdir(username): def delete_file_mail_userdir(username):
pass """
This task recursively deletes the mail base directory for a user if it
does not exist yet.
:param str username: the user name
:raises Exception: if the mail base directory of the user cannot be removed
:return: the removed directory name
:rtype: str
"""
@shared_task @shared_task
def create_file_mailbox(username, mailboxname): def create_file_mailbox(username, mailboxname):
pass """
This task creates a new mailbox directory for the given user and mailbox
name.
:param str username: the user name
:param str mailboxname: the mailbox name
:raises GVAFileException: if the mailbox directory cannot be created
:return: the created mailbox directory name
:rtype: str
"""
@shared_task @shared_task
def delete_file_mailbox(username, mailboxname): def delete_file_mailbox(username, mailboxname):
pass """
This task deletes the given mailbox of the given user.
:param str username: the user name
:param str mailboxname: the mailbox name
:raises GVAFileException: if the mailbox directory cannot be deleted
:return: the deleted mailbox directory name
:rtype: str
"""