[squash me] token progress

tokens have scopes

    instead of roles, which allow tokens to change permissions over time

    This is mostly a low-level change,
    with little outward-facing effects.

    - on upgrade, evaluate all token role assignments to their current scopes,
      and store those scopes on the tokens
    - assigning roles to tokens still works, but scopes are evaluated and validated immediately,
      rather than lazily stored as roles
    - no longer need to check for role permission changes on startup, because token permissions aren't affected
    - move a few scope utilities from roles to scopes
    - oauth allows specifying scopes, not just roles.
      But these are still at the level specified in roles,
      not fully-resolved scopes.
    - more granular APIs for working with scopes and roles

    Still to do later:

    - expose scopes config for Spawner/service
    - compute 'full' intersection of requested scopes, rather than on the 'raw' scope list in roles
This commit is contained in:
Min RK
2022-03-24 15:05:50 +01:00
parent a08aa3398c
commit 7e22614a4e
12 changed files with 311 additions and 109 deletions

View File

@@ -560,7 +560,19 @@ paths:
description: A note attached to the token for future bookkeeping description: A note attached to the token for future bookkeeping
roles: roles:
type: array type: array
description: A list of role names that the token should have description: |
A list of role names from which to derive scopes.
This is a shortcut for assigning collections of scopes;
Tokens do not retain role assignment.
(Changed in 2.3: roles are immediately resolved to scopes
instead of stored on roles.)
items:
type: string
scopes:
type: array
description: |
A list of scopes that the token should have.
(new in JupyterHub 2.3).
items: items:
type: string type: string
required: false required: false
@@ -1314,7 +1326,15 @@ components:
description: The service that owns the token (undefined of owned by a user) description: The service that owns the token (undefined of owned by a user)
roles: roles:
type: array type: array
description: The names of roles this token has description: Deprecated in JupyterHub 2.3, always an empty list.
Tokens have 'scopes' starting from JupyterHub 2.3.
items:
type: string
scopes:
type: array
description: List of scopes this token has been assigned.
New in JupyterHub 2.3. In JupyterHub 2.0-2.2,
tokens were assigned 'roles' insead of scopes.
items: items:
type: string type: string
note: note:

View File

@@ -41,7 +41,7 @@ Services do not have a default role. Services without roles have no access to th
A group does not require any role, and has no roles by default. If a user is a member of a group, they automatically inherit any of the group's permissions (see {ref}`resolving-roles-scopes-target` for more details). This is useful for assigning a set of common permissions to several users. A group does not require any role, and has no roles by default. If a user is a member of a group, they automatically inherit any of the group's permissions (see {ref}`resolving-roles-scopes-target` for more details). This is useful for assigning a set of common permissions to several users.
**Tokens** \ **Tokens** \
A tokens permissions are evaluated based on their owning entity. Since a token is always issued for a user or service, it can never have more permissions than its owner. If no specific role is requested for a new token, the token is assigned the `token` role. A tokens permissions are evaluated based on their owning entity. Since a token is always issued for a user or service, it can never have more permissions than its owner. If no specific scopes are requested for a new token, the token is assigned the `token` role.
(define-role-target)= (define-role-target)=

View File

@@ -7,11 +7,11 @@ Roles and scopes utilities can be found in `roles.py` and `scopes.py` modules. S
```{admonition} **Scope variable nomenclature** ```{admonition} **Scope variable nomenclature**
:class: tip :class: tip
- _scopes_ \ - _scopes_ \
List of scopes with abbreviations (used in role definitions). E.g., `["users:activity!user"]`. List of scopes that may contain abbreviations (used in role definitions). E.g., `["users:activity!user", "self"]`.
- _expanded scopes_ \ - _expanded scopes_ \
Set of expanded scopes without abbreviations (i.e., resolved metascopes, filters and subscopes). E.g., `{"users:activity!user=charlie", "read:users:activity!user=charlie"}`. Set of fully expanded scopes without abbreviations (i.e., resolved metascopes, filters, and subscopes). E.g., `{"users:activity!user=charlie", "read:users:activity!user=charlie"}`.
- _parsed scopes_ \ - _parsed scopes_ \
Dictionary JSON like format of expanded scopes. E.g., `{"users:activity": {"user": ["charlie"]}, "read:users:activity": {"users": ["charlie"]}}`. Dictionary represenation of expanded scopes. E.g., `{"users:activity": {"user": ["charlie"]}, "read:users:activity": {"users": ["charlie"]}}`.
- _intersection_ \ - _intersection_ \
Set of expanded scopes as intersection of 2 expanded scope sets. Set of expanded scopes as intersection of 2 expanded scope sets.
- _identify scopes_ \ - _identify scopes_ \
@@ -32,17 +32,29 @@ Roles and scopes are resolved on several occasions, for example when requesting
### Requesting API token with specific roles ### Requesting API token with specific roles
API tokens grant access to JupyterHub's APIs. The RBAC framework allows for requesting tokens with specific existing roles. To date, it is only possible to add roles to a token through the _POST /users/:name/tokens_ API where the roles can be specified in the token parameters body (see [](../reference/rest-api.rst)). :::{versionchanged} 2.3
API tokens have _scopes_ instead of roles,
so that their permissions cannot be updated.
You may still request roles for a token,
but those roles will be evaluated to the corresponding _scopes_ immediately.
:::
API tokens grant access to JupyterHub's APIs. The RBAC framework allows for requesting tokens with specific permissions.
As of JupyterHub 2.3, it is only possible to specify scopes for a token through the _POST /users/:name/tokens_ API where the scopes can be specified in the token parameters body (see [](../reference/rest-api.rst)).
RBAC adds several steps into the token issue flow. RBAC adds several steps into the token issue flow.
If no roles are requested, the token is issued with the default `token` role (providing the requester is allowed to create the token). If no scopes are requested, the token is issued with the permissions stored on the default `token` role
(providing the requester is allowed to create the token).
If the token is requested with any roles, the permissions of requesting entity are checked against the requested permissions to ensure the token would not grant its owner additional privileges. If the token is requested with any scopes, the permissions of requesting entity are checked against the requested permissions to ensure the token would not grant its owner additional privileges.
If, due to modifications of roles or entities, at API request time a token has any scopes that its owner does not, those scopes are removed. The API request is resolved without additional errors using the scopes _intersection_, but the Hub logs a warning (see {ref}`Figure 2 <api-request-chart>`). If, due to modifications of roles or entities, at API request time a token has any scopes that its owner does not, those scopes are removed.
The API request is resolved without additional errors using the scope _intersection_,
but the Hub logs a warning (see {ref}`Figure 2 <api-request-chart>`).
Resolving a token's roles (yellow box in {ref}`Figure 1 <token-request-chart>`) corresponds to resolving all the token's owner roles (including the roles associated with their groups) and the token's requested roles into a set of scopes. The two sets are compared (Resolve the scopes box in orange in {ref}`Figure 1 <token-request-chart>`), taking into account the scope hierarchy but, solely for role assignment, omitting any {ref}`horizontal filter <horizontal-filtering-target>` comparison. If the token's scopes are a subset of the token owner's scopes, the token is issued with the requested roles; if not, JupyterHub will raise an error. Resolving a token's scope (yellow box in {ref}`Figure 1 <token-request-chart>`) corresponds to resolving all the token's owner roles (including the roles associated with their groups) and the token's requested roles into a set of scopes. The two sets are compared (Resolve the scopes box in orange in {ref}`Figure 1 <token-request-chart>`), taking into account the scope hierarchy but, solely for role assignment, omitting any {ref}`horizontal filter <horizontal-filtering-target>` comparison. If the token's scopes are a subset of the token owner's scopes, the token is issued with the requested roles; if not, JupyterHub will raise an error.
{ref}`Figure 1 <token-request-chart>` below illustrates the steps involved. The orange rectangles highlight where in the process the roles and scopes are resolved. {ref}`Figure 1 <token-request-chart>` below illustrates the steps involved. The orange rectangles highlight where in the process the roles and scopes are resolved.

View File

@@ -224,7 +224,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
else: else:
self.log.debug( self.log.debug(
f"User {user.name} has authorized {oauth_client.identifier}" f"User {user.name} has authorized {oauth_client.identifier}"
f" for scopes {authorized_scopes}, confirming additonal scopes {requested_scopes}" f" for scopes {authorized_scopes}, confirming additional scopes {requested_scopes}"
) )
# default: require confirmation # default: require confirmation
return True return True
@@ -289,13 +289,22 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
user = self.current_user user = self.current_user
# raw, _not_ expanded scopes # raw, _not_ expanded scopes
user_scopes = roles.roles_to_scopes(roles.get_roles_for(user.orm_user)) user_scopes = roles.roles_to_scopes(roles.get_roles_for(user.orm_user))
# these are some scopes the user may not have
# in 'raw' form, but definitely have at this point
# make sure they are here, because we are computing the
# 'raw' scope intersection,
# rather than the expanded_scope intersection
required_scopes = {*scopes.identify_scopes(), *scopes.access_scopes(client)}
user_scopes.update({"inherit", *required_scopes})
allowed_scopes = requested_scopes.intersection(user_scopes) allowed_scopes = requested_scopes.intersection(user_scopes)
excluded_scopes = requested_scopes.difference(user_scopes) excluded_scopes = requested_scopes.difference(user_scopes)
# TODO: compute lower-level intersection # TODO: compute lower-level intersection of remaining _expanded_ scopes
# of _expanded_ scopes # (e.g. user has admin:users, requesting read:users!group=x)
if excluded_scopes: if excluded_scopes:
self.log.info( self.log.warning(
f"Service {client.description} requested scopes {','.join(requested_scopes)}" f"Service {client.description} requested scopes {','.join(requested_scopes)}"
f" for user {self.current_user.name}," f" for user {self.current_user.name},"
f" granting only {','.join(allowed_scopes) or '[]'}." f" granting only {','.join(allowed_scopes) or '[]'}."
@@ -311,8 +320,14 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
self._complete_login(uri, headers, allowed_scopes, credentials) self._complete_login(uri, headers, allowed_scopes, credentials)
return return
# resolve roles to scopes for authorization page # discard 'required' scopes from description
if not allowed_scopes: # no need to describe the ability to access itself
scopes_to_describe = allowed_scopes.difference(required_scopes)
if not scopes_to_describe:
# TODO: describe all scopes?
# Not right now, because the no-scope default 'identify' text
# is clearer than what we produce for those scopes individually
scope_descriptions = [ scope_descriptions = [
{ {
"scope": None, "scope": None,
@@ -322,8 +337,8 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
"filter": "", "filter": "",
} }
] ]
elif 'inherit' in allowed_scopes: elif 'inherit' in scopes_to_describe:
allowed_scopes = ['inherit'] allowed_scopes = scopes_to_describe = ['inherit']
scope_descriptions = [ scope_descriptions = [
{ {
"scope": "inherit", "scope": "inherit",
@@ -335,7 +350,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
] ]
else: else:
scope_descriptions = scopes.describe_raw_scopes( scope_descriptions = scopes.describe_raw_scopes(
allowed_scopes, scopes_to_describe,
username=self.current_user.name, username=self.current_user.name,
) )
# Render oauth 'Authorize application...' page # Render oauth 'Authorize application...' page

View File

@@ -55,7 +55,6 @@ from traitlets import (
Bool, Bool,
Any, Any,
Tuple, Tuple,
Type,
Set, Set,
Instance, Instance,
Bytes, Bytes,

View File

@@ -12,6 +12,8 @@ from tornado.log import app_log
from .. import orm from .. import orm
from ..roles import roles_to_scopes from ..roles import roles_to_scopes
from ..scopes import _check_scopes_exist from ..scopes import _check_scopes_exist
from ..scopes import access_scopes
from ..scopes import identify_scopes
from ..utils import compare_token from ..utils import compare_token
from ..utils import hash_token from ..utils import hash_token
@@ -154,7 +156,13 @@ class JupyterHubRequestValidator(RequestValidator):
) )
if orm_client is None: if orm_client is None:
raise ValueError("No such client: %s" % client_id) raise ValueError("No such client: %s" % client_id)
return roles_to_scopes(orm_client.allowed_roles) scopes = roles_to_scopes(orm_client.allowed_roles)
if 'inherit' not in scopes:
# add identify-user scope
scopes.update(identify_scopes())
# add access-service scope
scopes.update(access_scopes(orm_client))
return scopes
def get_original_scopes(self, refresh_token, request, *args, **kwargs): def get_original_scopes(self, refresh_token, request, *args, **kwargs):
"""Get the list of scopes associated with the refresh token. """Get the list of scopes associated with the refresh token.
@@ -254,7 +262,7 @@ class JupyterHubRequestValidator(RequestValidator):
code=code['code'], code=code['code'],
# oauth has 5 minutes to complete # oauth has 5 minutes to complete
expires_at=int(orm.OAuthCode.now() + 300), expires_at=int(orm.OAuthCode.now() + 300),
scopes=list(request._jupyterhub_scopes), scopes=list(request.scopes),
user=request.user.orm_user, user=request.user.orm_user,
redirect_uri=orm_client.redirect_uri, redirect_uri=orm_client.redirect_uri,
session_id=request.session_id, session_id=request.session_id,
@@ -539,7 +547,7 @@ class JupyterHubRequestValidator(RequestValidator):
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
"""Ensure the client is authorized access to requested scopes. """Ensure the client is authorized access to requested scopes.
:param client_id: Unicode client identifier :param client_id: Unicode client identifier
:param scopes: List of scopes (defined by you) :param scopes: List of 'raw' scopes (defined by you)
:param client: Client object set by you, see authenticate_client. :param client: Client object set by you, see authenticate_client.
:param request: The HTTP Request (oauthlib.common.Request) :param request: The HTTP Request (oauthlib.common.Request)
:rtype: True or False :rtype: True or False
@@ -557,30 +565,50 @@ class JupyterHubRequestValidator(RequestValidator):
app_log.warning("No such oauth client %s", client_id) app_log.warning("No such oauth client %s", client_id)
return False return False
requested_scopes = set(scopes)
# explicitly allow 'identify', which was the only allowed scope previously # explicitly allow 'identify', which was the only allowed scope previously
# requesting 'identify' gets no actual permissions other than self-identification # requesting 'identify' gets no actual permissions other than self-identification
scopes = set(scopes) if "identify" in requested_scopes:
scopes.discard("identify") app_log.warning(
f"Ignoring deprecated 'identify' scope, requested by {client_id}"
)
requested_scopes.discard("identify")
# TODO: handle roles->scopes transition # TODO: handle roles->scopes transition
# at this point, 'scopes' _may_ be roles # In 2.0-2.2, `?scopes=` only accepted _role_ names,
# but in 2.3 we accept and prefer scopes.
# For backward-compatibility, we still accept both.
# Should roles be deprecated here, or kept as a convenience?
try: try:
_check_scopes_exist(scopes) _check_scopes_exist(requested_scopes)
except KeyError as e: except KeyError as e:
# scopes don't exist, maybe they are role names # scopes don't exist, maybe they are role names
requested_roles = list( requested_roles = list(
self.db.query(orm.Role).filter(orm.Role.name.in_(scopes)) self.db.query(orm.Role).filter(orm.Role.name.in_(requested_scopes))
) )
if len(requested_roles) != len(scopes): if len(requested_roles) != len(requested_scopes):
# did not find roles # did not find roles
app_log.warning(f"No such scopes: {scopes}") app_log.warning(f"No such scopes: {requested_scopes}")
return False return False
app_log.info(f"OAuth client {client_id} requesting roles: {scopes}") app_log.info(
scopes = roles_to_scopes(requested_roles) f"OAuth client {client_id} requesting roles: {requested_scopes}"
)
requested_scopes = roles_to_scopes(requested_roles)
client_allowed_scopes = roles_to_scopes(orm_client.allowed_roles) client_allowed_scopes = roles_to_scopes(orm_client.allowed_roles)
requested_scopes = set(scopes) # 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
disallowed_scopes = requested_scopes.difference(client_allowed_scopes) disallowed_scopes = requested_scopes.difference(client_allowed_scopes)
if disallowed_scopes: if disallowed_scopes:
app_log.error( app_log.error(
@@ -588,12 +616,11 @@ class JupyterHubRequestValidator(RequestValidator):
) )
return False return False
# store resolved roles on request # store resolved scopes on request
app_log.debug( app_log.debug(
f"Allowing request for scope(s) for {client_id}: {','.join(requested_scopes) or '[]'}" f"Allowing request for scope(s) for {client_id}: {','.join(requested_scopes) or '[]'}"
) )
# these will be stored on the OAuthCode object request.scopes = requested_scopes
request._jupyterhub_scopes = requested_scopes
return True return True

View File

@@ -3,11 +3,12 @@ General scope definitions and utilities
Scope variable nomenclature Scope variable nomenclature
--------------------------- ---------------------------
scopes: list of scopes with abbreviations (e.g., in role definition) scopes or 'raw' scopes: collection of scopes that may contain abbreviations (e.g., in role definition)
expanded scopes: set of expanded scopes without abbreviations (i.e., resolved metascopes, filters and subscopes) expanded scopes: set of expanded scopes without abbreviations (i.e., resolved metascopes, filters, and subscopes)
parsed scopes: dictionary JSON like format of expanded scopes parsed scopes: dictionary format of expanded scopes (`read:users!user=name` -> `{'read:users': {user: [name]}`)
intersection : set of expanded scopes as intersection of 2 expanded scope sets intersection : set of expanded scopes as intersection of 2 expanded scope sets
identify scopes: set of expanded scopes needed for identify (whoami) endpoints identify scopes: set of expanded scopes needed for identify (whoami) endpoints
reduced scopes: expanded scopes that have been reduced
""" """
import functools import functools
import inspect import inspect
@@ -300,37 +301,30 @@ def get_scopes_for(orm_object):
f"Only allow orm objects or User wrappers, got {orm_object}" f"Only allow orm objects or User wrappers, got {orm_object}"
) )
owner = None
if isinstance(orm_object, orm.APIToken): if isinstance(orm_object, orm.APIToken):
owner = orm_object.user or orm_object.service owner = orm_object.user or orm_object.service
token_scopes = expand_scopes(orm_object.scopes, owner=owner)
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: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_roles = roles.get_roles_for(owner) owner_roles = roles.get_roles_for(owner)
owner_scopes = roles.roles_to_expanded_scopes(owner_roles, owner) owner_scopes = roles.roles_to_expanded_scopes(owner_roles, owner)
if token_scopes == {'inherit'}: token_scopes = set(orm_object.scopes)
# token_scopes is only 'inherit', return scopes inherited from owner as-is if 'inherit' in token_scopes:
# short-circuit common case where we don't need to compute an intersection # token_scopes includes 'inherit',
# so we know the intersection is exactly the owner's scopes
# only thing we miss by short-circuiting here: warning about excluded extra scopes
return owner_scopes return owner_scopes
if 'inherit' in token_scopes: token_scopes = expand_scopes(token_scopes, owner=owner)
token_scopes.remove('inherit')
token_scopes |= owner_scopes 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
access = access_scopes(orm_object.oauth_client)
token_scopes.update(access)
# reduce to collapse multiple filters on the same scope
# to avoid spurious logs about discarded scopes
token_scopes = reduce_scopes(token_scopes)
intersection = _intersect_expanded_scopes( intersection = _intersect_expanded_scopes(
token_scopes, token_scopes,
@@ -342,8 +336,14 @@ def get_scopes_for(orm_object):
# Not taking symmetric difference here because token owner can naturally have more scopes than token # Not taking symmetric difference here because token owner can naturally have more scopes than token
if discarded_token_scopes: if discarded_token_scopes:
app_log.warning( app_log.warning(
"discarding scopes [%s], not present in owner roles" f"discarding scopes [{discarded_token_scopes}],"
% ", ".join(discarded_token_scopes) f" not present in roles of owner {owner}"
)
app_log.debug(
"Owner %s has scopes: %s\nToken has scopes: %s",
owner,
owner_scopes,
token_scopes,
) )
expanded_scopes = intersection expanded_scopes = intersection
else: else:
@@ -351,6 +351,12 @@ def get_scopes_for(orm_object):
roles.get_roles_for(orm_object), roles.get_roles_for(orm_object),
owner=orm_object, owner=orm_object,
) )
if isinstance(orm_object, (orm.User, orm.Service)):
owner = orm_object
# always include identify scopes
if owner:
expanded_scopes.update(identify_scopes(owner))
return expanded_scopes return expanded_scopes
@@ -473,7 +479,8 @@ def expand_scopes(scopes, owner=None):
stacklevel=2, stacklevel=2,
) )
return expanded_scopes # reduce to minimize
return reduce_scopes(expanded_scopes)
def _needs_scope_expansion(filter_, filter_value, sub_scope): def _needs_scope_expansion(filter_, filter_value, sub_scope):
@@ -658,6 +665,14 @@ def unparse_scopes(parsed_scopes):
return expanded_scopes return expanded_scopes
def reduce_scopes(expanded_scopes):
"""Reduce expanded scopes to minimal set
Eliminates redundancy, such as access:services and access:services!service=x
"""
return unparse_scopes(parse_scopes(expanded_scopes))
def needs_scope(*scopes): def needs_scope(*scopes):
"""Decorator to restrict access to users or services with the required scope""" """Decorator to restrict access to users or services with the required scope"""
@@ -708,16 +723,20 @@ def needs_scope(*scopes):
return scope_decorator return scope_decorator
def identify_scopes(obj): def identify_scopes(obj=None):
"""Return 'identify' scopes for an orm object """Return 'identify' scopes for an orm object
Arguments: Arguments:
obj: orm.User or orm.Service obj (optional): orm.User or orm.Service
If not specified, 'raw' scopes for identifying the current user are returned,
which may need to be expanded, later.
Returns: Returns:
identify scopes (set): set of scopes needed for 'identify' endpoints identify scopes (set): set of scopes needed for 'identify' endpoints
""" """
if isinstance(obj, orm.User): if obj is None:
return {f"read:users:{field}!user" for field in {"name", "groups"}}
elif isinstance(obj, orm.User):
return {f"read:users:{field}!user={obj.name}" for field in {"name", "groups"}} return {f"read:users:{field}!user={obj.name}" for field in {"name", "groups"}}
elif isinstance(obj, orm.Service): elif isinstance(obj, orm.Service):
return {f"read:services:{field}!service={obj.name}" for field in {"name"}} return {f"read:services:{field}!service={obj.name}" for field in {"name"}}
@@ -725,6 +744,25 @@ def identify_scopes(obj):
raise TypeError(f"Expected orm.User or orm.Service, got {obj!r}") raise TypeError(f"Expected orm.User or orm.Service, got {obj!r}")
def access_scopes(oauth_client):
"""Return scope(s) required to access an oauth client"""
scopes = set()
if oauth_client.identifier == "jupyterhub":
return scopes
spawner = oauth_client.spawner
if spawner:
scopes.add(f"access:servers!server={spawner.user.name}/{spawner.name}")
else:
service = oauth_client.service
if service:
scopes.add(f"access:services!service={service.name}")
else:
app_log.warning(
f"OAuth client {oauth_client} has no associated service or spawner!"
)
return scopes
def check_scope_filter(sub_scope, orm_resource, kind): def check_scope_filter(sub_scope, orm_resource, kind):
"""Return whether a sub_scope filter applies to a given resource. """Return whether a sub_scope filter applies to a given resource.

View File

@@ -68,7 +68,7 @@ class WhoAmIHandler(HubAuthenticated, web.RequestHandler):
@web.authenticated @web.authenticated
def get(self): def get(self):
self.write(self.get_current_user()) self.write(json.dumps(self.get_current_user()))
class OWhoAmIHandler(HubOAuthenticated, web.RequestHandler): class OWhoAmIHandler(HubOAuthenticated, web.RequestHandler):
@@ -86,7 +86,7 @@ class OWhoAmIHandler(HubOAuthenticated, web.RequestHandler):
@web.authenticated @web.authenticated
def get(self): def get(self):
self.write(self.get_current_user()) self.write(json.dumps(self.get_current_user()))
def main(): def main():

View File

@@ -10,7 +10,6 @@ from tornado.log import app_log
from .. import orm from .. import orm
from .. import roles from .. import roles
from ..scopes import _expand_self_scope
from ..scopes import get_scopes_for from ..scopes import get_scopes_for
from ..scopes import scope_definitions from ..scopes import scope_definitions
from ..utils import utcnow from ..utils import utcnow
@@ -810,19 +809,6 @@ async def test_user_filter_expansion(app, scope_list, kind, test_for_token):
app.db.delete(test_role) app.db.delete(test_role)
async def test_large_filter_expansion(app, create_temp_role, create_user_with_scopes):
scope_list = _expand_self_scope('==')
# Mimic the role 'self' based on '!user' filter for tokens
scope_list = [scope.rstrip("=") for scope in scope_list]
filtered_role = create_temp_role(scope_list)
user = create_user_with_scopes('self')
user.new_api_token(roles=[filtered_role.name])
user.new_api_token(roles=['token'])
manual_scope_set = get_scopes_for(user.api_tokens[0])
auto_scope_set = get_scopes_for(user.api_tokens[1])
assert manual_scope_set == auto_scope_set
@mark.role @mark.role
@mark.parametrize( @mark.parametrize(
"name, valid", "name, valid",

View File

@@ -12,7 +12,9 @@ from .. import roles
from .. import scopes from .. import scopes
from ..handlers import BaseHandler from ..handlers import BaseHandler
from ..scopes import _check_scope_access from ..scopes import _check_scope_access
from ..scopes import _expand_self_scope
from ..scopes import _intersect_expanded_scopes from ..scopes import _intersect_expanded_scopes
from ..scopes import expand_scopes
from ..scopes import get_scopes_for from ..scopes import get_scopes_for
from ..scopes import needs_scope from ..scopes import needs_scope
from ..scopes import parse_scopes from ..scopes import parse_scopes
@@ -290,7 +292,7 @@ async def test_exceeding_user_permissions(
api_token = user.new_api_token() api_token = user.new_api_token()
orm_api_token = orm.APIToken.find(app.db, token=api_token) orm_api_token = orm.APIToken.find(app.db, token=api_token)
# store scopes user does not have # store scopes user does not have
orm_api_token.scopes = orm_api_token.scopes + ['list:users', 'read:users'] orm_api_token.update_scopes(orm_api_token.scopes + ['list:users', 'read:users'])
headers = {'Authorization': 'token %s' % api_token} headers = {'Authorization': 'token %s' % api_token}
r = await api_request(app, 'users', headers=headers) r = await api_request(app, 'users', headers=headers)
assert r.status_code == 200 assert r.status_code == 200
@@ -1127,3 +1129,45 @@ def test_custom_scopes_bad(preserve_scopes, custom_scopes):
with pytest.raises(ValueError): with pytest.raises(ValueError):
scopes.define_custom_scopes(custom_scopes) scopes.define_custom_scopes(custom_scopes)
assert scopes.scope_definitions == preserve_scopes assert scopes.scope_definitions == preserve_scopes
async def test_user_filter_expansion(app, create_user_with_scopes):
scope_list = _expand_self_scope('ignored')
# turn !user=ignored into !user
# Mimic the role 'self' based on '!user' filter for tokens
scope_list = [scope.partition("=")[0] for scope in scope_list]
user = create_user_with_scopes('self')
user.new_api_token(scopes=scope_list)
user.new_api_token()
manual_scope_set = get_scopes_for(user.api_tokens[0])
auto_scope_set = get_scopes_for(user.api_tokens[1])
assert manual_scope_set == auto_scope_set
@pytest.mark.parametrize(
"scopes, expected",
[
("read:users:name!user", ["read:users:name!user=$user"]),
(
"users:activity!user",
[
"read:users:activity!user=$user",
"users:activity!user=$user",
],
),
("self", ["*"]),
(["access:services", "access:services!service=x"], ["access:services"]),
],
)
def test_expand_scopes(user, scopes, expected):
if isinstance(scopes, str):
scopes = [scopes]
scopes = {s.replace("$user", user.name) for s in scopes}
expected = {s.replace("$user", user.name) for s in expected}
if "*" in expected:
expected.remove("*")
expected.update(_expand_self_scope(user.name))
expanded = expand_scopes(scopes, owner=user.orm_user)
assert sorted(expanded) == sorted(expected)

View File

@@ -12,6 +12,7 @@ import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from pytest import raises from pytest import raises
from tornado.httputil import url_concat from tornado.httputil import url_concat
from tornado.log import app_log
from .. import orm from .. import orm
from .. import roles from .. import roles
@@ -206,32 +207,50 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo
@pytest.mark.parametrize( @pytest.mark.parametrize(
"client_allowed_roles, request_roles, expected_roles", "client_allowed_roles, request_scopes, expected_scopes",
[ [
# allow empty roles # allow empty permissions
([], [], []), ([], [], []),
# allow original 'identify' scope to map to no role # allow original 'identify' scope to map to no role
([], ["identify"], []), ([], ["identify"], []),
# requesting roles outside client list doesn't work # requesting roles outside client list doesn't work
([], ["admin"], None), ([], ["admin"], None),
([], ["token"], None), ([], ["read:users"], None),
# requesting nonexistent roles fails in the same way (no server error) # requesting nonexistent roles or scopes fails in the same way (no server error)
([], ["nosuchrole"], None), ([], ["nosuchscope"], None),
# requesting exactly client allow list works ([], ["admin:invalid!no=bo!"], None),
# requesting role exactly client allow list works
(["user"], ["user"], ["user"]), (["user"], ["user"], ["user"]),
# Request individual scope, held by user, not listed in allowed role
# no explicit request, defaults to all # no explicit request, defaults to all
(["token", "user"], [], ["token", "user"]), (["token", "user"], [], ["token", "user"]),
# explicit 'identify' maps to none # explicit 'identify' maps to read:users:name!user
(["token", "user"], ["identify"], []), (["token", "user"], ["identify"], ["read:users:name!user=$user"]),
# any item outside the list isn't allowed # any item outside the list isn't allowed
(["token", "user"], ["token", "server"], None), (["token", "user"], ["token", "server"], None),
(["read-only"], ["access:services"], None),
# requesting subset # requesting subset
(["admin", "user"], ["user"], ["user"]), (["admin", "user"], ["user"], ["user"]),
(["user", "token", "server"], ["token", "user"], ["token", "user"]), (["user", "token", "server"], ["token", "user"], ["token", "user"]),
(["admin", "user", "read-only"], ["read-only"], ["read-only"]), (["admin", "user", "read-only"], ["read-only"], ["read-only"]),
# Request individual scopes, listed in allowed role
(["read-only"], ["access:servers"], ["access:servers"]),
# requesting valid subset, some not held by user # requesting valid subset, some not held by user
(["admin", "user"], ["admin", "user"], ["user"]), (
(["admin", "user"], ["admin"], []), ["admin", "user"],
["admin:users", "access:servers", "self"],
["access:servers", "user"],
),
(["other"], ["other"], []),
# custom scopes
(["user"], ["custom:jupyter_server:read:*"], None),
(
["read-only"],
["custom:jupyter_server:read:*"],
["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"]),
], ],
) )
async def test_oauth_service_roles( async def test_oauth_service_roles(
@@ -239,8 +258,8 @@ async def test_oauth_service_roles(
mockservice_url, mockservice_url,
create_user_with_scopes, create_user_with_scopes,
client_allowed_roles, client_allowed_roles,
request_roles, request_scopes,
expected_roles, expected_scopes,
preserve_scopes, preserve_scopes,
): ):
service = mockservice_url service = mockservice_url
@@ -267,13 +286,24 @@ async def test_oauth_service_roles(
], ],
}, },
) )
roles.create_role(
app.db,
{
"name": "other",
"description": "A role not held by our test user",
"scopes": [
"admin:users",
],
},
)
oauth_client.allowed_roles = [ oauth_client.allowed_roles = [
orm.Role.find(app.db, role_name) for role_name in client_allowed_roles orm.Role.find(app.db, role_name) for role_name in client_allowed_roles
] ]
app.db.commit() app.db.commit()
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x') url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
if request_roles: if request_scopes:
url = url_concat(url, {"request-scope": " ".join(request_roles)}) url = url_concat(url, {"request-scope": " ".join(request_scopes)})
# first request is only going to login and get us to the oauth form page # first request is only going to login and get us to the oauth form page
s = AsyncSession() s = AsyncSession()
user = create_user_with_scopes("access:services") user = create_user_with_scopes("access:services")
@@ -283,7 +313,7 @@ async def test_oauth_service_roles(
s.cookies = await app.login_user(name) s.cookies = await app.login_user(name)
r = await s.get(url) r = await s.get(url)
if expected_roles is None: if expected_scopes is None:
# expected failed auth, stop here # expected failed auth, stop here
# verify expected 'invalid scope' error, not server error # verify expected 'invalid scope' error, not server error
dest_url, _, query = r.url.partition("?") dest_url, _, query = r.url.partition("?")
@@ -291,6 +321,7 @@ async def test_oauth_service_roles(
assert parse_qs(query).get("error") == ["invalid_scope"] assert parse_qs(query).get("error") == ["invalid_scope"]
assert r.status_code == 400 assert r.status_code == 400
return return
r.raise_for_status() r.raise_for_status()
# we should be looking at the oauth confirmation page # we should be looking at the oauth confirmation page
assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize' assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
@@ -300,7 +331,7 @@ async def test_oauth_service_roles(
page = BeautifulSoup(r.text, "html.parser") page = BeautifulSoup(r.text, "html.parser")
scope_inputs = page.find_all("input", {"name": "scopes"}) scope_inputs = page.find_all("input", {"name": "scopes"})
scope_values = [input["value"] for input in scope_inputs] scope_values = [input["value"] for input in scope_inputs]
print("Submitting request with scope values", scope_values) app_log.info(f"Submitting request with scope values {scope_values}")
# submit the oauth form to complete authorization # submit the oauth form to complete authorization
data = {} data = {}
if scope_values: if scope_values:
@@ -317,10 +348,35 @@ async def test_oauth_service_roles(
r = await s.get(url, allow_redirects=False) r = await s.get(url, allow_redirects=False)
r.raise_for_status() r.raise_for_status()
assert r.status_code == 200 assert r.status_code == 200
assert len(r.history) == 0
reply = r.json() reply = r.json()
sub_reply = {key: reply.get(key, 'missing') for key in ('kind', 'name')} sub_reply = {key: reply.get(key, 'missing') for key in ('kind', 'name')}
assert sub_reply == {'name': user.name, 'kind': 'user'} assert sub_reply == {'name': user.name, 'kind': 'user'}
expected_scopes = {s.replace("$user", user.name) for s in expected_scopes}
# expand roles to scopes (shortcut)
for scope in list(expected_scopes):
role = orm.Role.find(app.db, scope)
if role:
expected_scopes.discard(role.name)
expected_scopes.update(
roles.roles_to_expanded_scopes([role], owner=user.orm_user)
)
if 'inherit' in expected_scopes:
expected_scopes = scopes.get_scopes_for(user.orm_user)
# always expect identify/access scopes
# on successful authentication
expected_scopes.update(scopes.identify_scopes(user.orm_user))
expected_scopes.update(scopes.access_scopes(oauth_client))
expected_scopes = scopes.reduce_scopes(expected_scopes)
have_scopes = scopes.reduce_scopes(set(reply['scopes']))
# pytest is better at reporting list differences
# than set differences, especially with `-vv`
assert sorted(have_scopes) == sorted(expected_scopes)
# token-authenticated request to HubOAuth # token-authenticated request to HubOAuth
token = app.users[name].new_api_token() token = app.users[name].new_api_token()
# token in ?token parameter # token in ?token parameter
@@ -428,18 +484,23 @@ async def test_oauth_page_hit(
user = create_user_with_scopes("access:services", "self") user = create_user_with_scopes("access:services", "self")
for role in test_roles.values(): for role in test_roles.values():
roles.grant_role(app.db, user, role) roles.grant_role(app.db, user, role)
token_scopes = roles.roles_to_scopes([test_roles[t] for t in token_roles])
user.new_api_token(scopes=token_scopes)
token = user.api_tokens[0]
# Create a token with the prior authorization
oauth_client = ( oauth_client = (
app.db.query(orm.OAuthClient) app.db.query(orm.OAuthClient)
.filter_by(identifier=service.oauth_client_id) .filter_by(identifier=service.oauth_client_id)
.one() .one()
) )
oauth_client.allowed_roles = list(test_roles.values()) oauth_client.allowed_roles = list(test_roles.values())
authorized_scopes = roles.roles_to_scopes([test_roles[t] for t in token_roles])
authorized_scopes.update(scopes.identify_scopes())
authorized_scopes.update(scopes.access_scopes(oauth_client))
user.new_api_token(scopes=authorized_scopes)
token = user.api_tokens[0]
token.client_id = service.oauth_client_id token.client_id = service.oauth_client_id
app.db.commit() app.db.commit()
s = AsyncSession() s = AsyncSession()
s.cookies = await app.login_user(user.name) s.cookies = await app.login_user(user.name)
url = url_path_join(public_url(app, service) + 'owhoami/?arg=x') url = url_path_join(public_url(app, service) + 'owhoami/?arg=x')

View File

@@ -14,7 +14,7 @@
<p> <p>
{{ oauth_client.description }} (oauth URL: {{ oauth_client.redirect_uri }}) {{ oauth_client.description }} (oauth URL: {{ oauth_client.redirect_uri }})
would like permission to identify you. would like permission to identify you.
{% if not role_names %} {% if scope_descriptions | length == 1 and not scope_descriptions[0].scope %}
It will not be able to take actions on It will not be able to take actions on
your behalf. your behalf.
{% endif %} {% endif %}
@@ -24,8 +24,8 @@
<div> <div>
<form method="POST" action=""> <form method="POST" action="">
{# these are the 'real' inputs to the form -#} {# these are the 'real' inputs to the form -#}
{% for role_name in role_names %} {% for scope in allowed_scopes %}
<input type="hidden" name="scopes" value="{{ role_name }}"/> <input type="hidden" name="scopes" value="{{ scope }}"/>
{% endfor %} {% endfor %}
{% for scope_info in scope_descriptions %} {% for scope_info in scope_descriptions %}