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

View File

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

View File

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

View File

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

View File

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

View File

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