diff --git a/config/jupyterhub/00-gateway-auth.py b/config/jupyterhub/00-gateway-auth.py index 118e341..8142450 100644 --- a/config/jupyterhub/00-gateway-auth.py +++ b/config/jupyterhub/00-gateway-auth.py @@ -446,12 +446,52 @@ 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: @@ -459,25 +499,29 @@ def _read_secret_file(secret_dir: Path, key: str) -> str: 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/ 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", diff --git a/config/jupyterhub/01-spawner.py b/config/jupyterhub/01-spawner.py index 0cdf91e..0a64c45 100644 --- a/config/jupyterhub/01-spawner.py +++ b/config/jupyterhub/01-spawner.py @@ -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) # --------------------------------------------------------------------------- diff --git a/files/keycloak_rbac_bootstrap.py b/files/keycloak_rbac_bootstrap.py index c89d754..52d06cb 100644 --- a/files/keycloak_rbac_bootstrap.py +++ b/files/keycloak_rbac_bootstrap.py @@ -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": @@ -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("/"), ) @@ -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/`` 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) @@ -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, ) @@ -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, @@ -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") diff --git a/templates/hub-config.yaml b/templates/hub-config.yaml index 6797863..ce55d09 100644 --- a/templates/hub-config.yaml +++ b/templates/hub-config.yaml @@ -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: | diff --git a/templates/keycloak-rbac-bootstrap-job.yaml b/templates/keycloak-rbac-bootstrap-job.yaml index 0db33e7..f57d3fb 100644 --- a/templates/keycloak-rbac-bootstrap-job.yaml +++ b/templates/keycloak-rbac-bootstrap-job.yaml @@ -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--``. 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." }} @@ -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: diff --git a/tests/integration/test_keycloak_rbac_bootstrap.py b/tests/integration/test_keycloak_rbac_bootstrap.py index 7dbd03d..78dbab4 100644 --- a/tests/integration/test_keycloak_rbac_bootstrap.py +++ b/tests/integration/test_keycloak_rbac_bootstrap.py @@ -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="", ) diff --git a/tests/unit/test_keycloak_authenticator.py b/tests/unit/test_keycloak_authenticator.py index 84b518b..aa7d74f 100644 --- a/tests/unit/test_keycloak_authenticator.py +++ b/tests/unit/test_keycloak_authenticator.py @@ -212,7 +212,7 @@ def test_any_keycloak_authenticated_user_is_allowed_by_default(): # --------------------------------------------------------------------------- -# Cycle 5 — production wiring is opt-in via env var +# Cycle 5 — production wiring is opt-in via chart-rendered URLs or env vars # --------------------------------------------------------------------------- def test_module_loads_in_jupyterhub_context_without_oauth_env(monkeypatch): @@ -234,3 +234,63 @@ def test_module_loads_in_jupyterhub_context_without_oauth_env(monkeypatch): assert "JupyterHub" not in c.__dict__, ( "Authenticator was configured despite missing OAUTH env vars" ) + + +def test_resolve_returns_none_when_placeholders_intact_and_env_unset(monkeypatch): + """No chart substitution + no env vars → no auth wiring intended.""" + for key in ("OAUTH_CALLBACK_URL", "OAUTH_EXTERNAL_URL"): + monkeypatch.delenv(key, raising=False) + mod = load_config_module("00-gateway-auth.py") + assert mod._resolve_oauth_urls() is None + + +def test_resolve_prefers_chart_rendered_urls_over_env(monkeypatch): + """Helm-rendered constants are the supported production path.""" + monkeypatch.setenv("OAUTH_CALLBACK_URL", "https://env-fallback.test/hub/oauth_callback") + monkeypatch.setenv("OAUTH_EXTERNAL_URL", "https://env-fallback.test/") + mod = load_config_module("00-gateway-auth.py") + monkeypatch.setattr(mod, "_CHART_OAUTH_CALLBACK_URL", "https://chart.test/hub/oauth_callback") + monkeypatch.setattr(mod, "_CHART_OAUTH_EXTERNAL_URL", "https://chart.test/") + assert mod._resolve_oauth_urls() == ( + "https://chart.test/hub/oauth_callback", + "https://chart.test/", + ) + + +def test_resolve_falls_back_to_env_when_placeholders_intact(monkeypatch): + """Env-var path stays as the escape hatch for non-substituting renders.""" + monkeypatch.setenv("OAUTH_CALLBACK_URL", "https://hub.example.test/hub/oauth_callback") + monkeypatch.setenv("OAUTH_EXTERNAL_URL", "https://hub.example.test/") + mod = load_config_module("00-gateway-auth.py") + # Placeholders left untouched (no Helm substitution at test time). + assert mod._CHART_OAUTH_CALLBACK_URL.startswith("__CHART_") + assert mod._resolve_oauth_urls() == ( + "https://hub.example.test/hub/oauth_callback", + "https://hub.example.test/", + ) + + +# --------------------------------------------------------------------------- +# Cycle 6 — KC realm admin URL derived from issuer URL +# --------------------------------------------------------------------------- + +def test_derive_realm_api_url_inserts_admin_segment(): + mod = load_config_module("00-gateway-auth.py") + assert mod._derive_realm_api_url( + "https://kc.example.test/realms/nebari" + ) == "https://kc.example.test/admin/realms/nebari" + + +def test_derive_realm_api_url_handles_path_prefix(): + """Some KC deployments serve at a sub-path (e.g. behind a gateway).""" + mod = load_config_module("00-gateway-auth.py") + assert mod._derive_realm_api_url( + "https://gw.example.test/keycloak/realms/nebari" + ) == "https://gw.example.test/keycloak/admin/realms/nebari" + + +def test_derive_realm_api_url_returns_empty_for_non_kc_url(): + """Unknown URL shape returns empty — caller falls back to env var.""" + mod = load_config_module("00-gateway-auth.py") + assert mod._derive_realm_api_url("https://example.test/no/realms/here") != "" + assert mod._derive_realm_api_url("https://example.test/oidc") == "" diff --git a/tests/unit/test_keycloak_rbac_bootstrap.py b/tests/unit/test_keycloak_rbac_bootstrap.py index bd3e756..9333a22 100644 --- a/tests/unit/test_keycloak_rbac_bootstrap.py +++ b/tests/unit/test_keycloak_rbac_bootstrap.py @@ -84,6 +84,17 @@ def respond(method, path, *, body=None, accept_409=False): scope_id = path.split("/")[-3] state["mappers"].setdefault(scope_id, []).append(body) return None + # PUT //client-scopes//protocol-mappers/models/ + if method == "PUT" and "/protocol-mappers/models/" in path: + parts = path.split("/") + scope_id = parts[3] + mapper_id = parts[-1] + for m in state["mappers"].get(scope_id, []): + if m.get("id") == mapper_id: + m.clear() + m.update(body) + return None + raise AssertionError(f"mapper {mapper_id} not found on scope {scope_id}") # GET //clients?clientId= if method == "GET" and path.startswith(f"/{REALM}/clients?clientId="): cid = path.rsplit("=", 1)[1] @@ -220,7 +231,7 @@ def fresh_state(): } -def make_config(groups=("/admin",)): +def make_config(groups=("/admin",), hub_external_url="https://hub.example.test"): return rbac.BootstrapConfig( kc_host="http://kc.test", admin_password="p", @@ -228,6 +239,7 @@ def make_config(groups=("/admin",)): hub_client_id=HUB_CLIENT_ID, role_name=ROLE_NAME, shared_mount_groups=tuple(groups), + hub_external_url=hub_external_url, ) @@ -351,6 +363,81 @@ def respond(method, path, **kw): # Role with stale attributes is reconciled # --------------------------------------------------------------------------- +def test_existing_group_mapper_with_short_group_paths_is_reconciled(): + """A nebari-operator-managed ``groups`` scope ships the mapper with + ``full.path: false``. KC's admin API returns role-group paths with + a leading ``/`` so the spawner's intersection silently goes empty + and ``/shared/`` never mounts. Bootstrap must PUT the existing + mapper to flip ``full.path`` to ``true``.""" + state = fresh_state() + state["mappers"]["scope-groups"] = [{ + "id": "mapper-1", + "name": "group-membership", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "config": { + "full.path": "false", + "claim.name": "groups", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + }, + }] + + def respond(method, path, **kw): + if method == "PUT" and path == f"/{REALM}/clients/{HUB_UUID}": + enable_sa_then_appear(state) + return _fake_realm(state)(method, path, **kw) + + kc = make_kc(respond) + rbac.run(make_config(), kc) + + mapper = state["mappers"]["scope-groups"][0] + assert mapper["config"]["full.path"] == "true" + assert mapper["config"]["claim.name"] == "groups" + + +def test_hub_client_urls_are_set_when_hub_external_url_given(): + """KC-initiated OAuth flows need ``rootUrl`` / ``baseUrl`` / + ``initiate.login.uri`` on the hub client so they route through + ``/hub/oauth_login`` first and set the JupyterHub state cookie. + Without these, JupyterHub raises ``400 OAuth state mismatch``.""" + state = fresh_state() + + def respond(method, path, **kw): + if method == "PUT" and path == f"/{REALM}/clients/{HUB_UUID}": + enable_sa_then_appear(state) + return _fake_realm(state)(method, path, **kw) + + kc = make_kc(respond) + rbac.run(make_config(hub_external_url="https://hub.example.test"), kc) + + client = next(c for c in state["clients"] if c["id"] == HUB_UUID) + assert client["rootUrl"] == "https://hub.example.test" + assert client["baseUrl"] == "/hub" + assert client["attributes"]["initiate.login.uri"] == ( + "https://hub.example.test/hub/oauth_login" + ) + + +def test_hub_client_urls_skipped_when_hub_external_url_empty(): + """Empty HUB_EXTERNAL_URL = deployer opted out; chart still bootstraps + the rest of the RBAC config but leaves rootUrl/baseUrl alone.""" + state = fresh_state() + + def respond(method, path, **kw): + if method == "PUT" and path == f"/{REALM}/clients/{HUB_UUID}": + enable_sa_then_appear(state) + return _fake_realm(state)(method, path, **kw) + + kc = make_kc(respond) + rbac.run(make_config(hub_external_url=""), kc) + + client = next(c for c in state["clients"] if c["id"] == HUB_UUID) + assert "rootUrl" not in client or client["rootUrl"] is None + assert "baseUrl" not in client or client["baseUrl"] is None + + def test_existing_role_with_wrong_attributes_is_reconciled(): """Deployer (or an older bootstrap) left the role around with the wrong attribute pair. The script must PUT to fix it, not leave the diff --git a/values.yaml b/values.yaml index f75557a..77590a9 100644 --- a/values.yaml +++ b/values.yaml @@ -190,15 +190,20 @@ nebi: # goes under ``jupyterhub.custom.rbac-*`` — see below. rbac: bootstrap: - enabled: false - # Namespace to run the Job in. Default (empty) = chart release - # namespace. Override to the keycloak namespace so the Job can - # read the admin-credentials Secret without cross-namespace copy. - namespace: "" + # On by default — every Nebari deployment uses bitnami/keycloakx + + # nebari-operator NebariApps, so the chart can self-bootstrap the + # KC role + group mapper that the spawner needs. Set ``false`` for + # non-Nebari (local-dev, kind, BYO-Keycloak) deployments. + enabled: true + # Namespace the Job runs in. Defaults to ``keycloak`` so the Job + # can read the admin-credentials Secret without cross-namespace + # copy. Override for non-bitnami KC layouts. + namespace: "keycloak" # Name of a K8s Secret holding the KC realm-admin password in key # ``password``. Job authenticates as ``admin`` in the ``master`` - # realm via kcadm.sh and applies config to ``realmName``. - kcAdminCredentialSecret: "" + # realm via kcadm.sh and applies config to ``realmName``. Defaults + # to bitnami/keycloakx's secret name; override for other layouts. + kcAdminCredentialSecret: "keycloak-admin-credentials" # Key within ``kcAdminCredentialSecret`` that holds the password. # Default ``admin-password`` matches the bitnami keycloak/keycloakx # chart's ``keycloak-admin-credentials`` Secret layout. @@ -206,7 +211,10 @@ rbac: # Realm to bootstrap. realmName: "nebari" # ClientId of the hub OAuth client in Keycloak (created by the - # nebari-operator NebariApp). The role + SA roles attach here. + # nebari-operator NebariApp). Leave empty to derive from the + # release + chart name (the operator's standard pattern: + # ``jupyterhub--``). Override for non-standard + # client ids. hubClientId: "" # Name of the client role the Job creates on the hub OIDC client. # Must match ``KC_SHARED_MOUNT_ROLE`` env var on the hub Deployment @@ -217,6 +225,16 @@ rbac: # - /admin # - /developer sharedMountGroups: [] + # External hub URL (origin) used to set ``rootUrl`` / ``baseUrl`` / + # ``initiate.login.uri`` on the hub OIDC client. Without these, + # KC-initiated SSO flows (account console, third-party launchers) + # redirect straight to ``/hub/oauth_callback`` without first hitting + # ``/hub/oauth_login``, so JupyterHub has no ``oauthenticator-state`` + # cookie set and returns ``400 OAuth state mismatch``. + # Leave empty to default to ``https://{nebariapp.hostname}``; set to + # an explicit URL for non-standard layouts. Set to "false" string or + # remove via Helm override to skip the patch entirely. + hubExternalUrl: "" # Keycloak server URL the bootstrap script talks to (Admin REST API # lives at ``/admin/realms/...``). In-cluster service URL by # default; override if Keycloak sits at a different hostname. @@ -244,6 +262,55 @@ jupyterhub: nebi-client-id: "" # Nebi's Keycloak OIDC client ID jupyterhub-client-id: "" # JupyterHub's Keycloak OIDC client ID nebi-environment-selector: false # Set true to show nebi workspaces in jhub-apps env dropdown + # --------------------------------------------------------------------------- + # Profiles — resource sizing for JupyterLab pods. + # Each entry maps directly to a KubeSpawner profile_list item. Slugs are + # derived from display_name via z2jh's slugify (e.g. "Small Instance" → + # "small-instance"). Set to [] to disable the profile selector + # (single-instance mode). + # kubespawner_override accepts any valid KubeSpawner trait (cpu_limit, + # mem_limit, node_selector, image, extra_resource_limits, etc.) so deployers + # can add GPU profiles, custom images, etc. without code changes. + # NOTE: when bumping singleuser.image.tag below, also bump the + # ``image: ...`` lines inside each profile_options.image.choices.default + # entry so the profile selector shows the right tag. (z2jh values.yaml + # cannot reference other values, so the duplication is unavoidable.) + profiles: + - slug: small-instance + display_name: "Small Instance" + description: "1 CPU / 2 GB RAM — interactive notebooks, light data exploration, teaching." + default: true + kubespawner_override: + cpu_limit: 1 + cpu_guarantee: 0.5 + mem_limit: "2G" + mem_guarantee: "1G" + profile_options: + image: + display_name: Image + choices: + default: + display_name: "nebari-data-science-pack-jupyterlab:sha-60c3d6f" + default: true + kubespawner_override: + image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-60c3d6f + - slug: medium-instance + display_name: "Medium Instance" + description: "4 CPU / 8 GB RAM — pandas / scikit-learn workloads on medium datasets." + kubespawner_override: + cpu_limit: 4 + cpu_guarantee: 2 + mem_limit: "8G" + mem_guarantee: "4G" + profile_options: + image: + display_name: Image + choices: + default: + display_name: "nebari-data-science-pack-jupyterlab:sha-60c3d6f" + default: true + kubespawner_override: + image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-60c3d6f # Terminal customization: controls Starship prompt in JupyterLab terminals. # When false, falls back to the default bash prompt. terminal-customization: true @@ -325,15 +392,47 @@ jupyterhub: # - email # ============================================================ - # Mount custom config files from ConfigMap + # Mount custom config files from ConfigMap + the operator-provisioned + # Keycloak OIDC client Secret. 00-gateway-auth.py reads client-id / + # client-secret / issuer-url from /etc/oauth/ when KC OAuth is wired. + # + # IMPORTANT: z2jh treats ``extraVolumes`` and ``extraVolumeMounts`` as + # lists that REPLACE (not merge) on override. If a deployer sets either + # of these, they MUST re-include the entries below or the hub falls + # back to dummy auth and/or jupyterhub_config.d is empty. + # + # The oauth-client secretName follows the operator's convention: + # ``{Release.Name}-{Chart.Name}-oidc-client``. Override here if you + # use a non-standard release name. extraVolumes: - name: custom-config configMap: name: nebari-data-science-pack-hub-config + - name: oauth-client + secret: + secretName: data-science-pack-nebari-data-science-pack-oidc-client + optional: true extraVolumeMounts: - name: custom-config mountPath: /usr/local/etc/jupyterhub/jupyterhub_config.d/ + - name: oauth-client + mountPath: /etc/oauth + readOnly: true + + # KC OIDC client secret. ``02-jhub-apps.py`` and ``03-nebi-envs.py`` + # read this env var to do KC token exchange for the Nebi service. + # ``00-gateway-auth.py`` reads the same secret from /etc/oauth (file) + # for the KeyCloakOAuthenticator wiring — both paths point at the + # same operator-provisioned Secret. + # The secretName follows the same convention as oauth-client above. + extraEnv: + JUPYTERHUB_OIDC_CLIENT_SECRET: + valueFrom: + secretKeyRef: + name: data-science-pack-nebari-data-science-pack-oidc-client + key: client-secret + optional: true service: extraPorts: