diff --git a/runtime/hub/core/authenticators/github_oauth.py b/runtime/hub/core/authenticators/github_oauth.py index 97c2f87..383456f 100644 --- a/runtime/hub/core/authenticators/github_oauth.py +++ b/runtime/hub/core/authenticators/github_oauth.py @@ -70,6 +70,24 @@ async def authenticate(self, handler, data=None): if expires_in is not None: result["auth_state"]["expires_at"] = time.time() + int(expires_in) + # Fetch GitHub team memberships and store in auth_state for group sync + access_token = result["auth_state"].get("access_token") + if access_token: + from core import z2jh + from core.groups import fetch_github_teams + + allowed_orgs = self.allowed_organizations or set( + z2jh.get_config("hub.config.GitHubOAuthenticator.allowed_organizations", []) + ) + org_name = next(iter(allowed_orgs), "") + if org_name: + try: + teams = await fetch_github_teams(access_token, org_name) + result["auth_state"]["github_teams"] = teams + log.info("Fetched %d GitHub teams for user %s", len(teams), result.get("name", "?")) + except Exception: + log.warning("Failed to fetch GitHub teams during authentication", exc_info=True) + return result async def refresh_user(self, user, handler=None, **kwargs): @@ -93,6 +111,14 @@ async def refresh_user(self, user, handler=None, **kwargs): if not refresh_token: return True + from core import z2jh + from core.groups import fetch_github_teams + + allowed_orgs = self.allowed_organizations or set( + z2jh.get_config("hub.config.GitHubOAuthenticator.allowed_organizations", []) + ) + org_name = next(iter(allowed_orgs), "") + # Proactively refresh if within 10 minutes of expiry expires_at = auth_state.get("expires_at") if expires_at and time.time() > expires_at - 600: @@ -130,6 +156,15 @@ async def refresh_user(self, user, handler=None, **kwargs): if expires_in is not None: auth_model["auth_state"]["expires_at"] = time.time() + int(expires_in) + # Re-fetch GitHub teams with the new token + new_token = auth_model["auth_state"].get("access_token") + if new_token and org_name: + try: + teams = await fetch_github_teams(new_token, org_name) + auth_model["auth_state"]["github_teams"] = teams + except Exception: + log.warning("Failed to refresh GitHub teams for %s", user.name, exc_info=True) + return auth_model # Not close to expiry — let the parent handle the standard flow diff --git a/runtime/hub/core/groups.py b/runtime/hub/core/groups.py new file mode 100644 index 0000000..35d07a5 --- /dev/null +++ b/runtime/hub/core/groups.py @@ -0,0 +1,222 @@ +# Copyright (C) 2025 Advanced Micro Devices, Inc. All rights reserved. +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Group Sync and Resource Resolution + +Provides functions for: +- Fetching GitHub team memberships via API +- Syncing GitHub teams to JupyterHub groups (protected, source=github-team) +- Resolving user resources from JupyterHub group memberships +""" + +from __future__ import annotations + +import logging + +import aiohttp +from jupyterhub.orm import Group as ORMGroup +from jupyterhub.user import User as JupyterHubUser +from sqlalchemy.orm import Session + +log = logging.getLogger("jupyterhub.groups") + +GITHUB_TEAM_SOURCE = "github-team" +SYSTEM_SOURCE = "system" + + +async def fetch_github_teams(access_token: str, org_name: str) -> list[str]: + """Fetch the user's GitHub team slugs for the given organization. + + Args: + access_token: GitHub OAuth access token. + org_name: GitHub organization name to filter teams by. + + Returns: + List of team slugs the user belongs to in the organization. + """ + headers = { + "Authorization": f"token {access_token}", + "Accept": "application/vnd.github.v3+json", + } + + teams: list[str] = [] + try: + async with ( + aiohttp.ClientSession() as session, + session.get("https://api.github.com/user/teams", headers=headers) as resp, + ): + if resp.status == 200: + data = await resp.json() + for team in data: + if team["organization"]["login"] == org_name: + teams.append(team["slug"]) + else: + log.warning("GitHub API returned status %d when fetching teams", resp.status) + except Exception as e: + log.warning("Error fetching GitHub teams: %s", e) + + return teams + + +def sync_user_github_teams( + user: JupyterHubUser, + team_slugs: list[str], + valid_mapping_keys: set[str], + db: Session, +) -> None: + """Sync a user's GitHub team memberships to JupyterHub groups. + + For each team slug that exists in ``valid_mapping_keys``, ensures + a JupyterHub group exists with ``properties.source = "github-team"`` + and adds the user to it. Removes the user from any github-team groups + they no longer belong to. + + Args: + user: JupyterHub User object. + team_slugs: Team slugs the user currently belongs to on GitHub. + valid_mapping_keys: Set of group names that have resource mappings in config. + db: JupyterHub database session (``self.db`` from a handler or hook). + """ + relevant_teams = set(team_slugs) & valid_mapping_keys + assert user.orm_user is not None # populated by JupyterHub on init + + # Ensure groups exist and add user + for team_slug in relevant_teams: + orm_group = db.query(ORMGroup).filter_by(name=team_slug).first() + if orm_group is None: + orm_group = ORMGroup(name=team_slug) + orm_group.properties = {"source": GITHUB_TEAM_SOURCE} # type: ignore[assignment] + db.add(orm_group) + db.commit() + log.info("Created JupyterHub group '%s' (source: github-team)", team_slug) + elif orm_group.properties.get("source") != GITHUB_TEAM_SOURCE: + # GitHub team always takes priority over admin-created groups + orm_group.properties = {**orm_group.properties, "source": GITHUB_TEAM_SOURCE} # type: ignore[assignment] + db.commit() + log.info("Group '%s' promoted to github-team source", team_slug) + + # Add user to group if not already a member + if orm_group not in user.orm_user.groups: + user.orm_user.groups.append(orm_group) + db.commit() + log.info("Added user '%s' to group '%s'", user.name, team_slug) + + # Remove user from github-team groups they no longer belong to + for orm_group in list(user.orm_user.groups): + if orm_group.properties.get("source") == GITHUB_TEAM_SOURCE and orm_group.name not in relevant_teams: + user.orm_user.groups.remove(orm_group) + db.commit() + log.info("Removed user '%s' from group '%s'", user.name, orm_group.name) + + +def assign_user_to_group( + user: JupyterHubUser, + group_name: str, + db: Session, +) -> None: + """Assign a user to a JupyterHub group, creating it if needed. + + Used for native users to assign them to pattern-based groups. + + Args: + user: JupyterHub User object. + group_name: Name of the group to assign to. + db: JupyterHub database session. + """ + assert user.orm_user is not None # populated by JupyterHub on init + + orm_group = db.query(ORMGroup).filter_by(name=group_name).first() + if orm_group is None: + orm_group = ORMGroup(name=group_name) + orm_group.properties = {"source": SYSTEM_SOURCE} # type: ignore[assignment] + db.add(orm_group) + db.commit() + log.info("Created JupyterHub group '%s' (source: system)", group_name) + elif not orm_group.properties.get("source"): + orm_group.properties = {**orm_group.properties, "source": SYSTEM_SOURCE} # type: ignore[assignment] + db.commit() + + if orm_group not in user.orm_user.groups: + user.orm_user.groups.append(orm_group) + db.commit() + log.info("Added user '%s' to group '%s'", user.name, group_name) + + +def get_resources_for_user( + user: JupyterHubUser, + team_resource_mapping: dict[str, list[str]], +) -> list[str]: + """Get available resources for a user based on their JupyterHub group memberships. + + Iterates over the user's groups and looks up each group name in the + ``team_resource_mapping``. If a group maps to ``"official"``, the full + official resource list is returned immediately (short-circuit). + + Args: + user: JupyterHub User object. + team_resource_mapping: Mapping of group/team names to resource lists. + + Returns: + Deduplicated list of resource names the user can access. + """ + assert user.orm_user is not None # populated by JupyterHub on init + user_group_names = {g.name for g in user.orm_user.groups} + available_resources: list[str] = [] + + for group_name in user_group_names: + if group_name not in team_resource_mapping: + continue + if group_name == "official": + return list(team_resource_mapping["official"]) + available_resources.extend(team_resource_mapping[group_name]) + + # Deduplicate while preserving order + return list(dict.fromkeys(available_resources)) + + +def is_readonly_group(group: ORMGroup) -> bool: + """Check if a group's membership is fully read-only. + + Only system-managed groups are fully read-only. GitHub-team groups + allow manual member additions (admins can add native users to grant + them the same resources). Synced GitHub members are auto-managed: + they may be re-added or removed on the next login sync. + + Args: + group: JupyterHub ORM Group object. + + Returns: + True if the group's source is "system". + """ + return group.properties.get("source") == SYSTEM_SOURCE # type: ignore[union-attr] + + +def is_undeletable_group(group: ORMGroup) -> bool: + """Check if a group cannot be deleted. + + Both GitHub-synced groups and system-managed groups are undeletable. + + Args: + group: JupyterHub ORM Group object. + + Returns: + True if the group's source is "github-team" or "system". + """ + return group.properties.get("source") in (GITHUB_TEAM_SOURCE, SYSTEM_SOURCE) # type: ignore[union-attr] diff --git a/runtime/hub/core/handlers.py b/runtime/hub/core/handlers.py index 49d1486..6e55922 100644 --- a/runtime/hub/core/handlers.py +++ b/runtime/hub/core/handlers.py @@ -59,6 +59,7 @@ "quota_enabled": False, "minimum_quota_to_start": 10, "default_quota": 0, + "team_resource_mapping": {}, } @@ -68,6 +69,8 @@ def configure_handlers( quota_enabled: bool = False, minimum_quota_to_start: int = 10, default_quota: int = 0, + team_resource_mapping: dict[str, list[str]] | None = None, + github_org: str = "", ) -> None: """Configure handler module with runtime settings.""" if accelerator_options is not None: @@ -77,6 +80,9 @@ def configure_handlers( _handler_config["quota_enabled"] = quota_enabled _handler_config["minimum_quota_to_start"] = minimum_quota_to_start _handler_config["default_quota"] = default_quota + if team_resource_mapping is not None: + _handler_config["team_resource_mapping"] = team_resource_mapping + _handler_config["github_org"] = github_org # ============================================================================= @@ -1158,6 +1164,261 @@ async def get(self): self.finish(json.dumps({"repos": repos, "installed": installed})) +# ============================================================================= +# Group Management API Handlers +# ============================================================================= + + +class GroupsAPIHandler(APIHandler): + """Admin API handler for listing groups with enriched source and resources info.""" + + @web.authenticated + async def get(self): + if not self.current_user.admin: + raise web.HTTPError(403, "Admin access required") + + from jupyterhub.orm import Group as ORMGroup + + team_resource_mapping = _handler_config.get("team_resource_mapping", {}) + orm_groups = self.db.query(ORMGroup).order_by(ORMGroup.name).all() + + # Lazy backfill: load_groups creates the group at startup but can't + # set properties on existing groups. Tag it here on first admin access. + from core.groups import SYSTEM_SOURCE + + for g in orm_groups: + if g.name == "native-users" and not g.properties.get("source"): + g.properties = {**g.properties, "source": SYSTEM_SOURCE} + self.db.commit() + + groups = [] + for g in orm_groups: + source = g.properties.get("source", "admin") + resources = team_resource_mapping.get(g.name, []) + groups.append( + { + "name": g.name, + "users": [u.name for u in g.users], + "properties": dict(g.properties), + "source": source, + "resources": resources, + } + ) + + github_org = _handler_config.get("github_org", "") + self.set_header("Content-Type", "application/json") + self.write(json.dumps({"groups": groups, "github_org": github_org})) + + +class GroupDetailAPIHandler(APIHandler): + """Admin API handler for a single group with protection for github-team groups.""" + + @web.authenticated + async def delete(self, group_name): + if not self.current_user.admin: + raise web.HTTPError(403, "Admin access required") + + from jupyterhub.orm import Group as ORMGroup + + orm_group = self.db.query(ORMGroup).filter_by(name=group_name).first() + if not orm_group: + raise web.HTTPError(404, f"Group '{group_name}' not found") + + from core.groups import is_undeletable_group + + if is_undeletable_group(orm_group): + raise web.HTTPError(403, "Cannot delete a protected group") + + self.db.delete(orm_group) + self.db.commit() + self.set_status(204) + + @web.authenticated + async def patch(self, group_name): + if not self.current_user.admin: + raise web.HTTPError(403, "Admin access required") + + from jupyterhub.orm import Group as ORMGroup + + orm_group = self.db.query(ORMGroup).filter_by(name=group_name).first() + if not orm_group: + raise web.HTTPError(404, f"Group '{group_name}' not found") + + try: + body = json.loads(self.request.body) + except (json.JSONDecodeError, ValueError): + raise web.HTTPError(400, "Invalid JSON body") from None + + # Release protection: convert a protected group to admin-managed + if body.get("release_protection"): + orm_group.properties = {k: v for k, v in orm_group.properties.items() if k != "source"} + self.db.commit() + elif "properties" in body: + new_props = body["properties"] + # Preserve system-managed reserved keys + reserved_keys = ("source",) + for key in reserved_keys: + existing = orm_group.properties.get(key) + if existing is not None: + new_props[key] = existing + orm_group.properties = new_props + self.db.commit() + + team_resource_mapping = _handler_config.get("team_resource_mapping", {}) + source = orm_group.properties.get("source", "admin") + resources = team_resource_mapping.get(orm_group.name, []) + + self.write( + json.dumps( + { + "name": orm_group.name, + "users": [u.name for u in orm_group.users], + "properties": dict(orm_group.properties), + "source": source, + "resources": resources, + } + ) + ) + + +class GroupMembersAPIHandler(APIHandler): + """Admin API handler for group membership with protection for github-team groups.""" + + @web.authenticated + async def post(self, group_name): + if not self.current_user.admin: + raise web.HTTPError(403, "Admin access required") + + from jupyterhub.orm import Group as ORMGroup + + orm_group = self.db.query(ORMGroup).filter_by(name=group_name).first() + if not orm_group: + raise web.HTTPError(404, f"Group '{group_name}' not found") + + from core.groups import is_readonly_group + + if is_readonly_group(orm_group): + raise web.HTTPError(403, "Cannot modify members of a protected group") + + try: + body = json.loads(self.request.body) + except (json.JSONDecodeError, ValueError): + raise web.HTTPError(400, "Invalid JSON body") from None + usernames = body.get("users", []) + + from jupyterhub.orm import User as ORMUser + + for username in usernames: + user = self.db.query(ORMUser).filter_by(name=username).first() + if user and user not in orm_group.users: + orm_group.users.append(user) + self.db.commit() + + team_resource_mapping = _handler_config.get("team_resource_mapping", {}) + self.write( + json.dumps( + { + "name": orm_group.name, + "users": [u.name for u in orm_group.users], + "properties": dict(orm_group.properties), + "source": orm_group.properties.get("source", "admin"), + "resources": team_resource_mapping.get(orm_group.name, []), + } + ) + ) + + @web.authenticated + async def delete(self, group_name): + if not self.current_user.admin: + raise web.HTTPError(403, "Admin access required") + + from jupyterhub.orm import Group as ORMGroup + + orm_group = self.db.query(ORMGroup).filter_by(name=group_name).first() + if not orm_group: + raise web.HTTPError(404, f"Group '{group_name}' not found") + + from core.groups import is_readonly_group + + if is_readonly_group(orm_group): + raise web.HTTPError(403, "Cannot modify members of a protected group") + + try: + body = json.loads(self.request.body) + except (json.JSONDecodeError, ValueError): + raise web.HTTPError(400, "Invalid JSON body") from None + usernames = body.get("users", []) + + from jupyterhub.orm import User as ORMUser + + for username in usernames: + user = self.db.query(ORMUser).filter_by(name=username).first() + if user and user in orm_group.users: + orm_group.users.remove(user) + self.db.commit() + + team_resource_mapping = _handler_config.get("team_resource_mapping", {}) + self.write( + json.dumps( + { + "name": orm_group.name, + "users": [u.name for u in orm_group.users], + "properties": dict(orm_group.properties), + "source": orm_group.properties.get("source", "admin"), + "resources": team_resource_mapping.get(orm_group.name, []), + } + ) + ) + + +class GroupSyncAPIHandler(APIHandler): + """Admin API handler to manually trigger GitHub team sync for all users.""" + + @web.authenticated + async def post(self): + if not self.current_user.admin: + raise web.HTTPError(403, "Admin access required") + + github_org = _handler_config.get("github_org", "") + if not github_org: + raise web.HTTPError(400, "No GitHub organization configured") + + from core.groups import fetch_github_teams, sync_user_github_teams + + team_resource_mapping = _handler_config.get("team_resource_mapping", {}) + valid_mapping_keys = set(team_resource_mapping.keys()) + + synced = 0 + failed = 0 + skipped = 0 + + for user in self.users.values(): + if not user.name.startswith("github:"): + skipped += 1 + continue + + try: + auth_state = await user.get_auth_state() + if not auth_state or "access_token" not in auth_state: + skipped += 1 + continue + + access_token = auth_state["access_token"] + teams = await fetch_github_teams(access_token, github_org) + sync_user_github_teams(user, teams, valid_mapping_keys, self.db) + + # Update auth_state so next spawn uses fresh data + auth_state["github_teams"] = teams + await user.save_auth_state(auth_state) + + synced += 1 + except Exception: + self.log.warning("Failed to sync teams for %s", user.name, exc_info=True) + failed += 1 + + self.write(json.dumps({"synced": synced, "failed": failed, "skipped": skipped})) + + # ============================================================================= # Handler Registration # ============================================================================= @@ -1181,6 +1442,11 @@ def get_handlers() -> list[tuple[str, type]]: (r"/admin/api/set-password", AdminAPISetPasswordHandler), (r"/admin/api/batch-set-password", AdminAPIBatchSetPasswordHandler), (r"/admin/api/generate-password", AdminAPIGeneratePasswordHandler), + # Group management API + (r"/admin/api/groups/?", GroupsAPIHandler), + (r"/admin/api/groups/sync/?", GroupSyncAPIHandler), + (r"/admin/api/groups/([^/]+)/?", GroupDetailAPIHandler), + (r"/admin/api/groups/([^/]+)/users/?", GroupMembersAPIHandler), # Accelerator info API (r"/api/accelerators", AcceleratorsAPIHandler), # Resources API @@ -1220,6 +1486,10 @@ def get_handlers() -> list[tuple[str, type]]: "UserQuotaInfoHandler", "ResourcesAPIHandler", "GitHubReposHandler", + # Group management handlers + "GroupsAPIHandler", + "GroupDetailAPIHandler", + "GroupMembersAPIHandler", # Configuration "configure_handlers", # Registration diff --git a/runtime/hub/core/setup.py b/runtime/hub/core/setup.py index 849af3d..340cc03 100644 --- a/runtime/hub/core/setup.py +++ b/runtime/hub/core/setup.py @@ -84,6 +84,14 @@ def setup_hub(c: Any) -> None: c.JupyterHub.spawner_class = RemoteLabKubeSpawner + # ========================================================================= + # Pre-create System Groups + # ========================================================================= + # Ensure system-managed groups exist at startup (before any user logs in). + # Note: load_groups does NOT set properties on existing groups, so the + # source=system backfill is handled lazily in the admin groups API handler. + c.JupyterHub.load_groups = {"native-users": []} + # ========================================================================= # Configure Authenticator # ========================================================================= @@ -94,9 +102,50 @@ def setup_hub(c: Any) -> None: async def auth_state_hook(spawner, auth_state): if auth_state is None: spawner.github_access_token = None + # Still assign native users to their default group + if not spawner.user.name.startswith("github:"): + try: + from core.groups import assign_user_to_group + + assign_user_to_group(spawner.user, "native-users", spawner.user.db) + except Exception as e: + print(f"[GROUPS] Warning: Failed to assign native user group for {spawner.user.name}: {e}") return spawner.github_access_token = auth_state.get("access_token") + # Sync GitHub teams to JupyterHub groups. + # Always fetch fresh teams from GitHub at spawn time so that team + # membership changes (add/remove) are reflected without requiring + # the user to log out and back in. + if spawner.user.name.startswith("github:"): + access_token = auth_state.get("access_token") + if access_token and config.github_org_name: + try: + from core.groups import fetch_github_teams, sync_user_github_teams + + github_teams = await fetch_github_teams(access_token, config.github_org_name) + valid_mapping_keys = set(config.teams.mapping.keys()) + sync_user_github_teams( + spawner.user, + github_teams, + valid_mapping_keys, + spawner.user.db, + ) + # Update cached teams in auth_state so refresh_user() + # retains the latest team list across token refreshes. + auth_state["github_teams"] = github_teams + await spawner.user.save_auth_state(auth_state) + except Exception as e: + print(f"[GROUPS] Warning: Failed to sync GitHub teams for {spawner.user.name}: {e}") + elif not spawner.user.name.startswith("github:"): + # Native user with auth_state but no GitHub teams + try: + from core.groups import assign_user_to_group + + assign_user_to_group(spawner.user, "native-users", spawner.user.db) + except Exception as e: + print(f"[GROUPS] Warning: Failed to assign native user group for {spawner.user.name}: {e}") + c.Spawner.auth_state_hook = auth_state_hook # Set authenticator based on mode @@ -127,6 +176,8 @@ async def auth_state_hook(spawner, auth_state): quota_enabled=config.quota_enabled, minimum_quota_to_start=config.quota.minimumToStart, default_quota=config.quota.defaultQuota, + team_resource_mapping=dict(config.teams.mapping), + github_org=config.github_org_name, ) if not hasattr(c.JupyterHub, "extra_handlers") or c.JupyterHub.extra_handlers is None: @@ -135,6 +186,59 @@ async def auth_state_hook(spawner, auth_state): for route, handler in get_handlers(): c.JupyterHub.extra_handlers.append((route, handler)) + # ========================================================================= + # Protect GitHub-synced groups in native JupyterHub API + # ========================================================================= + # + # JupyterHub registers its own /api/groups/* handlers in default_handlers + # BEFORE extra_handlers, so extra_handlers cannot override them (Tornado + # matches first-registered route first). We replace the handler classes + # in-place within the default_handlers list so that the native routes + # point to our protected subclasses. + + from jupyterhub.apihandlers import default_handlers as _api_default_handlers + from jupyterhub.apihandlers.groups import ( + GroupAPIHandler as _OrigGroupAPI, + ) + from jupyterhub.apihandlers.groups import ( + GroupUsersAPIHandler as _OrigGroupUsersAPI, + ) + from tornado import web + + from core.groups import is_readonly_group as _is_readonly + from core.groups import is_undeletable_group as _is_undeletable + + class _ProtectedGroupAPIHandler(_OrigGroupAPI): + def delete(self, group_name): + group = self.find_group(group_name) + if _is_undeletable(group): + raise web.HTTPError(403, "Cannot delete a protected group") + return super().delete(group_name) + + class _ProtectedGroupUsersAPIHandler(_OrigGroupUsersAPI): + def post(self, group_name): + group = self.find_group(group_name) + if _is_readonly(group): + raise web.HTTPError(403, "Cannot modify members of a protected group") + return super().post(group_name) + + async def delete(self, group_name): + group = self.find_group(group_name) + if _is_readonly(group): + raise web.HTTPError(403, "Cannot modify members of a protected group") + return await super().delete(group_name) + + _replacements = { + _OrigGroupAPI: _ProtectedGroupAPIHandler, + _OrigGroupUsersAPI: _ProtectedGroupUsersAPIHandler, + } + + for i, (route, handler) in enumerate(_api_default_handlers): + if handler in _replacements: + _api_default_handlers[i] = (route, _replacements[handler]) + + print("[SETUP] Protected GitHub-synced groups in native JupyterHub API") + # ========================================================================= # Determine Database URL # ========================================================================= diff --git a/runtime/hub/core/spawner/kubernetes.py b/runtime/hub/core/spawner/kubernetes.py index c869d3f..e6f6dfc 100644 --- a/runtime/hub/core/spawner/kubernetes.py +++ b/runtime/hub/core/spawner/kubernetes.py @@ -35,7 +35,6 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlparse -import aiohttp from jupyterhub.user import User as JupyterHubUser from kubespawner import KubeSpawner from tornado import web @@ -143,118 +142,66 @@ def configure_from_config(cls, config: HubConfig) -> None: cls.GITHUB_APP_NAME = git_config.githubAppName cls.DEFAULT_ACCESS_TOKEN = bool(git_config.defaultAccessToken) - async def get_user_teams(self) -> list[str]: - """ - Get available resources for the user based on their GitHub team membership. + async def get_user_resources(self) -> list[str]: + """Get available resources for the user based on their JupyterHub group memberships. + + For auto-login/dummy modes, returns the "official" resource set. + For all other users, resolves resources from JupyterHub groups + (which are synced from GitHub teams or assigned to native users + via the auth_state_hook). Falls back to legacy pattern matching + for native users with no group assignments. Returns: List of resource names the user can access """ username = self.user.name.strip() - username_upper = username.upper() - self.log.debug(f"Checking resource group for user: {username}") + self.log.debug(f"Resolving resources for user: {username}") # Auto-login or dummy mode: grant all resources if self.auth_mode in ["auto-login", "dummy"]: self.log.debug(f"Auth mode '{self.auth_mode}': granting all resources") return self.team_resource_mapping.get("official", []) - # Native users (no prefix) - check by absence of "github:" prefix - if not username.startswith("github:"): - self.log.debug(f"Native user detected: {username}") - if "AUP" in username_upper: - self.log.debug("Matched AUP user group") - return self.team_resource_mapping.get("AUP", []) - elif "TEST" in username_upper: - self.log.debug("Matched TEST user group") - return self.team_resource_mapping.get("official", []) - # Default for native users - self.log.debug("Native user with default resources") - return self.team_resource_mapping.get("native-users", self.team_resource_mapping.get("official", [])) - - # GitHub users - fetch team membership - auth_state = await self.user.get_auth_state() - if not auth_state or "access_token" not in auth_state: - self.log.debug( - "No auth state or access token found, setting to NONE, check if there is a local account config error." - ) - return ["none"] - - access_token = auth_state["access_token"] - headers = { - "Authorization": f"token {access_token}", - "Accept": "application/vnd.github.v3+json", - } - - teams = [] - try: - async with ( - aiohttp.ClientSession() as session, - session.get("https://api.github.com/user/teams", headers=headers) as resp, - ): - if resp.status == 200: - data = await resp.json() - for team in data: - if team["organization"]["login"] == self.github_org_name: - teams.append(team["slug"]) - else: - self.log.debug(f"GitHub API request failed with status {resp.status}") - except Exception as e: - self.log.debug(f"Error fetching teams: {e}") - - # Map teams to available resources - available_resources = [] - for team, resources in self.team_resource_mapping.items(): - if team in teams: - if team == "official": - available_resources = self.team_resource_mapping[team] - break - else: - available_resources.extend(resources) + # Resolve resources from JupyterHub groups + from core.groups import get_resources_for_user - # Remove duplicates while preserving order - available_resources = list(dict.fromkeys(available_resources)) + available_resources = get_resources_for_user(self.user, self.team_resource_mapping) - # If no teams found, provide basic access - if not available_resources: - available_resources = ["none"] - self.log.debug("No team info for this user, set to none") + if available_resources: + self.log.debug(f"User '{username}' resources from groups: {available_resources}") + return available_resources - self.log.debug(f"User teams: {teams} Available resources: {available_resources}") + # Defensive fallback: auth_state_hook should have already assigned + # native users to the "native-users" group, but if that failed for + # any reason, fall back to the mapping entry directly. + if not username.startswith("github:"): + self.log.debug(f"Native user '{username}' has no groups, using default fallback") + return self.team_resource_mapping.get("native-users", self.team_resource_mapping.get("official", [])) - return available_resources + # GitHub user with no matching groups + self.log.debug(f"No resources found for user '{username}', set to none") + return ["none"] async def options_form(self, _) -> str: - """Generate the HTML form for resource selection.""" + """Generate the HTML form for resource selection. + + Returns a -""" - - html_content = html_content.replace("", injection_script) - - self.log.debug(f"Successfully loaded template from {template_file}") - return html_content - else: - self.log.debug(f"Failed to load template from {template_file}, Fall back to basic form.") - return self._generate_fallback_form(available_resource_names) + return ( + "" + ) except Exception as e: self.log.error(f"Failed to load options form: {e}", exc_info=True) diff --git a/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx b/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx index 202708e..0460385 100644 --- a/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx +++ b/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx @@ -22,16 +22,21 @@ import { Modal, Button, Form, ListGroup, Alert, Badge } from 'react-bootstrap'; import type { Group } from '@auplc/shared'; import * as api from '@auplc/shared'; +// System-managed property keys that should not be shown or edited +const RESERVED_KEYS = new Set(['source']); + // Memoized property list item const PropertyItem = memo(function PropertyItem({ propKey, value, loading, + readOnly, onRemove, }: { propKey: string; value: unknown; loading: boolean; + readOnly: boolean; onRemove: (key: string) => void; }) { return ( @@ -40,14 +45,16 @@ const PropertyItem = memo(function PropertyItem({ {propKey} {String(value)} - + {!readOnly && ( + + )} ); }); @@ -67,10 +74,17 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop const [error, setError] = useState(null); const [properties, setProperties] = useState>({}); - // Initialize state when modal opens + const isGitHubTeam = group?.source === 'github-team'; + const isSystemGroup = group?.source === 'system'; + const isProtected = isGitHubTeam || isSystemGroup; + + // Initialize state when modal opens (exclude reserved keys) const handleEnter = () => { if (group) { - setProperties({ ...group.properties }); + const userProps = Object.fromEntries( + Object.entries(group.properties).filter(([k]) => !RESERVED_KEYS.has(k)) + ); + setProperties(userProps); setError(null); } }; @@ -81,6 +95,11 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop return; } + if (RESERVED_KEYS.has(newPropertyKey)) { + setError(`"${newPropertyKey}" is a reserved key`); + return; + } + setProperties(prev => { if (newPropertyKey in prev) { setError('Property key already exists'); @@ -137,48 +156,108 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop } }; + const handleReleaseProtection = async () => { + if (!group) return; + + const sourceLabel = isGitHubTeam ? 'GitHub-synced' : 'system-managed'; + if (!window.confirm( + `Release protection on "${group.name}"?\n\n` + + `This will convert it from a ${sourceLabel} group to a manually managed group. ` + + `Members will become editable and the group can be deleted.\n\n` + + `Note: If a GitHub team with this name still exists, the group will be re-protected when a team member logs in.` + )) { + return; + } + + try { + setLoading(true); + setError(null); + await api.updateGroup(group.name, { release_protection: true }); + onUpdate(); + onHide(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to release protection'); + } finally { + setLoading(false); + } + }; + if (!group) return null; return ( - Group Properties: {group.name} + + Group Properties: {group.name} + {error && setError(null)}>{error}} + {isGitHubTeam && ( + + + + Members are auto-synced from GitHub Teams. You can add users manually, + but synced members may be re-added or removed when the user next logs in and starts a server. + + + )} + + {isSystemGroup && ( + + System-managed group — membership is read-only. + + )} + + {/* Resources (read-only, from config) */} + {group.resources && group.resources.length > 0 && ( +
+ Mapped Resources +
+ {group.resources.map(r => ( + {r} + ))} +
+ + Resource mappings are defined in values.yaml and cannot be changed from the UI. + +
+ )} + {/* Manage Properties */}
+ Properties

Properties are key-value pairs that can be used to configure group behavior.

-
-
- setNewPropertyKey(e.target.value)} - disabled={loading} - /> -
-
- setNewPropertyValue(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && handleAddProperty()} - disabled={loading} - /> -
-
- +
+
+ setNewPropertyKey(e.target.value)} + disabled={loading} + /> +
+
+ setNewPropertyValue(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleAddProperty()} + disabled={loading} + /> +
+
+ +
-
{Object.keys(properties).length === 0 ? ( @@ -190,6 +269,7 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop propKey={key} value={value} loading={loading} + readOnly={false} onRemove={handleRemoveProperty} /> )) @@ -198,9 +278,20 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop
- + {isProtected ? ( + + ) : ( + + )}
+ {githubOrg && ( + <> + + + {!showInfo && ( + + )} + + )}
+ {/* Group behavior info */} + {githubOrg && showInfo && ( + { setShowInfo(false); localStorage.setItem('grouplist-hide-info', '1'); }}> + + Groups with GitHub badge are synced from{' '} + + {githubOrg} + {' '} + organization teams. Synced members are auto-managed by GitHub, but you can manually add + users (e.g. native users) to grant them the same resources. + Team data is captured at login, and group membership is updated when the user starts a server + — changes on GitHub may not appear until the user re-logs in and spawns. + Use "Sync Now" to immediately refresh all users' team memberships. + If a manually created group shares its name with a GitHub team, it will be automatically converted + to a GitHub-managed group when a team member logs in and spawns. Use "Release Protection" in group + properties to convert a protected group back to manual management. + + )} + {error && ( setError(null)}> {error} )} + {syncResult && ( + setSyncResult(null)}> + {syncResult} + + )} + {/* Search */}
@@ -390,6 +529,7 @@ export function GroupList() { Group Name Members + Resources Actions diff --git a/runtime/hub/frontend/packages/shared/src/api/users.ts b/runtime/hub/frontend/packages/shared/src/api/users.ts index 70c3ed5..0f38e9a 100644 --- a/runtime/hub/frontend/packages/shared/src/api/users.ts +++ b/runtime/hub/frontend/packages/shared/src/api/users.ts @@ -167,9 +167,25 @@ export async function generatePassword(): Promise<{ password: string }> { }); } -export async function getGroups(): Promise { - const response = await apiRequest>("/groups"); - return Object.values(response); +export interface GroupsResponse { + groups: Group[]; + github_org: string; +} + +export async function getGroups(): Promise { + return adminApiRequest("/groups"); +} + +export interface SyncGroupsResponse { + synced: number; + failed: number; + skipped: number; +} + +export async function syncGroups(): Promise { + return adminApiRequest("/groups/sync", { + method: "POST", + }); } export async function getGroup(groupName: string): Promise { @@ -183,16 +199,16 @@ export async function createGroup(groupName: string): Promise { } export async function deleteGroup(groupName: string): Promise { - return apiRequest(`/groups/${encodeURIComponent(groupName)}`, { + return adminApiRequest(`/groups/${encodeURIComponent(groupName)}`, { method: "DELETE", }); } export async function updateGroup( groupName: string, - data: { properties?: Record } + data: { properties?: Record; release_protection?: boolean } ): Promise { - return apiRequest(`/groups/${encodeURIComponent(groupName)}`, { + return adminApiRequest(`/groups/${encodeURIComponent(groupName)}`, { method: "PATCH", body: JSON.stringify(data), }); @@ -202,7 +218,7 @@ export async function addUserToGroup( groupName: string, username: string ): Promise { - return apiRequest(`/groups/${encodeURIComponent(groupName)}/users`, { + return adminApiRequest(`/groups/${encodeURIComponent(groupName)}/users`, { method: "POST", body: JSON.stringify({ users: [username] }), }); @@ -212,7 +228,7 @@ export async function removeUserFromGroup( groupName: string, username: string ): Promise { - return apiRequest(`/groups/${encodeURIComponent(groupName)}/users`, { + return adminApiRequest(`/groups/${encodeURIComponent(groupName)}/users`, { method: "DELETE", body: JSON.stringify({ users: [username] }), }); diff --git a/runtime/hub/frontend/packages/shared/src/types/user.ts b/runtime/hub/frontend/packages/shared/src/types/user.ts index 28cbd47..0a69f61 100644 --- a/runtime/hub/frontend/packages/shared/src/types/user.ts +++ b/runtime/hub/frontend/packages/shared/src/types/user.ts @@ -70,4 +70,6 @@ export interface Group { name: string; users: string[]; properties: Record; + source?: "github-team" | "system" | "admin"; + resources?: string[]; } diff --git a/runtime/hub/frontend/templates/spawn.html b/runtime/hub/frontend/templates/spawn.html index c1dbd45..102ec37 100644 --- a/runtime/hub/frontend/templates/spawn.html +++ b/runtime/hub/frontend/templates/spawn.html @@ -37,15 +37,13 @@
+ {{ spawner_options_form | safe }}
diff --git a/runtime/hub/tests/test_groups.py b/runtime/hub/tests/test_groups.py new file mode 100644 index 0000000..0325d2f --- /dev/null +++ b/runtime/hub/tests/test_groups.py @@ -0,0 +1,354 @@ +# Copyright (C) 2025 Advanced Micro Devices, Inc. All rights reserved. +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Unit tests for core.groups module.""" + +from __future__ import annotations + +import sys +from types import ModuleType +from unittest.mock import MagicMock + +# --------------------------------------------------------------------------- +# Stub out jupyterhub.orm so we can import core.groups without a real hub +# --------------------------------------------------------------------------- + + +class FakeORMGroup: + """Lightweight stand-in for jupyterhub.orm.Group.""" + + def __init__(self, name: str = "", properties: dict | None = None): + self.name = name + self.properties = properties or {} + self.users: list = [] + + def __repr__(self): + return f"" + + +class FakeQuery: + """Minimal query mock that supports filter_by().first().""" + + def __init__(self, groups: list[FakeORMGroup]): + self._groups = groups + + def filter_by(self, **kwargs): + name = kwargs.get("name") + self._filtered = [g for g in self._groups if g.name == name] + return self + + def first(self): + return self._filtered[0] if self._filtered else None + + +class FakeDB: + """Minimal DB mock with query(), add(), commit(), delete().""" + + def __init__(self, groups: list[FakeORMGroup] | None = None): + self.groups = groups or [] + self._added: list = [] + self._deleted: list = [] + self._committed = 0 + + def query(self, model): + return FakeQuery(self.groups) + + def add(self, obj): + self._added.append(obj) + self.groups.append(obj) + + def delete(self, obj): + self._deleted.append(obj) + self.groups.remove(obj) + + def commit(self): + self._committed += 1 + + +class FakeORMUser: + """Stand-in for jupyterhub.orm.User.""" + + def __init__(self, name: str = "", groups: list[FakeORMGroup] | None = None): + self.name = name + self.groups = groups or [] + + +class FakeUser: + """Stand-in for jupyterhub.user.User (wrapper around orm_user).""" + + def __init__(self, name: str = "", groups: list[FakeORMGroup] | None = None): + self.name = name + self.orm_user = FakeORMUser(name=name, groups=groups or []) + self.db = FakeDB(groups or []) + + +import importlib.util # noqa: E402 +from pathlib import Path # noqa: E402 + +# Install stubs before importing core.groups +_orm_mod = ModuleType("jupyterhub.orm") +_orm_mod.Group = FakeORMGroup # type: ignore[attr-defined] +sys.modules.setdefault("jupyterhub", ModuleType("jupyterhub")) +sys.modules["jupyterhub.orm"] = _orm_mod + +# Also stub aiohttp so the module-level import doesn't fail +sys.modules.setdefault("aiohttp", MagicMock()) + +_groups_path = Path(__file__).resolve().parent.parent / "core" / "groups.py" +_spec = importlib.util.spec_from_file_location("core.groups", _groups_path) +_groups_mod = importlib.util.module_from_spec(_spec) +sys.modules["core.groups"] = _groups_mod +_spec.loader.exec_module(_groups_mod) # type: ignore[union-attr] + +GITHUB_TEAM_SOURCE = _groups_mod.GITHUB_TEAM_SOURCE +SYSTEM_SOURCE = _groups_mod.SYSTEM_SOURCE +assign_user_to_group = _groups_mod.assign_user_to_group +get_resources_for_user = _groups_mod.get_resources_for_user +is_readonly_group = _groups_mod.is_readonly_group +is_undeletable_group = _groups_mod.is_undeletable_group +sync_user_github_teams = _groups_mod.sync_user_github_teams + + +# ========================================================================= +# is_readonly_group / is_undeletable_group +# ========================================================================= + + +class TestGroupProtection: + def test_github_team_is_not_readonly(self): + # GitHub-team groups allow manual member additions + g = FakeORMGroup("gpu", {"source": GITHUB_TEAM_SOURCE}) + assert is_readonly_group(g) is False + + def test_system_is_readonly(self): + g = FakeORMGroup("native-users", {"source": SYSTEM_SOURCE}) + assert is_readonly_group(g) is True + + def test_admin_is_not_readonly(self): + g = FakeORMGroup("custom", {"source": "admin"}) + assert is_readonly_group(g) is False + + def test_no_source_is_not_readonly(self): + g = FakeORMGroup("old-group", {}) + assert is_readonly_group(g) is False + + def test_github_team_is_undeletable(self): + g = FakeORMGroup("gpu", {"source": GITHUB_TEAM_SOURCE}) + assert is_undeletable_group(g) is True + + def test_system_is_undeletable(self): + g = FakeORMGroup("native-users", {"source": SYSTEM_SOURCE}) + assert is_undeletable_group(g) is True + + def test_admin_is_deletable(self): + g = FakeORMGroup("custom", {"source": "admin"}) + assert is_undeletable_group(g) is False + + +# ========================================================================= +# sync_user_github_teams +# ========================================================================= + + +class TestSyncUserGitHubTeams: + def test_creates_new_group_and_adds_user(self): + db = FakeDB() + user = FakeUser("alice") + user.db = db + + sync_user_github_teams(user, ["gpu"], {"gpu", "cpu"}, db) + + assert len(db.groups) == 1 + assert db.groups[0].name == "gpu" + assert db.groups[0].properties["source"] == GITHUB_TEAM_SOURCE + assert db.groups[0] in user.orm_user.groups + + def test_ignores_teams_not_in_mapping(self): + db = FakeDB() + user = FakeUser("alice") + user.db = db + + sync_user_github_teams(user, ["unknown-team"], {"gpu", "cpu"}, db) + + assert len(db.groups) == 0 + + def test_adds_user_to_existing_group(self): + existing = FakeORMGroup("gpu", {"source": GITHUB_TEAM_SOURCE}) + db = FakeDB([existing]) + user = FakeUser("alice") + user.db = db + + sync_user_github_teams(user, ["gpu"], {"gpu"}, db) + + assert existing in user.orm_user.groups + + def test_removes_user_from_old_github_team(self): + old_group = FakeORMGroup("cpu", {"source": GITHUB_TEAM_SOURCE}) + db = FakeDB([old_group]) + user = FakeUser("alice", groups=[old_group]) + user.db = db + + # User is no longer in "cpu" team, only in "gpu" + sync_user_github_teams(user, ["gpu"], {"gpu", "cpu"}, db) + + assert old_group not in user.orm_user.groups + + def test_does_not_remove_user_from_non_github_group(self): + admin_group = FakeORMGroup("custom", {"source": "admin"}) + db = FakeDB([admin_group]) + user = FakeUser("alice", groups=[admin_group]) + user.db = db + + sync_user_github_teams(user, [], {"gpu"}, db) + + # Admin group should not be touched + assert admin_group in user.orm_user.groups + + def test_promotes_admin_group_to_github_team(self): + admin_group = FakeORMGroup("gpu", {"source": "admin"}) + db = FakeDB([admin_group]) + user = FakeUser("alice") + user.db = db + + sync_user_github_teams(user, ["gpu"], {"gpu"}, db) + + assert admin_group.properties["source"] == GITHUB_TEAM_SOURCE + + def test_backfills_source_on_group_without_source(self): + no_source = FakeORMGroup("gpu", {}) + db = FakeDB([no_source]) + user = FakeUser("alice") + user.db = db + + sync_user_github_teams(user, ["gpu"], {"gpu"}, db) + + assert no_source.properties["source"] == GITHUB_TEAM_SOURCE + + +# ========================================================================= +# assign_user_to_group +# ========================================================================= + + +class TestAssignUserToGroup: + def test_creates_group_if_not_exists(self): + db = FakeDB() + user = FakeUser("bob") + user.db = db + + assign_user_to_group(user, "native-users", db) + + assert len(db.groups) == 1 + assert db.groups[0].name == "native-users" + assert db.groups[0].properties["source"] == SYSTEM_SOURCE + assert db.groups[0] in user.orm_user.groups + + def test_adds_user_to_existing_group(self): + existing = FakeORMGroup("native-users", {"source": SYSTEM_SOURCE}) + db = FakeDB([existing]) + user = FakeUser("bob") + user.db = db + + assign_user_to_group(user, "native-users", db) + + assert existing in user.orm_user.groups + + def test_does_not_duplicate_membership(self): + existing = FakeORMGroup("native-users", {"source": SYSTEM_SOURCE}) + db = FakeDB([existing]) + user = FakeUser("bob", groups=[existing]) + user.db = db + + assign_user_to_group(user, "native-users", db) + + # No extra commit for membership since user is already a member + assert user.orm_user.groups.count(existing) == 1 + + def test_backfills_source_on_existing_group_without_source(self): + no_source = FakeORMGroup("native-users", {}) + db = FakeDB([no_source]) + user = FakeUser("bob") + user.db = db + + assign_user_to_group(user, "native-users", db) + + assert no_source.properties["source"] == SYSTEM_SOURCE + + +# ========================================================================= +# get_resources_for_user +# ========================================================================= + + +class TestGetResourcesForUser: + def _make_user_with_groups(self, group_names: list[str]) -> FakeUser: + groups = [FakeORMGroup(name) for name in group_names] + return FakeUser("alice", groups=groups) + + def test_returns_resources_for_matching_groups(self): + user = self._make_user_with_groups(["gpu"]) + mapping = {"gpu": ["res-a", "res-b"], "cpu": ["res-c"]} + + result = get_resources_for_user(user, mapping) + + assert result == ["res-a", "res-b"] + + def test_official_shortcircuits(self): + user = self._make_user_with_groups(["official", "gpu"]) + mapping = { + "official": ["res-a", "res-b", "res-c"], + "gpu": ["res-a"], + } + + result = get_resources_for_user(user, mapping) + + assert result == ["res-a", "res-b", "res-c"] + + def test_merges_multiple_groups(self): + user = self._make_user_with_groups(["gpu", "cpu"]) + mapping = {"gpu": ["res-a"], "cpu": ["res-b", "res-c"]} + + result = get_resources_for_user(user, mapping) + + assert set(result) == {"res-a", "res-b", "res-c"} + + def test_deduplicates_resources(self): + user = self._make_user_with_groups(["gpu", "cpu"]) + mapping = {"gpu": ["res-a", "res-b"], "cpu": ["res-b", "res-c"]} + + result = get_resources_for_user(user, mapping) + + assert set(result) == {"res-a", "res-b", "res-c"} + assert len(result) == 3 # no duplicates + + def test_returns_empty_for_no_matching_groups(self): + user = self._make_user_with_groups(["unknown"]) + mapping = {"gpu": ["res-a"]} + + result = get_resources_for_user(user, mapping) + + assert result == [] + + def test_returns_empty_for_user_with_no_groups(self): + user = self._make_user_with_groups([]) + mapping = {"gpu": ["res-a"]} + + result = get_resources_for_user(user, mapping) + + assert result == []