Skip to content
Merged
72 changes: 58 additions & 14 deletions config/jupyterhub/00-gateway-auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,38 +446,82 @@ def _read_secret_file(secret_dir: Path, key: str) -> str:
return (secret_dir / key).read_text().strip()


def _derive_realm_api_url(issuer_url: str) -> str:
"""Convert a KC realm issuer URL to its admin-API counterpart.

Issuer URL: ``https://kc.example/realms/nebari``
Admin API URL: ``https://kc.example/admin/realms/nebari``

Returns ``""`` when the URL doesn't match the standard KC layout —
callers fall back to the explicit ``KC_REALM_API_URL`` env var.
"""
marker = "/realms/"
idx = issuer_url.find(marker)
if idx == -1:
return ""
return f"{issuer_url[:idx]}/admin{issuer_url[idx:]}"


# Chart-rendered constants. ``templates/hub-config.yaml`` substitutes the
# ``__CHART_*__`` placeholders with values computed from ``nebariapp.hostname``
# at Helm render time, so deployers do not need to repeat the URLs in their
# ``hub.extraEnv``. Untouched placeholders (``__CHART_*__``) mean we are
# either running under a non-substituting renderer (a unit test, a local
# ``kind`` deploy without nebariapp) or the deployer opted out — both cases
# fall back to the historical ``OAUTH_CALLBACK_URL`` env-var path.
_CHART_OAUTH_CALLBACK_URL = "__CHART_OAUTH_CALLBACK_URL__"
_CHART_OAUTH_EXTERNAL_URL = "__CHART_OAUTH_EXTERNAL_URL__"


def _resolve_oauth_urls() -> tuple[str, str] | None:
"""Return (callback_url, external_url) or None when OAuth is opted out.

Chart-rendered values win; env vars are the legacy escape hatch.
"""
if _CHART_OAUTH_CALLBACK_URL.startswith("https://"):
return _CHART_OAUTH_CALLBACK_URL, _CHART_OAUTH_EXTERNAL_URL
env_callback = os.environ.get("OAUTH_CALLBACK_URL")
if env_callback:
return env_callback, os.environ["OAUTH_EXTERNAL_URL"]
return None


# When loaded by JupyterHub, `c` is a magic global. On host imports (tests),
# `c` is undefined and the production wiring is skipped.
#
# Production wiring is gated TWICE:
# 1. `c` must exist (real JupyterHub run, not a host import).
# 2. `OAUTH_CALLBACK_URL` must be set (deployer opted into KC OAuth).
# 2. OAuth URLs must resolve — via chart-rendered constants OR env vars.
# Without (2), the chart's default authenticator (dummy) stays in place,
# so plain `kind` deploys come up without needing the operator Secret.
try:
c # type: ignore[used-before-def]
except NameError:
pass
else:
if os.environ.get("OAUTH_CALLBACK_URL"):
_urls = _resolve_oauth_urls()
if _urls is not None:
_callback_url, _external_url = _urls
_secret_dir = Path(os.environ.get("OAUTH_SECRET_DIR", "/etc/oauth"))
# RBAC for role-gated /shared/<group> mounts.
# Read from env vars on the hub Deployment rather than via
# z2jh.get_config (which sources from the hub Secret's
# embedded values.yaml). The Secret is hold-stable by
# ArgoCD-side ignoreDifferences on rotating fields, and
# mutating non-rotating fields inside it via Helm-template
# +ArgoCD-SSA doesn't reliably propagate. Env-var-on-Deployment
# flows through standard SSA + checksum-driven hub rollout, so
# it actually reaches every cluster on chart upgrade.
# ``realm_api_url`` is normally derived from the same issuer URL
# we mount for the OIDC client (one host, two paths). Deployers
# with a non-standard layout (e.g. KC admin behind a different
# gateway) can still pin it via the ``KC_REALM_API_URL`` env var.
# Role-name is rarely overridden; env-var path kept for parity.
_issuer = _read_secret_file(_secret_dir, "issuer-url")
_realm_api_url = (
os.environ.get("KC_REALM_API_URL")
or _derive_realm_api_url(_issuer)
)
configure(
c, # noqa: F821
issuer=_read_secret_file(_secret_dir, "issuer-url"),
issuer=_issuer,
client_id=_read_secret_file(_secret_dir, "client-id"),
client_secret=_read_secret_file(_secret_dir, "client-secret"),
callback_url=os.environ["OAUTH_CALLBACK_URL"],
external_url=os.environ["OAUTH_EXTERNAL_URL"],
realm_api_url=os.environ.get("KC_REALM_API_URL", ""),
callback_url=_callback_url,
external_url=_external_url,
realm_api_url=_realm_api_url,
shared_mount_role_name=os.environ.get(
"KC_SHARED_MOUNT_ROLE",
"allow-group-directory-creation-role",
Expand Down
22 changes: 22 additions & 0 deletions config/jupyterhub/01-spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,28 @@
c.KubeSpawner.environment = env


# ---------------------------------------------------------------------------
# Profiles (resource sizing)
# ---------------------------------------------------------------------------
# Each profile maps directly to a KubeSpawner profile_list entry. The
# optional ``slug`` key gives a stable identifier independent of the
# human-facing ``display_name``; kubespawner falls back to slugifying
# ``display_name`` when ``slug`` is omitted.
# ``kubespawner_override`` accepts any valid KubeSpawner trait so deployers
# can add node_selector, image, extra_resource_limits (GPU), etc. without
# code changes. Empty list = no profile selector (single-instance mode).
_profiles = get_config("custom.profiles", [])
if _profiles:
c.KubeSpawner.profile_list = _profiles
log.info(
"profiles: loaded %d profile(s): %s",
len(_profiles),
[p.get("slug") or p.get("display_name") for p in _profiles],
)
else:
log.info("profiles: none configured — single-instance mode")


# ---------------------------------------------------------------------------
# Keycloak token exchange helpers (synchronous, shared)
# ---------------------------------------------------------------------------
Expand Down
85 changes: 75 additions & 10 deletions files/keycloak_rbac_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class BootstrapConfig:
hub_client_id: str
role_name: str
shared_mount_groups: tuple[str, ...]
hub_external_url: str

@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "BootstrapConfig":
Expand All @@ -84,6 +85,7 @@ def from_env(cls, env: dict[str, str] | None = None) -> "BootstrapConfig":
hub_client_id=env["HUB_CLIENT_ID"],
role_name=env["ROLE_NAME"],
shared_mount_groups=tuple(g for g in raw_groups.split(",") if g),
hub_external_url=env.get("HUB_EXTERNAL_URL", "").rstrip("/"),
)


Expand Down Expand Up @@ -203,12 +205,40 @@ def ensure_groups_mapper(self, realm: str, scope_name: str = "groups") -> None:
if scope_id is None:
raise RuntimeError(f"client-scope {scope_name!r} creation failed")

desired_config = {
"full.path": "true",
"introspection.token.claim": "true",
"userinfo.token.claim": "true",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "groups",
}
mappers = self._request(
"GET",
f"/{realm}/client-scopes/{scope_id}/protocol-mappers/models",
) or []
if any(m["name"] == "group-membership" for m in mappers):
log.info("scope %r already has group-membership mapper", scope_name)
existing = next((m for m in mappers if m["name"] == "group-membership"), None)
if existing is not None:
# An operator-managed scope may pre-create the mapper with
# ``full.path: false`` — that yields ``groups: ["admin"]`` in
# the token, but the KC admin API returns role-groups as
# ``["/admin"]``, so the spawner's intersection comes up
# empty and ``/shared/<group>`` never mounts. Reconcile the
# mapper config to the chart's desired state on every run.
current = existing.get("config") or {}
if all(current.get(k) == v for k, v in desired_config.items()):
log.info("scope %r group-membership mapper already in desired state", scope_name)
return
log.info(
"reconciling group-membership mapper config on scope %r (was %s)",
scope_name, {k: current.get(k) for k in desired_config},
)
merged = {**current, **desired_config}
self._request(
"PUT",
f"/{realm}/client-scopes/{scope_id}/protocol-mappers/models/{existing['id']}",
body={**existing, "config": merged},
)
return

log.info("adding oidc-group-membership-mapper to scope %r", scope_name)
Expand All @@ -219,14 +249,7 @@ def ensure_groups_mapper(self, realm: str, scope_name: str = "groups") -> None:
"name": "group-membership",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"config": {
"full.path": "true",
"introspection.token.claim": "true",
"userinfo.token.claim": "true",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "groups",
},
"config": desired_config,
},
accept_409=True,
)
Expand All @@ -253,6 +276,47 @@ def enable_service_accounts(self, realm: str, client_uuid: str) -> None:
client["serviceAccountsEnabled"] = True
self._request("PUT", f"/{realm}/clients/{client_uuid}", body=client)

def ensure_hub_client_urls(
self,
realm: str,
client_uuid: str,
hub_external_url: str,
) -> None:
"""Set ``rootUrl`` / ``baseUrl`` / ``initiate.login.uri`` on the hub
OIDC client so KC-initiated flows (account console "Sign in",
third-party launchers) route through ``/hub/oauth_login`` first,
which gives JupyterHub a chance to set its ``oauthenticator-state``
cookie before the callback runs. Without these the
OAuth flow lands directly on ``/hub/oauth_callback`` with no
matching cookie and JupyterHub raises a 400 "OAuth state mismatch".
"""
if not hub_external_url:
log.info(
"hub client URL reconcile skipped: HUB_EXTERNAL_URL unset",
)
return
client = self._request("GET", f"/{realm}/clients/{client_uuid}")
desired_root = hub_external_url
desired_base = "/hub"
desired_initiate = f"{hub_external_url}/hub/oauth_login"
attrs = dict(client.get("attributes") or {})
if (
client.get("rootUrl") == desired_root
and client.get("baseUrl") == desired_base
and attrs.get("initiate.login.uri") == desired_initiate
):
log.info("hub client URLs already in desired state")
return
log.info(
"reconciling hub client URLs (rootUrl=%r, baseUrl=%r, initiate.login.uri=%r)",
desired_root, desired_base, desired_initiate,
)
attrs["initiate.login.uri"] = desired_initiate
client["rootUrl"] = desired_root
client["baseUrl"] = desired_base
client["attributes"] = attrs
self._request("PUT", f"/{realm}/clients/{client_uuid}", body=client)

def get_service_account_user_id(
self,
realm: str,
Expand Down Expand Up @@ -414,6 +478,7 @@ def run(config: BootstrapConfig, kc: KCAdmin) -> None:
log.info("==> 2. hub OIDC client + service account")
hub_uuid = kc.get_client_uuid(config.realm, config.hub_client_id)
kc.enable_service_accounts(config.realm, hub_uuid)
kc.ensure_hub_client_urls(config.realm, hub_uuid, config.hub_external_url)
sa_user_id = kc.get_service_account_user_id(config.realm, hub_uuid)

log.info("==> 3. realm-management roles bound to hub SA")
Expand Down
13 changes: 12 additions & 1 deletion templates/hub-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,20 @@ metadata:
name: {{ include "nebari-data-science-pack.name" . }}-hub-config
labels:
{{- include "nebari-data-science-pack.labels" . | nindent 4 }}
{{- /*
Substitute OAuth URL placeholders in 00-gateway-auth.py with values
computed from .Values.nebariapp.hostname. When nebariapp is disabled
or hostname is unset, leave the __CHART_*__ placeholders intact so the
python file falls back to its OAUTH_CALLBACK_URL env-var path (or to
dummy auth on a plain kind deploy).
*/}}
{{- $gatewayAuthPy := .Files.Get "config/jupyterhub/00-gateway-auth.py" -}}
{{- if and .Values.nebariapp.enabled .Values.nebariapp.hostname }}
{{- $gatewayAuthPy = $gatewayAuthPy | replace "__CHART_OAUTH_CALLBACK_URL__" (printf "https://%s/hub/oauth_callback" .Values.nebariapp.hostname) | replace "__CHART_OAUTH_EXTERNAL_URL__" (printf "https://%s/" .Values.nebariapp.hostname) -}}
{{- end }}
data:
00-gateway-auth.py: |
{{ .Files.Get "config/jupyterhub/00-gateway-auth.py" | indent 4 }}
{{ $gatewayAuthPy | indent 4 }}
01-spawner.py: |
{{ .Files.Get "config/jupyterhub/01-spawner.py" | replace "__SINGLEUSER_CONFIG_CM__" (include "nebari-data-science-pack.singleuser-config" .) | indent 4 }}
02-jhub-apps.py: |
Expand Down
29 changes: 21 additions & 8 deletions templates/keycloak-rbac-bootstrap-job.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,22 @@ where it cannot be tested, linted, or single-stepped. Bumping the
Python image tag forces a checksum-driven Job re-roll on the next
chart upgrade.

Gated on ``rbac.bootstrap.enabled`` AND the deployer providing a
Secret reference for the KC admin password — the chart deploys
fine without RBAC bootstrap pre-configured.
Gated on:
- ``rbac.bootstrap.enabled`` AND
- the deployer providing a KC admin password Secret reference AND
- ``nebariapp.enabled`` — no Nebari operator / KC realm means there
is nothing to bootstrap; lets kind / local-dev installs come up
cleanly without the post-install Job hitting a missing namespace.
The chart deploys fine without RBAC bootstrap pre-configured.
*/}}
{{- if and .Values.rbac.bootstrap.enabled .Values.rbac.bootstrap.kcAdminCredentialSecret }}
{{- if not .Values.rbac.bootstrap.hubClientId }}
{{- fail "rbac.bootstrap.hubClientId must be set when rbac.bootstrap.enabled=true — the role + SA bindings attach to this Keycloak client." }}
{{- end }}
{{- if and .Values.nebariapp.enabled .Values.rbac.bootstrap.enabled .Values.rbac.bootstrap.kcAdminCredentialSecret }}
{{/*
Auto-derive hubClientId from release + chart name when the deployer
hasn't set one explicitly. Matches the nebari-operator NebariApp
client-id pattern: ``jupyterhub-<release>-<chart>``. Override via
``rbac.bootstrap.hubClientId`` for non-standard layouts.
*/}}
{{- $hubClientId := .Values.rbac.bootstrap.hubClientId | default (printf "jupyterhub-%s-%s" .Release.Name .Chart.Name) }}
{{- $script := .Files.Get "files/keycloak_rbac_bootstrap.py" }}
{{- if not $script }}
{{- fail "files/keycloak_rbac_bootstrap.py missing from the chart — repackage with the file included." }}
Expand Down Expand Up @@ -95,11 +103,16 @@ spec:
- name: REALM
value: {{ .Values.rbac.bootstrap.realmName | quote }}
- name: HUB_CLIENT_ID
value: {{ .Values.rbac.bootstrap.hubClientId | quote }}
value: {{ $hubClientId | quote }}
- name: ROLE_NAME
value: {{ .Values.rbac.bootstrap.sharedMountRoleName | quote }}
- name: SHARED_MOUNT_GROUPS
value: {{ .Values.rbac.bootstrap.sharedMountGroups | join "," | quote }}
- name: HUB_EXTERNAL_URL
{{- /* Defaults to https://{nebariapp.hostname} when unset so the
bootstrap can patch rootUrl / baseUrl / initiate.login.uri on the
hub OIDC client. Empty value disables the patch. */}}
value: {{ .Values.rbac.bootstrap.hubExternalUrl | default (printf "https://%s" .Values.nebariapp.hostname) | quote }}
- name: PYTHONUNBUFFERED
value: "1"
volumeMounts:
Expand Down
1 change: 1 addition & 0 deletions tests/integration/test_keycloak_rbac_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ def _config_for(scratch: Scratch, realm: str, kc_url: str,
hub_client_id=scratch.client_id,
role_name=ROLE_NAME,
shared_mount_groups=groups or (scratch.group_path,),
hub_external_url="",
)


Expand Down
Loading
Loading