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.