Files
jupyterhub/jupyterhub/scopes.py
Min RK fdf23600c0 allow custom scopes
defined with

    c.JupyterHub.custom_scopes = {
        'custom:scope': {'description': "text shown on oauth confirm"}
    }

Allows injecting custom scopes to roles,
allowing extension of granular permissions to service-defined custom scopes.

Custom scopes:

- MUST start with `custom:`
- MUST only contain ascii lowercase, numbers, colon, hyphen, asterisk, underscore
- MUST define a `description`
- MAY also define `subscopes` list(s), each of which must also be explicitly defined

HubAuth can be used to retrieve and check for custom scopes to authorize requests.
2022-03-11 11:37:26 +01:00

748 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
General scope definitions and utilities
Scope variable nomenclature
---------------------------
scopes: list of scopes with abbreviations (e.g., in role definition)
expanded scopes: set of expanded scopes without abbreviations (i.e., resolved metascopes, filters and subscopes)
parsed scopes: dictionary JSON like format of expanded scopes
intersection : set of expanded scopes as intersection of 2 expanded scope sets
identify scopes: set of expanded scopes needed for identify (whoami) endpoints
"""
import functools
import inspect
import re
import warnings
from enum import Enum
from functools import lru_cache
from textwrap import indent
import sqlalchemy as sa
from tornado import web
from tornado.log import app_log
from . import orm
from . import roles
"""when modifying the scope definitions, make sure that `docs/source/rbac/generate-scope-table.py` is run
so that changes are reflected in the documentation and REST API description."""
scope_definitions = {
'(no_scope)': {'description': 'Identify the owner of the requesting entity.'},
'self': {
'description': 'Your own resources',
'doc_description': 'The users own resources _(metascope for users, resolves to (no_scope) for services)_',
},
'inherit': {
'description': 'Anything you have access to',
'doc_description': 'Everything that the token-owning entity can access _(metascope for tokens)_',
},
'admin:users': {
'description': 'Read, write, create and delete users and their authentication state, not including their servers or tokens.',
'subscopes': ['admin:auth_state', 'users', 'read:roles:users', 'delete:users'],
},
'admin:auth_state': {'description': 'Read a users authentication state.'},
'users': {
'description': 'Read and write permissions to user models (excluding servers, tokens and authentication state).',
'subscopes': ['read:users', 'list:users', 'users:activity'],
},
'delete:users': {
'description': "Delete users.",
},
'list:users': {
'description': 'List users, including at least their names.',
'subscopes': ['read:users:name'],
},
'read:users': {
'description': 'Read user models (excluding including servers, tokens and authentication state).',
'subscopes': [
'read:users:name',
'read:users:groups',
'read:users:activity',
],
},
'read:users:name': {'description': 'Read names of users.'},
'read:users:groups': {'description': 'Read users group membership.'},
'read:users:activity': {'description': 'Read time of last user activity.'},
'read:roles': {
'description': 'Read role assignments.',
'subscopes': ['read:roles:users', 'read:roles:services', 'read:roles:groups'],
},
'read:roles:users': {'description': 'Read user role assignments.'},
'read:roles:services': {'description': 'Read service role assignments.'},
'read:roles:groups': {'description': 'Read group role assignments.'},
'users:activity': {
'description': 'Update time of last user activity.',
'subscopes': ['read:users:activity'],
},
'admin:servers': {
'description': 'Read, start, stop, create and delete user servers and their state.',
'subscopes': ['admin:server_state', 'servers'],
},
'admin:server_state': {'description': 'Read and write users server state.'},
'servers': {
'description': 'Start and stop user servers.',
'subscopes': ['read:servers', 'delete:servers'],
},
'read:servers': {
'description': 'Read users names and their server models (excluding the server state).',
'subscopes': ['read:users:name'],
},
'delete:servers': {'description': "Stop and delete users' servers."},
'tokens': {
'description': 'Read, write, create and delete user tokens.',
'subscopes': ['read:tokens'],
},
'read:tokens': {'description': 'Read user tokens.'},
'admin:groups': {
'description': 'Read and write group information, create and delete groups.',
'subscopes': ['groups', 'read:roles:groups', 'delete:groups'],
},
'groups': {
'description': 'Read and write group information, including adding/removing users to/from groups.',
'subscopes': ['read:groups', 'list:groups'],
},
'list:groups': {
'description': 'List groups, including at least their names.',
'subscopes': ['read:groups:name'],
},
'read:groups': {
'description': 'Read group models.',
'subscopes': ['read:groups:name'],
},
'read:groups:name': {'description': 'Read group names.'},
'delete:groups': {
'description': "Delete groups.",
},
'list:services': {
'description': 'List services, including at least their names.',
'subscopes': ['read:services:name'],
},
'read:services': {
'description': 'Read service models.',
'subscopes': ['read:services:name'],
},
'read:services:name': {'description': 'Read service names.'},
'read:hub': {'description': 'Read detailed information about the Hub.'},
'access:servers': {
'description': 'Access user servers via API or browser.',
},
'access:services': {
'description': 'Access services via API or browser.',
},
'proxy': {
'description': 'Read information about the proxys routing table, sync the Hub with the proxy and notify the Hub about a new proxy.'
},
'shutdown': {'description': 'Shutdown the hub.'},
'read:metrics': {
'description': "Read prometheus metrics.",
},
}
class Scope(Enum):
ALL = True
def _intersect_expanded_scopes(scopes_a, scopes_b, db=None):
"""Intersect two sets of scopes by comparing their permissions
Arguments:
scopes_a, scopes_b: sets of expanded scopes
db (optional): db connection for resolving group membership
Returns:
intersection: set of expanded scopes as intersection of the arguments
If db is given, group membership will be accounted for in intersections,
Otherwise, it can result in lower than intended permissions,
(i.e. users!group=x & users!user=y will be empty, even if user y is in group x.)
"""
empty_set = frozenset()
# cached lookups for group membership of users and servers
@lru_cache()
def groups_for_user(username):
"""Get set of group names for a given username"""
user = db.query(orm.User).filter_by(name=username).first()
if user is None:
return empty_set
else:
return {group.name for group in user.groups}
@lru_cache()
def groups_for_server(server):
"""Get set of group names for a given server"""
username, _, servername = server.partition("/")
return groups_for_user(username)
parsed_scopes_a = parse_scopes(scopes_a)
parsed_scopes_b = parse_scopes(scopes_b)
common_bases = parsed_scopes_a.keys() & parsed_scopes_b.keys()
common_filters = {}
warned = False
for base in common_bases:
filters_a = parsed_scopes_a[base]
filters_b = parsed_scopes_b[base]
if filters_a == Scope.ALL:
common_filters[base] = filters_b
elif filters_b == Scope.ALL:
common_filters[base] = filters_a
else:
common_entities = filters_a.keys() & filters_b.keys()
all_entities = filters_a.keys() | filters_b.keys()
# if we don't have a db session, we can't check group membership
# warn *if* there are non-overlapping user= and group= filters that we can't check
if (
db is None
and not warned
and 'group' in all_entities
and ('user' in all_entities or 'server' in all_entities)
):
# this could resolve wrong if there's a user or server only on one side and a group only on the other
# check both directions: A has group X not in B group list AND B has user Y not in A user list
for a, b in [(filters_a, filters_b), (filters_b, filters_a)]:
for b_key in ('user', 'server'):
if (
not warned
and "group" in a
and b_key in b
and a["group"].difference(b.get("group", []))
and b[b_key].difference(a.get(b_key, []))
):
warnings.warn(
f"{base}[!{b_key}={b[b_key]}, !group={a['group']}] combinations of filters present,"
" without db access. Intersection between not considered."
" May result in lower than intended permissions.",
UserWarning,
)
warned = True
common_filters[base] = {
entity: filters_a[entity] & filters_b[entity]
for entity in common_entities
}
# resolve hierarchies (group/user/server) in both directions
common_servers = common_filters[base].get("server", set())
common_users = common_filters[base].get("user", set())
for a, b in [(filters_a, filters_b), (filters_b, filters_a)]:
if 'server' in a and b.get('server') != a['server']:
# skip already-added servers (includes overlapping servers)
servers = a['server'].difference(common_servers)
# resolve user/server hierarchy
if servers and 'user' in b:
for server in servers:
username, _, servername = server.partition("/")
if username in b['user']:
common_servers.add(server)
# resolve group/server hierarchy if db available
servers = servers.difference(common_servers)
if db is not None and servers and 'group' in b:
for server in servers:
server_groups = groups_for_server(server)
if server_groups & b['group']:
common_servers.add(server)
# resolve group/user hierarchy if db available and user sets aren't identical
if (
db is not None
and 'user' in a
and 'group' in b
and b.get('user') != a['user']
):
# skip already-added users (includes overlapping users)
users = a['user'].difference(common_users)
for username in users:
groups = groups_for_user(username)
if groups & b["group"]:
common_users.add(username)
# add server filter if there wasn't one before
if common_servers and "server" not in common_filters[base]:
common_filters[base]["server"] = common_servers
# add user filter if it's non-empty and there wasn't one before
if common_users and "user" not in common_filters[base]:
common_filters[base]["user"] = common_users
return unparse_scopes(common_filters)
def get_scopes_for(orm_object):
"""Find scopes for a given user or token from their roles and resolve permissions
Arguments:
orm_object: orm object or User wrapper
Returns:
expanded scopes (set) for the orm object
or
intersection (set) if orm_object == orm.APIToken
"""
expanded_scopes = set()
if orm_object is None:
return expanded_scopes
if not isinstance(orm_object, orm.Base):
from .user import User
if isinstance(orm_object, User):
orm_object = orm_object.orm_user
else:
raise TypeError(
f"Only allow orm objects or User wrappers, got {orm_object}"
)
if isinstance(orm_object, orm.APIToken):
app_log.debug(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: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 token_scopes == {'inherit'}:
# token_scopes is only 'inherit', return scopes inherited from owner as-is
# short-circuit common case where we don't need to compute an intersection
return owner_scopes
if 'inherit' in token_scopes:
token_scopes.remove('inherit')
token_scopes |= owner_scopes
intersection = _intersect_expanded_scopes(
token_scopes,
owner_scopes,
db=sa.inspect(orm_object).session,
)
discarded_token_scopes = token_scopes - intersection
# Not taking symmetric difference here because token owner can naturally have more scopes than token
if discarded_token_scopes:
app_log.warning(
"discarding scopes [%s], not present in owner roles"
% ", ".join(discarded_token_scopes)
)
expanded_scopes = intersection
else:
expanded_scopes = roles.expand_roles_to_scopes(orm_object)
return expanded_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.
Assumptions:
filter_ != Scope.ALL
"""
if not (filter_ == 'user' and 'group' in sub_scope):
return False
if 'user' in sub_scope:
return filter_value not in sub_scope['user']
else:
return True
def _check_user_in_expanded_scope(handler, user_name, scope_group_names):
"""Check if username is present in set of allowed groups"""
user = handler.find_user(user_name)
if user is None:
raise web.HTTPError(404, "No access to resources or resources not found")
group_names = {group.name for group in user.groups}
return bool(set(scope_group_names) & group_names)
def _check_scope_access(api_handler, req_scope, **kwargs):
"""Check if scopes satisfy requirements
Returns True for (potentially restricted) access, False for refused access
"""
# Parse user name and server name together
try:
api_name = api_handler.request.path
except AttributeError:
api_name = type(api_handler).__name__
if 'user' in kwargs and 'server' in kwargs:
kwargs['server'] = "{}/{}".format(kwargs['user'], kwargs['server'])
if req_scope not in api_handler.parsed_scopes:
app_log.debug("No access to %s via %s", api_name, req_scope)
return False
if api_handler.parsed_scopes[req_scope] == Scope.ALL:
app_log.debug("Unrestricted access to %s via %s", api_name, req_scope)
return True
# Apply filters
sub_scope = api_handler.parsed_scopes[req_scope]
if not kwargs:
app_log.debug(
"Client has restricted access to %s via %s. Internal filtering may apply",
api_name,
req_scope,
)
return True
for (filter_, filter_value) in kwargs.items():
if filter_ in sub_scope and filter_value in sub_scope[filter_]:
app_log.debug("Argument-based access to %s via %s", api_name, req_scope)
return True
if _needs_scope_expansion(filter_, filter_value, sub_scope):
group_names = sub_scope['group']
if _check_user_in_expanded_scope(api_handler, filter_value, group_names):
app_log.debug("Restricted client access supported with group expansion")
return True
app_log.debug(
"Client access refused; filters do not match API endpoint %s request" % api_name
)
raise web.HTTPError(404, "No access to resources or resources not found")
def parse_scopes(scope_list):
"""
Parses scopes and filters in something akin to JSON style
For instance, scope list ["users", "groups!group=foo", "servers!server=user/bar", "servers!server=user/baz"]
would lead to scope model
{
"users":scope.ALL,
"admin:users":{
"user":[
"alice"
]
},
"servers":{
"server":[
"user/bar",
"user/baz"
]
}
}
"""
parsed_scopes = {}
for scope in scope_list:
base_scope, _, filter_ = scope.partition('!')
if not filter_:
parsed_scopes[base_scope] = Scope.ALL
elif base_scope not in parsed_scopes:
parsed_scopes[base_scope] = {}
if parsed_scopes[base_scope] != Scope.ALL:
key, _, value = filter_.partition('=')
if key not in parsed_scopes[base_scope]:
parsed_scopes[base_scope][key] = {value}
else:
parsed_scopes[base_scope][key].add(value)
return parsed_scopes
def unparse_scopes(parsed_scopes):
"""Turn a parsed_scopes dictionary back into a expanded scopes set"""
expanded_scopes = set()
for base, filters in parsed_scopes.items():
if filters == Scope.ALL:
expanded_scopes.add(base)
else:
for entity, names_list in filters.items():
for name in names_list:
expanded_scopes.add(f'{base}!{entity}={name}')
return expanded_scopes
def needs_scope(*scopes):
"""Decorator to restrict access to users or services with the required scope"""
for scope in scopes:
if scope not in scope_definitions:
raise ValueError(f"Scope {scope} is not a valid scope")
def scope_decorator(func):
@functools.wraps(func)
def _auth_func(self, *args, **kwargs):
sig = inspect.signature(func)
bound_sig = sig.bind(self, *args, **kwargs)
bound_sig.apply_defaults()
# Load scopes in case they haven't been loaded yet
if not hasattr(self, 'expanded_scopes'):
self.expanded_scopes = {}
self.parsed_scopes = {}
s_kwargs = {}
for resource in {'user', 'server', 'group', 'service'}:
resource_name = resource + '_name'
if resource_name in bound_sig.arguments:
resource_value = bound_sig.arguments[resource_name]
s_kwargs[resource] = resource_value
for scope in scopes:
app_log.debug("Checking access via scope %s", scope)
has_access = _check_scope_access(self, scope, **s_kwargs)
if has_access:
return func(self, *args, **kwargs)
try:
end_point = self.request.path
except AttributeError:
end_point = self.__name__
app_log.warning(
"Not authorizing access to {}. Requires any of [{}], not derived from scopes [{}]".format(
end_point, ", ".join(scopes), ", ".join(self.expanded_scopes)
)
)
raise web.HTTPError(
403,
"Action is not authorized with current scopes; requires any of [{}]".format(
", ".join(scopes)
),
)
return _auth_func
return scope_decorator
def identify_scopes(obj):
"""Return 'identify' scopes for an orm object
Arguments:
obj: orm.User or orm.Service
Returns:
identify scopes (set): set of scopes needed for 'identify' endpoints
"""
if isinstance(obj, orm.User):
return {f"read:users:{field}!user={obj.name}" for field in {"name", "groups"}}
elif isinstance(obj, orm.Service):
return {f"read:services:{field}!service={obj.name}" for field in {"name"}}
else:
raise TypeError(f"Expected orm.User or orm.Service, got {obj!r}")
def check_scope_filter(sub_scope, orm_resource, kind):
"""Return whether a sub_scope filter applies to a given resource.
param sub_scope: parsed_scopes filter (i.e. dict or Scope.ALL)
param orm_resource: User or Service or Group or Spawner
param kind: 'user' or 'service' or 'group' or 'server'.
Returns True or False
"""
if sub_scope is Scope.ALL:
return True
elif kind in sub_scope and orm_resource.name in sub_scope[kind]:
return True
if kind == 'server':
server_format = f"{orm_resource.user.name}/{orm_resource.name}"
if server_format in sub_scope.get(kind, []):
return True
# Fall back on checking if we have user access
if 'user' in sub_scope and orm_resource.user.name in sub_scope['user']:
return True
# Fall back on checking if we have group access for this user
orm_resource = orm_resource.user
kind = 'user'
if kind == 'user' and 'group' in sub_scope:
group_names = {group.name for group in orm_resource.groups}
user_in_group = bool(group_names & set(sub_scope['group']))
if user_in_group:
return True
return False
def describe_parsed_scopes(parsed_scopes, username=None):
"""Return list of descriptions of parsed scopes
Highly detailed, often redundant descriptions
"""
descriptions = []
for scope, filters in parsed_scopes.items():
base_text = scope_definitions[scope]["description"]
if filters == Scope.ALL:
# no filter
filter_text = ""
else:
filter_chunks = []
for kind, names in filters.items():
if kind == 'user' and names == {username}:
filter_chunks.append("only you")
else:
kind_text = kind
if kind == 'group':
kind_text = "users in group"
if len(names) == 1:
filter_chunks.append(f"{kind}: {list(names)[0]}")
else:
filter_chunks.append(f"{kind}s: {', '.join(names)}")
filter_text = "; or ".join(filter_chunks)
descriptions.append(
{
"scope": scope,
"description": scope_definitions[scope]["description"],
"filter": filter_text,
}
)
return descriptions
def describe_raw_scopes(raw_scopes, username=None):
"""Return list of descriptions of raw scopes
A much shorter list than describe_parsed_scopes
"""
descriptions = []
for raw_scope in raw_scopes:
scope, _, filter_ = raw_scope.partition("!")
base_text = scope_definitions[scope]["description"]
if not filter_:
# no filter
filter_text = ""
elif filter_ == "user":
filter_text = "only you"
else:
kind, _, name = filter_.partition("=")
if kind == "user" and name == username:
filter_text = "only you"
else:
kind_text = kind
if kind == 'group':
kind_text = "users in group"
filter_text = f"{kind_text} {name}"
descriptions.append(
{
"scope": scope,
"description": scope_definitions[scope]["description"],
"filter": filter_text,
}
)
return descriptions
# regex for custom scope
# for-humans description below
# note: scope description duplicated in docs/source/rbac/scopes.md
# update docs when making changes here
_custom_scope_pattern = re.compile(r"^custom:[a-z0-9][a-z0-9_\-\*:]+[a-z0-9_\*]$")
# custom scope pattern description
# used in docstring below and error message when scopes don't match _custom_scope_pattern
_custom_scope_description = """
Custom scopes must start with `custom:`
and contain only lowercase ascii letters, numbers, hyphen, underscore, colon, and asterisk (-_:*).
The part after `custom:` must start with a letter or number.
Scopes may not end with a hyphen or colon.
"""
def define_custom_scopes(scopes):
"""Define custom scopes
Scopes must start with `custom:`.
It is recommended to name custom scopes with a pattern like::
custom:$your-project:$action:$resource
e.g.::
custom:jupyter_server:read:contents
That makes them easy to parse and avoids collisions across projects.
All scopes must have at least a `definition`,
which will be displayed on the oauth authorization page,
and _may_ have a `subscopes` list of other scopes if having one scope
should imply having other, more specific scopes.
Args:
scopes: dict
A dictionary of scope definitions.
The keys are the scopes,
while the values are dictionaries with at least a `definition` field,
and optional `subscopes` field.
%s
Examples::
define_custom_scopes(
{
"custom:jupyter_server:read:contents": {
"description": "read-only access to files in a Jupyter server",
},
"custom:jupyter_server:read": {
"description": "read-only access to a Jupyter server",
"subscopes": [
"custom:jupyter_server:read:contents",
"custom:jupyter_server:read:kernels",
"...",
},
}
)
""" % indent(
_custom_scope_description, " " * 8
)
for scope, scope_definition in scopes.items():
if scope in scope_definitions and scope_definitions[scope] != scope_definition:
raise ValueError(
f"Cannot redefine scope {scope}={scope_definition}. Already have {scope}={scope_definitions[scope]}"
)
if not _custom_scope_pattern.match(scope):
# note: keep this description in sync with docstring above
raise ValueError(
f"Invalid scope name: {scope!r}.\n{_custom_scope_description}"
" and contain only lowercase ascii letters, numbers, hyphen, underscore, colon, and asterisk."
" The part after `custom:` must start with a letter or number."
" Scopes may not end with a hyphen or colon."
)
if "description" not in scope_definition:
raise ValueError(
f"scope {scope}={scope_definition} missing key 'description'"
)
if "subscopes" in scope_definition:
subscopes = scope_definition["subscopes"]
if not isinstance(subscopes, list) or not all(
isinstance(s, str) for s in subscopes
):
raise ValueError(
f"subscopes must be a list of scope strings, got {subscopes!r}"
)
for subscope in subscopes:
if subscope not in scopes:
if subscope in scope_definitions:
raise ValueError(
f"non-custom subscope {subscope} in {scope}={scope_definition} is not allowed."
f" Custom scopes may only have custom subscopes."
f" Roles should be used to assign multiple scopes together."
)
raise ValueError(
f"subscope {subscope} in {scope}={scope_definition} not found. All scopes must be defined."
)
extra_keys = set(scope_definition.keys()).difference(
["description", "subscopes"]
)
if extra_keys:
warnings.warn(
f"Ignoring unrecognized key(s) {', '.join(extra_keys)!r} in {scope}={scope_definition}",
UserWarning,
stacklevel=2,
)
app_log.info(f"Defining custom scope {scope}")
# deferred evaluation for debug-logging
app_log.debug("Defining custom scope %s=%s", scope, scope_definition)
scope_definitions[scope] = scope_definition