diff --git a/docs/source/getting-started/authenticators-users-basics.md b/docs/source/getting-started/authenticators-users-basics.md index 39ea2c92..d0d7d41d 100644 --- a/docs/source/getting-started/authenticators-users-basics.md +++ b/docs/source/getting-started/authenticators-users-basics.md @@ -4,7 +4,7 @@ The default Authenticator uses [PAM][] to authenticate system users with their username and password. With the default Authenticator, any user with an account and password on the system will be allowed to login. -## Create a set of allowed users +## Create a set of allowed users (`allowed_users`) You can restrict which users are allowed to login with a set, `Authenticator.allowed_users`: @@ -25,7 +25,7 @@ If this configuration value is not set, then **all authenticated users will be a ```{note} As of JupyterHub 2.0, the full permissions of `admin_users` should not be required. -Instead, you can assign [roles][] to users or groups +Instead, you can assign [roles](https://jupyterhub.readthedocs.io/en/stable/rbac/roles.html#define-role-target) to users or groups with only the scopes they require. ``` @@ -43,9 +43,9 @@ Users in the admin set are automatically added to the user `allowed_users` set, if they are not already present. Each authenticator may have different ways of determining whether a user is an -administrator. By default JupyterHub uses the PAMAuthenticator which provides the +administrator. By default, JupyterHub uses the PAMAuthenticator which provides the `admin_groups` option and can set administrator status based on a user -group. For example we can let any user in the `wheel` group be admin: +group. For example, we can let any user in the `wheel` group be an admin: ```python c.PAMAuthenticator.admin_groups = {'wheel'} @@ -57,12 +57,12 @@ Since the default `JupyterHub.admin_access` setting is `False`, the admins do not have permission to log in to the single user notebook servers owned by _other users_. If `JupyterHub.admin_access` is set to `True`, then admins have permission to log in _as other users_ on their -respective machines, for debugging. **As a courtesy, you should make +respective machines for debugging. **As a courtesy, you should make sure your users know if admin_access is enabled.** ## Add or remove users from the Hub -Users can be added to and removed from the Hub via either the admin +Users can be added to and removed from the Hub via the admin panel or the REST API. When a user is **added**, the user will be automatically added to the `allowed_users` set and database. Restarting the Hub will not require manually updating the `allowed_users` set in your config file, @@ -81,7 +81,7 @@ the ability to manage users on the local system. When you try to add a new user to the Hub, a `LocalAuthenticator` will check if the user already exists. If you set the configuration value, `create_system_users`, to `True` in the configuration file, the `LocalAuthenticator` has -the privileges to add users to the system. The setting in the config +the ability to add users to the system. The setting in the config file is: ```python @@ -91,7 +91,7 @@ c.LocalAuthenticator.create_system_users = True Adding a user to the Hub that doesn't already exist on the system will result in the Hub creating that user via the system `adduser` command line tool. This option is typically used on hosted deployments of -JupyterHub, to avoid the need to manually create all your users before +JupyterHub to avoid the need to manually create all your users before launching the service. This approach is not recommended when running JupyterHub in situations where JupyterHub users map directly onto the system's UNIX users. @@ -101,19 +101,19 @@ system's UNIX users. JupyterHub's [OAuthenticator][] currently supports the following popular services: -- Auth0 -- Azure AD -- Bitbucket -- CILogon -- GitHub -- GitLab -- Globus -- Google -- MediaWiki -- Okpy -- OpenShift +- [Auth0](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.auth0.html#module-oauthenticator.auth0) +- [Azure AD](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.azuread.html#module-oauthenticator.azuread) +- [Bitbucket](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.bitbucket.html#module-oauthenticator.bitbucket) +- [CILogon](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.cilogon.html#module-oauthenticator.cilogon) +- [GitHub](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.github.html#module-oauthenticator.github) +- [GitLab](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.gitlab.html#module-oauthenticator.gitlab) +- [Globus](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.globus.html#module-oauthenticator.globus) +- [Google](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.google.html#module-oauthenticator.google) +- [MediaWiki](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.mediawiki.html#module-oauthenticator.mediawiki) +- [Okpy](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.okpy.html#module-oauthenticator.okpy) +- [OpenShift](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.openshift.html#module-oauthenticator.openshift) -A generic implementation, which you can use for OAuth authentication +A [generic implementation](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.generic.html#module-oauthenticator.generic), which you can use for OAuth authentication with any provider, is also available. ## Use DummyAuthenticator for testing diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index 11171752..0102aa4f 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -3,6 +3,7 @@ # Distributed under the terms of the Modified BSD License. import json from datetime import datetime +from unittest import mock from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse from oauthlib import oauth2 @@ -241,12 +242,18 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler): uri, http_method, body, headers = self.extract_oauth_params() try: - ( - requested_scopes, - credentials, - ) = self.oauth_provider.validate_authorization_request( - uri, http_method, body, headers - ) + with mock.patch.object( + self.oauth_provider.request_validator, + "_current_user", + self.current_user, + create=True, + ): + ( + requested_scopes, + credentials, + ) = self.oauth_provider.validate_authorization_request( + uri, http_method, body, headers + ) credentials = self.add_credentials(credentials) client = self.oauth_provider.fetch_by_client_id(credentials['client_id']) allowed = False @@ -289,12 +296,15 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler): required_scopes = {*scopes.identify_scopes(), *scopes.access_scopes(client)} user_scopes |= {"inherit", *required_scopes} - allowed_scopes = requested_scopes.intersection(user_scopes) - excluded_scopes = requested_scopes.difference(user_scopes) - # TODO: compute lower-level intersection of remaining _expanded_ scopes - # (e.g. user has admin:users, requesting read:users!group=x) + allowed_scopes, disallowed_scopes = scopes._resolve_requested_scopes( + requested_scopes, + user_scopes, + user=user.orm_user, + client=client, + db=self.db, + ) - if excluded_scopes: + if disallowed_scopes: self.log.warning( f"Service {client.description} requested scopes {','.join(requested_scopes)}" f" for user {self.current_user.name}," diff --git a/jupyterhub/oauth/provider.py b/jupyterhub/oauth/provider.py index fddc9e0d..d3e17c46 100644 --- a/jupyterhub/oauth/provider.py +++ b/jupyterhub/oauth/provider.py @@ -9,7 +9,13 @@ from tornado.log import app_log from .. import orm from ..roles import roles_to_scopes -from ..scopes import _check_scopes_exist, access_scopes, identify_scopes +from ..scopes import ( + _check_scopes_exist, + _resolve_requested_scopes, + access_scopes, + expand_scopes, + identify_scopes, +) from ..utils import compare_token, hash_token # patch absolute-uri check @@ -551,7 +557,6 @@ class JupyterHubRequestValidator(RequestValidator): - Resource Owner Password Credentials Grant - Client Credentials Grant """ - orm_client = ( self.db.query(orm.OAuthClient).filter_by(identifier=client_id).one_or_none() ) @@ -591,19 +596,23 @@ class JupyterHubRequestValidator(RequestValidator): client_allowed_scopes = set(orm_client.allowed_scopes) + # scope resolution only works if we have a user defined + user = request.user or getattr(self, "_current_user") + # always grant reading the token-owner's name # and accessing the service itself required_scopes = {*identify_scopes(), *access_scopes(orm_client)} requested_scopes.update(required_scopes) client_allowed_scopes.update(required_scopes) - # TODO: handle expanded_scopes intersection here? - # e.g. client allowed to request admin:users, - # but requests admin:users!name=x will not be allowed - # This can probably be dealt with in config by listing expected requests - # as explcitly allowed + allowed_scopes, disallowed_scopes = _resolve_requested_scopes( + requested_scopes, + client_allowed_scopes, + user=user.orm_user, + client=orm_client, + db=self.db, + ) - disallowed_scopes = requested_scopes.difference(client_allowed_scopes) if disallowed_scopes: app_log.error( f"Scope(s) not allowed for {client_id}: {', '.join(disallowed_scopes)}" diff --git a/jupyterhub/scopes.py b/jupyterhub/scopes.py index 936d1db0..fb345f3d 100644 --- a/jupyterhub/scopes.py +++ b/jupyterhub/scopes.py @@ -461,16 +461,13 @@ def _expand_scope(scope): # reapply !filter if filter_: expanded_scopes = { - f"{scope_name}!{filter_}" for scope_name in expanded_scope_names + f"{scope_name}!{filter_}" + for scope_name in expanded_scope_names + # server scopes have some cross-resource subscopes + # where the !server filter doesn't make sense, + # e.g. read:servers -> read:users:name + if not (filter_.startswith("server") and scope_name.startswith("read:user")) } - # special handling of server filter - # any read access via server filter includes permission to read the user's name - resource, _, value = filter_.partition('=') - if resource == 'server' and any( - scope_name.startswith("read:") for scope_name in expanded_scope_names - ): - username, _, server = value.partition('/') - expanded_scopes.add(f'read:users:name!user={username}') else: expanded_scopes = expanded_scope_names @@ -569,6 +566,76 @@ def expand_scopes(scopes, owner=None, oauth_client=None): return frozenset(reduce_scopes(expanded_scopes)) +def _resolve_requested_scopes(requested_scopes, have_scopes, user, client, db): + """Resolve requested scopes for an OAuth token + + Intersects requested scopes with user scopes. + + First, at the raw scope level, + then if some scopes remain, intersect expanded scopes. + + Args: + requested_scopes (set): + raw scopes being requested. + have_scopes (set): + raw scopes currently held, against which requested_scopes will be checked. + user (orm.User): + user for whom the scopes will be issued + client (orm.OAuthClient): + oauth client which will own the token + db: + database session, required to resolve user|group intersections + + Returns: + (allowed_scopes, disallowed_scopes): + sets of allowed and disallowed scopes from the request + """ + + allowed_scopes = requested_scopes.intersection(have_scopes) + disallowed_scopes = requested_scopes.difference(have_scopes) + + if not disallowed_scopes: + # simple intersection worked, all scopes granted + return (allowed_scopes, disallowed_scopes) + + # if we got here, some scopes were disallowed. + # resolve fully expanded scopes to make sure scope intersections are properly allowed. + expanded_allowed = expand_scopes(allowed_scopes, user, client) + expanded_have = expand_scopes(have_scopes, user, client) + # compute one at a time so we can keep the abbreviated scopes + # if they are a subset of user scopes (e.g. requested !server, have !user) + for scope in list(disallowed_scopes): + expanded_disallowed = expand_scopes({scope}, user, client) + # don't check already-allowed scopes + expanded_disallowed -= expanded_allowed + if expanded_disallowed: + allowed_intersection = _intersect_expanded_scopes( + expanded_disallowed, expanded_have, db=db + ) + else: + allowed_intersection = set() + + if allowed_intersection == expanded_disallowed: + # full scope allowed (requested scope is subset of user scopes) + allowed_scopes.add(scope) + disallowed_scopes.remove(scope) + expanded_allowed = expand_scopes(allowed_scopes, user, client) + + elif allowed_intersection: + # some scopes get through, but not all, + # allow the subset + allowed_scopes |= allowed_intersection + expanded_allowed = expand_scopes(allowed_scopes, user, client) + # choice: report that the requested scope wasn't _fully_ granted (current behavior) + # or report the exact (likely too detailed) set of not granted scopes (below) + # disallowed_scopes.remove(scope) + # disallowed_scopes |= expanded_disallowed.difference(allowed_intersection) + else: + # no new scopes granted, original check was right + pass + return (allowed_scopes, disallowed_scopes) + + def _needs_scope_expansion(filter_, filter_value, sub_scope): """ Check if there is a requirements to expand the `group` scope to individual `user` scopes. diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py index 403fc526..d61239e5 100644 --- a/jupyterhub/services/auth.py +++ b/jupyterhub/services/auth.py @@ -349,7 +349,9 @@ class HubAuth(SingletonConfigurable): @property def oauth_scopes(self): warnings.warn( - "HubAuth.oauth_scopes is deprecated in JupyterHub 3.0. Use .access_scopes" + "HubAuth.oauth_scopes is deprecated in JupyterHub 3.0. Use .access_scopes", + DeprecationWarning, + stacklevel=2, ) return self.access_scopes diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 988533a3..39837b08 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -378,8 +378,8 @@ class Spawner(LoggingConfigurable): raise ValueError(f"No such role(s): {', '.join(missing_roles)}") scopes.extend(roles_to_scopes(roles)) - # always add access scopes - scopes.extend(self.oauth_access_scopes) + # always add access scope + scopes.append(f"access:servers!server={self.user.name}/{self.name}") return sorted(set(scopes)) will_resume = Bool( diff --git a/jupyterhub/tests/test_scopes.py b/jupyterhub/tests/test_scopes.py index 4e8f7a9f..c4792472 100644 --- a/jupyterhub/tests/test_scopes.py +++ b/jupyterhub/tests/test_scopes.py @@ -15,6 +15,7 @@ from ..scopes import ( _check_scope_access, _expand_self_scope, _intersect_expanded_scopes, + _resolve_requested_scopes, expand_scopes, get_scopes_for, identify_scopes, @@ -554,21 +555,28 @@ async def test_server_state_access( await api_request( app, 'users', user.name, 'servers', server_name, method='post' ) - service = create_service_with_scopes(*scopes) + service = create_service_with_scopes("read:users:name!user=", *scopes) api_token = service.new_api_token() headers = {'Authorization': 'token %s' % api_token} + + # can I get the user model? r = await api_request(app, 'users', user.name, headers=headers) - r.raise_for_status() - user_model = r.json() - if num_servers: - assert 'servers' in user_model - server_models = user_model['servers'] - assert len(server_models) == num_servers - for server, server_model in server_models.items(): - assert keys_in.issubset(server_model) - assert keys_out.isdisjoint(server_model) + can_read_user_model = num_servers > 1 or 'read:users' in scopes + if can_read_user_model: + r.raise_for_status() + user_model = r.json() + if num_servers > 1: + assert 'servers' in user_model + server_models = user_model['servers'] + assert len(server_models) == num_servers + for server, server_model in server_models.items(): + assert keys_in.issubset(server_model) + assert keys_out.isdisjoint(server_model) + else: + assert 'servers' not in user_model else: - assert 'servers' not in user_model + assert r.status_code == 404 + r = await api_request( app, 'users', @@ -1200,3 +1208,88 @@ def test_expand_scopes(app, user, scopes, expected, mockservice_external): expanded = expand_scopes(scopes, owner=user.orm_user, oauth_client=oauth_client) assert isinstance(expanded, frozenset) assert sorted(expanded) == sorted(expected) + + +@pytest.mark.parametrize( + "requested_scopes, have_scopes, expected_allowed, expected_disallowed", + [ + ( + ["read:users:name!user"], + ["read:users:name!user={user}"], + ["read:users:name!user"], + [], + ), + ( + ["read:servers!server"], + ["read:servers!user"], + ["read:servers!server"], + [], + ), + ( + ["read:servers!server={server}"], + ["read:servers"], + ["read:servers!server={server}"], + [], + ), + ( + ["admin:servers!server"], + ["read:servers"], + ["read:servers!server={server}"], + ["admin:servers!server"], + ), + ( + ["admin:servers", "read:users"], + ["read:users"], + ["read:users"], + ["admin:servers"], + ), + ], +) +def test_resolve_requested_scopes( + app, + user, + group, + requested_scopes, + have_scopes, + expected_allowed, + expected_disallowed, + mockservice_external, +): + if isinstance(requested_scopes, str): + requested_scopes = [requested_scopes] + + db = app.db + service = mockservice_external + spawner_name = "salmon" + server_name = f"{user.name}/{spawner_name}" + if '!server' in str(requested_scopes + have_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 + } + + requested_scopes = format_scopes(requested_scopes) + have_scopes = format_scopes(have_scopes) + expected_allowed = format_scopes(expected_allowed) + expected_disallowed = format_scopes(expected_disallowed) + + allowed, disallowed = _resolve_requested_scopes( + requested_scopes, + have_scopes, + user=user.orm_user, + client=oauth_client, + db=db, + ) + assert allowed == expected_allowed + assert disallowed == expected_disallowed diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py index 8d5bba6d..c558252a 100644 --- a/jupyterhub/tests/test_services_auth.py +++ b/jupyterhub/tests/test_services_auth.py @@ -223,7 +223,7 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo # explicit 'identify' maps to read:users:name!user (["token", "user"], ["identify"], ["read:users:name!user=$user"]), # any item outside the list isn't allowed - (["token", "user"], ["token", "server"], None), + (["token", "server"], ["token", "user"], None), (["read-only"], ["access:services"], None), # requesting subset (["admin", "user"], ["user"], ["user"]), @@ -246,7 +246,11 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo ["custom:jupyter_server:read:*"], ), # this one _should_ work, but doesn't until we implement expanded_scope filtering - # (["read-only"], ["custom:jupyter_server:read:*!user=$user"], ["custom:jupyter_server:read:*!user=$user"]), + ( + ["read-only"], + ["custom:jupyter_server:read:*!user=$user"], + ["custom:jupyter_server:read:*!user=$user"], + ), ], ) async def test_oauth_service_roles( @@ -289,7 +293,7 @@ async def test_oauth_service_roles( "name": "other", "description": "A role not held by our test user", "scopes": [ - "admin:users", + "admin-ui", ], }, ) @@ -299,12 +303,13 @@ async def test_oauth_service_roles( ) ) app.db.commit() + user = create_user_with_scopes("access:services") url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x') if request_scopes: + request_scopes = {s.replace("$user", user.name) for s in request_scopes} url = url_concat(url, {"request-scope": " ".join(request_scopes)}) # first request is only going to login and get us to the oauth form page s = AsyncSession() - user = create_user_with_scopes("access:services") roles.grant_role(app.db, user, "user") roles.grant_role(app.db, user, "read-only") name = user.name diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index 0fbe7e9b..cd8dfa73 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -17,6 +17,7 @@ import pytest from .. import orm from .. import spawner as spawnermod from ..objects import Hub, Server +from ..scopes import access_scopes from ..spawner import LocalProcessSpawner, Spawner from ..user import User from ..utils import AnyTimeoutError, new_token, url_path_join @@ -444,7 +445,7 @@ async def test_spawner_oauth_scopes(app, user): await spawner.user.spawn() oauth_client = spawner.orm_spawner.oauth_client assert sorted(oauth_client.allowed_scopes) == sorted( - allowed_scopes + spawner.oauth_access_scopes + allowed_scopes + list(access_scopes(oauth_client)) ) await spawner.user.stop()