mirror of
https://github.com/jupyterhub/the-littlest-jupyterhub.git
synced 2025-12-18 21:54:05 +08:00
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:
108
tests/test_user.py
Normal file
108
tests/test_user.py
Normal 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)
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
123
tljh/user.py
Normal 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
|
||||||
|
])
|
||||||
Reference in New Issue
Block a user