diff --git a/jupyterhub/oauth/provider.py b/jupyterhub/oauth/provider.py index c1369863..55aa55bb 100644 --- a/jupyterhub/oauth/provider.py +++ b/jupyterhub/oauth/provider.py @@ -149,7 +149,12 @@ class JupyterHubRequestValidator(RequestValidator): - Resource Owner Password Credentials Grant - Client Credentials grant """ - return ['identify'] + orm_client = ( + self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first() + ) + if orm_client is None: + raise ValueError("No such client: %s" % client_id) + return [role.name for role in orm_client.allowed_roles] def get_original_scopes(self, refresh_token, request, *args, **kwargs): """Get the list of scopes associated with the refresh token. @@ -249,8 +254,7 @@ class JupyterHubRequestValidator(RequestValidator): code=code['code'], # oauth has 5 minutes to complete expires_at=int(orm.OAuthCode.now() + 300), - # TODO: persist oauth scopes - # scopes=request.scopes, + roles=request._jupyterhub_roles, user=request.user.orm_user, redirect_uri=orm_client.redirect_uri, session_id=request.session_id, @@ -324,10 +328,6 @@ class JupyterHubRequestValidator(RequestValidator): """ log_token = {} log_token.update(token) - scopes = token['scope'].split(' ') - # TODO: - if scopes != ['identify']: - raise ValueError("Only 'identify' scope is supported") # redact sensitive keys in log for key in ('access_token', 'refresh_token', 'state'): if key in token: @@ -335,6 +335,7 @@ class JupyterHubRequestValidator(RequestValidator): if isinstance(value, str): log_token[key] = 'REDACTED' app_log.debug("Saving bearer token %s", log_token) + if request.user is None: raise ValueError("No user for access token: %s" % request.user) client = ( @@ -342,9 +343,6 @@ class JupyterHubRequestValidator(RequestValidator): .filter_by(identifier=request.client.client_id) .first() ) - # FIXME: pick a role - # this will be empty for now - roles = list(self.db.query(orm.Role).filter_by(name='identify')) # FIXME: support refresh tokens # These should be in a new table token.pop("refresh_token", None) @@ -353,7 +351,7 @@ class JupyterHubRequestValidator(RequestValidator): orm.APIToken.new( client_id=client.identifier, expires_in=token['expires_in'], - roles=roles, + roles=request._jupyterhub_roles, token=token['access_token'], session_id=request.session_id, user=request.user, @@ -455,9 +453,8 @@ class JupyterHubRequestValidator(RequestValidator): return False request.user = orm_code.user request.session_id = orm_code.session_id - # TODO: record state on oauth codes - # TODO: specify scopes - request.scopes = ['identify'] + request.scopes = [role.name for role in orm_code.roles] + request._jupyterhub_roles = orm_code.roles return True def validate_grant_type( @@ -553,6 +550,34 @@ 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() + ) + if orm_client is None: + app_log.warning("No such oauth client %s", client_id) + return False + client_allowed_roles = {role.name: role for role in orm_client.allowed_roles} + # explicitly allow 'identify', which was the only allowed scope previously + # requesting 'identify' gets no actual permissions other than self-identification + client_allowed_roles.setdefault('identify', None) + roles = [] + requested_roles = set(scopes) + disallowed_roles = requested_roles.difference(client_allowed_roles) + if disallowed_roles: + app_log.error( + f"Role(s) not allowed for {client_id}: {','.join(disallowed_roles)}" + ) + return False + + # store resolved roles on request + app_log.debug( + f"Allowing request for role(s) for {client_id}: {','.join(requested_roles) or '[]'}" + ) + request._jupyterhub_roles = [ + client_allowed_roles[name] + for name in requested_roles + if client_allowed_roles[name] is not None + ] return True diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index c35a7255..e78fcc02 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -137,43 +137,34 @@ class Server(Base): return "" % (self.ip, self.port) -# user:role many:many mapping table -user_role_map = Table( - 'user_role_map', - Base.metadata, - Column('user_id', ForeignKey('users.id', ondelete='CASCADE'), primary_key=True), - Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), -) +# lots of things have roles +# mapping tables are the same for all of them -# service:role many:many mapping table -service_role_map = Table( - 'service_role_map', - Base.metadata, - Column( - 'service_id', ForeignKey('services.id', ondelete='CASCADE'), primary_key=True - ), - Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), -) +_role_map_tables = [] -# token:role many:many mapping table -api_token_role_map = Table( - 'api_token_role_map', - Base.metadata, - Column( - 'api_token_id', - ForeignKey('api_tokens.id', ondelete='CASCADE'), - primary_key=True, - ), - Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), -) - -# group:role many:many mapping table -group_role_map = Table( - 'group_role_map', - Base.metadata, - Column('group_id', ForeignKey('groups.id', ondelete='CASCADE'), primary_key=True), - Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), -) +for has_role in ( + 'user', + 'group', + 'service', + 'api_token', + 'oauth_client', + 'oauth_code', +): + role_map = Table( + f'{has_role}_role_map', + Base.metadata, + Column( + f'{has_role}_id', + ForeignKey(f'{has_role}s.id', ondelete='CASCADE'), + primary_key=True, + ), + Column( + 'role_id', + ForeignKey('roles.id', ondelete='CASCADE'), + primary_key=True, + ), + ) + _role_map_tables.append(role_map) class Role(Base): @@ -714,6 +705,8 @@ 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') + @staticmethod def now(): return datetime.utcnow().timestamp() @@ -745,6 +738,10 @@ class OAuthClient(Base): ) codes = relationship(OAuthCode, backref='client', cascade='all, delete-orphan') + # these are the roles an oauth client is allowed to request + # *not* the roles of the client itself + allowed_roles = relationship('Role', secondary='oauth_client_role_map') + # General database utilities