Skip to content

Commit b73c6c3

Browse files
authorisation: reinstate decorator
* revert to decorator approach
1 parent a521675 commit b73c6c3

File tree

4 files changed

+145
-64
lines changed

4 files changed

+145
-64
lines changed

cylc/uiserver/handlers.py

+106-28
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1515

1616
from asyncio import Queue
17+
from functools import wraps
1718
import json
1819
import getpass
1920
import socket
21+
from typing import Callable, Union
2022

2123
from graphene_tornado.tornado_graphql_handler import TornadoGraphQLHandler
2224
from graphql import get_default_backend
@@ -31,6 +33,101 @@
3133
ME = getpass.getuser()
3234

3335

36+
def authorised(fun: Callable) -> Callable:
37+
"""Provides Cylc authorisation.
38+
39+
When the UIS is run standalone (token-authenticated) application,
40+
authorisation is deactivated, the bearer of the token has full privileges.
41+
42+
When the UIS is spawned by Jupyter Hub (hub authenticated), multi-user
43+
access is permitted. Users are authorised by _authorise.
44+
"""
45+
46+
@wraps(fun)
47+
def _inner(
48+
handler: 'CylcAppHandler',
49+
*args,
50+
**kwargs,
51+
):
52+
nonlocal fun
53+
user: Union[
54+
None, # unauthenticated
55+
bytes, # token auth (bug in jupyter_server)
56+
dict, # hub auth
57+
str, # token auth
58+
59+
] = handler.get_current_user()
60+
if user is None or user == 'anonymous':
61+
# user is not authenticated - additional protection incase the
62+
# endpoint is not authenticate protected by mistake
63+
# NOTE: Auth tests will hit this line unless mocked authentication
64+
# is provided.
65+
raise web.HTTPError(403, reason='Forbidden')
66+
handler.log.critical(
67+
f'authorise {user} ' # type: ignore
68+
f'for {handler.__class__.__name__}'
69+
)
70+
if not (
71+
is_token_authenticated(handler, user)
72+
or (
73+
isinstance(user, dict)
74+
and _authorise(handler, user['name'], '')
75+
)
76+
):
77+
raise web.HTTPError(403, reason='authorisation insufficient')
78+
return fun(handler, *args, **kwargs)
79+
return _inner
80+
81+
82+
def is_token_authenticated(
83+
handler: JupyterHandler,
84+
user: Union[bytes, dict, str],
85+
) -> bool:
86+
"""Returns True if the UIS is running "standalone".
87+
88+
At present we cannot use handler.is_token_authenticated because it
89+
returns False when the token is cached in a cookie.
90+
91+
https://github.com/jupyter-server/jupyter_server/pull/562
92+
"""
93+
if isinstance(user, bytes):
94+
# Cookie authentication:
95+
# * The URL token is added to a secure cookie, it can then be
96+
# removed rrom the URL for subsequent requests, the cookie is
97+
# used in its place.
98+
# * If the token was used token_authenticated is True.
99+
# * If the cookie was used it is False (despite the cookie auth
100+
# being derived from token auth).
101+
# * Due to a bug in jupyter_server the user is returned as bytes
102+
# when cookie auth is used so at present we can use this to
103+
# tell.
104+
# https://github.com/jupyter-server/jupyter_server/pull/562
105+
# TODO: this hack is obviously not suitable for production!
106+
return True
107+
elif handler.token_authenticated:
108+
# standalone UIS, the bearer of the token is the owner
109+
# (no multi-user functionality so no futher auth required)
110+
return True
111+
return False
112+
113+
114+
def _authorise(
115+
handler: 'CylcAppHandler',
116+
username: str,
117+
action: str = 'READ'
118+
) -> bool:
119+
"""Authorises a user to perform an action.
120+
121+
Currently this returns False unless the authenticated user is the same
122+
as the user this server is running under.
123+
"""
124+
if username != ME:
125+
# auth provided by the hub, check the user name
126+
handler.log.warning(f'Authorisation failed for {username}')
127+
return False
128+
return True
129+
130+
34131
class CylcAppHandler(JupyterHandler):
35132
"""Base handler for Cylc endpoints.
36133
@@ -42,46 +139,21 @@ class CylcAppHandler(JupyterHandler):
42139
this handler to insert the HubOAuthenticated bases class high up
43140
in the inheritance order.
44141
45-
https://github.com/jupyterhub/jupyterhub/blob/
46-
2c8b29b6bbd7197f34f553668365dbe16d001f03/
47-
jupyterhub/singleuser/mixins.py#L716
48-
49-
TODO:
50-
* Implement authorisation!!!
51-
* Make authorisation configurabe via this class.
52-
* Make properties traitlets.
53-
142+
https://github.com/jupyterhub/jupyterhub/blob/3800ceaf9edf33a0171922b93ea3d94f87aa8d91/jupyterhub/singleuser/mixins.py#L826
54143
"""
55144

56145
auth_level = None
57146

58147
@property
59148
def hub_users(self):
60-
# allow all users (handled by authorisation)
149+
# allow all users (handled by Cylc authorisation decorator)
61150
return None
62151

63152
@property
64153
def hub_groups(self):
65-
# allow all groups (handled by authorisation)
154+
# allow all groups (handled by Cylc authorisation decorator)
66155
return None
67156

68-
def get_current_user(self):
69-
user = CylcAppHandler.__bases__[0].get_current_user(self)
70-
self.authorise(user)
71-
return user
72-
73-
def authorise(self, user):
74-
if not self._authorise(user):
75-
raise web.HTTPError(403, reason='authorisation insufficient')
76-
77-
def _authorise(self, user):
78-
self.log.debug(f'authorise {user} for {self.__class__}')
79-
return True
80-
# if user != ME:
81-
# self.log.warning(f'Authorisation failed for {user}')
82-
# return False
83-
# return True
84-
85157

86158
class CylcStaticHandler(CylcAppHandler, web.StaticFileHandler):
87159
pass
@@ -94,6 +166,7 @@ def set_default_headers(self) -> None:
94166
self.set_header("Content-Type", 'application/json')
95167

96168
@web.authenticated
169+
@authorised
97170
def get(self):
98171
user_info = self.get_current_user()
99172

@@ -158,10 +231,12 @@ def context(self):
158231
return wider_context
159232

160233
@web.authenticated
234+
@authorised
161235
def prepare(self):
162236
super().prepare()
163237

164238
@web.authenticated
239+
@authorised
165240
async def execute(self, *args, **kwargs):
166241
# Use own backend, and TornadoGraphQLHandler already does validation.
167242
return await self.schema.execute(
@@ -173,6 +248,7 @@ async def execute(self, *args, **kwargs):
173248
)
174249

175250
@web.authenticated
251+
@authorised
176252
async def run(self, *args, **kwargs):
177253
await TornadoGraphQLHandler.run(self, *args, **kwargs)
178254

@@ -188,11 +264,13 @@ def select_subprotocol(self, subprotocols):
188264
return GRAPHQL_WS
189265

190266
@websockets_authenticated
267+
@authorised
191268
def get(self, *args, **kwargs):
192269
# forward this call so we can authenticate/authorise it
193270
return websocket.WebSocketHandler.get(self, *args, **kwargs)
194271

195272
@websockets_authenticated
273+
@authorised
196274
def open(self, *args, **kwargs):
197275
IOLoop.current().spawn_callback(self.subscription_server.handle, self,
198276
self.context)

cylc/uiserver/tests/conftest.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ def _mock_authentication(user=None, server=None, none=False):
203203
'server': server or gethostname()
204204
}
205205
if none:
206-
ret = None
206+
ret = 'anonymous'
207207
monkeypatch.setattr(
208208
'cylc.uiserver.handlers.CylcAppHandler.get_current_user',
209209
lambda x: ret

cylc/uiserver/tests/test_auth.py

+35-35
Original file line numberDiff line numberDiff line change
@@ -104,27 +104,27 @@ async def test_authorised_and_authenticated(
104104
@pytest.mark.parametrize(
105105
'endpoint,code,message,body',
106106
[
107-
# pytest.param(
108-
# ('cylc', 'graphql'),
109-
# 403,
110-
# 'Forbidden',
111-
# None,
112-
# id='cylc/graphql',
113-
# ),
107+
pytest.param(
108+
('cylc', 'graphql'),
109+
403,
110+
'Forbidden',
111+
None,
112+
id='cylc/graphql',
113+
),
114114
pytest.param(
115115
('cylc', 'subscriptions'),
116116
403,
117117
'Forbidden',
118118
None,
119119
id='cylc/subscriptions',
120120
),
121-
# pytest.param(
122-
# ('cylc', 'userprofile'),
123-
# 403,
124-
# 'Forbidden',
125-
# None,
126-
# id='cylc/userprofile',
127-
# )
121+
pytest.param(
122+
('cylc', 'userprofile'),
123+
403,
124+
'Forbidden',
125+
None,
126+
id='cylc/userprofile',
127+
)
128128
]
129129
)
130130
async def test_unauthenticated(
@@ -143,27 +143,27 @@ async def test_unauthenticated(
143143
@pytest.mark.parametrize(
144144
'endpoint,code,message,body',
145145
[
146-
# pytest.param(
147-
# ('cylc', 'graphql'),
148-
# 403,
149-
# 'authorisation insufficient',
150-
# None,
151-
# id='cylc/graphql',
152-
# ),
153-
# pytest.param(
154-
# ('cylc', 'subscriptions'),
155-
# 403,
156-
# 'authorisation insufficient',
157-
# None,
158-
# id='cylc/subscriptions',
159-
# ),
160-
# pytest.param(
161-
# ('cylc', 'userprofile'),
162-
# 403,
163-
# 'authorisation insufficient',
164-
# None,
165-
# id='cylc/userprofile',
166-
# )
146+
pytest.param(
147+
('cylc', 'graphql'),
148+
403,
149+
'authorisation insufficient',
150+
None,
151+
id='cylc/graphql',
152+
),
153+
pytest.param(
154+
('cylc', 'subscriptions'),
155+
403,
156+
'authorisation insufficient',
157+
None,
158+
id='cylc/subscriptions',
159+
),
160+
pytest.param(
161+
('cylc', 'userprofile'),
162+
403,
163+
'authorisation insufficient',
164+
None,
165+
id='cylc/userprofile',
166+
)
167167
]
168168
)
169169
async def test_unauthorised(

pyproject.toml

+3
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ testpaths = [
1010
collect_ignore = [
1111
'cylc/uiserver/jupyter_config.py'
1212
]
13+
markers = [
14+
'integration: tests which run servers and try to connect to them'
15+
]

0 commit comments

Comments
 (0)