Skip to content

Commit 644540b

Browse files
akshaychitneniAkshay Chitneni
andauthored
Update pytest_plugin with fixtures to test auth in core and extensions (#956)
Co-authored-by: Akshay Chitneni <[email protected]>
1 parent 6dc7d53 commit 644540b

File tree

4 files changed

+165
-77
lines changed

4 files changed

+165
-77
lines changed

examples/simple/simple_ext1/handlers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from jupyter_server.auth import authorized
12
from jupyter_server.base.handlers import JupyterHandler
23
from jupyter_server.extension.handler import (
34
ExtensionHandlerJinjaMixin,
@@ -7,6 +8,9 @@
78

89

910
class DefaultHandler(ExtensionHandlerMixin, JupyterHandler):
11+
auth_resource = "simple_ext1:default"
12+
13+
@authorized
1014
def get(self):
1115
# The name of the extension to which this handler is linked.
1216
self.log.info(f"Extension Name in {self.name} Default Handler: {self.name}")

examples/simple/tests/test_handlers.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,31 @@
22

33

44
@pytest.fixture
5-
def jp_server_config(jp_template_dir):
5+
def jp_server_auth_resources(jp_server_auth_core_resources):
6+
for url_regex in [
7+
"/simple_ext1/default",
8+
]:
9+
jp_server_auth_core_resources[url_regex] = "simple_ext1:default"
10+
return jp_server_auth_core_resources
11+
12+
13+
@pytest.fixture
14+
def jp_server_config(jp_template_dir, jp_server_authorizer):
615
return {
7-
"ServerApp": {"jpserver_extensions": {"simple_ext1": True}},
16+
"ServerApp": {
17+
"jpserver_extensions": {"simple_ext1": True},
18+
"authorizer_class": jp_server_authorizer,
19+
},
820
}
921

1022

11-
async def test_handler_default(jp_fetch):
23+
async def test_handler_default(jp_fetch, jp_serverapp):
24+
jp_serverapp.authorizer.permissions = {
25+
"actions": ["read"],
26+
"resources": [
27+
"simple_ext1:default",
28+
],
29+
}
1230
r = await jp_fetch("simple_ext1/default", method="GET")
1331
assert r.code == 200
1432
assert r.body.decode().index("Hello Simple 1 - I am the default...") > -1

jupyter_server/pytest_plugin.py

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Copyright (c) Jupyter Development Team.
22
# Distributed under the terms of the Modified BSD License.
3+
import importlib
34
import io
45
import json
56
import logging
@@ -14,10 +15,13 @@
1415
import pytest
1516
import tornado
1617
from tornado.escape import url_escape
17-
from traitlets.config import Config
18+
from tornado.httpclient import HTTPClientError
19+
from tornado.websocket import WebSocketHandler
20+
from traitlets.config import Config, re
1821

22+
from jupyter_server.auth import Authorizer
1923
from jupyter_server.extension import serverextension
20-
from jupyter_server.serverapp import ServerApp
24+
from jupyter_server.serverapp import JUPYTER_SERVICE_HANDLERS, ServerApp
2125
from jupyter_server.services.contents.filemanager import FileContentsManager
2226
from jupyter_server.services.contents.largefilemanager import LargeFileManager
2327
from jupyter_server.utils import url_path_join
@@ -494,3 +498,122 @@ async def _():
494498
pass
495499

496500
return _
501+
502+
503+
@pytest.fixture
504+
def send_request(jp_fetch, jp_ws_fetch):
505+
"""Send to Jupyter Server and return response code."""
506+
507+
async def _(url, **fetch_kwargs):
508+
if url.endswith("channels") or "/websocket/" in url:
509+
fetch = jp_ws_fetch
510+
else:
511+
fetch = jp_fetch
512+
513+
try:
514+
r = await fetch(url, **fetch_kwargs, allow_nonstandard_methods=True)
515+
code = r.code
516+
except HTTPClientError as err:
517+
code = err.code
518+
else:
519+
if fetch is jp_ws_fetch:
520+
r.close()
521+
522+
return code
523+
524+
return _
525+
526+
527+
@pytest.fixture
528+
def jp_server_auth_core_resources():
529+
modules = []
530+
for mod_name in JUPYTER_SERVICE_HANDLERS.values():
531+
if mod_name:
532+
modules.extend(mod_name)
533+
resource_map = {}
534+
for handler_module in modules:
535+
mod = importlib.import_module(handler_module)
536+
name = mod.AUTH_RESOURCE
537+
for handler in mod.default_handlers:
538+
url_regex = handler[0]
539+
resource_map[url_regex] = name
540+
return resource_map
541+
542+
543+
@pytest.fixture
544+
def jp_server_auth_resources(jp_server_auth_core_resources):
545+
return jp_server_auth_core_resources
546+
547+
548+
@pytest.fixture
549+
def jp_server_authorizer(jp_server_auth_resources):
550+
class _(Authorizer):
551+
552+
# Set these class attributes from within a test
553+
# to verify that they match the arguments passed
554+
# by the REST API.
555+
permissions: dict = {}
556+
557+
HTTP_METHOD_TO_AUTH_ACTION = {
558+
"GET": "read",
559+
"HEAD": "read",
560+
"OPTIONS": "read",
561+
"POST": "write",
562+
"PUT": "write",
563+
"PATCH": "write",
564+
"DELETE": "write",
565+
"WEBSOCKET": "execute",
566+
}
567+
568+
def match_url_to_resource(self, url, regex_mapping=None):
569+
"""Finds the JupyterHandler regex pattern that would
570+
match the given URL and returns the resource name (str)
571+
of that handler.
572+
573+
e.g.
574+
/api/contents/... returns "contents"
575+
"""
576+
if not regex_mapping:
577+
regex_mapping = jp_server_auth_resources
578+
for regex, auth_resource in regex_mapping.items():
579+
pattern = re.compile(regex)
580+
if pattern.fullmatch(url):
581+
return auth_resource
582+
583+
def normalize_url(self, path):
584+
"""Drop the base URL and make sure path leads with a /"""
585+
base_url = self.parent.base_url
586+
# Remove base_url
587+
if path.startswith(base_url):
588+
path = path[len(base_url) :]
589+
# Make sure path starts with /
590+
if not path.startswith("/"):
591+
path = "/" + path
592+
return path
593+
594+
def is_authorized(self, handler, user, action, resource):
595+
# Parse Request
596+
if isinstance(handler, WebSocketHandler):
597+
method = "WEBSOCKET"
598+
else:
599+
method = handler.request.method
600+
url = self.normalize_url(handler.request.path)
601+
602+
# Map request parts to expected action and resource.
603+
expected_action = self.HTTP_METHOD_TO_AUTH_ACTION[method]
604+
expected_resource = self.match_url_to_resource(url)
605+
606+
# Assert that authorization layer returns the
607+
# correct action + resource.
608+
assert action == expected_action
609+
assert resource == expected_resource
610+
611+
# Now, actually apply the authorization layer.
612+
return all(
613+
[
614+
action in self.permissions.get("actions", []),
615+
resource in self.permissions.get("resources", []),
616+
]
617+
)
618+
619+
return _

tests/auth/test_authorizer.py

Lines changed: 15 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -5,86 +5,29 @@
55
from jupyter_client.kernelspec import NATIVE_KERNEL_NAME
66
from nbformat import writes
77
from nbformat.v4 import new_notebook
8-
from tornado.httpclient import HTTPClientError
9-
from tornado.websocket import WebSocketHandler
108

11-
from jupyter_server.auth.authorizer import Authorizer
12-
from jupyter_server.auth.utils import HTTP_METHOD_TO_AUTH_ACTION, match_url_to_resource
139
from jupyter_server.services.security import csp_report_uri
1410

1511

16-
class AuthorizerforTesting(Authorizer):
17-
18-
# Set these class attributes from within a test
19-
# to verify that they match the arguments passed
20-
# by the REST API.
21-
permissions: dict = {}
22-
23-
def normalize_url(self, path):
24-
"""Drop the base URL and make sure path leads with a /"""
25-
base_url = self.parent.base_url
26-
# Remove base_url
27-
if path.startswith(base_url):
28-
path = path[len(base_url) :]
29-
# Make sure path starts with /
30-
if not path.startswith("/"):
31-
path = "/" + path
32-
return path
33-
34-
def is_authorized(self, handler, user, action, resource):
35-
# Parse Request
36-
if isinstance(handler, WebSocketHandler):
37-
method = "WEBSOCKET"
38-
else:
39-
method = handler.request.method
40-
url = self.normalize_url(handler.request.path)
41-
42-
# Map request parts to expected action and resource.
43-
expected_action = HTTP_METHOD_TO_AUTH_ACTION[method]
44-
expected_resource = match_url_to_resource(url)
45-
46-
# Assert that authorization layer returns the
47-
# correct action + resource.
48-
assert action == expected_action
49-
assert resource == expected_resource
50-
51-
# Now, actually apply the authorization layer.
52-
return all(
53-
[
54-
action in self.permissions.get("actions", []),
55-
resource in self.permissions.get("resources", []),
56-
]
57-
)
58-
59-
6012
@pytest.fixture
61-
def jp_server_config():
62-
return {"ServerApp": {"authorizer_class": AuthorizerforTesting}}
13+
def jp_server_config(jp_server_authorizer):
14+
return {
15+
"ServerApp": {"authorizer_class": jp_server_authorizer},
16+
"jpserver_extensions": {"jupyter_server_terminals": True},
17+
}
6318

6419

6520
@pytest.fixture
66-
def send_request(jp_fetch, jp_ws_fetch):
67-
"""Send to Jupyter Server and return response code."""
68-
69-
async def _(url, **fetch_kwargs):
70-
if url.endswith("channels") or "/websocket/" in url:
71-
fetch = jp_ws_fetch
72-
else:
73-
fetch = jp_fetch
74-
75-
try:
76-
r = await fetch(url, **fetch_kwargs, allow_nonstandard_methods=True)
77-
code = r.code
78-
except HTTPClientError as err:
79-
code = err.code
80-
else:
81-
if fetch is jp_ws_fetch:
82-
r.close()
83-
84-
print(code, url, fetch_kwargs)
85-
return code
86-
87-
return _
21+
def jp_server_auth_resources(jp_server_auth_core_resources):
22+
# terminal plugin doesn't have importable url patterns
23+
# get these from terminal/__init__.py
24+
for url_regex in [
25+
r"/terminals/websocket/(\w+)",
26+
"/api/terminals",
27+
r"/api/terminals/(\w+)",
28+
]:
29+
jp_server_auth_core_resources[url_regex] = "terminals"
30+
return jp_server_auth_core_resources
8831

8932

9033
HTTP_REQUESTS = [

0 commit comments

Comments
 (0)