mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 01:54:09 +00:00
authorize subsets of roles
- oauth clients can request a list of roles - authorization will proceed with the _subset_ of those roles held by the user - in the future, this subsetting will be refined to the scope level
This commit is contained in:
@@ -43,13 +43,31 @@ def require_scope(scopes):
|
||||
return wrap
|
||||
|
||||
|
||||
class MyGradesHandler(HubOAuthenticated, RequestHandler):
|
||||
# no hub_scopes, anyone with access to this service
|
||||
# will be able to visit this URL
|
||||
|
||||
@authenticated
|
||||
def get(self):
|
||||
self.write("<h1>My grade</h1>")
|
||||
name = self.current_user["name"]
|
||||
grades = self.settings["grades"]
|
||||
self.write(f"<p>My name is: {escape(name)}</p>")
|
||||
if name in grades:
|
||||
self.write(f"<p>My grade is: {escape(str(grades[name]))}</p>")
|
||||
else:
|
||||
self.write("<p>No grade entered</p>")
|
||||
if READ_SCOPE in self.current_user["scopes"]:
|
||||
self.write('<a href="grades/">enter grades</a>')
|
||||
|
||||
|
||||
class GradesHandler(HubOAuthenticated, RequestHandler):
|
||||
# default scope for this Handler: read-only
|
||||
hub_scopes = [READ_SCOPE]
|
||||
|
||||
def _render(self):
|
||||
grades = self.settings["grades"]
|
||||
self.write("<h1>Grades</h1>")
|
||||
self.write("<h1>All grades</h1>")
|
||||
self.write("<table>")
|
||||
self.write("<tr><th>Student</th><th>Grade</th></tr>")
|
||||
for student, grade in grades.items():
|
||||
@@ -92,7 +110,8 @@ def main():
|
||||
|
||||
app = Application(
|
||||
[
|
||||
(base_url, GradesHandler),
|
||||
(base_url, MyGradesHandler),
|
||||
(url_path_join(base_url, 'grades/'), GradesHandler),
|
||||
(
|
||||
url_path_join(base_url, 'oauth_callback'),
|
||||
HubOAuthCallbackHandler,
|
||||
|
@@ -31,7 +31,13 @@ c.JupyterHub.load_roles = [
|
||||
"name": "grader",
|
||||
# grant graders access to write grades
|
||||
"scopes": ["custom:grades:write"],
|
||||
"users": ["grader", "instructor"],
|
||||
"users": ["grader"],
|
||||
},
|
||||
{
|
||||
"name": "instructor",
|
||||
# grant instructors access to read, but not write grades
|
||||
"scopes": ["custom:grades:read"],
|
||||
"users": ["instructor"],
|
||||
},
|
||||
]
|
||||
|
||||
|
@@ -283,6 +283,21 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
raise web.HTTPError(
|
||||
403, f"You do not have permission to access {client.description}"
|
||||
)
|
||||
|
||||
# subset role names to those held by authenticating user
|
||||
requested_role_names = set(role_names)
|
||||
user = self.current_user
|
||||
user_role_names = {role.name for role in user.roles}
|
||||
allowed_role_names = requested_role_names.intersection(user_role_names)
|
||||
excluded_role_names = requested_role_names.difference(allowed_role_names)
|
||||
if excluded_role_names:
|
||||
self.log.info(
|
||||
f"Service {client.description} requested roles {','.join(role_names)}"
|
||||
f" for user {self.current_user.name},"
|
||||
f" granting only {','.join(allowed_role_names) or '[]'}."
|
||||
)
|
||||
role_names = list(allowed_role_names)
|
||||
|
||||
if not self.needs_oauth_confirm(self.current_user, client, role_names):
|
||||
self.log.debug(
|
||||
"Skipping oauth confirmation for %s accessing %s",
|
||||
@@ -381,6 +396,10 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
# The scopes the user actually authorized, i.e. checkboxes
|
||||
# that were selected.
|
||||
scopes = self.get_arguments('scopes')
|
||||
if scopes == []:
|
||||
# avoid triggering default scopes (provider selects default scopes when scopes is falsy)
|
||||
# when an explicit empty list is authorized
|
||||
scopes = ["identify"]
|
||||
# credentials we need in the validator
|
||||
credentials = self.add_credentials()
|
||||
|
||||
|
@@ -23,6 +23,7 @@ from tornado import httpserver
|
||||
from tornado import ioloop
|
||||
from tornado import log
|
||||
from tornado import web
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
from jupyterhub.services.auth import HubAuthenticated
|
||||
from jupyterhub.services.auth import HubOAuthCallbackHandler
|
||||
@@ -76,6 +77,13 @@ class OWhoAmIHandler(HubOAuthenticated, web.RequestHandler):
|
||||
Uses OAuth login flow
|
||||
"""
|
||||
|
||||
def get_login_url(self):
|
||||
login_url = super().get_login_url()
|
||||
scopes = self.get_argument("request-scope", None)
|
||||
if scopes is not None:
|
||||
login_url = url_concat(login_url, {"scope": scopes})
|
||||
return login_url
|
||||
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
self.write(self.get_current_user())
|
||||
|
@@ -1134,6 +1134,7 @@ async def test_oauth_page_scope_appearance(
|
||||
)
|
||||
service = mockservice_url
|
||||
user = create_user_with_scopes("access:services")
|
||||
roles.grant_role(app.db, user, service_role)
|
||||
oauth_client = (
|
||||
app.db.query(orm.OAuthClient)
|
||||
.filter_by(identifier=service.oauth_client_id)
|
||||
|
@@ -5,9 +5,11 @@ import sys
|
||||
from binascii import hexlify
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from pytest import raises
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
@@ -227,6 +229,9 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo
|
||||
(["admin", "user"], ["user"], ["user"]),
|
||||
(["user", "token", "server"], ["token", "user"], ["token", "user"]),
|
||||
(["admin", "user", "read-only"], ["read-only"], ["read-only"]),
|
||||
# requesting valid subset, some not held by user
|
||||
(["admin", "user"], ["admin", "user"], ["user"]),
|
||||
(["admin", "user"], ["admin"], []),
|
||||
],
|
||||
)
|
||||
async def test_oauth_service_roles(
|
||||
@@ -267,6 +272,8 @@ async def test_oauth_service_roles(
|
||||
]
|
||||
app.db.commit()
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||
if request_roles:
|
||||
url = url_concat(url, {"request-scope": " ".join(request_roles)})
|
||||
# first request is only going to login and get us to the oauth form page
|
||||
s = AsyncSession()
|
||||
user = create_user_with_scopes("access:services")
|
||||
@@ -276,17 +283,6 @@ async def test_oauth_service_roles(
|
||||
s.cookies = await app.login_user(name)
|
||||
|
||||
r = await s.get(url)
|
||||
r.raise_for_status()
|
||||
# we should be looking at the oauth confirmation page
|
||||
assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
|
||||
# verify oauth state cookie was set at some point
|
||||
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
||||
|
||||
# submit the oauth form to complete authorization
|
||||
data = {}
|
||||
if request_roles:
|
||||
data["scopes"] = request_roles
|
||||
r = await s.post(r.url, data=data, headers={'Referer': r.url})
|
||||
if expected_roles is None:
|
||||
# expected failed auth, stop here
|
||||
# verify expected 'invalid scope' error, not server error
|
||||
@@ -296,6 +292,21 @@ async def test_oauth_service_roles(
|
||||
assert r.status_code == 400
|
||||
return
|
||||
r.raise_for_status()
|
||||
# we should be looking at the oauth confirmation page
|
||||
assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
|
||||
# verify oauth state cookie was set at some point
|
||||
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
||||
|
||||
page = BeautifulSoup(r.text, "html.parser")
|
||||
scope_inputs = page.find_all("input", {"name": "scopes"})
|
||||
scope_values = [input["value"] for input in scope_inputs]
|
||||
print("Submitting request with scope values", scope_values)
|
||||
# submit the oauth form to complete authorization
|
||||
data = {}
|
||||
if scope_values:
|
||||
data["scopes"] = scope_values
|
||||
r = await s.post(r.url, data=data, headers={'Referer': r.url})
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
# verify oauth cookie is set
|
||||
assert 'service-%s' % service.name in set(s.cookies.keys())
|
||||
@@ -395,7 +406,11 @@ async def test_oauth_access_scopes(
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token_roles, hits_page",
|
||||
[([], True), (['writer'], True), (['writer', 'reader'], False)],
|
||||
[
|
||||
([], True),
|
||||
(['writer'], True),
|
||||
(['writer', 'reader'], False),
|
||||
],
|
||||
)
|
||||
async def test_oauth_page_hit(
|
||||
app,
|
||||
@@ -411,6 +426,8 @@ async def test_oauth_page_hit(
|
||||
}
|
||||
service = mockservice_url
|
||||
user = create_user_with_scopes("access:services", "self")
|
||||
for role in test_roles.values():
|
||||
roles.grant_role(app.db, user, role)
|
||||
user.new_api_token()
|
||||
token = user.api_tokens[0]
|
||||
token.roles = [test_roles[t] for t in token_roles]
|
||||
|
Reference in New Issue
Block a user