diff --git a/jupyterhub_config.py b/jupyterhub_config.py index bde26fe..47158e8 100644 --- a/jupyterhub_config.py +++ b/jupyterhub_config.py @@ -5,7 +5,7 @@ from jupyterhub.auth import DummyAuthenticator from tljh.configurer import apply_config, load_config -from tljh_repo2docker import tljh_custom_jupyterhub_config +from tljh_repo2docker import tljh_custom_jupyterhub_config, TLJH_R2D_ADMIN_SCOPE import sys c.JupyterHub.services = [] @@ -47,10 +47,18 @@ "6789", ], "oauth_no_confirm": True, + "oauth_client_allowed_scopes": [ + TLJH_R2D_ADMIN_SCOPE, + ], } ] ) +c.JupyterHub.custom_scopes = { + "custom:tljh_repo2docker:admin": { + "description": "Admin access to myservice", + }, +} c.JupyterHub.load_roles = [ { @@ -59,7 +67,11 @@ "scopes": ["read:users", "read:servers", "read:roles:users"], "services": ["tljh_repo2docker"], }, - {"name": "tljh-repo2docker-service-admin", "users": ["alice"]}, + { + "name": 'tljh-repo2docker-service-admin', + "users": ["alice"], + "scopes": [TLJH_R2D_ADMIN_SCOPE], + }, { "name": "user", "scopes": [ diff --git a/tljh_repo2docker/__init__.py b/tljh_repo2docker/__init__.py index 3204a4a..c323cc4 100644 --- a/tljh_repo2docker/__init__.py +++ b/tljh_repo2docker/__init__.py @@ -14,7 +14,7 @@ # See: https://docs.docker.com/config/containers/resource_constraints/#limit-a-containers-access-to-memory#configure-the-default-cfs-scheduler CPU_PERIOD = 100_000 -TLJH_R2D_ADMIN_ROLE = 'tljh-repo2docker-service-admin' +TLJH_R2D_ADMIN_SCOPE = "custom:tljh_repo2docker:admin" class SpawnerMixin(Configurable): diff --git a/tljh_repo2docker/__main__.py b/tljh_repo2docker/__main__.py index b35a2b9..981ed24 100644 --- a/tljh_repo2docker/__main__.py +++ b/tljh_repo2docker/__main__.py @@ -1,3 +1,4 @@ if __name__ == "__main__": from .app import main + main() diff --git a/tljh_repo2docker/base.py b/tljh_repo2docker/base.py index 24d7e29..98fb7da 100644 --- a/tljh_repo2docker/base.py +++ b/tljh_repo2docker/base.py @@ -1,19 +1,22 @@ +import functools import json import os +from http.client import responses from httpx import AsyncClient from jinja2 import Template from jupyterhub.services.auth import HubOAuthenticated from jupyterhub.utils import url_path_join from tornado import web -from tljh_repo2docker import TLJH_R2D_ADMIN_ROLE -import functools + +from tljh_repo2docker import TLJH_R2D_ADMIN_SCOPE + from .model import UserModel -from jupyterhub.scopes import needs_scope def require_admin_role(func): """decorator to require admin role to perform an action""" + @functools.wraps(func) async def wrapped_func(self, *args, **kwargs): user = await self.fetch_user() @@ -21,6 +24,7 @@ async def wrapped_func(self, *args, **kwargs): raise web.HTTPError(status_code=404, reason="Unauthorized.") else: return await func(self, *args, **kwargs) + return wrapped_func @@ -53,10 +57,7 @@ async def fetch_user(self) -> UserModel: user_model.setdefault("admin", False) if not user_model["admin"]: - if ( - "admin" in user_model["roles"] - or TLJH_R2D_ADMIN_ROLE in user_model["roles"] - ): + if "admin" in user_model["roles"] or TLJH_R2D_ADMIN_SCOPE in user["scopes"]: user_model["admin"] = True return UserModel.from_dict(user_model) @@ -84,7 +85,9 @@ async def render_template(self, name: str, **kwargs) -> str: service_prefix=self.settings.get("service_prefix", "/"), hub_prefix=self.settings.get("hub_prefix", "/"), base_url=base_url, - logout_url=self.settings.get("logout_url", url_path_join(base_url, 'logout')), + logout_url=self.settings.get( + "logout_url", url_path_join(base_url, "logout") + ), static_url=self.static_url, xsrf_token=self.xsrf_token.decode("ascii"), user=user, @@ -106,3 +109,50 @@ def get_json_body(self): self.log.error("Couldn't parse JSON", exc_info=True) raise web.HTTPError(400, "Invalid JSON in body of request") return model + + def check_xsrf_cookie(self): + """ + Copy from https://github.com/jupyterhub/jupyterhub/blob/main/jupyterhub/apihandlers/base.py#L89 + """ + if not hasattr(self, "_jupyterhub_user"): + return + if self._jupyterhub_user is None and "Origin" not in self.request.headers: + return + if getattr(self, "_token_authenticated", False): + # if token-authenticated, ignore XSRF + return + return super().check_xsrf_cookie() + + def write_error(self, status_code, **kwargs): + """Write JSON errors instead of HTML""" + exc_info = kwargs.get("exc_info") + message = "" + exception = None + status_message = responses.get(status_code, "Unknown Error") + if exc_info: + exception = exc_info[1] + # get the custom message, if defined + try: + message = exception.log_message % exception.args + except Exception: + pass + + # construct the custom reason, if defined + reason = getattr(exception, "reason", "") + if reason: + status_message = reason + + self.set_header("Content-Type", "application/json") + if isinstance(exception, web.HTTPError): + # allow setting headers from exceptions + # since exception handler clears headers + headers = getattr(exception, "headers", None) + if headers: + for key, value in headers.items(): + self.set_header(key, value) + # Content-Length must be recalculated. + self.clear_header("Content-Length") + + self.write( + json.dumps({"status": status_code, "message": message or status_message}) + ) diff --git a/tljh_repo2docker/builder.py b/tljh_repo2docker/builder.py index 013ffa9..6e12472 100644 --- a/tljh_repo2docker/builder.py +++ b/tljh_repo2docker/builder.py @@ -27,6 +27,7 @@ async def delete(self): raise web.HTTPError(e.status, e.message) self.set_status(200) + self.set_header("content-type", "application/json") self.finish(json.dumps({"status": "ok"})) @web.authenticated @@ -74,4 +75,5 @@ async def post(self): ) self.set_status(200) + self.set_header("content-type", "application/json") self.finish(json.dumps({"status": "ok"})) diff --git a/tljh_repo2docker/environments.py b/tljh_repo2docker/environments.py index c934069..27044e2 100644 --- a/tljh_repo2docker/environments.py +++ b/tljh_repo2docker/environments.py @@ -14,7 +14,6 @@ class EnvironmentsHandler(BaseHandler): @web.authenticated @require_admin_role async def get(self): - images = await list_images() containers = await list_containers() result = self.render_template( diff --git a/tljh_repo2docker/logs.py b/tljh_repo2docker/logs.py index e595a02..287d65a 100644 --- a/tljh_repo2docker/logs.py +++ b/tljh_repo2docker/logs.py @@ -37,4 +37,4 @@ async def _emit(self, msg): await self.flush() except StreamClosedError: self.log.warning("Stream closed while handling %s", self.request.uri) - raise web.Finish() \ No newline at end of file + raise web.Finish() diff --git a/tljh_repo2docker/model.py b/tljh_repo2docker/model.py index b06343e..ec4f5d6 100644 --- a/tljh_repo2docker/model.py +++ b/tljh_repo2docker/model.py @@ -31,4 +31,3 @@ def all_spawners(self) -> list: } ) return sp - diff --git a/tljh_repo2docker/servers.py b/tljh_repo2docker/servers.py index 192ce24..8a84c83 100644 --- a/tljh_repo2docker/servers.py +++ b/tljh_repo2docker/servers.py @@ -32,4 +32,4 @@ async def get(self): if isawaitable(result): self.write(await result) else: - self.write(result) \ No newline at end of file + self.write(result) diff --git a/tljh_repo2docker/tests/conftest.py b/tljh_repo2docker/tests/conftest.py index b548404..602f765 100644 --- a/tljh_repo2docker/tests/conftest.py +++ b/tljh_repo2docker/tests/conftest.py @@ -1,3 +1,5 @@ +import sys + import pytest from aiodocker import Docker, DockerError from traitlets.config import Config @@ -13,22 +15,22 @@ async def remove_docker_image(image_name): pass -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def minimal_repo(): return "https://github.com/plasmabio/tljh-repo2docker-test-binder" -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def minimal_repo_uppercase(): return "https://github.com/plasmabio/TLJH-REPO2DOCKER-TEST-BINDER" -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def generated_image_name(): return "plasmabio-tljh-repo2docker-test-binder:HEAD" -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def image_name(): return "tljh-repo2docker-test:HEAD" @@ -38,6 +40,34 @@ async def app(hub_app): config = Config() tljh_custom_jupyterhub_config(config) + config.JupyterHub.services.extend( + [ + { + "name": "tljh_repo2docker", + "url": "http://127.0.0.1:6789", + "command": [ + sys.executable, + "-m", + "tljh_repo2docker", + "--ip", + "127.0.0.1", + "--port", + "6789", + ], + "oauth_no_confirm": True, + } + ] + ) + + config.JupyterHub.load_roles = [ + { + "description": "Role for tljh_repo2docker service", + "name": "tljh-repo2docker-service", + "scopes": ["read:users", "read:servers", "read:roles:users"], + "services": ["tljh_repo2docker"], + } + ] + app = await hub_app(config=config) return app diff --git a/tljh_repo2docker/tests/test_builder.py b/tljh_repo2docker/tests/test_builder.py index e9257b9..8bd0114 100644 --- a/tljh_repo2docker/tests/test_builder.py +++ b/tljh_repo2docker/tests/test_builder.py @@ -42,7 +42,8 @@ async def test_uppercase_repo(app, minimal_repo_uppercase, generated_image_name) assert r.status_code == 200 image = await wait_for_image(image_name=generated_image_name) assert ( - image["ContainerConfig"]["Labels"]["tljh_repo2docker.image_name"] == generated_image_name + image["ContainerConfig"]["Labels"]["tljh_repo2docker.image_name"] + == generated_image_name ) @@ -55,11 +56,17 @@ async def test_no_repo(app, image_name): @pytest.mark.asyncio @pytest.mark.parametrize( - "memory, cpu", [("abcded", ""), ("", "abcde"),], + "memory, cpu", + [ + ("abcded", ""), + ("", "abcde"), + ], ) async def test_wrong_limits(app, minimal_repo, image_name, memory, cpu): name, ref = image_name.split(":") - r = await add_environment(app, repo=minimal_repo, name=name, ref=ref, memory=memory, cpu=cpu) + r = await add_environment( + app, repo=minimal_repo, name=name, ref=ref, memory=memory, cpu=cpu + ) assert r.status_code == 400 assert "must be a number" in r.text diff --git a/tljh_repo2docker/tests/test_images.py b/tljh_repo2docker/tests/test_images.py index b2cf1d9..e90aff0 100644 --- a/tljh_repo2docker/tests/test_images.py +++ b/tljh_repo2docker/tests/test_images.py @@ -1,30 +1,40 @@ import pytest from jupyterhub.tests.utils import get_page -from .utils import add_environment, wait_for_image +from .utils import add_environment, get_service_page, wait_for_image @pytest.mark.asyncio async def test_images_list_admin(app): - cookies = await app.login_user('admin') - r = await get_page('environments', app, cookies=cookies, allow_redirects=False) + cookies = await app.login_user("admin") + r = await get_service_page( + "environments", + app, + cookies=cookies, + allow_redirects=True, + ) r.raise_for_status() - assert '{"images": [], "default_mem_limit": "None", "default_cpu_limit":"None", "machine_profiles": []}' in r.text + assert ( + '{"images": [], "default_mem_limit": "None", "default_cpu_limit":"None", "machine_profiles": []}' + in r.text + ) @pytest.mark.asyncio async def test_images_list_not_admin(app): - cookies = await app.login_user('wash') - r = await get_page('environments', app, cookies=cookies, allow_redirects=False) + cookies = await app.login_user("wash") + r = await get_service_page( + "environments", app, cookies=cookies, allow_redirects=True + ) assert r.status_code == 403 @pytest.mark.asyncio async def test_spawn_page(app, minimal_repo, image_name): - cookies = await app.login_user('admin') + cookies = await app.login_user("admin") # go to the spawn page - r = await get_page('spawn', app, cookies=cookies, allow_redirects=False) + r = await get_page("spawn", app, cookies=cookies, allow_redirects=False) r.raise_for_status() assert minimal_repo not in r.text @@ -35,7 +45,7 @@ async def test_spawn_page(app, minimal_repo, image_name): await wait_for_image(image_name=image_name) # the environment should be on the page - r = await get_page('spawn', app, cookies=cookies, allow_redirects=False) + r = await get_page("spawn", app, cookies=cookies, allow_redirects=False) r.raise_for_status() assert r.status_code == 200 assert minimal_repo in r.text diff --git a/tljh_repo2docker/tests/test_logs.py b/tljh_repo2docker/tests/test_logs.py index 09eb496..8e3ab3b 100644 --- a/tljh_repo2docker/tests/test_logs.py +++ b/tljh_repo2docker/tests/test_logs.py @@ -1,9 +1,9 @@ import json import pytest -from jupyterhub.tests.utils import api_request, async_requests +from jupyterhub.tests.utils import async_requests -from .utils import add_environment, wait_for_image +from .utils import add_environment, api_request, wait_for_image def next_event(it): @@ -38,6 +38,8 @@ async def test_stream_simple(app, minimal_repo, image_name): @pytest.mark.asyncio async def test_no_build(app, image_name, request): - r = await api_request(app, "environments", "image-not-found:12345", "logs", stream=True) + r = await api_request( + app, "environments", "image-not-found:12345", "logs", stream=True + ) request.addfinalizer(r.close) assert r.status_code == 404 diff --git a/tljh_repo2docker/tests/utils.py b/tljh_repo2docker/tests/utils.py index 7a97956..14d9d3e 100644 --- a/tljh_repo2docker/tests/utils.py +++ b/tljh_repo2docker/tests/utils.py @@ -2,19 +2,62 @@ import json from aiodocker import Docker, DockerError -from jupyterhub.tests.utils import api_request +from jupyterhub.tests.utils import (async_requests, auth_header, + check_db_locks, public_host, public_url) +from jupyterhub.utils import url_path_join as ujoin +from tornado.httputil import url_concat -async def add_environment( - app, *, repo, ref="HEAD", name="", memory="", cpu="" -): +@check_db_locks +async def api_request(app, *api_path, method="get", noauth=False, **kwargs): + """Make an API request""" + + base_url = public_url(app, path="services/tljh_repo2docker") + + headers = kwargs.setdefault("headers", {}) + if "Authorization" not in headers and not noauth and "cookies" not in kwargs: + # make a copy to avoid modifying arg in-place + kwargs["headers"] = h = {} + h.update(headers) + h.update(auth_header(app.db, kwargs.pop("name", "admin"))) + + url = ujoin(base_url, "api", *api_path) + if "cookies" in kwargs: + # for cookie-authenticated requests, + # add _xsrf to url params + if "_xsrf" in kwargs["cookies"] and not noauth: + url = url_concat(url, {"_xsrf": kwargs["cookies"]["_xsrf"]}) + + f = getattr(async_requests, method) + if app.internal_ssl: + kwargs["cert"] = (app.internal_ssl_cert, app.internal_ssl_key) + kwargs["verify"] = app.internal_ssl_ca + resp = await f(url, **kwargs) + + return resp + + +def get_service_page(path, app, **kw): + prefix = app.base_url + service_prefix = "services/tljh_repo2docker" + url = ujoin(public_host(app), prefix, service_prefix, path) + return async_requests.get(url, **kw) + + +async def add_environment(app, *, repo, ref="HEAD", name="", memory="", cpu=""): """Use the POST endpoint to add a new environment""" r = await api_request( app, "environments", method="post", data=json.dumps( - {"repo": repo, "ref": ref, "name": name, "memory": memory, "cpu": cpu,} + { + "repo": repo, + "ref": ref, + "name": name, + "memory": memory, + "cpu": cpu, + } ), ) return r @@ -40,6 +83,13 @@ async def wait_for_image(*, image_name): async def remove_environment(app, *, image_name): """Use the DELETE endpoint to remove an environment""" r = await api_request( - app, "environments", method="delete", data=json.dumps({"name": image_name,}), + app, + "environments", + method="delete", + data=json.dumps( + { + "name": image_name, + } + ), ) return r