Implement load_managed_roles, allow to assign scopes

and update roles (but not assign them to users/groups)
by using `load_roles` when `Authenticator.manage_roles` is on.
This commit is contained in:
krassowski
2024-03-26 17:51:25 +00:00
parent 26906cca07
commit b7d68ca255
5 changed files with 141 additions and 12 deletions

View File

@@ -337,7 +337,11 @@ which is a list of roles that user should be assigned to:
If authenticator-managed groups are enabled, If authenticator-managed groups are enabled,
all group-management via the API is disabled, all group-management via the API is disabled,
and roles cannot be specified with `load_roles` traitlet. and roles cannot be assigned to groups nor users via `load_roles` traitlet
(roles can still be created via `load_roles` or assigned to services).
When an authenticator manages roles, the initial roles and role assignments
can be loaded from role specifications returned by the `Authenticator.load_managed_roles()` method.
## pre_spawn_start and post_spawn_stop hooks ## pre_spawn_start and post_spawn_stop hooks

View File

@@ -2241,8 +2241,20 @@ class JupyterHub(Application):
self.log.info(f"Defining {len(self.custom_scopes)} custom scopes.") self.log.info(f"Defining {len(self.custom_scopes)} custom scopes.")
scopes.define_custom_scopes(self.custom_scopes) scopes.define_custom_scopes(self.custom_scopes)
roles_to_load = self.load_roles
if self.authenticator.manage_roles and self.load_roles: if self.authenticator.manage_roles and self.load_roles:
raise ValueError("Role management has been offloaded to the authenticator") for role_spec in roles_to_load:
user_role_assignments = role_spec.get('users', [])
group_role_assignments = role_spec.get('groups', [])
if user_role_assignments or group_role_assignments:
raise ValueError(
"When authenticator manages roles, `load_roles` can not"
" be used for assigning roles to users nor groups."
)
if self.authenticator.manage_roles:
roles_to_load.extend(await self.authenticator.load_managed_roles())
self.log.debug('Loading roles into database') self.log.debug('Loading roles into database')
default_roles = roles.get_default_roles() default_roles = roles.get_default_roles()

View File

@@ -661,6 +661,18 @@ class Authenticator(LoggingConfigurable):
Raising errors directly allows customizing the message shown to the user. Raising errors directly allows customizing the message shown to the user.
""" """
async def load_managed_roles(self):
"""Load roles managed by authenticator.
Returns a list of predefined role dictionaries to load at startup,
following the same format as `JupyterHub.load_roles`
"""
if not self.manage_roles:
raise ValueError(
'Managed roles can only be loaded when `manage_roles` is True'
)
return []
def pre_spawn_start(self, user, spawner): def pre_spawn_start(self, user, spawner):
"""Hook called before spawning a user's server """Hook called before spawning a user's server

View File

@@ -13,7 +13,7 @@ from traitlets.config import Config
from jupyterhub import auth, crypto, orm from jupyterhub import auth, crypto, orm
from .mocking import MockPAMAuthenticator, MockStructGroup, MockStructPasswd from .mocking import MockHub, MockPAMAuthenticator, MockStructGroup, MockStructPasswd
from .utils import add_user, async_requests, get_page, public_url from .utils import add_user, async_requests, get_page, public_url
@@ -598,6 +598,7 @@ async def test_auth_managed_groups(
class MockRolesAuthenticator(auth.Authenticator): class MockRolesAuthenticator(auth.Authenticator):
authenticated_roles = Any() authenticated_roles = Any()
refresh_roles = Any() refresh_roles = Any()
initial_roles = Any()
manage_roles = True manage_roles = True
def authenticate(self, handler, data): def authenticate(self, handler, data):
@@ -612,6 +613,62 @@ class MockRolesAuthenticator(auth.Authenticator):
"roles": self.refresh_roles, "roles": self.refresh_roles,
} }
async def load_managed_roles(self):
return self.initial_roles
def role_to_dict(role):
return {col: getattr(role, col) for col in role.__table__.columns.keys()}
@pytest.mark.parametrize(
"initial_roles",
[
pytest.param(
[{'name': 'test-role'}],
id="should have the same effect as `load_roles` when creating a new role",
),
pytest.param(
[
{
'name': 'server',
'users': ['test-user'],
}
],
id="should have the same effect as `load_roles` when assigning a role to a user",
),
pytest.param(
[
{
'name': 'server',
'groups': ['test-group'],
}
],
id="should have the same effect as `load_roles` when assigning a role to a group",
),
],
)
async def test_auth_load_managed_roles(app, initial_roles):
authenticator = MockRolesAuthenticator(
parent=app,
initial_roles=initial_roles,
)
# create the roles using `load_roles`
hub = MockHub(load_roles=initial_roles)
hub.init_db()
await hub.init_role_creation()
expected_roles = [role_to_dict(role) for role in hub.db.query(orm.Role).all()]
# create the roles using authenticator's `load_managed_roles`
hub = MockHub(load_roles=[], authenticator=authenticator)
hub.init_db()
await hub.init_role_creation()
actual_roles = [role_to_dict(role) for role in hub.db.query(orm.Role).all()]
# `load_managed_roles` should produce the same set of roles as `load_roles` does
assert expected_roles == actual_roles
@pytest.mark.parametrize( @pytest.mark.parametrize(
"authenticated_roles", "authenticated_roles",

View File

@@ -1273,18 +1273,62 @@ async def test_admin_role_membership(in_db, role_users, admin_users, expected_me
assert role_members == expected_members assert role_members == expected_members
async def test_manage_roles_disallows_load_roles(): @mark.parametrize(
roles_to_load = [ "role_spec",
[
pytest.param(
{ {
'name': 'elephant', 'name': 'elephant',
'description': 'pacing about', 'users': ['admin'],
'scopes': ['read:hub'],
}, },
] id="should not allow assigning a role to a user",
),
pytest.param(
{
'name': 'elephant',
'groups': ['test-group'],
},
id="should not allow assigning a role to a group",
),
],
)
async def test_manage_roles_disallows_role_assignment(role_spec):
roles_to_load = [role_spec]
hub = MockHub(load_roles=roles_to_load)
hub.init_db()
hub.authenticator.manage_roles = True
with pytest.raises(
ValueError,
match="`load_roles` can not be used for assigning roles to users nor groups",
):
await hub.init_role_creation()
@mark.parametrize(
"role_spec",
[
pytest.param(
{'name': 'elephant', 'description': 'pacing about'},
id="should allow creating a new role",
),
pytest.param(
{
'name': 'elephant',
'scopes': ['read:hub'],
},
id="should allow assigning a scope to a new role",
),
pytest.param(
{'name': 'user', 'scopes': ['read:hub']},
id="should allow assigning a scope to a default role",
),
],
)
async def test_manage_roles_allows_using_load_roles(role_spec):
roles_to_load = [role_spec]
hub = MockHub(load_roles=roles_to_load) hub = MockHub(load_roles=roles_to_load)
hub.init_db() hub.init_db()
hub.authenticator.manage_roles = True hub.authenticator.manage_roles = True
with pytest.raises(ValueError, match="offloaded to the authenticator"):
await hub.init_role_creation() await hub.init_role_creation()