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
c.Authenticator.whitelist = ['minrk', 'ellisonbg', 'willingc']
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
# 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
c.JupyterHub.services = [
{
'name': 'shared-notebook',
'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
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_NAME=shared-notebook
export JUPYTERHUB_SERVICE_PREFIX="/services/${JUPYTERHUB_SERVICE_NAME}/"
export JUPYTERHUB_CLIENT_ID="service-${JUPYTERHUB_SERVICE_NAME}"
jupyterhub-singleuser \
--group='shared'
jupyterhub-singleuser

View File

@@ -216,6 +216,31 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
)
credentials = self.add_credentials(credentials)
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):
self.log.debug(
"Skipping oauth confirmation for %s accessing %s",

View File

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

View File

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

View File

@@ -97,6 +97,12 @@ scope_definitions = {
'read: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': {
'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}")
owner = orm_object.user or orm_object.service
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)
if 'all' in token_scopes:
token_scopes.remove('all')

View File

@@ -33,13 +33,50 @@ from traitlets import Dict
from traitlets import Instance
from traitlets import Integer
from traitlets import observe
from traitlets import Set
from traitlets import Unicode
from traitlets import validate
from traitlets.config import SingletonConfigurable
from ..scopes import _intersect_scopes
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):
"""Dict-like cache for Hub API requests
@@ -285,6 +322,24 @@ class HubAuth(SingletonConfigurable):
def _default_cache(self):
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):
"""Identify a user with the Hub
@@ -495,6 +550,10 @@ class HubAuth(SingletonConfigurable):
app_log.debug("No user identified")
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):
"""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:
- .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.
If left unspecified or None, username will not be checked.
- .hub_groups: A set of group names to allow.
@@ -795,13 +855,19 @@ class HubAuthenticated(object):
hub_groups = None # set of allowed groups
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
def allow_all(self):
"""Property indicating that all successfully identified user
or service should be allowed.
"""
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_groups is None
)
@@ -842,22 +908,41 @@ class HubAuthenticated(object):
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:
model (dict): the user or service model returned from :class:`HubAuth`
user_model (dict): the user or service model returned from :class:`HubAuth`
Returns:
user_model (dict): The user model if the user should be allowed, None otherwise.
"""
name = model['name']
kind = model.setdefault('kind', 'user')
if self.allow_all:
app_log.debug(
"Allowing Hub %s %s (all Hub users and services allowed)", kind, name
)
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):
app_log.debug("Allowing Hub admin %s", name)
return model

View File

@@ -98,6 +98,14 @@ class _ServiceSpawner(LocalProcessSpawner):
cwd = Unicode()
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):
if not name:
@@ -330,6 +338,10 @@ class Service(LoggingConfigurable):
"""
return bool(self.server is not None or self.oauth_redirect_uri)
@property
def oauth_client(self):
return self.orm.oauth_client
@property
def server(self):
if self.orm.server:
@@ -384,6 +396,7 @@ class Service(LoggingConfigurable):
environment=env,
api_token=self.api_token,
oauth_client_id=self.oauth_client_id,
_service_name=self.name,
cookie_options=self.cookie_options,
cwd=self.cwd,
hub=self.hub,

View File

@@ -216,6 +216,16 @@ class Spawner(LoggingConfigurable):
admin_access = Bool(False)
api_token = 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()
oauth_roles = Union(
@@ -803,6 +813,8 @@ class Spawner(LoggingConfigurable):
self.user.url, self.name, 'oauth_callback'
)
env['JUPYTERHUB_OAUTH_SCOPES'] = json.dumps(self.oauth_scopes)
# Info previously passed on args
env['JUPYTERHUB_USER'] = self.user.name
env['JUPYTERHUB_SERVER_NAME'] = self.name

View File

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

View File

@@ -25,6 +25,7 @@ from tornado.web import HTTPError
from tornado.web import RequestHandler
from .. import orm
from .. import roles
from ..services.auth import _ExpiringDict
from ..services.auth import HubOAuth
from ..services.auth import HubOAuthenticated
@@ -87,6 +88,7 @@ async def test_hubauth_token(app, mockservice_url):
public_url(app, mockservice_url) + '/whoami/',
headers={'Authorization': 'token %s' % token},
)
r.raise_for_status()
reply = r.json()
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
assert sub_reply == {'name': 'river', 'admin': False}
@@ -111,36 +113,101 @@ async def test_hubauth_token(app, mockservice_url):
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"""
scopes = [scope.replace('$service', mockservice_url.name) for scope in scopes]
token = hexlify(os.urandom(5)).decode('utf8')
name = 'test-api-service'
app.service_tokens[token] = name
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
r = await async_requests.get(
public_url(app, mockservice_url) + '/whoami/',
public_url(app, mockservice_url) + 'whoami/',
headers={'Authorization': 'token %s' % token},
allow_redirects=False,
)
if allowed:
r.raise_for_status()
assert r.status_code == 200
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
# token in ?token parameter
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()
assert r.status_code == 200
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(
public_url(app, mockservice_url) + '/whoami/?token=no-such-token',
public_url(app, mockservice_url) + 'whoami/?token=no-such-token',
allow_redirects=False,
)
assert r.status_code == 302