Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 additions & 1 deletion files/keycloak_rbac_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@

from __future__ import annotations

import base64
import dataclasses
import json
import logging
import os
import ssl
import sys
import time
import urllib.error
Expand All @@ -73,6 +75,8 @@ class BootstrapConfig:
role_name: str
shared_mount_groups: tuple[str, ...]
hub_external_url: str
hub_client_secret_name: str = ""
hub_client_secret_namespace: str = ""

@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "BootstrapConfig":
Expand All @@ -82,10 +86,100 @@ def from_env(cls, env: dict[str, str] | None = None) -> "BootstrapConfig":
kc_host=env["KC_HOST"],
admin_password=env["KC_ADMIN_PASSWORD"],
realm=env["REALM"],
hub_client_id=env["HUB_CLIENT_ID"],
hub_client_id=env.get("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("/"),
hub_client_secret_name=env.get("HUB_CLIENT_SECRET_NAME", ""),
hub_client_secret_namespace=env.get(
"HUB_CLIENT_SECRET_NAMESPACE", ""
),
)


class K8sSecretReader:
"""Reads keys out of a Kubernetes Secret via the API server.

Used to fetch the hub OIDC client-id from the Secret the
nebari-operator provisions next to the NebariApp, so the bootstrap
never has to guess the operator's client-naming convention.
Standard library only, mirroring :py:class:`KCAdmin`.
"""

SERVICEACCOUNT_DIR = "/var/run/secrets/kubernetes.io/serviceaccount"

def __init__(
self,
*,
base_url: str,
token: str,
opener: Any = None,
request_timeout: int = 30,
):
self._base_url = base_url.rstrip("/")
self._token = token
self._opener = opener or urllib.request.build_opener()
self._timeout = request_timeout

@classmethod
def in_cluster(cls) -> "K8sSecretReader":
"""Build a reader from the pod's mounted ServiceAccount
credentials and the standard in-cluster API endpoint."""
host = os.environ["KUBERNETES_SERVICE_HOST"]
port = os.environ.get("KUBERNETES_SERVICE_PORT", "443")
if ":" in host: # bare IPv6 address
host = f"[{host}]"
with open(f"{cls.SERVICEACCOUNT_DIR}/token") as f:
token = f.read().strip()
context = ssl.create_default_context(
cafile=f"{cls.SERVICEACCOUNT_DIR}/ca.crt"
)
opener = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=context)
)
return cls(
base_url=f"https://{host}:{port}", token=token, opener=opener,
)

def read_key(
self,
namespace: str,
name: str,
key: str,
*,
retries: int = 60,
sleep_seconds: float = 5.0,
) -> str:
"""The operator writes the Secret asynchronously after the
NebariApp is created, so a fresh install's post-install hook can
race it: poll through 404s instead of failing the Job."""
url = f"{self._base_url}/api/v1/namespaces/{namespace}/secrets/{name}"
req = urllib.request.Request(
url, headers={"Authorization": f"Bearer {self._token}"},
)
for attempt in range(retries):
try:
with self._opener.open(req, timeout=self._timeout) as resp:
secret = json.loads(resp.read())
data = secret.get("data") or {}
if key not in data:
raise RuntimeError(
f"secret {namespace}/{name} has no {key!r} key; "
f"found keys: {sorted(data)}"
)
return base64.b64decode(data[key]).decode()
except urllib.error.HTTPError as e:
if e.code != 404:
raise
if attempt < retries - 1:
log.info(
"secret %s/%s not there yet (operator still "
"provisioning?); retrying", namespace, name,
)
time.sleep(sleep_seconds)
raise RuntimeError(
f"secret {namespace}/{name} never appeared; is the "
"nebari-operator running and reconciling the NebariApp?"
)


Expand Down Expand Up @@ -471,6 +565,33 @@ def assign_role_to_group(
)


def resolve_hub_client_id(
config: BootstrapConfig, secret_reader: K8sSecretReader
) -> str:
"""The clientId the nebari-operator gave the hub OIDC client.

An explicit ``HUB_CLIENT_ID`` (chart value) always wins. Otherwise
read the id out of the operator-provisioned OIDC client Secret,
the same source of truth the hub itself mounts at ``/etc/oauth``,
so the bootstrap never depends on the operator's client-naming
convention.
"""
if config.hub_client_id:
return config.hub_client_id
if not (
config.hub_client_secret_name and config.hub_client_secret_namespace
):
raise RuntimeError(
"no hub client id available: set HUB_CLIENT_ID or both "
"HUB_CLIENT_SECRET_NAME and HUB_CLIENT_SECRET_NAMESPACE"
)
return secret_reader.read_key(
config.hub_client_secret_namespace,
config.hub_client_secret_name,
"client-id",
)


def run(config: BootstrapConfig, kc: KCAdmin) -> None:
log.info("==> 1. group-membership mapper on 'groups' scope")
kc.ensure_groups_mapper(config.realm)
Expand Down Expand Up @@ -512,6 +633,12 @@ def main() -> int:
except KeyError as missing:
log.error("missing required env var: %s", missing)
return 2
secret_reader = (
None if config.hub_client_id else K8sSecretReader.in_cluster()
)
hub_client_id = resolve_hub_client_id(config, secret_reader)
log.info("hub OIDC clientId: %s", hub_client_id)
config = dataclasses.replace(config, hub_client_id=hub_client_id)
kc = KCAdmin(config.kc_host, config.admin_password)
run(config, kc)
return 0
Expand Down
81 changes: 75 additions & 6 deletions templates/keycloak-rbac-bootstrap-job.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ The chart deploys fine without RBAC bootstrap pre-configured.
*/}}
{{- 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.
The hub OIDC client id is read at runtime from the operator-provisioned
OIDC client Secret (the same Secret the hub mounts at /etc/oauth), so
the Job never has to guess the operator's client-naming convention.
``rbac.bootstrap.hubClientId`` remains as an explicit override that
skips the Secret read entirely.
*/}}
{{- $hubClientId := .Values.rbac.bootstrap.hubClientId | default (printf "jupyterhub-%s-%s" .Release.Name .Chart.Name) }}
{{- $jobNamespace := .Values.rbac.bootstrap.namespace | default .Release.Namespace }}
{{- $oidcSecretName := .Values.rbac.bootstrap.oidcClientSecretName | default (printf "%s-oidc-client" (include "nebari-data-science-pack.fullname" .)) }}
{{- $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 All @@ -55,6 +57,66 @@ data:
keycloak_rbac_bootstrap.py: |
{{ $script | indent 4 }}
---
{{/*
The Job usually runs in the ``keycloak`` namespace (to read the KC
admin Secret via env) while the operator writes the OIDC client Secret
into the release namespace, a cross-namespace read that env
``secretKeyRef`` can't do. The script reads it through the K8s API
instead, so the Job gets a ServiceAccount plus a get-one-secret Role
in the release namespace.
*/}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "nebari-data-science-pack.fullname" . }}-rbac-bootstrap
namespace: {{ $jobNamespace | quote }}
labels:
{{- include "nebari-data-science-pack.labels" . | nindent 4 }}
app.kubernetes.io/component: rbac-bootstrap
annotations:
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": before-hook-creation
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "nebari-data-science-pack.fullname" . }}-rbac-bootstrap-oidc-read
namespace: {{ .Release.Namespace }}
labels:
{{- include "nebari-data-science-pack.labels" . | nindent 4 }}
app.kubernetes.io/component: rbac-bootstrap
annotations:
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": before-hook-creation
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: [{{ $oidcSecretName | quote }}]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "nebari-data-science-pack.fullname" . }}-rbac-bootstrap-oidc-read
namespace: {{ .Release.Namespace }}
labels:
{{- include "nebari-data-science-pack.labels" . | nindent 4 }}
app.kubernetes.io/component: rbac-bootstrap
annotations:
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": before-hook-creation
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ include "nebari-data-science-pack.fullname" . }}-rbac-bootstrap-oidc-read
subjects:
- kind: ServiceAccount
name: {{ include "nebari-data-science-pack.fullname" . }}-rbac-bootstrap
namespace: {{ $jobNamespace | quote }}
---
apiVersion: batch/v1
kind: Job
metadata:
Expand Down Expand Up @@ -86,6 +148,7 @@ spec:
app.kubernetes.io/component: rbac-bootstrap
spec:
restartPolicy: Never
serviceAccountName: {{ include "nebari-data-science-pack.fullname" . }}-rbac-bootstrap
containers:
- name: bootstrap
image: {{ .Values.rbac.bootstrap.image | quote }}
Expand All @@ -102,8 +165,14 @@ spec:
key: {{ .Values.rbac.bootstrap.kcAdminCredentialSecretKey | default "password" | quote }}
- name: REALM
value: {{ .Values.rbac.bootstrap.realmName | quote }}
{{- with .Values.rbac.bootstrap.hubClientId }}
- name: HUB_CLIENT_ID
value: {{ $hubClientId | quote }}
value: {{ . | quote }}
{{- end }}
- name: HUB_CLIENT_SECRET_NAME
value: {{ $oidcSecretName | quote }}
- name: HUB_CLIENT_SECRET_NAMESPACE
value: {{ .Release.Namespace | quote }}
- name: ROLE_NAME
value: {{ .Values.rbac.bootstrap.sharedMountRoleName | quote }}
- name: SHARED_MOUNT_GROUPS
Expand Down
Loading
Loading