mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-16 22:43:00 +00:00
add !service and !server scope filters
allows oauth clients to issue scopes that only grant access to the issuing service e.g. access:service!service or access:servers!server especially useful with custom scopes
This commit is contained in:
@@ -72,13 +72,29 @@ Requested resources are filtered based on the filter of the corresponding scope.
|
|||||||
|
|
||||||
In case a user resource is being accessed, any scopes with _group_ filters will be expanded to filters for each _user_ in those groups.
|
In case a user resource is being accessed, any scopes with _group_ filters will be expanded to filters for each _user_ in those groups.
|
||||||
|
|
||||||
### `!user` filter
|
### Self-referencing filters
|
||||||
|
|
||||||
|
There are some 'shortcut' filters,
|
||||||
|
which can be applied to all scopes,
|
||||||
|
that filter based on the entities associated with the request.
|
||||||
|
|
||||||
The `!user` filter is a special horizontal filter that strictly refers to the **"owner only"** scopes, where _owner_ is a user entity. The filter resolves internally into `!user=<ownerusername>` ensuring that only the owner's resources may be accessed through the associated scopes.
|
The `!user` filter is a special horizontal filter that strictly refers to the **"owner only"** scopes, where _owner_ is a user entity. The filter resolves internally into `!user=<ownerusername>` ensuring that only the owner's resources may be accessed through the associated scopes.
|
||||||
|
|
||||||
For example, the `server` role assigned by default to server tokens contains `access:servers!user` and `users:activity!user` scopes. This allows the token to access and post activity of only the servers owned by the token owner.
|
For example, the `server` role assigned by default to server tokens contains `access:servers!user` and `users:activity!user` scopes. This allows the token to access and post activity of only the servers owned by the token owner.
|
||||||
|
|
||||||
The filter can be applied to any scope.
|
:::{versionadded} 2.3
|
||||||
|
`!service` and `!server` filters.
|
||||||
|
:::
|
||||||
|
|
||||||
|
In addition to `!user`, _tokens_ may have filters `!service`
|
||||||
|
or `!server`, which expand similarly to `!service=servicename`
|
||||||
|
and `!server=servername`.
|
||||||
|
This only applies to tokens issued via the OAuth flow.
|
||||||
|
In these cases, the name is the _issuing_ entity (a service or single-user server),
|
||||||
|
so that access can be restricted to the issuing service,
|
||||||
|
e.g. `access:servers!server` would grant access only to the server that requested the token.
|
||||||
|
|
||||||
|
These filters can be applied to any scope.
|
||||||
|
|
||||||
(vertical-filtering-target)=
|
(vertical-filtering-target)=
|
||||||
|
|
||||||
|
@@ -351,7 +351,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
|||||||
|
|
||||||
# APIToken.new commits the token to the db
|
# APIToken.new commits the token to the db
|
||||||
orm.APIToken.new(
|
orm.APIToken.new(
|
||||||
client_id=client.identifier,
|
oauth_client=client,
|
||||||
expires_in=token['expires_in'],
|
expires_in=token['expires_in'],
|
||||||
scopes=request.scopes,
|
scopes=request.scopes,
|
||||||
token=token['access_token'],
|
token=token['access_token'],
|
||||||
|
@@ -682,7 +682,8 @@ class APIToken(Hashed, Base):
|
|||||||
generated=True,
|
generated=True,
|
||||||
session_id=None,
|
session_id=None,
|
||||||
expires_in=None,
|
expires_in=None,
|
||||||
client_id='jupyterhub',
|
client_id=None,
|
||||||
|
oauth_client=None,
|
||||||
return_orm=False,
|
return_orm=False,
|
||||||
):
|
):
|
||||||
"""Generate a new API token for a user or service"""
|
"""Generate a new API token for a user or service"""
|
||||||
@@ -726,11 +727,20 @@ class APIToken(Hashed, Base):
|
|||||||
orm_roles.append(role)
|
orm_roles.append(role)
|
||||||
scopes = roles_to_scopes(orm_roles)
|
scopes = roles_to_scopes(orm_roles)
|
||||||
|
|
||||||
|
if oauth_client is None:
|
||||||
|
# lookup oauth client by identifier
|
||||||
|
if client_id is None:
|
||||||
|
# default: global 'jupyterhub' client
|
||||||
|
client_id = "jupyterhub"
|
||||||
|
oauth_client = db.query(OAuthClient).filter_by(identifier=client_id).one()
|
||||||
|
if client_id is None:
|
||||||
|
client_id = oauth_client.identifier
|
||||||
|
|
||||||
# avoid circular import
|
# avoid circular import
|
||||||
from .scopes import _check_scopes_exist, _check_token_scopes
|
from .scopes import _check_scopes_exist, _check_token_scopes
|
||||||
|
|
||||||
_check_scopes_exist(scopes, who_for="token")
|
_check_scopes_exist(scopes, who_for="token")
|
||||||
_check_token_scopes(scopes, owner=user or service)
|
_check_token_scopes(scopes, owner=user or service, oauth_client=oauth_client)
|
||||||
|
|
||||||
# two stages to ensure orm_token.generated has been set
|
# two stages to ensure orm_token.generated has been set
|
||||||
# before token setter is called
|
# before token setter is called
|
||||||
@@ -760,7 +770,9 @@ class APIToken(Hashed, Base):
|
|||||||
from .scopes import _check_scopes_exist, _check_token_scopes
|
from .scopes import _check_scopes_exist, _check_token_scopes
|
||||||
|
|
||||||
_check_scopes_exist(new_scopes, who_for="token")
|
_check_scopes_exist(new_scopes, who_for="token")
|
||||||
_check_token_scopes(new_scopes, owner=self.owner)
|
_check_token_scopes(
|
||||||
|
new_scopes, owner=self.owner, oauth_client=self.oauth_client
|
||||||
|
)
|
||||||
self.scopes = new_scopes
|
self.scopes = new_scopes
|
||||||
|
|
||||||
|
|
||||||
|
@@ -341,7 +341,13 @@ def get_scopes_for(orm_object):
|
|||||||
# only thing we miss by short-circuiting here: warning about excluded extra scopes
|
# only thing we miss by short-circuiting here: warning about excluded extra scopes
|
||||||
return owner_scopes
|
return owner_scopes
|
||||||
|
|
||||||
token_scopes = set(expand_scopes(token_scopes, owner=owner))
|
token_scopes = set(
|
||||||
|
expand_scopes(
|
||||||
|
token_scopes,
|
||||||
|
owner=owner,
|
||||||
|
oauth_client=orm_object.oauth_client,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if orm_object.client_id != "jupyterhub":
|
if orm_object.client_id != "jupyterhub":
|
||||||
# oauth tokens can be used to access the service issuing the token,
|
# oauth tokens can be used to access the service issuing the token,
|
||||||
@@ -468,7 +474,7 @@ def _expand_scope(scope):
|
|||||||
return frozenset(expanded_scopes)
|
return frozenset(expanded_scopes)
|
||||||
|
|
||||||
|
|
||||||
def _expand_scopes_key(scopes, owner=None):
|
def _expand_scopes_key(scopes, owner=None, oauth_client=None):
|
||||||
"""Cache key function for expand_scopes
|
"""Cache key function for expand_scopes
|
||||||
|
|
||||||
scopes is usually a mutable list or set,
|
scopes is usually a mutable list or set,
|
||||||
@@ -484,11 +490,15 @@ def _expand_scopes_key(scopes, owner=None):
|
|||||||
else:
|
else:
|
||||||
# owner key is the type and name
|
# owner key is the type and name
|
||||||
owner_key = (type(owner).__name__, owner.name)
|
owner_key = (type(owner).__name__, owner.name)
|
||||||
return (frozen_scopes, owner_key)
|
if oauth_client is None:
|
||||||
|
oauth_client_key = None
|
||||||
|
else:
|
||||||
|
oauth_client_key = oauth_client.identifier
|
||||||
|
return (frozen_scopes, owner_key, oauth_client_key)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache_key(_expand_scopes_key)
|
@lru_cache_key(_expand_scopes_key)
|
||||||
def expand_scopes(scopes, owner=None):
|
def expand_scopes(scopes, owner=None, oauth_client=None):
|
||||||
"""Returns a set of fully expanded scopes for a collection of raw scopes
|
"""Returns a set of fully expanded scopes for a collection of raw scopes
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
@@ -496,28 +506,47 @@ def expand_scopes(scopes, owner=None):
|
|||||||
owner (obj, optional): orm.User or orm.Service as owner of orm.APIToken
|
owner (obj, optional): orm.User or orm.Service as owner of orm.APIToken
|
||||||
Used for expansion of metascopes such as `self`
|
Used for expansion of metascopes such as `self`
|
||||||
and owner-based filters such as `!user`
|
and owner-based filters such as `!user`
|
||||||
|
oauth_client (obj, optional): orm.OAuthClient
|
||||||
|
The issuing OAuth client of an API token.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
expanded scopes (set): set of all expanded scopes, with filters applied for the owner
|
expanded scopes (set): set of all expanded scopes, with filters applied for the owner
|
||||||
"""
|
"""
|
||||||
expanded_scopes = set(chain.from_iterable(map(_expand_scope, scopes)))
|
expanded_scopes = set(chain.from_iterable(map(_expand_scope, scopes)))
|
||||||
|
|
||||||
|
filter_replacements = {
|
||||||
|
"user": None,
|
||||||
|
"service": None,
|
||||||
|
"server": None,
|
||||||
|
}
|
||||||
|
owner_name = None
|
||||||
if isinstance(owner, orm.User):
|
if isinstance(owner, orm.User):
|
||||||
owner_name = owner.name
|
owner_name = owner.name
|
||||||
else:
|
filter_replacements["user"] = f"user={owner_name}"
|
||||||
owner_name = None
|
elif isinstance(owner, orm.Service):
|
||||||
|
filter_replacements["service"] = f"service={owner.name}"
|
||||||
|
|
||||||
|
if oauth_client is not None:
|
||||||
|
if oauth_client.service is not None:
|
||||||
|
filter_replacements["service"] = f"service={oauth_client.service.name}"
|
||||||
|
elif oauth_client.spawner is not None:
|
||||||
|
spawner = oauth_client.spawner
|
||||||
|
filter_replacements["server"] = f"server={spawner.user.name}/{spawner.name}"
|
||||||
|
|
||||||
for scope in expanded_scopes.copy():
|
for scope in expanded_scopes.copy():
|
||||||
base_scope, _, filter = scope.partition('!')
|
base_scope, _, filter = scope.partition('!')
|
||||||
if filter == 'user':
|
if filter in filter_replacements:
|
||||||
# translate !user into !user={username}
|
# translate !user into !user={username}
|
||||||
|
# and !service into !service={servicename}
|
||||||
|
# and !server into !server={username}/{servername}
|
||||||
expanded_scopes.remove(scope)
|
expanded_scopes.remove(scope)
|
||||||
if owner_name:
|
expanded_filter = filter_replacements[filter]
|
||||||
|
if expanded_filter:
|
||||||
# translate
|
# translate
|
||||||
expanded_scopes.add(f'{base_scope}!user={owner_name}')
|
expanded_scopes.add(f'{base_scope}!{expanded_filter}')
|
||||||
else:
|
else:
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
f"Not expanding !user filter without owner in {scope}",
|
f"Not expanding !{filter} filter without target {filter} in {scope}",
|
||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -610,7 +639,8 @@ def _check_scopes_exist(scopes, who_for=None):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
allowed_scopes = set(scope_definitions.keys())
|
allowed_scopes = set(scope_definitions.keys())
|
||||||
allowed_filters = ('!user=', '!service=', '!group=', '!server=', '!user')
|
filter_prefixes = ('!user=', '!service=', '!group=', '!server=')
|
||||||
|
exact_filters = {"!user", "!service", "!server"}
|
||||||
|
|
||||||
if who_for:
|
if who_for:
|
||||||
log_for = f"for {who_for}"
|
log_for = f"for {who_for}"
|
||||||
@@ -625,13 +655,15 @@ def _check_scopes_exist(scopes, who_for=None):
|
|||||||
raise KeyError(f"Scope '{scope}' {log_for} does not exist")
|
raise KeyError(f"Scope '{scope}' {log_for} does not exist")
|
||||||
if filter_:
|
if filter_:
|
||||||
full_filter = f"!{filter_}"
|
full_filter = f"!{filter_}"
|
||||||
if not full_filter.startswith(allowed_filters):
|
if full_filter not in exact_filters and not full_filter.startswith(
|
||||||
|
filter_prefixes
|
||||||
|
):
|
||||||
raise KeyError(
|
raise KeyError(
|
||||||
f"Scope filter {filter_} '{full_filter}' in scope '{scope}' {log_for} does not exist"
|
f"Scope filter {filter_} '{full_filter}' in scope '{scope}' {log_for} does not exist"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _check_token_scopes(scopes, owner):
|
def _check_token_scopes(scopes, owner, oauth_client):
|
||||||
"""Check that scopes to be assigned to a token
|
"""Check that scopes to be assigned to a token
|
||||||
are in fact
|
are in fact
|
||||||
|
|
||||||
@@ -648,7 +680,7 @@ def _check_token_scopes(scopes, owner):
|
|||||||
return
|
return
|
||||||
scopes.discard("inherit")
|
scopes.discard("inherit")
|
||||||
# common short circuit
|
# common short circuit
|
||||||
token_scopes = expand_scopes(scopes, owner=owner)
|
token_scopes = expand_scopes(scopes, owner=owner, oauth_client=oauth_client)
|
||||||
|
|
||||||
if not token_scopes:
|
if not token_scopes:
|
||||||
return
|
return
|
||||||
|
@@ -285,7 +285,22 @@ class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner):
|
|||||||
_mock_service_counter = 0
|
_mock_service_counter = 0
|
||||||
|
|
||||||
|
|
||||||
def _mockservice(request, app, url=False):
|
def _mockservice(request, app, external=False, url=False):
|
||||||
|
"""
|
||||||
|
Add a service to the application
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: pytest request fixture
|
||||||
|
app: MockHub application
|
||||||
|
external (bool):
|
||||||
|
If False (default), launch the service.
|
||||||
|
Otherwise, consider it 'external,
|
||||||
|
registering a service in the database,
|
||||||
|
but don't start it.
|
||||||
|
url (bool):
|
||||||
|
If True, register the service at a URL
|
||||||
|
(as opposed to headless, API-only).
|
||||||
|
"""
|
||||||
global _mock_service_counter
|
global _mock_service_counter
|
||||||
_mock_service_counter += 1
|
_mock_service_counter += 1
|
||||||
name = 'mock-service-%i' % _mock_service_counter
|
name = 'mock-service-%i' % _mock_service_counter
|
||||||
@@ -296,6 +311,10 @@ def _mockservice(request, app, url=False):
|
|||||||
else:
|
else:
|
||||||
spec['url'] = 'http://127.0.0.1:%i' % random_port()
|
spec['url'] = 'http://127.0.0.1:%i' % random_port()
|
||||||
|
|
||||||
|
if external:
|
||||||
|
|
||||||
|
spec['oauth_redirect_uri'] = 'http://127.0.0.1:%i' % random_port()
|
||||||
|
|
||||||
io_loop = app.io_loop
|
io_loop = app.io_loop
|
||||||
|
|
||||||
with mock.patch.object(
|
with mock.patch.object(
|
||||||
@@ -313,17 +332,20 @@ def _mockservice(request, app, url=False):
|
|||||||
await app.proxy.add_all_services(app._service_map)
|
await app.proxy.add_all_services(app._service_map)
|
||||||
await service.start()
|
await service.start()
|
||||||
|
|
||||||
io_loop.run_sync(start)
|
if not external:
|
||||||
|
io_loop.run_sync(start)
|
||||||
|
|
||||||
def cleanup():
|
def cleanup():
|
||||||
asyncio.get_event_loop().run_until_complete(service.stop())
|
if not external:
|
||||||
|
asyncio.get_event_loop().run_until_complete(service.stop())
|
||||||
app.services[:] = []
|
app.services[:] = []
|
||||||
app._service_map.clear()
|
app._service_map.clear()
|
||||||
|
|
||||||
request.addfinalizer(cleanup)
|
request.addfinalizer(cleanup)
|
||||||
# ensure process finishes starting
|
# ensure process finishes starting
|
||||||
with raises(TimeoutExpired):
|
if not external:
|
||||||
service.proc.wait(1)
|
with raises(TimeoutExpired):
|
||||||
|
service.proc.wait(1)
|
||||||
if url:
|
if url:
|
||||||
io_loop.run_sync(partial(service.server.wait_up, http=True))
|
io_loop.run_sync(partial(service.server.wait_up, http=True))
|
||||||
return service
|
return service
|
||||||
@@ -335,6 +357,12 @@ def mockservice(request, app):
|
|||||||
yield _mockservice(request, app, url=False)
|
yield _mockservice(request, app, url=False)
|
||||||
|
|
||||||
|
|
||||||
|
@fixture
|
||||||
|
def mockservice_external(request, app):
|
||||||
|
"""Mock an externally managed service (don't start anything)"""
|
||||||
|
yield _mockservice(request, app, external=True, url=False)
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@fixture
|
||||||
def mockservice_url(request, app):
|
def mockservice_url(request, app):
|
||||||
"""Mock a service with its own url to test external services"""
|
"""Mock a service with its own url to test external services"""
|
||||||
|
@@ -1151,28 +1151,52 @@ async def test_user_filter_expansion(app, create_user_with_scopes):
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"scopes, expected",
|
"scopes, expected",
|
||||||
[
|
[
|
||||||
("read:users:name!user", ["read:users:name!user=$user"]),
|
("read:users:name!user", ["read:users:name!user={user}"]),
|
||||||
(
|
(
|
||||||
"users:activity!user",
|
"users:activity!user",
|
||||||
[
|
[
|
||||||
"read:users:activity!user=$user",
|
"read:users:activity!user={user}",
|
||||||
"users:activity!user=$user",
|
"users:activity!user={user}",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
("self", ["*"]),
|
("self", ["*"]),
|
||||||
(["access:services", "access:services!service=x"], ["access:services"]),
|
(["access:services", "access:services!service=x"], ["access:services"]),
|
||||||
|
("access:services!service", ["access:services!service={service}"]),
|
||||||
|
("access:servers!server", ["access:servers!server={server}"]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_expand_scopes(user, scopes, expected):
|
def test_expand_scopes(app, user, scopes, expected, mockservice_external):
|
||||||
if isinstance(scopes, str):
|
if isinstance(scopes, str):
|
||||||
scopes = [scopes]
|
scopes = [scopes]
|
||||||
scopes = {s.replace("$user", user.name) for s in scopes}
|
|
||||||
expected = {s.replace("$user", user.name) for s in expected}
|
db = app.db
|
||||||
|
service = mockservice_external
|
||||||
|
spawner_name = "salmon"
|
||||||
|
server_name = f"{user.name}/{spawner_name}"
|
||||||
|
if 'server' in str(scopes):
|
||||||
|
oauth_client = orm.OAuthClient()
|
||||||
|
db.add(oauth_client)
|
||||||
|
spawner = user.spawners[spawner_name]
|
||||||
|
spawner.orm_spawner.oauth_client = oauth_client
|
||||||
|
db.commit()
|
||||||
|
assert oauth_client.spawner is spawner.orm_spawner
|
||||||
|
else:
|
||||||
|
oauth_client = service.oauth_client
|
||||||
|
assert oauth_client is not None
|
||||||
|
|
||||||
|
def format_scopes(scopes):
|
||||||
|
return {
|
||||||
|
s.format(service=service.name, server=server_name, user=user.name)
|
||||||
|
for s in scopes
|
||||||
|
}
|
||||||
|
|
||||||
|
scopes = format_scopes(scopes)
|
||||||
|
expected = format_scopes(expected)
|
||||||
|
|
||||||
if "*" in expected:
|
if "*" in expected:
|
||||||
expected.remove("*")
|
expected.remove("*")
|
||||||
expected.update(_expand_self_scope(user.name))
|
expected.update(_expand_self_scope(user.name))
|
||||||
|
|
||||||
expanded = expand_scopes(scopes, owner=user.orm_user)
|
expanded = expand_scopes(scopes, owner=user.orm_user, oauth_client=oauth_client)
|
||||||
assert isinstance(expanded, frozenset)
|
assert isinstance(expanded, frozenset)
|
||||||
assert sorted(expanded) == sorted(expected)
|
assert sorted(expanded) == sorted(expected)
|
||||||
|
Reference in New Issue
Block a user