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
|
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):
|
class GradesHandler(HubOAuthenticated, RequestHandler):
|
||||||
# default scope for this Handler: read-only
|
# default scope for this Handler: read-only
|
||||||
hub_scopes = [READ_SCOPE]
|
hub_scopes = [READ_SCOPE]
|
||||||
|
|
||||||
def _render(self):
|
def _render(self):
|
||||||
grades = self.settings["grades"]
|
grades = self.settings["grades"]
|
||||||
self.write("<h1>Grades</h1>")
|
self.write("<h1>All grades</h1>")
|
||||||
self.write("<table>")
|
self.write("<table>")
|
||||||
self.write("<tr><th>Student</th><th>Grade</th></tr>")
|
self.write("<tr><th>Student</th><th>Grade</th></tr>")
|
||||||
for student, grade in grades.items():
|
for student, grade in grades.items():
|
||||||
@@ -92,7 +110,8 @@ def main():
|
|||||||
|
|
||||||
app = Application(
|
app = Application(
|
||||||
[
|
[
|
||||||
(base_url, GradesHandler),
|
(base_url, MyGradesHandler),
|
||||||
|
(url_path_join(base_url, 'grades/'), GradesHandler),
|
||||||
(
|
(
|
||||||
url_path_join(base_url, 'oauth_callback'),
|
url_path_join(base_url, 'oauth_callback'),
|
||||||
HubOAuthCallbackHandler,
|
HubOAuthCallbackHandler,
|
||||||
|
@@ -31,7 +31,13 @@ c.JupyterHub.load_roles = [
|
|||||||
"name": "grader",
|
"name": "grader",
|
||||||
# grant graders access to write grades
|
# grant graders access to write grades
|
||||||
"scopes": ["custom:grades:write"],
|
"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(
|
raise web.HTTPError(
|
||||||
403, f"You do not have permission to access {client.description}"
|
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):
|
if not self.needs_oauth_confirm(self.current_user, client, role_names):
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
"Skipping oauth confirmation for %s accessing %s",
|
"Skipping oauth confirmation for %s accessing %s",
|
||||||
@@ -381,6 +396,10 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
|||||||
# The scopes the user actually authorized, i.e. checkboxes
|
# The scopes the user actually authorized, i.e. checkboxes
|
||||||
# that were selected.
|
# that were selected.
|
||||||
scopes = self.get_arguments('scopes')
|
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 we need in the validator
|
||||||
credentials = self.add_credentials()
|
credentials = self.add_credentials()
|
||||||
|
|
||||||
|
@@ -23,6 +23,7 @@ from tornado import httpserver
|
|||||||
from tornado import ioloop
|
from tornado import ioloop
|
||||||
from tornado import log
|
from tornado import log
|
||||||
from tornado import web
|
from tornado import web
|
||||||
|
from tornado.httputil import url_concat
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubAuthenticated
|
from jupyterhub.services.auth import HubAuthenticated
|
||||||
from jupyterhub.services.auth import HubOAuthCallbackHandler
|
from jupyterhub.services.auth import HubOAuthCallbackHandler
|
||||||
@@ -76,6 +77,13 @@ class OWhoAmIHandler(HubOAuthenticated, web.RequestHandler):
|
|||||||
Uses OAuth login flow
|
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
|
@web.authenticated
|
||||||
def get(self):
|
def get(self):
|
||||||
self.write(self.get_current_user())
|
self.write(self.get_current_user())
|
||||||
|
@@ -1134,6 +1134,7 @@ async def test_oauth_page_scope_appearance(
|
|||||||
)
|
)
|
||||||
service = mockservice_url
|
service = mockservice_url
|
||||||
user = create_user_with_scopes("access:services")
|
user = create_user_with_scopes("access:services")
|
||||||
|
roles.grant_role(app.db, user, service_role)
|
||||||
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)
|
||||||
|
@@ -5,9 +5,11 @@ import sys
|
|||||||
from binascii import hexlify
|
from binascii import hexlify
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
from urllib.parse import quote
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from pytest import raises
|
from pytest import raises
|
||||||
from tornado.httputil import url_concat
|
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"]),
|
(["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"]),
|
||||||
|
# requesting valid subset, some not held by user
|
||||||
|
(["admin", "user"], ["admin", "user"], ["user"]),
|
||||||
|
(["admin", "user"], ["admin"], []),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_oauth_service_roles(
|
async def test_oauth_service_roles(
|
||||||
@@ -267,6 +272,8 @@ async def test_oauth_service_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:
|
||||||
|
url = url_concat(url, {"request-scope": " ".join(request_roles)})
|
||||||
# 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")
|
||||||
@@ -276,17 +283,6 @@ 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)
|
||||||
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:
|
if expected_roles 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
|
||||||
@@ -296,6 +292,21 @@ async def test_oauth_service_roles(
|
|||||||
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
|
||||||
|
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
|
assert r.url == url
|
||||||
# verify oauth cookie is set
|
# verify oauth cookie is set
|
||||||
assert 'service-%s' % service.name in set(s.cookies.keys())
|
assert 'service-%s' % service.name in set(s.cookies.keys())
|
||||||
@@ -395,7 +406,11 @@ async def test_oauth_access_scopes(
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"token_roles, hits_page",
|
"token_roles, hits_page",
|
||||||
[([], True), (['writer'], True), (['writer', 'reader'], False)],
|
[
|
||||||
|
([], True),
|
||||||
|
(['writer'], True),
|
||||||
|
(['writer', 'reader'], False),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
async def test_oauth_page_hit(
|
async def test_oauth_page_hit(
|
||||||
app,
|
app,
|
||||||
@@ -411,6 +426,8 @@ async def test_oauth_page_hit(
|
|||||||
}
|
}
|
||||||
service = mockservice_url
|
service = mockservice_url
|
||||||
user = create_user_with_scopes("access:services", "self")
|
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()
|
user.new_api_token()
|
||||||
token = user.api_tokens[0]
|
token = user.api_tokens[0]
|
||||||
token.roles = [test_roles[t] for t in token_roles]
|
token.roles = [test_roles[t] for t in token_roles]
|
||||||
|
Reference in New Issue
Block a user