Use classic unix users rather than systemd dynamic users

Dynamic Users are neat and probably very useful for a tmpnb
style situation. However, for regular use they have the following
problems:

1. Can't set ProtectHome=no, so you can never apt install or
   similar from inside admin accounts.
2. Dynamic uid / gid makes it hard to write sudo rules. We want
   admin users to have sudo.
3. Persistent uids / gids are very useful for ad-hoc ACLs between
   users. gid sharing isn't the most flexible sharing mechanism,
   but it is well known & quite useful.
4. /etc/skel is pretty useful!
This commit is contained in:
yuvipanda
2018-06-26 23:30:06 -07:00
parent 335ba3c8a6
commit f90a0fa540
5 changed files with 260 additions and 17 deletions

108
tests/test_user.py Normal file
View File

@@ -0,0 +1,108 @@
"""
Test wrappers in tljw.user module
"""
from tljh import user
import os
import uuid
import pwd
import grp
import pytest
def test_ensure_user():
"""
Test user creation & removal
"""
# Use a prefix to make sure we never start with a number
username = 'u' + str(uuid.uuid4())[:8]
# Validate that no user exists
with pytest.raises(KeyError):
pwd.getpwnam(username)
try:
# Create user!
user.ensure_user(username)
# This raises exception if user doesn't exist
ent = pwd.getpwnam(username)
# Home directory must also exist
assert os.path.exists(ent.pw_shell)
# Run ensure_user again, should be a noop
user.ensure_user(username)
# User still exists, after our second ensure_user call
pwd.getpwnam(username)
finally:
# We clean up and remove user!
user.remove_user(username)
with pytest.raises(KeyError):
pwd.getpwnam(username)
def test_ensure_group():
"""
Test group creation & removal
"""
# Use a prefix to make sure we never start with a number
groupname = 'g' + str(uuid.uuid4())[:8]
# Validate that no group exists
with pytest.raises(KeyError):
grp.getgrnam(groupname)
try:
# Create group
user.ensure_group(groupname)
# This raises if group doesn't exist
grp.getgrnam(groupname)
# Do it again, this should be a noop
user.ensure_group(groupname)
grp.getgrnam(groupname)
finally:
# Remove the group
user.remove_group(groupname)
with pytest.raises(KeyError):
grp.getgrnam(groupname)
def test_group_membership():
"""
Test group memberships can be added / removed
"""
username = 'u' + str(uuid.uuid4())[:8]
groupname = 'g' + str(uuid.uuid4())[:8]
# Validate that no group exists
with pytest.raises(KeyError):
grp.getgrnam(groupname)
with pytest.raises(KeyError):
pwd.getpwnam(username)
try:
user.ensure_group(groupname)
user.ensure_user(username)
user.ensure_user_group(username, groupname)
assert username in grp.getgrnam(groupname).gr_mem
# Do it again, this should be a noop
user.ensure_user_group(username, groupname)
assert username in grp.getgrnam(groupname).gr_mem
# Remove it
user.remove_user_group(username, groupname)
assert username not in grp.getgrnam(groupname).gr_mem
# Do it again, this should be a noop
user.remove_user_group(username, groupname)
assert username not in grp.getgrnam(groupname).gr_mem
finally:
# Remove the group
user.remove_user(username)
user.remove_group(groupname)
with pytest.raises(KeyError):
grp.getgrnam(groupname)
with pytest.raises(KeyError):
pwd.getpwnam(username)

View File

@@ -2,6 +2,7 @@ import sys
import os import os
import tljh.systemd as systemd import tljh.systemd as systemd
import tljh.conda as conda import tljh.conda as conda
from tljh import user
INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh') INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh')
HUB_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'hub') HUB_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'hub')
@@ -17,7 +18,7 @@ def ensure_jupyterhub_service(prefix):
unit = unit_template.format( unit = unit_template.format(
python_interpreter_path=sys.executable, python_interpreter_path=sys.executable,
jupyterhub_config_path=os.path.join(HERE, 'jupyterhub_config.py'), jupyterhub_config_path=os.path.join(HERE, 'jupyterhub_config.py'),
prefix=prefix install_prefix=INSTALL_PREFIX
) )
systemd.install_unit('jupyterhub.service', unit) systemd.install_unit('jupyterhub.service', unit)
@@ -33,13 +34,20 @@ def ensure_jupyterhub_package(prefix):
conda.ensure_conda_packages(prefix, ['jupyterhub==0.9.0']) conda.ensure_conda_packages(prefix, ['jupyterhub==0.9.0'])
conda.ensure_pip_packages(prefix, [ conda.ensure_pip_packages(prefix, [
'jupyterhub-dummyauthenticator==0.3.1', 'jupyterhub-dummyauthenticator==0.3.1',
'jupyterhub-systemdspawner==0.9.12' 'jupyterhub-systemdspawner==0.9.12',
'escapism'
]) ])
ensure_jupyterhub_package(HUB_ENV_PREFIX) ensure_jupyterhub_package(HUB_ENV_PREFIX)
ensure_jupyterhub_service(HUB_ENV_PREFIX) ensure_jupyterhub_service(HUB_ENV_PREFIX)
user.ensure_group('jupyterhub-admins')
user.ensure_group('jupyterhub-users')
with open('/etc/sudoers.d/jupyterhub-admins', 'w') as f:
f.write('%jupyterhub-admins ALL = (ALL) NOPASSWD: ALL')
conda.ensure_conda_env(USER_ENV_PREFIX) conda.ensure_conda_env(USER_ENV_PREFIX)
conda.ensure_conda_packages(USER_ENV_PREFIX, [ conda.ensure_conda_packages(USER_ENV_PREFIX, [
'jupyterhub==0.9.0', 'jupyterhub==0.9.0',

View File

@@ -1,26 +1,29 @@
""" """
JupyterHub config for the littlest jupyterhub. JupyterHub config for the littlest jupyterhub.
This is run on startup & restarts. This file has the following
responsibilities:
1. Set up & maintain user conda environment
2. Configure JupyterHub from YAML file
This code will run as an unprivileged user, but with unlimited
sudo access. Code here can block, since it all runs before JupyterHub
starts.
""" """
from tljh import conda from escapism import escape
import os import os
from systemdspawner import SystemdSpawner
from tljh import user
INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh') INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX')
USER_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'user') USER_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'user')
c.JupyterHub.spawner_class = 'systemdspawner.SystemdSpawner' class CustomSpawner(SystemdSpawner):
def start(self):
"""
Perform system user activities before starting server
"""
# FIXME: Move this elsewhere? Into the Authenticator?
user.ensure_user(self.user.name)
user.ensure_user_group(self.user.name, 'jupyterhub-users')
if self.user.admin:
user.ensure_user_group(self.user.name, 'jupyterhub-admins')
return super().start()
c.JupyterHub.spawner_class = CustomSpawner
c.JupyterHub.authenticator_class = 'dummyauthenticator.DummyAuthenticator' c.JupyterHub.authenticator_class = 'dummyauthenticator.DummyAuthenticator'
c.SystemdSpawner.extra_paths = [os.path.join(USER_ENV_PREFIX, 'bin')] c.SystemdSpawner.extra_paths = [os.path.join(USER_ENV_PREFIX, 'bin')]
c.SystemdSpawner.use_sudo = True c.SystemdSpawner.use_sudo = True
c.SystemdSpawner.dynamic_users = True

View File

@@ -7,6 +7,7 @@ Wants=network-online.target
[Service] [Service]
User=root User=root
Restart=always Restart=always
Environment=TLJH_INSTALL_PREFIX={install_prefix}
StateDirectory=jupyterhub StateDirectory=jupyterhub
WorkingDirectory=/var/lib/jupyterhub WorkingDirectory=/var/lib/jupyterhub
ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path} ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path}

123
tljh/user.py Normal file
View File

@@ -0,0 +1,123 @@
"""
User management for tljh.
Supports user creation, deletion & sudo
"""
import pwd
import grp
import subprocess
def ensure_user(username):
"""
Make sure a given user exists
"""
# Check if user exists
try:
pwd.getpwnam(username)
# User exists, nothing to do!
return
except KeyError:
# User doesn't exist, time to create!
pass
subprocess.check_call([
'sudo',
'adduser',
'--disabled-password',
'--force-badname',
'--quiet',
username
])
def remove_user(username):
"""
Remove user from system if exists
"""
try:
pwd.getpwnam(username)
except KeyError:
# User doesn't exist, nothing to do
return
subprocess.check_call([
'sudo',
'deluser',
'--quiet',
username
])
def ensure_group(groupname):
"""
Ensure given group exists
"""
try:
grp.getgrnam(groupname)
# Group exists, nothing to do!
return
except KeyError:
pass
subprocess.check_call([
'sudo',
'addgroup',
'--quiet',
groupname
])
def remove_group(groupname):
"""
Remove user from system if exists
"""
try:
grp.getgrnam(groupname)
except KeyError:
# Group doesn't exist, nothing to do
return
subprocess.check_call([
'sudo',
'delgroup',
'--quiet',
groupname
])
def ensure_user_group(username, groupname):
"""
Ensure given user is member of given group
Group and User must already exist.
"""
group = grp.getgrnam(groupname)
if username in group.gr_mem:
return
subprocess.check_call([
'sudo',
'usermod',
'--append',
'--groups',
groupname,
username
])
def remove_user_group(username, groupname):
"""
Ensure given user is *not* a member of given group
"""
group = grp.getgrnam(groupname)
if username not in group.gr_mem:
return
subprocess.check_call([
'sudo',
'deluser',
'--quiet',
username,
groupname
])