webappusers in new servers

This commit is contained in:
jorgepastorr 2023-07-24 17:39:18 +02:00
parent 476a8591c0
commit afabe560a3
13 changed files with 349 additions and 3 deletions

View file

@ -0,0 +1,48 @@
# Generated by Django 2.2.28 on 2023-07-22 08:01
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import orchestra.core.validators
class Migration(migrations.Migration):
initial = True
dependencies = [
('orchestration', '__first__'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SystemUser',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')),
('password', models.CharField(max_length=128, verbose_name='password')),
('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')),
('directory', models.CharField(blank=True, help_text="Optional directory relative to user's home.", max_length=256, verbose_name='directory')),
('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='systemusers', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('groups', models.ManyToManyField(blank=True, help_text='A new group will be created for the user. Which additional groups would you like them to be a member of?', to='systemusers.SystemUser')),
],
),
migrations.CreateModel(
name='WebappUsers',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')),
('password', models.CharField(max_length=128, verbose_name='password')),
('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')),
('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('target_server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orchestration.Server', verbose_name='Server')),
],
options={
'unique_together': {('username', 'target_server')},
},
),
]

View file

@ -0,0 +1,3 @@
[Trash Info]
Path=orchestra/contrib/systemusers/migrations/0001_initial.py
DeletionDate=2023-07-22T10:04:42

View file

@ -9,8 +9,8 @@ from orchestra.contrib.accounts.filters import IsActiveListFilter
from .actions import set_permission, create_link
from .filters import IsMainListFilter
from .forms import SystemUserCreationForm, SystemUserChangeForm
from .models import SystemUser
from .forms import SystemUserCreationForm, SystemUserChangeForm, WebappUserChangeForm, WebappUserCreationForm
from .models import SystemUser, WebappUsers
class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin):
@ -78,4 +78,34 @@ class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, Extende
return super(SystemUserAdmin, self).has_delete_permission(request, obj)
class WebappUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin):
list_display = (
'username', 'account_link', 'shell', 'home', 'target_server'
)
fieldsets = (
(None, {
'fields': ('account_link', 'username', 'password', )
}),
(_("System"), {
'fields': ('shell', 'home', 'target_server'),
}),
)
add_fieldsets = (
(None, {
'fields': ('account_link', 'username', 'password1', 'password2')
}),
(_("System"), {
'fields': ('shell', 'home', 'target_server'),
}),
)
search_fields = ('username', 'account__username')
readonly_fields = ('account_link',)
change_readonly_fields = ('username', 'home', 'target_server')
add_form = WebappUserCreationForm
form = WebappUserChangeForm
ordering = ('-id',)
admin.site.register(SystemUser, SystemUserAdmin)
admin.site.register(WebappUsers, WebappUserAdmin)

View file

@ -2,6 +2,7 @@ import sys
from django.apps import AppConfig
from django.db.models.signals import post_migrate
from django.utils.translation import gettext_lazy as _
from orchestra.core import services
@ -11,11 +12,12 @@ class SystemUsersConfig(AppConfig):
verbose_name = "System users"
def ready(self):
from .models import SystemUser
from .models import SystemUser, WebappUsers
services.register(SystemUser, icon='roleplaying.png')
if 'migrate' in sys.argv and 'accounts' not in sys.argv:
post_migrate.connect(self.create_initial_systemuser,
dispatch_uid="orchestra.contrib.systemusers.apps.create_initial_systemuser")
services.register(WebappUsers, icon='roleplaying.png', verbose_name =_('WebApp User'), verbose_name_plural=_("Webapp users"))
def create_initial_systemuser(self, **kwargs):
from .models import SystemUser

View file

@ -719,3 +719,114 @@ class UNIXUserControllerNewServers(ServiceController):
}
context['deleted_home'] = settings.SYSTEMUSERS_MOVE_ON_DELETE_PATH % context
return replace(context, "'", '"')
class WebappUserController(ServiceController):
"""
Basic UNIX system user/group support based on <tt>useradd</tt>, <tt>usermod</tt>, <tt>userdel</tt> and <tt>groupdel</tt>.
Autodetects and uses ACL if available, for better permission management.
"""
verbose_name = _("SFTP Webapp user")
model = 'systemusers.WebappUsers'
actions = ('save', 'delete',)
doc_settings = (settings, (
'SYSTEMUSERS_DEFAULT_GROUP_MEMBERS',
'SYSTEMUSERS_MOVE_ON_DELETE_PATH',
'SYSTEMUSERS_FORBIDDEN_PATHS'
))
def save(self, user):
context = self.get_context(user)
if not context['user']:
return
self.append(textwrap.dedent("""
# Update/create user state for %(user)s
if id %(user)s &> /dev/null; then
usermod %(user)s --home '/%(home)s' \\
--password '%(password)s' \\
--shell '%(shell)s' \\
--groups '%(groups)s'
else
useradd_code=0
useradd %(user)s --home '/%(home)s' \\
--password '%(password)s' \\
--shell '%(shell)s' \\
--groups '%(groups)s' || useradd_code=$?
if [[ $useradd_code -eq 8 ]]; then
# User is logged in, kill and retry
pkill -u %(user)s; sleep 2
pkill -9 -u %(user)s; sleep 1
useradd %(user)s --home '/%(home)s' \\
--password '%(password)s' \\
--shell '%(shell)s' \\
--groups '%(groups)s'
elif [[ $useradd_code -ne 0 ]]; then
exit $useradd_code
fi
fi
usermod -aG %(user)s %(parent)s
# Ensure homedir exists and has correct perms
mkdir -p '%(webapp_path)s' || exit_code=1
chown %(user)s:%(user)s %(webapp_path)s || exit_code=1
chmod 750 '%(webapp_path)s' || exit_code=1
# Create /chroots/$uid symlink into /home/$user.parent/webapps/
uid=$(id -u "%(user)s")
ln -n -f -s %(base_home)s/webapps /chroots/$uid || exit_code=1
""") % context
)
def delete(self, user):
context = self.get_context(user)
if not context['user']:
return
self.append(textwrap.dedent("""\
# Delete %(user)s user
uid=$(id -u "%(user)s")
nohup bash -c 'sleep 2 && killall -u %(user)s -s KILL' &> /dev/null &
killall -u %(user)s || true
userdel %(user)s || exit_code=$?
groupdel %(group)s || exit_code=$?
# Delete /chroots/$uid symlink into /home/$user.parent/webapps/
rm /chroots/$uid
""") % context
)
if context['deleted_home']:
self.append(textwrap.dedent("""\
# Move home into SYSTEMUSERS_MOVE_ON_DELETE_PATH, nesting if exists.
mv '%(webapp_path)s' '%(deleted_home)s' || exit_code=$?
""") % context
)
else:
self.append("rm -fr -- '%(webapp_path)s'" % context)
def get_groups(self, user):
groups = []
groups = list(user.account.systemusers.exclude(username=user.username).values_list('username', flat=True))
groups.append("webapp-systemusers")
return groups
def get_context(self, user):
context = {
'object_id': user.pk,
'user': user.username,
'group': user.username,
'groups': ','.join(self.get_groups(user)),
'password': user.password, #if user.active else '*%s' % user.password,
'shell': user.shell,
'home': user.home,
'base_home': user.get_base_home(),
'webapp_path': os.path.normpath(user.get_base_home() + "/webapps/" + user.home),
'parent': user.get_parent(),
}
context['deleted_home'] = context['webapp_path'] + ".delete"
return replace(context, "'", '"')

View file

@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from orchestra.forms import UserCreationForm, UserChangeForm
from orchestra.contrib.webapps.settings import WEBAPP_NEW_SERVERS
from . import settings
from .models import SystemUser
@ -162,3 +163,27 @@ class PermissionForm(LinkForm):
('r', _("Read only")),
('w', _("Write only"))
))
# ----------------------------
class WebappUserFormMixin(object):
def __init__(self, *args, **kwargs):
super(WebappUserFormMixin, self).__init__(*args, **kwargs)
def clean(self):
if not self.instance.pk:
server = self.cleaned_data.get('target_server')
if server:
if server.name not in WEBAPP_NEW_SERVERS:
self.add_error("target_server", _(f"{server} does not belong to the new servers"))
return self.cleaned_data
class WebappUserCreationForm(WebappUserFormMixin, UserCreationForm):
pass
class WebappUserChangeForm(WebappUserFormMixin, UserChangeForm):
pass

View file

@ -0,0 +1,32 @@
# Generated by Django 2.2.28 on 2023-07-22 08:04
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import orchestra.core.validators
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SystemUser',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')),
('password', models.CharField(max_length=128, verbose_name='password')),
('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')),
('directory', models.CharField(blank=True, help_text="Optional directory relative to user's home.", max_length=256, verbose_name='directory')),
('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='systemusers', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('groups', models.ManyToManyField(blank=True, help_text='A new group will be created for the user. Which additional groups would you like them to be a member of?', to='systemusers.SystemUser')),
],
),
]

View file

@ -0,0 +1,33 @@
# Generated by Django 2.2.28 on 2023-07-22 08:05
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import orchestra.core.validators
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('orchestration', '__first__'),
('systemusers', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='WebappUsers',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')),
('password', models.CharField(max_length=128, verbose_name='password')),
('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')),
('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('target_server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orchestration.Server', verbose_name='Server')),
],
options={
'unique_together': {('username', 'target_server')},
},
),
]

View file

@ -136,3 +136,43 @@ class SystemUser(models.Model):
def get_home(self):
return os.path.normpath(os.path.join(self.home, self.directory))
# ------------------
class WebappUsers(models.Model):
"""
System users for webapp
Username max_length determined by LINUX system user/group lentgh: 32
"""
username = models.CharField(_("username"), max_length=32, unique=True,
help_text=_("Required. 32 characters or fewer. Letters, digits and ./-/_ only."),
validators=[validators.validate_username])
password = models.CharField(_("password"), max_length=128)
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='accounts', on_delete=models.CASCADE)
home = models.CharField(_("WebappDir"), max_length=256, blank=True,
help_text=_("name dir webapp /home/&lt;main&gt;/webapps/&lt;DirName&gt;"),
validators=[validators.validate_string_dir])
shell = models.CharField(_("shell"), max_length=32, choices=settings.WEBAPPUSERS_SHELLS,
default='/dev/null')
target_server = models.ForeignKey('orchestration.Server', on_delete=models.CASCADE,
verbose_name=_("Server"))
class Meta:
unique_together = ('username', 'target_server')
verbose_name = 'WebAppUser'
verbose_name_plural = 'WebappUsers'
def __str__(self):
return self.username
def set_password(self, raw_password):
self.password = make_password(raw_password)
def get_base_home(self):
return os.path.normpath(self.account.main_systemuser.home)
def get_parent(self):
return self.account.main_systemuser

View file

@ -7,6 +7,13 @@ _names = ('user', 'username')
_backend_names = _names + ('group', 'shell', 'mainuser', 'home', 'base_home')
WEBAPPUSERS_SHELLS = Setting('SYSTEMUSERS_SHELLS',
(
('/dev/null', _("No shell, SFTP only")),
('/bin/bash', "/bin/bash"),
),
)
SYSTEMUSERS_SHELLS = Setting('SYSTEMUSERS_SHELLS',
(
('/dev/null', _("No shell, FTP only")),

View file

@ -282,3 +282,11 @@ WEBAPPS_CMS_CACHE_DIR = Setting('WEBAPPS_CMS_CACHE_DIR',
'/tmp/orchestra_cms_cache',
help_text="Server-side cache directori for CMS tarballs.",
)
WEBAPP_NEW_SERVERS = Setting('WEBAPP_NEW_SERVERS',
(
'bookworm',
'web-11.pangea.lan',
'web-12.pangea.lan',
)
)

View file

@ -177,3 +177,10 @@ def validate_phone(value, country):
raise ValidationError(msg)
if not phonenumbers.is_valid_number(number):
raise ValidationError(msg)
def validate_string_dir(value):
"""
A single non-empty line of free-form text with no whitespace.
"""
validators.RegexValidator('^[\_\-0-9a-z]+$',
_("Enter a valid name dir (spaceless lowercase text, number and _- )"), 'invalid')(value)