Skip to content

Commit

Permalink
Fixed non-root-user access (#244)
Browse files Browse the repository at this point in the history
* Fixed non-root-user access
* fixed create_image and test
* Removed variable re-declaration which caused an infinite loop in ansible :)
* re-ordered setuid and setgid commands

uid must be changes last, otherwise
user is no longer permitted to change groups or gid.

* Updated notebook-tests

use user ubuntu in Dockerfile
use sudo for moving test files
change user to juypter for actual test execution

* Updated entrypoint

implemented new strategy as specified in ticket comment
#241 (comment)

* Made entrypoint fail on insufficient group permissions for docker.sock

added test verifying exception in container.logs()

[run-notebook-tests]

Co-authored-by: Torsten Kilias <[email protected]>
  • Loading branch information
ckunki and tkilias authored Mar 14, 2024
1 parent 1bb4d77 commit c1c8077
Show file tree
Hide file tree
Showing 19 changed files with 622 additions and 90 deletions.
2 changes: 1 addition & 1 deletion doc/changes/changes_1.1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ n/a

## Bug Fixes

n/a
* #241: Fixed non-root-user access

## Documentation

Expand Down
10 changes: 8 additions & 2 deletions exasol/ds/sandbox/lib/dss_docker/create_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,28 @@ def jupyter():
command = get_fact(facts, "jupyter", "command")
if command is None:
return []
docker_group = get_fact(facts, "docker_group")
port = get_fact(facts, "jupyter", "port")
user_name = get_fact(facts, "jupyter", "user")
group = get_fact(facts, "jupyter", "group")
user_home = get_fact(facts, "jupyter", "home")
password = get_fact(facts, "jupyter", "password")
logfile = get_fact(facts, "jupyter", "logfile")
return [
"--home", user_home,
"--jupyter-server", command,
"--port", port,
"--user", user_name,
"--group", group,
"--docker-group", docker_group,
"--password", password,
"--jupyter-logfile", logfile,
]

entrypoint = get_fact(facts, "entrypoint")
if entrypoint is None:
return ["sleep", "infinity"]
entry_cmd = ["python3", entrypoint]
entry_cmd = ["sudo", "python3", entrypoint]
folder = get_fact(facts, "notebook_folder")
if not folder:
return entry_cmd + jupyter()
Expand Down Expand Up @@ -162,10 +168,10 @@ def _commit_container(
notebook_folder_initial = get_fact(facts, "notebook_folder", "initial")
conf = {
"Entrypoint": entrypoint(facts),
"User": get_fact(facts, "jupyter", "user"),
"Cmd": [],
"Volumes": {notebook_folder_final: {}, },
"ExposedPorts": {f"{port}/tcp": {}},
"User": get_fact(facts, "docker_user"),
"Env": [
f"VIRTUAL_ENV={virtualenv}",
f"NOTEBOOK_FOLDER_FINAL={notebook_folder_final}",
Expand Down
7 changes: 4 additions & 3 deletions exasol/ds/sandbox/runtime/ansible/ai_lab_docker_playbook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@
gather_facts: true
vars:
ansible_python_interpreter: python3
user_name: "jupyter"
user_group: "jupyter"
user_home: "/home/jupyter"
user_name: jupyter
user_group: jupyter
docker_group: docker
user_home: /home/jupyter
initial_notebook_folder: "{{ user_home }}/notebook-defaults"
need_sudo: yes
docker_integration_test: true
Expand Down
1 change: 1 addition & 0 deletions exasol/ds/sandbox/runtime/ansible/ec2_playbook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
ansible_python_interpreter: /usr/bin/python3
user_name: jupyter
user_home: /home/jupyter
docker_group: docker
user_group: jupyter
initial_notebook_folder: "{{ user_home }}/notebooks"
need_sudo: yes
Expand Down
6 changes: 5 additions & 1 deletion exasol/ds/sandbox/runtime/ansible/general_setup_tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
ansible.builtin.set_fact:
dss_facts:
entrypoint: "{{user_home}}/entrypoint.py"
docker_user: "{{ ansible_user }}"
docker_group: "{{ docker_group }}"
jupyter:
virtualenv: "{{ jupyter_virtualenv }}"
command: "{{ jupyter_virtualenv }}/bin/jupyter-lab"
port: "49494"
user: "{{ user_name }}"
group: "{{ user_group }}"
home: "{{ user_home }}"
password: "{{ lookup('ansible.builtin.env', 'JUPYTER_LAB_PASSWORD', default='ailab') }}"
logfile: "{{ user_home }}/jupyter-server.log"
notebook_folder:
Expand Down Expand Up @@ -40,7 +44,7 @@
owner: "{{ user_name }}"
group: "{{ user_group }}"
recurse: true
become: "{{ need_sudo }}"
become: "{{need_sudo}}"
- name: Clear pip Cache
ansible.builtin.file:
path: /root/.cache/pip
Expand Down
4 changes: 2 additions & 2 deletions exasol/ds/sandbox/runtime/ansible/roles/docker/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

- name: Adding docker users (for use without sudo)
user:
name: "{{user_name}}"
name: "{{ user_name }}"
append: yes
groups: docker
groups: "{{ docker_group }}"
become: "{{need_sudo}}"
168 changes: 162 additions & 6 deletions exasol/ds/sandbox/runtime/ansible/roles/entrypoint/files/entrypoint.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import argparse
import logging
import grp
import os
import pwd
import re
import resource
import shutil
import subprocess
import stat
import sys
import time

from inspect import cleandoc
from pathlib import Path
from typing import List, TextIO


_logger = logging.getLogger(__name__)
_logger.setLevel(logging.DEBUG)
logging.basicConfig(
format="%(asctime)s %(levelname)-7s %(filename)s: %(message)s",
datefmt="%Y-%m-%d %X",
)


def arg_parser():
Expand All @@ -39,6 +48,18 @@ def arg_parser():
"--user", type=str,
help="user name for running jupyter server",
)
parser.add_argument(
"--group", type=str,
help="user group for running jupyter server",
)
parser.add_argument(
"--docker-group", type=str,
help="user group for accessing Docker socket",
)
parser.add_argument(
"--home", type=str,
help="home directory of user running jupyter server",
)
parser.add_argument(
"--password", type=str,
help="initial default password for Jupyter server",
Expand All @@ -55,6 +76,7 @@ def arg_parser():


def start_jupyter_server(
home_directory: str,
binary_path: str,
port: int,
notebook_dir: str,
Expand All @@ -70,10 +92,9 @@ def start_jupyter_server(
def exit_on_error(rc):
if rc is not None and rc != 0:
log_messages = logfile.read_text()
print(
_logger.error(
f"Jupyter Server terminated with error code {rc},"
f" Logfile {logfile} contains:\n{log_messages}",
flush=True,
)
sys.exit(rc)

Expand All @@ -83,8 +104,11 @@ def exit_on_error(rc):
"--no-browser",
"--allow-root",
]

env = os.environ.copy()
env["HOME"] = home_directory
with open(logfile, "w") as f:
p = subprocess.Popen(command_line, stdout=f, stderr=f)
p = subprocess.Popen(command_line, stdout=f, stderr=f, env=env)

url = "http://<host>:<port>"
localhost_url = url.replace("<host>", "localhost").replace("<port>", str(port))
Expand All @@ -110,7 +134,7 @@ def exit_on_error(rc):
time.sleep(poll_sleep)
line = f.readline()
if re.search(regexp, line):
print(success_message, flush=True)
_logger.info(success_message)
break
exit_on_error(p.poll())
exit_on_error(p.wait())
Expand Down Expand Up @@ -151,29 +175,161 @@ def ensure_file(src: Path, dst: Path):

def disable_core_dumps():
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
_logger.info("Disabled coredumps")


def sleep_inifinity():
def sleep_infinity():
while True:
time.sleep(1)


class Group:
def __init__(self, name: str, id: int = None):
self.name = name
self._id = id

@property
def id(self):
if self._id is None:
self._id = grp.getgrnam(self.name).gr_gid
return self._id

def __eq__(self, other) -> bool:
if not isinstance(other, Group):
return False
return other.name == self.name

def __repr__(self):
return f"Group(name='{self.name}', id={self._id})"


class FileInspector:
def __init__(self, path: Path):
self._path = path
self._stat = path.stat() if path.exists() else None

@property
def group_id(self) -> int:
if self._stat is None:
raise FileNotFoundError(self._path)
return self._stat.st_gid

def is_group_accessible(self) -> bool:
if self._stat is None:
_logger.debug(f"File not found {self._path}")
return False
permissions = stat.filemode(self._stat.st_mode)
if permissions[4:6] == "rw":
return True
raise PermissionError(
"No rw permissions for group in"
f" {permissions} {self._path}."
)


class GroupAccess:
"""
If there is already a group with group-ID `gid`, then add the user to
this group, otherwise change the group ID to `gid` for the specified group
name. The other group is expected to exist already and user to be added
to it.
"""
def __init__(self, user: str, group: Group):
self._user = user
self._group = group

def _find_group_name(self, id: int) -> str:
try:
return grp.getgrgid(id).gr_name
except KeyError:
return None

def _run(self, command: str) -> int:
_logger.debug(f"Executing {command}")
return subprocess.run(command.split()).returncode

def enable(self) -> Group:
gid = self._group.id
existing = self._find_group_name(gid)
if existing:
self._run(f"usermod --append --groups {existing} {self._user}")
return Group(existing, gid)
else:
self._run(f"groupmod -g {gid} {self._group.name}")
return self._group


class User:
def __init__(self, user_name: str, group: Group, docker_group: Group):
self.name = user_name
self._id = None
self.group = group
self.docker_group = docker_group

@property
def is_specified(self) -> bool:
return bool(
self.name
and self.group.name
and self.docker_group.name
)

@property
def id(self):
if self._id is None:
self._id = pwd.getpwnam(self.name).pw_uid
return self._id

def enable_group_access(self, path: Path):
file = FileInspector(path)
if file.is_group_accessible():
group = GroupAccess(
self.name,
Group(self.docker_group.name, file.group_id),
).enable()
os.setgroups([group.id])
self.docker_group = group
_logger.info(f"Enabled access to {path}")
return self

def switch_to(self):
gid = self.group.id
uid = self.id
os.setresgid(gid, gid, gid)
os.setresuid(uid, uid, uid)
_logger.debug(
f"uid = {os.getresuid()}"
f" gid = {os.getresgid()}"
f" extra groups = {os.getgroups()}"
)
_logger.info(f"Switched uid/gid to {self.name}/{self.group.name}")
return self


def main():
args = arg_parser().parse_args()
user = User(args.user, Group(args.group), Group(args.docker_group))
if user.is_specified:
user.enable_group_access(Path("/var/run/docker.sock")).switch_to()
if args.notebook_defaults and args.notebooks:
copy_rec(
args.notebook_defaults,
args.notebooks,
args.warning_as_error,
)
_logger.info(
"Copied notebooks from"
f" {args.notebook_defaults} to {args.notebooks}")
disable_core_dumps()
if (args.jupyter_server
and args.notebooks
and args.jupyter_logfile
and args.user
and args.home
and args.password
):
start_jupyter_server(
args.home,
args.jupyter_server,
args.port,
args.notebooks,
Expand All @@ -182,7 +338,7 @@ def main():
args.password,
)
else:
sleep_inifinity()
sleep_infinity()


if __name__ == "__main__":
Expand Down
Loading

0 comments on commit c1c8077

Please sign in to comment.