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
This commit is contained in:
Min RK
2022-03-11 10:56:56 +01:00
parent 454e356e4d
commit 3076845927
18 changed files with 550 additions and 465 deletions

View File

@@ -3,7 +3,6 @@
# Distributed under the terms of the Modified BSD License.
import enum
import json
import warnings
from base64 import decodebytes
from base64 import encodebytes
from datetime import datetime
@@ -40,10 +39,7 @@ from sqlalchemy.types import Text
from sqlalchemy.types import TypeDecorator
from tornado.log import app_log
from .roles import assign_default_roles
from .roles import create_role
from .roles import get_default_roles
from .roles import update_roles
from .roles import roles_to_scopes
from .utils import compare_token
from .utils import hash_token
from .utils import new_token
@@ -110,7 +106,9 @@ class JSONList(JSONDict):
return value
def process_result_value(self, value, dialect):
if value is not None:
if value is None:
return []
else:
value = json.loads(value)
return value
@@ -157,9 +155,7 @@ for has_role in (
'user',
'group',
'service',
'api_token',
'oauth_client',
'oauth_code',
):
role_map = Table(
f'{has_role}_role_map',
@@ -185,10 +181,9 @@ class Role(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(Unicode(255), unique=True)
description = Column(Unicode(1023))
scopes = Column(JSONList)
scopes = Column(JSONList, default=[])
users = relationship('User', secondary='user_role_map', backref='roles')
services = relationship('Service', secondary='service_role_map', backref='roles')
tokens = relationship('APIToken', secondary='api_token_role_map', backref='roles')
groups = relationship('Group', secondary='group_role_map', backref='roles')
def __repr__(self):
@@ -597,6 +592,10 @@ class APIToken(Hashed, Base):
def api_id(self):
return 'a%i' % self.id
@property
def owner(self):
return self.user or self.service
# added in 2.0
client_id = Column(
Unicode(255),
@@ -624,6 +623,7 @@ class APIToken(Hashed, Base):
expires_at = Column(DateTime, default=None, nullable=True)
last_activity = Column(DateTime)
note = Column(Unicode(1023))
scopes = Column(JSONList, default=[])
def __repr__(self):
if self.user is not None:
@@ -676,9 +676,11 @@ class APIToken(Hashed, Base):
def new(
cls,
token=None,
*,
user=None,
service=None,
roles=None,
scopes=None,
note='',
generated=True,
session_id=None,
@@ -697,6 +699,42 @@ class APIToken(Hashed, Base):
generated = True
else:
cls.check_token(db, token)
if scopes is not None and roles is not None:
raise ValueError(
"Can only assign one of scopes or roles when creating tokens."
)
elif scopes is None and roles is None:
# this is the default branch
# use the default 'token' role to specify default permissions for API tokens
default_token_role = Role.find(db, 'token')
if not default_token_role:
scopes = ["inherit"]
else:
scopes = roles_to_scopes([default_token_role])
elif roles is not None:
# evaluate roles to scopes immediately
# TODO: should this be deprecated, or not?
# warnings.warn(
# "Setting roles on tokens is deprecated in JupyterHub 2.2. Use scopes.",
# DeprecationWarning,
# stacklevel=3,
# )
orm_roles = []
for rolename in roles:
role = Role.find(db, name=rolename)
if role is None:
raise ValueError(f"No such role: {rolename}")
orm_roles.append(role)
scopes = roles_to_scopes(orm_roles)
# avoid circular import
from .scopes import _check_scopes_exist, _check_token_scopes
_check_scopes_exist(scopes, who_for="token")
_check_token_scopes(scopes, owner=user or service)
# two stages to ensure orm_token.generated has been set
# before token setter is called
orm_token = cls(
@@ -704,6 +742,7 @@ class APIToken(Hashed, Base):
note=note or '',
client_id=client_id,
session_id=session_id,
scopes=list(scopes),
)
orm_token.token = token
if user:
@@ -716,21 +755,17 @@ class APIToken(Hashed, Base):
orm_token.expires_at = cls.now() + timedelta(seconds=expires_in)
db.add(orm_token)
if not Role.find(db, 'token'):
raise RuntimeError("Default token role has not been created")
try:
if roles is not None:
update_roles(db, entity=orm_token, roles=roles)
else:
assign_default_roles(db, entity=orm_token)
except Exception:
db.delete(orm_token)
db.commit()
raise
db.commit()
return token
def update_scopes(self, new_scopes):
"""Set new scopes, checking that they are allowed"""
from .scopes import _check_scopes_exist, _check_token_scopes
_check_scopes_exist(new_scopes, who_for="token")
_check_token_scopes(new_scopes, owner=self.owner)
self.scopes = new_scopes
class OAuthCode(Expiring, Base):
__tablename__ = 'oauth_codes'
@@ -746,7 +781,7 @@ class OAuthCode(Expiring, Base):
# state = Column(Unicode(1023))
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
roles = relationship('Role', secondary='oauth_code_role_map')
scopes = Column(JSONList, default=[])
@staticmethod
def now():