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:
Min RK
2022-03-11 13:02:46 +01:00
parent fdf23600c0
commit d2eaf90df2
6 changed files with 85 additions and 15 deletions

View File

@@ -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,

View File

@@ -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"],
},
]

View File

@@ -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()

View File

@@ -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())

View File

@@ -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)

View File

@@ -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]