implement access scopes

- access:services for services
- access:users:servers for servers
- tokens automatically have access to their issuing client (if their owner does, too)
- Check access scope in HubAuth integration
This commit is contained in:
Min RK
2021-05-12 14:49:06 +02:00
parent e5198b4039
commit e2076e6c91
11 changed files with 304 additions and 36 deletions

View File

@@ -1,15 +1,33 @@
# our user list # our user list
c.Authenticator.whitelist = ['minrk', 'ellisonbg', 'willingc'] c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
# ellisonbg and willingc have access to a shared server: # ellisonbg and willingc have access to a shared server:
c.JupyterHub.load_groups = {'shared': ['ellisonbg', 'willingc']} c.JupyterHub.load_groups = {'shared-notebook-grp': ['ellisonbg', 'willingc']}
c.JupyterHub.load_roles = [
{
"name": "shared-notebook",
"groups": ["shared-notebook-grp"],
"scopes": ["access:services!service=shared-notebook"],
},
# by default, the user role has access to all services
# we want to limit that, so give users only access to 'self'
{
"name": "user",
"scopes": ["self"],
},
]
# start the notebook server as a service # start the notebook server as a service
c.JupyterHub.services = [ c.JupyterHub.services = [
{ {
'name': 'shared-notebook', 'name': 'shared-notebook',
'url': 'http://127.0.0.1:9999', 'url': 'http://127.0.0.1:9999',
'api_token': 'super-secret', 'api_token': 'c3a29e5d386fd7c9aa1e8fe9d41c282ec8b',
} }
] ]
# dummy spawner and authenticator for testing, don't actually use these!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'

View File

@@ -1,9 +1,11 @@
#!/bin/bash -l #!/bin/bash -l
set -e set -e
export JUPYTERHUB_API_TOKEN=super-secret # these must match the values in jupyterhub_config.py
export JUPYTERHUB_API_TOKEN=c3a29e5d386fd7c9aa1e8fe9d41c282ec8b
export JUPYTERHUB_SERVICE_URL=http://127.0.0.1:9999 export JUPYTERHUB_SERVICE_URL=http://127.0.0.1:9999
export JUPYTERHUB_SERVICE_NAME=shared-notebook export JUPYTERHUB_SERVICE_NAME=shared-notebook
export JUPYTERHUB_SERVICE_PREFIX="/services/${JUPYTERHUB_SERVICE_NAME}/"
export JUPYTERHUB_CLIENT_ID="service-${JUPYTERHUB_SERVICE_NAME}"
jupyterhub-singleuser \ jupyterhub-singleuser
--group='shared'

View File

@@ -216,6 +216,31 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
) )
credentials = self.add_credentials(credentials) credentials = self.add_credentials(credentials)
client = self.oauth_provider.fetch_by_client_id(credentials['client_id']) client = self.oauth_provider.fetch_by_client_id(credentials['client_id'])
allowed = False
# check for access to target resource
if client.spawner:
scope_filter = self.get_scope_filter("access:users:servers")
allowed = scope_filter(client.spawner, kind='server')
elif client.service:
scope_filter = self.get_scope_filter("access:services")
allowed = scope_filter(client.service, kind='service')
else:
# client is not associated with a service or spawner.
# This shouldn't happen, but it might if this is a stale or forged request
# from a service or spawner that's since been deleted
self.log.error(
f"OAuth client {client} has no service or spawner, cannot resolve scopes."
)
raise web.HTTPError(500, "OAuth configuration error")
if not allowed:
self.log.error(
f"User {self.current_user} not allowed to access {client.description}"
)
raise web.HTTPError(
403, f"You do not have permission to access {client.description}"
)
if not self.needs_oauth_confirm(self.current_user, client): if not self.needs_oauth_confirm(self.current_user, client):
self.log.debug( self.log.debug(
"Skipping oauth confirmation for %s accessing %s", "Skipping oauth confirmation for %s accessing %s",

View File

@@ -35,21 +35,31 @@ class SelfAPIHandler(APIHandler):
user = self.current_user user = self.current_user
if user is None: if user is None:
raise web.HTTPError(403) raise web.HTTPError(403)
_added_scopes = set()
if isinstance(user, orm.Service): if isinstance(user, orm.Service):
# ensure we have the minimal 'identify' scopes for the token owner # ensure we have the minimal 'identify' scopes for the token owner
self.expanded_scopes.update(scopes.identify_scopes(user)) identify_scopes = scopes.identify_scopes(user)
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes) get_model = self.service_model
model = self.service_model(user)
else: else:
self.expanded_scopes.update(scopes.identify_scopes(user.orm_user)) identify_scopes = scopes.identify_scopes(user.orm_user)
print('Expanded scopes in selfapihandler') get_model = self.user_model
# ensure we have permission to identify ourselves
# all tokens can do this on this endpoint
for scope in identify_scopes:
if scope not in self.expanded_scopes:
_added_scopes.add(scope)
self.expanded_scopes.add(scope)
if _added_scopes:
# re-parse with new scopes
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes) self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
model = self.user_model(user)
# validate return, should have at least kind and name, model = get_model(user)
# otherwise our filters did something wrong
for key in ("kind", "name"): # add scopes to identify model,
if key not in model: # but not the scopes we added to ensure we could read our own model
raise ValueError(f"Missing identify model for {user}: {model}") model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes))
self.write(json.dumps(model)) self.write(json.dumps(model))

View File

@@ -24,11 +24,13 @@ def get_default_roles():
{ {
'name': 'user', 'name': 'user',
'description': 'Standard user privileges', 'description': 'Standard user privileges',
'scopes': ['self'], 'scopes': [
'self',
],
}, },
{ {
'name': 'admin', 'name': 'admin',
'description': 'Admin privileges (currently can do everything)', 'description': 'Admin privileges (can do everything)',
'scopes': [ 'scopes': [
'admin:users', 'admin:users',
'admin:users:servers', 'admin:users:servers',
@@ -38,12 +40,17 @@ def get_default_roles():
'read:hub', 'read:hub',
'proxy', 'proxy',
'shutdown', 'shutdown',
'access:services',
'access:users:servers',
], ],
}, },
{ {
'name': 'server', 'name': 'server',
'description': 'Post activity only', 'description': 'Post activity only',
'scopes': ['users:activity!user'], 'scopes': [
'users:activity!user',
'access:users:servers!user',
],
}, },
{ {
'name': 'token', 'name': 'token',
@@ -64,6 +71,7 @@ def expand_self_scope(name):
users:activity users:activity
users:servers users:servers
users:tokens users:tokens
access:users:servers
Arguments: Arguments:
name (str): user name name (str): user name
@@ -80,6 +88,8 @@ def expand_self_scope(name):
'users:tokens', 'users:tokens',
] ]
read_scope_list = ['read:' + scope for scope in scope_list] read_scope_list = ['read:' + scope for scope in scope_list]
# access doesn't want the 'read:' prefix
scope_list.append('access:users:servers')
scope_list.extend(read_scope_list) scope_list.extend(read_scope_list)
return {"{}!user={}".format(scope, name) for scope in scope_list} return {"{}!user={}".format(scope, name) for scope in scope_list}

View File

@@ -97,6 +97,12 @@ scope_definitions = {
'read:hub': { 'read:hub': {
'description': 'Read-only access to detailed information about the Hub.' 'description': 'Read-only access to detailed information about the Hub.'
}, },
'access:users:servers': {
'description': 'Access user servers via API or browser.',
},
'access:services': {
'description': 'Access services via API or browser.',
},
'proxy': { 'proxy': {
'description': 'Allows for obtaining information about the proxys routing table, for syncing the Hub with proxy and notifying the Hub about a new proxy.' 'description': 'Allows for obtaining information about the proxys routing table, for syncing the Hub with proxy and notifying the Hub about a new proxy.'
}, },
@@ -220,6 +226,23 @@ def get_scopes_for(orm_object):
app_log.warning(f"Authenticated with token {orm_object}") app_log.warning(f"Authenticated with token {orm_object}")
owner = orm_object.user or orm_object.service owner = orm_object.user or orm_object.service
token_scopes = roles.expand_roles_to_scopes(orm_object) token_scopes = roles.expand_roles_to_scopes(orm_object)
if orm_object.client_id != "jupyterhub":
# oauth tokens can be used to access the service issuing the token,
# assuming the owner itself still has permission to do so
spawner = orm_object.oauth_client.spawner
if spawner:
token_scopes.add(
f"access:users:servers!server={spawner.user.name}/{spawner.name}"
)
else:
service = orm_object.oauth_client.service
if service:
token_scopes.add(f"access:services!service={service.name}")
else:
app_log.warning(
f"Token {orm_object} has no associated service or spawner!"
)
owner_scopes = roles.expand_roles_to_scopes(owner) owner_scopes = roles.expand_roles_to_scopes(owner)
if 'all' in token_scopes: if 'all' in token_scopes:
token_scopes.remove('all') token_scopes.remove('all')

View File

@@ -33,13 +33,50 @@ from traitlets import Dict
from traitlets import Instance from traitlets import Instance
from traitlets import Integer from traitlets import Integer
from traitlets import observe from traitlets import observe
from traitlets import Set
from traitlets import Unicode from traitlets import Unicode
from traitlets import validate from traitlets import validate
from traitlets.config import SingletonConfigurable from traitlets.config import SingletonConfigurable
from ..scopes import _intersect_scopes
from ..utils import url_path_join from ..utils import url_path_join
def check_scopes(required_scopes, scopes):
"""Check that required_scope(s) are in scopes
Returns the subset of scopes matching required_scopes,
which is truthy if any scopes match any required scopes.
Correctly resolves scope filters *except* for groups -> user,
e.g. require: access:server!user=x, have: access:server!group=y
will not grant access to user x even if user x is in group y.
Parameters
----------
required_scopes: set
The set of scopes required.
scopes: set
The set (or list) of scopes to check against required_scopes
Returns
-------
relevant_scopes: set
The set of scopes in required_scopes that are present in scopes,
which is truthy if any required scopes are present,
and falsy otherwise.
"""
if isinstance(required_scopes, str):
required_scopes = {required_scopes}
intersection = _intersect_scopes(required_scopes, scopes)
# re-intersect with required_scopes in case the intersection
# applies stricter filters than required_scopes declares
# e.g. required_scopes = {'read:users'} and intersection has only {'read:users!user=x'}
return set(required_scopes) & intersection
class _ExpiringDict(dict): class _ExpiringDict(dict):
"""Dict-like cache for Hub API requests """Dict-like cache for Hub API requests
@@ -285,6 +322,24 @@ class HubAuth(SingletonConfigurable):
def _default_cache(self): def _default_cache(self):
return _ExpiringDict(self.cache_max_age) return _ExpiringDict(self.cache_max_age)
oauth_scopes = Set(
Unicode(),
help="""OAuth scopes to use for allowing access.
Get from $JUPYTERHUB_OAUTH_SCOPES by default.
""",
).tag(config=True)
@default('oauth_scopes')
def _default_scopes(self):
env_scopes = os.getenv('JUPYTERHUB_OAUTH_SCOPES')
if env_scopes:
return set(json.loads(env_scopes))
service_name = os.getenv("JUPYTERHUB_SERVICE_NAME")
if service_name:
return {f'access:services!service={service_name}'}
return set()
def _check_hub_authorization(self, url, api_token, cache_key=None, use_cache=True): def _check_hub_authorization(self, url, api_token, cache_key=None, use_cache=True):
"""Identify a user with the Hub """Identify a user with the Hub
@@ -495,6 +550,10 @@ class HubAuth(SingletonConfigurable):
app_log.debug("No user identified") app_log.debug("No user identified")
return user_model return user_model
def check_scopes(self, required_scopes, user):
"""Check whether the user has required scope(s)"""
return check_scopes(required_scopes, set(user["scopes"]))
class HubOAuth(HubAuth): class HubOAuth(HubAuth):
"""HubAuth using OAuth for login instead of cookies set by the Hub. """HubAuth using OAuth for login instead of cookies set by the Hub.
@@ -771,6 +830,7 @@ class HubAuthenticated(object):
A handler that mixes this in must have the following attributes/properties: A handler that mixes this in must have the following attributes/properties:
- .hub_auth: A HubAuth instance - .hub_auth: A HubAuth instance
- .hub_scopes: A set of JupyterHub 2.0 OAuth scopes to allow.
- .hub_users: A set of usernames to allow. - .hub_users: A set of usernames to allow.
If left unspecified or None, username will not be checked. If left unspecified or None, username will not be checked.
- .hub_groups: A set of group names to allow. - .hub_groups: A set of group names to allow.
@@ -795,13 +855,19 @@ class HubAuthenticated(object):
hub_groups = None # set of allowed groups hub_groups = None # set of allowed groups
allow_admin = False # allow any admin user access allow_admin = False # allow any admin user access
@property
def hub_scopes(self):
"""Set of allowed scopes (use hub_auth.oauth_scopes by default)"""
return self.hub_auth.oauth_scopes or None
@property @property
def allow_all(self): def allow_all(self):
"""Property indicating that all successfully identified user """Property indicating that all successfully identified user
or service should be allowed. or service should be allowed.
""" """
return ( return (
self.hub_services is None self.hub_scopes is None
and self.hub_services is None
and self.hub_users is None and self.hub_users is None
and self.hub_groups is None and self.hub_groups is None
) )
@@ -842,22 +908,41 @@ class HubAuthenticated(object):
Returns the input if the user should be allowed, None otherwise. Returns the input if the user should be allowed, None otherwise.
Override if you want to check anything other than the username's presence in hub_users list. Override for custom logic in authenticating users.
Args: Args:
model (dict): the user or service model returned from :class:`HubAuth` user_model (dict): the user or service model returned from :class:`HubAuth`
Returns: Returns:
user_model (dict): The user model if the user should be allowed, None otherwise. user_model (dict): The user model if the user should be allowed, None otherwise.
""" """
name = model['name'] name = model['name']
kind = model.setdefault('kind', 'user') kind = model.setdefault('kind', 'user')
if self.allow_all: if self.allow_all:
app_log.debug( app_log.debug(
"Allowing Hub %s %s (all Hub users and services allowed)", kind, name "Allowing Hub %s %s (all Hub users and services allowed)", kind, name
) )
return model return model
if self.hub_scopes:
scopes = self.hub_auth.check_scopes(self.hub_scopes, model)
if scopes:
app_log.debug(
f"Allowing Hub {kind} {name} based on oauth scopes {scopes}"
)
return model
else:
app_log.warning(
f"Not allowing Hub {kind} {name}: missing required scopes"
)
app_log.debug(
f"Hub {kind} {name} needs scope(s) {self.hub_scopes}, has scope(s) {model['scopes']}"
)
# if hub_scopes are used, *only* hub_scopes are used
# note: this means successful authentication, but insufficient permission
raise UserNotAllowed(model)
if self.allow_admin and model.get('admin', False): if self.allow_admin and model.get('admin', False):
app_log.debug("Allowing Hub admin %s", name) app_log.debug("Allowing Hub admin %s", name)
return model return model

View File

@@ -98,6 +98,14 @@ class _ServiceSpawner(LocalProcessSpawner):
cwd = Unicode() cwd = Unicode()
cmd = Command(minlen=0) cmd = Command(minlen=0)
_service_name = Unicode()
@default("oauth_scopes")
def _default_oauth_scopes(self):
return [
"access:services",
f"access:services!service={self._service_name}",
]
def make_preexec_fn(self, name): def make_preexec_fn(self, name):
if not name: if not name:
@@ -330,6 +338,10 @@ class Service(LoggingConfigurable):
""" """
return bool(self.server is not None or self.oauth_redirect_uri) return bool(self.server is not None or self.oauth_redirect_uri)
@property
def oauth_client(self):
return self.orm.oauth_client
@property @property
def server(self): def server(self):
if self.orm.server: if self.orm.server:
@@ -384,6 +396,7 @@ class Service(LoggingConfigurable):
environment=env, environment=env,
api_token=self.api_token, api_token=self.api_token,
oauth_client_id=self.oauth_client_id, oauth_client_id=self.oauth_client_id,
_service_name=self.name,
cookie_options=self.cookie_options, cookie_options=self.cookie_options,
cwd=self.cwd, cwd=self.cwd,
hub=self.hub, hub=self.hub,

View File

@@ -216,6 +216,16 @@ class Spawner(LoggingConfigurable):
admin_access = Bool(False) admin_access = Bool(False)
api_token = Unicode() api_token = Unicode()
oauth_client_id = Unicode() oauth_client_id = Unicode()
oauth_scopes = List(Unicode())
@default("oauth_scopes")
def _default_oauth_scopes(self):
return [
f"access:users:server!server={self.user.name}/{self.name}",
f"access:users:server!user={self.user.name}",
]
handler = Any() handler = Any()
oauth_roles = Union( oauth_roles = Union(
@@ -803,6 +813,8 @@ class Spawner(LoggingConfigurable):
self.user.url, self.name, 'oauth_callback' self.user.url, self.name, 'oauth_callback'
) )
env['JUPYTERHUB_OAUTH_SCOPES'] = json.dumps(self.oauth_scopes)
# Info previously passed on args # Info previously passed on args
env['JUPYTERHUB_USER'] = self.user.name env['JUPYTERHUB_USER'] = self.user.name
env['JUPYTERHUB_SERVER_NAME'] = self.name env['JUPYTERHUB_SERVER_NAME'] = self.name

View File

@@ -21,6 +21,7 @@ from urllib.parse import urlparse
import requests import requests
from tornado import httpserver from tornado import httpserver
from tornado import ioloop from tornado import ioloop
from tornado import log
from tornado import web from tornado import web
from jupyterhub.services.auth import HubAuthenticated from jupyterhub.services.auth import HubAuthenticated
@@ -114,7 +115,9 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
from tornado.options import parse_command_line from tornado.options import parse_command_line, options
parse_command_line() parse_command_line()
options.logging = 'debug'
log.enable_pretty_logging()
main() main()

View File

@@ -25,6 +25,7 @@ from tornado.web import HTTPError
from tornado.web import RequestHandler from tornado.web import RequestHandler
from .. import orm from .. import orm
from .. import roles
from ..services.auth import _ExpiringDict from ..services.auth import _ExpiringDict
from ..services.auth import HubOAuth from ..services.auth import HubOAuth
from ..services.auth import HubOAuthenticated from ..services.auth import HubOAuthenticated
@@ -87,6 +88,7 @@ async def test_hubauth_token(app, mockservice_url):
public_url(app, mockservice_url) + '/whoami/', public_url(app, mockservice_url) + '/whoami/',
headers={'Authorization': 'token %s' % token}, headers={'Authorization': 'token %s' % token},
) )
r.raise_for_status()
reply = r.json() reply = r.json()
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']} sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
assert sub_reply == {'name': 'river', 'admin': False} assert sub_reply == {'name': 'river', 'admin': False}
@@ -111,36 +113,101 @@ async def test_hubauth_token(app, mockservice_url):
assert path.endswith('/hub/login') assert path.endswith('/hub/login')
async def test_hubauth_service_token(app, mockservice_url): @pytest.mark.parametrize(
"scopes, allowed",
[
(
[
"access:services",
],
True,
),
(
[
"access:services!service=$service",
],
True,
),
(
[
"access:services!service=other-service",
],
False,
),
(
[
"access:users:servers!user=$service",
],
False,
),
],
)
async def test_hubauth_service_token(request, app, mockservice_url, scopes, allowed):
"""Test HubAuthenticated service with service API tokens""" """Test HubAuthenticated service with service API tokens"""
scopes = [scope.replace('$service', mockservice_url.name) for scope in scopes]
token = hexlify(os.urandom(5)).decode('utf8') token = hexlify(os.urandom(5)).decode('utf8')
name = 'test-api-service' name = 'test-api-service'
app.service_tokens[token] = name app.service_tokens[token] = name
await app.init_api_tokens() await app.init_api_tokens()
orm_service = app.db.query(orm.Service).filter_by(name=name).one()
role_name = "test-hubauth-service-token"
roles.create_role(
app.db,
{
"name": role_name,
"description": "role for test",
"scopes": scopes,
},
)
request.addfinalizer(lambda: roles.delete_role(app.db, role_name))
roles.grant_role(app.db, orm_service, role_name)
# token in Authorization header # token in Authorization header
r = await async_requests.get( r = await async_requests.get(
public_url(app, mockservice_url) + '/whoami/', public_url(app, mockservice_url) + 'whoami/',
headers={'Authorization': 'token %s' % token}, headers={'Authorization': 'token %s' % token},
allow_redirects=False, allow_redirects=False,
) )
if allowed:
r.raise_for_status() r.raise_for_status()
assert r.status_code == 200 assert r.status_code == 200
reply = r.json() reply = r.json()
assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []} assert reply == {
'kind': 'service',
'name': name,
'admin': False,
'roles': [role_name],
'scopes': scopes,
}
assert not r.cookies assert not r.cookies
else:
assert r.status_code == 403
# token in ?token parameter # token in ?token parameter
r = await async_requests.get( r = await async_requests.get(
public_url(app, mockservice_url) + '/whoami/?token=%s' % token public_url(app, mockservice_url) + 'whoami/?token=%s' % token
) )
if allowed:
r.raise_for_status() r.raise_for_status()
assert r.status_code == 200
reply = r.json() reply = r.json()
assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []} assert reply == {
'kind': 'service',
'name': name,
'admin': False,
'roles': [role_name],
'scopes': scopes,
}
assert not r.cookies
else:
assert r.status_code == 403
r = await async_requests.get( r = await async_requests.get(
public_url(app, mockservice_url) + '/whoami/?token=no-such-token', public_url(app, mockservice_url) + 'whoami/?token=no-such-token',
allow_redirects=False, allow_redirects=False,
) )
assert r.status_code == 302 assert r.status_code == 302