From 417808b3983bc3793aa59b85b7848d213d31b140 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:33:26 +0800 Subject: [PATCH 01/10] feat: unify GitHub Teams and JupyterHub Groups with protection and resource mapping - Add core/groups.py module for GitHub team sync, resource resolution, and group protection logic (readonly/undeletable checks) - Fetch GitHub teams during OAuth authentication and store in auth_state - Sync GitHub teams to JupyterHub groups via auth_state_hook at spawn time, with automatic source tagging (github-team, system, admin) - GitHub team groups take priority: if an admin group shares a name with a GitHub team, it is promoted to github-team source on next login - Refactor spawner get_user_resources() to resolve from group memberships instead of legacy username pattern matching (AUP/TEST removed) - Protect native JupyterHub API: replace default group handlers with protected subclasses that block delete/member changes on protected groups - Add admin API endpoints for groups with enriched source/resources info, reserved property key protection, and github_org metadata - Assign native users to system-managed "native-users" group even when auth_state is None (JUPYTERHUB_CRYPT_KEY not required) - Frontend: add group type badges (GitHub/System/Manual), disable member editing for readonly groups, show GitHub org link and behavior docs, filter reserved keys from property editor --- .../hub/core/authenticators/github_oauth.py | 18 ++ runtime/hub/core/groups.py | 247 ++++++++++++++++++ runtime/hub/core/handlers.py | 206 +++++++++++++++ runtime/hub/core/setup.py | 87 ++++++ runtime/hub/core/spawner/kubernetes.py | 90 ++----- .../admin/src/components/EditGroupModal.tsx | 129 ++++++--- .../admin/src/components/EditUserModal.tsx | 2 +- .../apps/admin/src/pages/GroupList.tsx | 74 +++++- .../frontend/packages/shared/src/api/users.ts | 18 +- .../packages/shared/src/types/user.ts | 2 + 10 files changed, 752 insertions(+), 121 deletions(-) create mode 100644 runtime/hub/core/groups.py diff --git a/runtime/hub/core/authenticators/github_oauth.py b/runtime/hub/core/authenticators/github_oauth.py index 97c2f87..65ebe52 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): diff --git a/runtime/hub/core/groups.py b/runtime/hub/core/groups.py new file mode 100644 index 0000000..46a6f6c --- /dev/null +++ b/runtime/hub/core/groups.py @@ -0,0 +1,247 @@ +# 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 +from typing import TYPE_CHECKING + +import aiohttp + +if TYPE_CHECKING: + from jupyterhub.orm import Group as ORMGroup + from jupyterhub.user import User as JupyterHubUser + +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: object, +) -> 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). + """ + from jupyterhub.orm import Group as ORMGroup + + relevant_teams = set(team_slugs) & valid_mapping_keys + + # 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} + 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} + 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: object, +) -> 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. + """ + from jupyterhub.orm import Group as ORMGroup + + 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} + 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} + 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 ensure_system_group(group_name: str, db: object) -> None: + """Ensure a system-managed group exists with source=system. + + Called during hub startup to guarantee system groups are always present + and properly tagged, even before any user logs in. + + Args: + group_name: Name of the system group. + db: JupyterHub database session. + """ + from jupyterhub.orm import Group as ORMGroup + + 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} + db.add(orm_group) + db.commit() + log.info("Created system group '%s' on startup", group_name) + elif not orm_group.properties.get("source"): + orm_group.properties = {**orm_group.properties, "source": SYSTEM_SOURCE} + db.commit() + log.info("Backfilled source=system on group '%s'", 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. + """ + 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 is read-only (managed by GitHub Teams or the system). + + Read-only groups cannot have their members, properties, or existence + modified through the admin UI or API. + + 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) + + +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) diff --git a/runtime/hub/core/handlers.py b/runtime/hub/core/handlers.py index 49d1486..8d2fa89 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,198 @@ 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() + + # Backfill source for known system groups + 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") + + body = json.loads(self.request.body) + if "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") + + body = json.loads(self.request.body) + 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") + + body = json.loads(self.request.body) + 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, []), + } + ) + ) + + # ============================================================================= # Handler Registration # ============================================================================= @@ -1181,6 +1379,10 @@ 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/([^/]+)/?", GroupDetailAPIHandler), + (r"/admin/api/groups/([^/]+)/users/?", GroupMembersAPIHandler), # Accelerator info API (r"/api/accelerators", AcceleratorsAPIHandler), # Resources API @@ -1220,6 +1422,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..3b86654 100644 --- a/runtime/hub/core/setup.py +++ b/runtime/hub/core/setup.py @@ -94,9 +94,41 @@ 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 + github_teams = auth_state.get("github_teams") + if github_teams is not None: + try: + from core.groups import sync_user_github_teams + + valid_mapping_keys = set(config.teams.mapping.keys()) + sync_user_github_teams( + spawner.user, + github_teams, + valid_mapping_keys, + spawner.user.db, + ) + 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 +159,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 +169,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..1ee16c5 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,91 +142,48 @@ 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", [])) + # Resolve resources from JupyterHub groups + from core.groups import get_resources_for_user - # 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"] + available_resources = get_resources_for_user(self.user, self.team_resource_mapping) - access_token = auth_state["access_token"] - headers = { - "Authorization": f"token {access_token}", - "Accept": "application/vnd.github.v3+json", - } + if available_resources: + self.log.debug(f"User '{username}' resources from groups: {available_resources}") + return available_resources - 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) - - # Remove duplicates while preserving order - available_resources = list(dict.fromkeys(available_resources)) - - # 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") - - self.log.debug(f"User teams: {teams} Available resources: {available_resources}") + # Fallback: native users with no group assignments + 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.""" try: - available_resource_names = await self.get_user_teams() + available_resource_names = await self.get_user_resources() self.log.debug(f"Providing users with following resources: {available_resource_names}") # Use template path diff --git a/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx b/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx index 202708e..a212c23 100644 --- a/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx +++ b/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx @@ -27,11 +27,13 @@ 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 +42,16 @@ const PropertyItem = memo(function PropertyItem({ {propKey} {String(value)} - + {!readOnly && ( + + )} ); }); @@ -67,10 +71,20 @@ 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 isUndeletable = isGitHubTeam || isSystemGroup; + + // System-managed property keys that should not be shown or edited + const RESERVED_KEYS = new Set(['source']); + + // 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'); @@ -142,43 +161,74 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop return ( - Group Properties: {group.name} + + Group Properties: {group.name} + {error && setError(null)}>{error}} + {isGitHubTeam && ( + + + Synced from GitHub Teams — membership and properties are read-only. + + )} + + {isSystemGroup && ( + + System-managed group — membership and properties are 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 +240,7 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop propKey={key} value={value} loading={loading} + readOnly={false} onRemove={handleRemoveProperty} /> )) @@ -198,9 +249,13 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop
- + {isUndeletable ? ( +
+ ) : ( + + )}
+ {githubOrg && ( + + )}
+ {/* Group behavior info */} + {githubOrg && ( + + + Groups with GitHub badge are synced from{' '} + + {githubOrg} + {' '} + organization teams. Their members are managed by GitHub and cannot be modified here. + 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. + + )} + {error && ( setError(null)}> {error} @@ -390,6 +445,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..87e9584 100644 --- a/runtime/hub/frontend/packages/shared/src/api/users.ts +++ b/runtime/hub/frontend/packages/shared/src/api/users.ts @@ -167,9 +167,13 @@ 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 async function getGroup(groupName: string): Promise { @@ -183,7 +187,7 @@ 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", }); } @@ -192,7 +196,7 @@ export async function updateGroup( groupName: string, data: { properties?: Record } ): Promise { - return apiRequest(`/groups/${encodeURIComponent(groupName)}`, { + return adminApiRequest(`/groups/${encodeURIComponent(groupName)}`, { method: "PATCH", body: JSON.stringify(data), }); @@ -202,7 +206,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 +216,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[]; } From 1d9a8b3faa6fabb67422ffe09134de2b54021167 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:33:53 +0800 Subject: [PATCH 02/10] fix: remove dead code, add release protection, and improve sync docs - Remove unused ensure_system_group() function (replaced by load_groups) - Add Release Protection button for protected groups in EditGroupModal - Add release_protection PATCH support in handlers.py - Move RESERVED_KEYS to module-level constant in EditGroupModal - Update info banner with sync timing and release protection docs - Improve lazy backfill comment in admin groups API handler - Add release_protection to updateGroup API type signature - Pre-create native-users group via load_groups at startup --- runtime/hub/core/groups.py | 25 ---------- runtime/hub/core/handlers.py | 10 +++- runtime/hub/core/setup.py | 8 +++ .../admin/src/components/EditGroupModal.tsx | 49 ++++++++++++++++--- .../apps/admin/src/pages/GroupList.tsx | 4 +- .../frontend/packages/shared/src/api/users.ts | 2 +- 6 files changed, 61 insertions(+), 37 deletions(-) diff --git a/runtime/hub/core/groups.py b/runtime/hub/core/groups.py index 46a6f6c..f059d35 100644 --- a/runtime/hub/core/groups.py +++ b/runtime/hub/core/groups.py @@ -162,31 +162,6 @@ def assign_user_to_group( log.info("Added user '%s' to group '%s'", user.name, group_name) -def ensure_system_group(group_name: str, db: object) -> None: - """Ensure a system-managed group exists with source=system. - - Called during hub startup to guarantee system groups are always present - and properly tagged, even before any user logs in. - - Args: - group_name: Name of the system group. - db: JupyterHub database session. - """ - from jupyterhub.orm import Group as ORMGroup - - 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} - db.add(orm_group) - db.commit() - log.info("Created system group '%s' on startup", group_name) - elif not orm_group.properties.get("source"): - orm_group.properties = {**orm_group.properties, "source": SYSTEM_SOURCE} - db.commit() - log.info("Backfilled source=system on group '%s'", group_name) - - def get_resources_for_user( user: JupyterHubUser, team_resource_mapping: dict[str, list[str]], diff --git a/runtime/hub/core/handlers.py b/runtime/hub/core/handlers.py index 8d2fa89..bdf7294 100644 --- a/runtime/hub/core/handlers.py +++ b/runtime/hub/core/handlers.py @@ -1182,7 +1182,8 @@ async def get(self): team_resource_mapping = _handler_config.get("team_resource_mapping", {}) orm_groups = self.db.query(ORMGroup).order_by(ORMGroup.name).all() - # Backfill source for known system groups + # 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: @@ -1244,7 +1245,12 @@ async def patch(self, group_name): raise web.HTTPError(404, f"Group '{group_name}' not found") body = json.loads(self.request.body) - if "properties" in body: + + # 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",) diff --git a/runtime/hub/core/setup.py b/runtime/hub/core/setup.py index 3b86654..750d9cc 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 # ========================================================================= diff --git a/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx b/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx index a212c23..3aa0452 100644 --- a/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx +++ b/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx @@ -22,6 +22,9 @@ 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, @@ -73,10 +76,7 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop const isGitHubTeam = group?.source === 'github-team'; const isSystemGroup = group?.source === 'system'; - const isUndeletable = isGitHubTeam || isSystemGroup; - - // System-managed property keys that should not be shown or edited - const RESERVED_KEYS = new Set(['source']); + const isProtected = isGitHubTeam || isSystemGroup; // Initialize state when modal opens (exclude reserved keys) const handleEnter = () => { @@ -156,6 +156,32 @@ 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 ( @@ -171,13 +197,13 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop {isGitHubTeam && ( - Synced from GitHub Teams — membership and properties are read-only. + Synced from GitHub Teams — membership is read-only. )} {isSystemGroup && ( - System-managed group — membership and properties are read-only. + System-managed group — membership is read-only. )} @@ -249,8 +275,15 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop
- {isUndeletable ? ( -
+ {isProtected ? ( + ) : ( {githubOrg && ( - + <> + + + )}
@@ -414,6 +446,7 @@ export function GroupList() { 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. @@ -426,6 +459,12 @@ export function GroupList() { )} + {syncResult && ( + setSyncResult(null)}> + {syncResult} + + )} + {/* Search */}
diff --git a/runtime/hub/frontend/packages/shared/src/api/users.ts b/runtime/hub/frontend/packages/shared/src/api/users.ts index d6b9949..0f38e9a 100644 --- a/runtime/hub/frontend/packages/shared/src/api/users.ts +++ b/runtime/hub/frontend/packages/shared/src/api/users.ts @@ -176,6 +176,18 @@ 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 { return apiRequest(`/groups/${encodeURIComponent(groupName)}`); } From ff09fa67059c072fba87e827ba60baab90ccb7e2 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:45:05 +0800 Subject: [PATCH 07/10] fix: inject AVAILABLE_RESOURCES via spawner options_form for resource filtering The custom spawn.html template referenced a non-existent `spawner_options` variable, so `window.AVAILABLE_RESOURCES` was never set. The React spawn app then showed all resources to every user regardless of group membership. Fix: `options_form()` now returns a ` -""" + available_resources_js = json.dumps(available_resource_names) + single_node_mode_js = "true" if self.single_node_mode else "false" - 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/pages/GroupList.tsx b/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx index 0430049..5207e60 100644 --- a/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx +++ b/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx @@ -251,6 +251,9 @@ export function GroupList() { const [createError, setCreateError] = useState(null); const [syncing, setSyncing] = useState(false); const [syncResult, setSyncResult] = useState(null); + const [showInfo, setShowInfo] = useState(() => + localStorage.getItem('grouplist-hide-info') !== '1' + ); // Debounce search input useEffect(() => { @@ -353,6 +356,7 @@ export function GroupList() { setSyncResult( `Sync complete: ${result.synced} synced, ${result.failed} failed, ${result.skipped} skipped` ); + setTimeout(() => setSyncResult(null), 5000); await loadGroups(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to sync groups'); @@ -395,7 +399,7 @@ export function GroupList() { {githubOrg && ( <> + {!showInfo && ( + + )} )}
@@ -435,8 +448,8 @@ export function GroupList() {
{/* Group behavior info */} - {githubOrg && ( - + {githubOrg && showInfo && ( + { setShowInfo(false); localStorage.setItem('grouplist-hide-info', '1'); }}> Groups with GitHub badge are synced from{' '} 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 }}
From 89d7c1b604c2ec216952ed1375b69740a7c9051a Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:06:12 +0800 Subject: [PATCH 08/10] feat(admin): collapse resource badges in group list with expand toggle --- .../apps/admin/src/pages/GroupList.tsx | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx b/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx index 5207e60..52852b1 100644 --- a/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx +++ b/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx @@ -103,6 +103,42 @@ interface UserOption { label: string; } +const COLLAPSED_LIMIT = 3; + +function ResourceBadges({ resources }: { resources: string[] }) { + const [expanded, setExpanded] = useState(false); + if (resources.length === 0) return --; + const visible = expanded ? resources : resources.slice(0, COLLAPSED_LIMIT); + const hidden = resources.length - COLLAPSED_LIMIT; + return ( +
+ {visible.map(r => {r})} + {!expanded && hidden > 0 && ( + setExpanded(true)} + title="Show all" + > + +{hidden} + + )} + {expanded && resources.length > COLLAPSED_LIMIT && ( + setExpanded(false)} + title="Collapse" + > + ▲ + + )} +
+ ); +} + // Memoized GroupRow component with inline member management interface GroupRowProps { group: Group; @@ -211,15 +247,7 @@ const GroupRow = memo(function GroupRow({ group, onEdit, onMembersChange, loadUs /> - {group.resources && group.resources.length > 0 ? ( -
- {group.resources.map(r => ( - {r} - ))} -
- ) : ( - -- - )} +