From b5a3b5ad4ebcf13ad1b1fd48e592a720f75a6cf0 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 4 May 2026 13:49:15 +0200 Subject: [PATCH 1/3] Sync template with nebari-operator v0.1.0-alpha.19 The template was pinned to nebari-operator v0.1.0-alpha.5 (March 2026), but the operator is now at v0.1.0-alpha.19 (April 2026). 14 alpha releases of CRD additions and behavior changes had not been reflected here. Notable consequences before this change: - examples/auth-fastapi used routing.publicRoutes, a field added to the operator after alpha.5. Against the alpha.5 CI install, the field was silently dropped, so the documented "/health bypasses OIDC" behavior was not actually being tested. - docs/nebariapp-crd-reference.md was missing roughly ten spec fields, most status fields, and one condition reason. - README claimed CI installs the latest operator release; it actually pinned to alpha.5. - release.yaml force-pushed to gh-pages from a fresh git init on every release, silently overwriting prior chart releases in a multi-chart repo. Changes: - Bump operator pin to v0.1.0-alpha.19 in dev/Makefile and test-integration.yaml. URLs now derive from a single OPERATOR_REF variable so the next bump is one line. Update the README claim. - Regenerate docs/nebariapp-crd-reference.md against the alpha.19 types, adding serviceAccountName, service.namespace, routing.publicRoutes, routing.annotations, the new auth.* fields (forwardAccessToken, denyRedirect, spaClient, deviceFlowClient, keycloakConfig, tokenExchange), the landingPage block (with healthCheck), the missing status fields, and the GatewayListenerConflict condition reason. - Drop redirectURI: / from every example and let the operator default (/oauth2/callback) apply. Helm templates only emit redirectURI when the user sets it. Fix README cookie-suffix description (7-char to 8-char, FNV-32a). - Add a "NebariApp CRD vs Envoy Gateway SecurityPolicy" boundary section to docs/auth-flow.md so readers know which OIDC fields are surfaced on NebariApp and which require dropping enforceAtGateway and managing a SecurityPolicy directly. - Add a "Beyond the basics" feature list to README pointing at the CRD reference for each newer field. - Replace release.yaml's "init from scratch + force-push" with a fetch-existing + helm repo index --merge flow, so prior chart releases are preserved. --- .github/workflows/release.yaml | 32 +++- .github/workflows/test-integration.yaml | 4 +- README.md | 35 ++++- dev/Makefile | 4 +- docs/auth-flow.md | 39 ++++- docs/nebariapp-crd-reference.md | 146 ++++++++++++++++-- .../chart/templates/nebariapp.yaml | 4 +- examples/auth-fastapi/chart/values.yaml | 2 +- .../chart/templates/nebariapp.yaml | 4 +- examples/basic-nginx/chart/values.yaml | 2 +- examples/kustomize-nginx/base/nebariapp.yaml | 2 +- examples/vanilla-yaml/nebariapp.yaml | 2 +- .../chart/templates/nebariapp.yaml | 4 +- .../wrap-existing-chart/chart/values.yaml | 2 +- 14 files changed, 243 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cf4ff64..64976d0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -70,16 +70,36 @@ jobs: - name: Update Helm repo index if: steps.check.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - mkdir -p gh-pages + set -euo pipefail + GH_PAGES_URL="https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + REPO_URL="https://github.com/${{ github.repository }}/releases/download/${{ steps.chart.outputs.name }}-${{ steps.chart.outputs.version }}" + + # Preserve any existing gh-pages history (and index) so prior chart + # releases are not lost. Bootstrap the branch on first release. + if git ls-remote --exit-code --heads "${GH_PAGES_URL}" gh-pages >/dev/null 2>&1; then + git clone --depth 1 --branch gh-pages "${GH_PAGES_URL}" gh-pages + else + mkdir -p gh-pages + git -C gh-pages init -b gh-pages + git -C gh-pages remote add origin "${GH_PAGES_URL}" + fi + cp *.tgz gh-pages/ cd gh-pages - git init - git checkout -b gh-pages + + # Merge with the existing index when present so old chart versions + # remain installable from the Helm repo. + if [ -f index.yaml ]; then + helm repo index . --url "${REPO_URL}" --merge index.yaml + else + helm repo index . --url "${REPO_URL}" + fi + git config user.name "$GITHUB_ACTOR" git config user.email "$GITHUB_ACTOR@users.noreply.github.com" - git remote add origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} - helm repo index . --url https://github.com/${{ github.repository }}/releases/download/${{ steps.chart.outputs.name }}-${{ steps.chart.outputs.version }} git add . git commit -m "Release ${{ steps.chart.outputs.name }}-${{ steps.chart.outputs.version }}" - git push origin gh-pages --force + git push origin gh-pages diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index b38c556..593dcc3 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -16,6 +16,7 @@ jobs: timeout-minutes: 45 env: CLUSTER_NAME: pack-integration + OPERATOR_REF: v0.1.0-alpha.19 steps: - name: Checkout software-pack-template uses: actions/checkout@v4 @@ -24,6 +25,7 @@ jobs: uses: actions/checkout@v4 with: repository: nebari-dev/nebari-operator + ref: ${{ env.OPERATOR_REF }} path: nebari-operator - name: Create kind cluster @@ -86,7 +88,7 @@ jobs: # ============================================================ - name: Install nebari-operator run: | - kubectl apply -f https://github.com/nebari-dev/nebari-operator/releases/download/v0.1.0-alpha.5/install.yaml + kubectl apply -f https://github.com/nebari-dev/nebari-operator/releases/download/${OPERATOR_REF}/install.yaml - name: Wait for nebari-operator run: | diff --git a/README.md b/README.md index d37e403..2053ce8 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ spec: enabled: true # Require login (default: false) provider: keycloak # keycloak or generic-oidc provisionClient: true # Auto-create Keycloak client (default: true) - redirectURI: / # OAuth callback path + # redirectURI: /oauth2/callback # OAuth callback path (default shown; rarely needs overriding) scopes: # OIDC scopes to request - openid - profile @@ -247,6 +247,35 @@ kind: NebariApp With plain YAML or Kustomize, the NebariApp manifest is always present. When deploying standalone, simply skip that file or exclude it from your apply command. +### Beyond the basics + +The fields shown above cover the common cases. The operator also supports several +more specialized features. Each is documented in +[docs/nebariapp-crd-reference.md](docs/nebariapp-crd-reference.md): + +- **`routing.publicRoutes`** - paths that bypass OIDC auth (e.g., `/healthz`, + webhooks, public APIs). +- **`routing.annotations`** - extra annotations on the generated HTTPRoute, useful + for ArgoCD tracking or other tooling. +- **`auth.forwardAccessToken`** - send the user's access token to your app as + `Authorization: Bearer ` so it can decode the JWT itself. +- **`auth.denyRedirect`** - return 401 instead of redirecting to Keycloak when + matching headers are present (avoids PKCE races on SPA page loads). +- **`auth.spaClient`** - provision a separate public Keycloak client for browser- + based PKCE flows (React + `keycloak-js`, etc.). +- **`auth.deviceFlowClient`** - provision a public client for the OAuth2 Device + Authorization Grant (CLIs and native apps). +- **`auth.keycloakConfig`** - declaratively manage Keycloak realm groups and + client-level protocol mappers from the NebariApp. +- **`auth.tokenExchange`** - opt this app into RFC 8693 token exchange so other + NebariApp clients can mint tokens for its audience. +- **`landingPage`** - register the app on the Nebari landing page with an icon, + category, priority, and optional health check. +- **`serviceAccountName`** - which ServiceAccount the operator should grant access + to the OIDC client Secret. +- **`service.namespace`** - point the NebariApp at a Service in a different + namespace. + For the complete field reference, see [docs/nebariapp-crd-reference.md](docs/nebariapp-crd-reference.md). ## Example 1: Vanilla YAML (Plain Manifests) @@ -427,7 +456,7 @@ Gateway SecurityPolicy that handles the full OIDC flow: - IdToken- (JWT with user claims) - AccessToken- - RefreshToken- - ( is an 7-char, lower-case hex derived from the SecurityPolicy UID) + ( is an 8-char, lower-case hex derived from the SecurityPolicy UID via FNV-32a) 7. Request (now with cookies) is forwarded to your app ``` @@ -531,7 +560,7 @@ file. Tests each example with `nebariapp.enabled=true` on a full Nebari infrastructure stack: - Creates a kind cluster with MetalLB, Envoy Gateway, cert-manager, and Keycloak -- Installs the nebari-operator from the latest published release +- Installs the nebari-operator from a pinned release (currently `v0.1.0-alpha.19`) - Deploys each example with NebariApp enabled and a `*.nebari.local` hostname - Verifies NebariApp reaches `Ready` condition (HTTPRoute created, TLS configured) - For auth-enabled examples (kustomize production, auth-fastapi), verifies diff --git a/dev/Makefile b/dev/Makefile index 9ae3372..343eccd 100644 --- a/dev/Makefile +++ b/dev/Makefile @@ -68,7 +68,7 @@ _cluster-create: _clone-operator _kind-create _metallb _services _keycloak-setup @echo "" @echo "Run 'make up-basic' (or any up-* target) to deploy an example." -OPERATOR_REF := main +OPERATOR_REF := v0.1.0-alpha.19 _clone-operator: @if [ ! -d "$(OPERATOR_REPO)" ]; then \ @@ -110,7 +110,7 @@ _keycloak-setup: _operator: @echo "Installing nebari-operator..." - kubectl apply -f https://github.com/nebari-dev/nebari-operator/releases/download/v0.1.0-alpha.5/install.yaml + kubectl apply -f https://github.com/nebari-dev/nebari-operator/releases/download/$(OPERATOR_REF)/install.yaml kubectl wait --for=condition=available deployment/nebari-operator-controller-manager \ -n nebari-operator-system --timeout=120s @echo "nebari-operator is running" diff --git a/docs/auth-flow.md b/docs/auth-flow.md index 4148dfb..139e814 100644 --- a/docs/auth-flow.md +++ b/docs/auth-flow.md @@ -354,6 +354,38 @@ userinfo endpoint and maps them to Superset roles (Admin, Gamma, Public). See it [nebari-values.yaml](https://github.com/nebari-dev/nebari-superset-pack/blob/main/examples/nebari-values.yaml) for the complete configuration. +## NebariApp CRD vs Envoy Gateway SecurityPolicy + +The fields documented in [nebariapp-crd-reference.md](nebariapp-crd-reference.md) are the +fields the **operator** understands - they go on `spec.auth` of the NebariApp resource. +At runtime, the operator generates an Envoy Gateway `SecurityPolicy` from the NebariApp, +and that SecurityPolicy has its own (much larger) set of OIDC tuning knobs. + +When you read Envoy Gateway docs and see OIDC tuning fields, mentally place each +one in one of these buckets: + +1. **Surfaced on NebariApp.** The operator exposes the field as a NebariApp + `spec.auth.*` field and copies it into the SecurityPolicy at reconcile time. + Currently this includes `forwardAccessToken` (`auth.forwardAccessToken`) and + redirect-deny rules (`auth.denyRedirect`). Set these on the NebariApp. + +2. **Not surfaced on NebariApp.** Most fine-grained OIDC fields - including + `cookieNames`, `disableIdToken`, `disableAccessToken`, `passThroughAuthHeader`, + custom logout URLs, and similar - are not exposed on NebariApp today. To use + them, either: + - File an issue / PR on + [nebari-operator](https://github.com/nebari-dev/nebari-operator) asking for the + field to be plumbed through. + - Set `auth.enforceAtGateway: false` and manage your own `SecurityPolicy` + resource alongside the NebariApp. The operator will still provision the + Keycloak client and Secret, but won't generate a SecurityPolicy to conflict + with yours. + +The shape of the SecurityPolicy the operator generates is documented in +[What the Operator Creates → 3. Envoy Gateway SecurityPolicy](#3-envoy-gateway-securitypolicy-when-enforceatgateway-true). +Refer to the [Envoy Gateway SecurityPolicy reference](https://gateway.envoyproxy.io/docs/api/extension_types/#securitypolicy) +for the full set of OIDC fields. + ## Limitations - **Local development:** The OIDC flow requires Keycloak and Envoy Gateway. When @@ -365,6 +397,7 @@ for the complete configuration. - **Cookie size:** Very large JWTs (many groups/roles) may exceed browser cookie size limits (typically 4KB). If this is an issue, reduce token size by limiting - scopes/claims at the Keycloak level, or use `disableIdToken`/`disableAccessToken` - in the SecurityPolicy OIDC config to skip setting cookies for tokens your app - doesn't need. + scopes/claims at the Keycloak level. Token-cookie suppression + (`disableIdToken`/`disableAccessToken`) is a SecurityPolicy field the operator + does not currently expose - see the boundary section above for how to use it + if you need to. diff --git a/docs/nebariapp-crd-reference.md b/docs/nebariapp-crd-reference.md index 6781e8b..1d137c8 100644 --- a/docs/nebariapp-crd-reference.md +++ b/docs/nebariapp-crd-reference.md @@ -4,7 +4,8 @@ Complete field-by-field reference for the NebariApp custom resource. **API Version:** `reconcilers.nebari.dev/v1` **Kind:** `NebariApp` -**Source:** [nebari-operator/api/v1/nebariapp_types.go](https://github.com/nebari-dev/nebari-operator/blob/main/api/v1/nebariapp_types.go) +**Source:** [nebari-operator/api/v1/nebariapp_types.go](https://github.com/nebari-dev/nebari-operator/blob/v0.1.0-alpha.19/api/v1/nebariapp_types.go) +**Operator version this doc tracks:** `v0.1.0-alpha.19` ## Full Example @@ -16,6 +17,7 @@ metadata: namespace: my-pack spec: hostname: my-pack.nebari.example.com + serviceAccountName: my-pack service: name: my-pack port: 80 @@ -23,14 +25,20 @@ spec: routes: - pathPrefix: / pathType: PathPrefix + publicRoutes: + - pathPrefix: /healthz + pathType: Exact tls: enabled: true + annotations: + argocd.argoproj.io/tracking-id: my-pack auth: enabled: true provider: keycloak provisionClient: true enforceAtGateway: true - redirectURI: / + forwardAccessToken: false + redirectURI: /oauth2/callback scopes: - openid - profile @@ -38,38 +46,52 @@ spec: groups: - admin gateway: public + landingPage: + enabled: true + displayName: My Pack + description: A short description for the landing page card. + icon: jupyter + category: Development + priority: 100 ``` ## spec | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `hostname` | string | Yes | - | FQDN where the app will be accessible. Used to generate HTTPRoute and TLS certificate. Must match pattern `^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`. | +| `hostname` | string | Yes | - | FQDN where the app will be accessible. Used to generate the HTTPRoute and TLS certificate. Must match pattern `^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`. | | `service` | [ServiceReference](#specservice) | Yes | - | The backend Kubernetes Service that receives traffic. | -| `routing` | [RoutingConfig](#specrouting) | No | - | Routing behavior including path rules and TLS. | +| `routing` | [RoutingConfig](#specrouting) | No | - | Routing behavior including path rules, TLS, and HTTPRoute annotations. **Omitting `routing` disables operator-managed routing entirely** - the operator skips HTTPRoute creation and cleans up any existing HTTPRoute. TLS is also considered disabled in that case. | | `auth` | [AuthConfig](#specauth) | No | - | Authentication/authorization configuration. | | `gateway` | string | No | `"public"` | Which shared Gateway to use. Valid values: `public`, `internal`. | +| `serviceAccountName` | string | No | NebariApp name | Name of the ServiceAccount used by the app's pods. The operator scopes RBAC on the OIDC client Secret to this ServiceAccount, so only the app's pods can read its credentials. | +| `landingPage` | [LandingPageConfig](#speclandingpage) | No | - | Controls how this service appears on the Nebari landing page. | ## spec.service | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `name` | string | Yes | - | Name of the Kubernetes Service in the same namespace. | +| `name` | string | Yes | - | Name of the Kubernetes Service. | | `port` | int32 | Yes | - | Port number on the Service to route traffic to. Range: 1-65535. | +| `namespace` | string | No | NebariApp's namespace | Namespace of the Service. Allows referencing services in other namespaces for centralized service architectures. The operator has cluster-scoped read permission on Services. | ## spec.routing | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `routes` | [][RouteMatch](#specroutingroutes) | No | - | Path-based routing rules. If omitted, all traffic to the hostname is routed to the service. | +| `routes` | [][RouteMatch](#routematch) | No | - | Path-based routing rules. If omitted, all traffic to the hostname is routed to the service. | +| `publicRoutes` | [][RouteMatch](#routematch) | No | - | Paths that bypass OIDC authentication. When `auth.enabled=true` and `auth.enforceAtGateway=true`, these paths are routed via a separate HTTPRoute that is not protected by the SecurityPolicy. Default `pathType` is `Exact` here (vs `PathPrefix` for `routes`) - safer for auth bypass. | | `tls` | [RoutingTLSConfig](#specroutingtls) | No | - | TLS certificate management configuration. | +| `annotations` | map[string]string | No | - | Additional annotations merged onto the generated HTTPRoute. Useful for tools like ArgoCD that track resources via annotations (e.g. `argocd.argoproj.io/tracking-id`). Operator-managed annotations always take precedence. | + +### RouteMatch -### spec.routing.routes[] +Used in both `spec.routing.routes[]` and `spec.routing.publicRoutes[]`. | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `pathPrefix` | string | Yes | - | Path prefix to match. Must start with `/`. Examples: `/`, `/api/v1`, `/dashboard`. | -| `pathType` | string | No | `"PathPrefix"` | How the path is matched. Values: `PathPrefix` (match prefix), `Exact` (exact match). | +| `pathPrefix` | string | Yes | - | Path to match. Must start with `/`. Examples: `/`, `/api/v1`, `/dashboard`. | +| `pathType` | string | No | `PathPrefix` (in `routes`), `Exact` (in `publicRoutes`) | How the path is matched. Values: `PathPrefix`, `Exact`. | ### spec.routing.tls @@ -79,27 +101,109 @@ spec: ## spec.auth +> **Validation rule:** `forwardAccessToken: true` requires `enforceAtGateway: true`. + | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `enabled` | bool | No | `false` | Whether to enforce OIDC authentication. | | `provider` | string | No | `"keycloak"` | OIDC provider. Values: `keycloak`, `generic-oidc`. | | `provisionClient` | *bool | No | `true` | Auto-provision an OIDC client in the provider. Only supported for `keycloak`. The operator creates the client and stores credentials in a Secret named `-oidc-client`. The client ID follows the convention `-`. See [auth-flow.md](auth-flow.md#2-kubernetes-secret) for the full secret structure. | | `enforceAtGateway` | *bool | No | `true` | Create an Envoy Gateway SecurityPolicy for gateway-level auth. When `false`, the operator provisions the client and Secret but does NOT create a SecurityPolicy - the app handles OAuth natively. See [auth-flow.md](auth-flow.md#app-native-oauth) for wiring guidance. | +| `forwardAccessToken` | *bool | No | `false` | When `enforceAtGateway: true`, forward the user's OAuth access token to the upstream service via the `Authorization: Bearer ` header. Use when the app needs to read the JWT itself (e.g., to inspect the `groups` claim for per-user authorization). Without this, the gateway only stores the token in an encrypted session cookie that the backend cannot decode. | +| `denyRedirect` | [][DenyRedirectHeader](#denyredirectheader) | No | - | Headers that, when matched, prevent the OIDC filter from redirecting to the IdP and instead return 401. Helps avoid PKCE race conditions when SPAs fire multiple parallel requests on page load. Only applies when `enforceAtGateway: true`. | | `redirectURI` | string | No | `"/oauth2/callback"` | OAuth2 callback path. The full URL is `https://`. | -| `clientSecretRef` | *string | No | - | Reference to a Secret containing `client-id` and `client-secret`. If omitted and `provisionClient` is true, the operator creates `-oidc-client` with keys: `client-id`, `client-secret`, and optionally `issuer-url`. | +| `clientSecretRef` | *string | No | - | Reference to a Secret containing `client-id` and `client-secret`. If omitted and `provisionClient` is true, the operator creates `-oidc-client` with keys: `client-id`, `client-secret`, and `issuer-url`. | | `scopes` | []string | No | `["openid", "profile", "email"]` | OIDC scopes to request during authentication. | | `groups` | []string | No | - | Groups that have access. When specified, only users in these groups are authorized. Case-sensitive. | | `issuerURL` | string | No | - | OIDC issuer URL. Required when `provider=generic-oidc`, ignored for `keycloak`. Example: `https://accounts.google.com`. | +| `spaClient` | [SPAClientConfig](#specauthspaclient) | No | - | Provisions a public Keycloak client for browser-based PKCE flows (e.g., React apps using `keycloak-js`). Distinct from the confidential client used by gateway-enforced auth. | +| `deviceFlowClient` | [DeviceFlowClientConfig](#specauthdeviceflowclient) | No | - | Provisions a public Keycloak client for the OAuth2 Device Authorization Grant (RFC 8628), for CLI/native apps. | +| `keycloakConfig` | [KeycloakClientConfig](#specauthkeycloakconfig) | No | - | Keycloak-specific configuration: realm groups (with optional member assignments) and client-level protocol mappers. Only applied when `provider=keycloak` and `provisionClient=true`. | +| `tokenExchange` | [TokenExchangeConfig](#specauthtokenexchange) | No | - | OAuth 2.0 Token Exchange (RFC 8693). When enabled, other NebariApp clients in the same Keycloak realm can exchange their access tokens for tokens with this client's audience. Requires `KC_FEATURES=token-exchange` on the Keycloak server. | + +### DenyRedirectHeader + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `name` | string | Yes | - | Header name to match against. | +| `value` | string | Yes | - | Header value to match. | +| `type` | string | No | `Exact` | Match type. Values: `Exact`, `Prefix`, `Suffix`, `RegularExpression`. | + +### spec.auth.spaClient + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `enabled` | bool | No | `false` | Provision a public OIDC client for SPA use (PKCE, no client secret). The public client ID is written to the OIDC Secret as `spa-client-id`. | +| `clientId` | string | No | `--spa` | Override the generated client ID. | + +### spec.auth.deviceFlowClient + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `enabled` | bool | No | `false` | Provision a public OIDC client configured for the Device Authorization Grant. The client ID is written to the OIDC Secret as `device-client-id`. | + +### spec.auth.keycloakConfig + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `groups` | [][KeycloakGroup](#keycloakgroup) | No | - | Groups to ensure exist in the realm, with optional user membership assignments. | +| `protocolMappers` | [][KeycloakProtocolMapperConfig](#keycloakprotocolmapperconfig) | No | - | Client-level protocol mappers applied directly to the OIDC client. When specified, the operator's default mappers (e.g., group-membership) are not auto-created. | + +#### KeycloakGroup + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `name` | string | Yes | - | Group name to create in Keycloak. | +| `members` | []string | No | - | Keycloak usernames to add to the group. Membership sync is **additive-only** - users not in this list are not removed. | + +#### KeycloakProtocolMapperConfig + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `name` | string | Yes | - | Protocol mapper name. | +| `protocolMapper` | string | Yes | - | Mapper type identifier (e.g., `oidc-group-membership-mapper`). | +| `config` | map[string]string | No | - | Mapper configuration as arbitrary key-value pairs. Keys/values are mapper-type specific. | + +### spec.auth.tokenExchange + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `enabled` | bool | No | `false` | Enable authorization services on the Keycloak client and create policies allowing other NebariApp clients in the same realm to exchange tokens for this client's audience. | + +## spec.landingPage + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `enabled` | bool | No | `false` | Whether this service appears on the Nebari landing page. Set to `true` to opt in. | +| `displayName` | string | No (required when enabled) | - | Human-readable name on the landing page. Max 64 chars. | +| `description` | string | No | - | Supplementary text on the service card. Max 256 chars. | +| `icon` | string | No | - | Icon identifier or URL. Built-in icons: `jupyter`, `grafana`, `prometheus`, `keycloak`, `argocd`, `kubernetes`. | +| `category` | string | No | - | Group services together. Common categories: `Development`, `Monitoring`, `Platform`, `Data Science`. | +| `priority` | *int | No | `100` | Sort order within a category (lower = higher priority). Range: 0-1000. | +| `externalUrl` | string | No | `https://` | Override the default URL. | +| `healthCheck` | [HealthCheckConfig](#speclandingpagehealthcheck) | No | - | Health-status monitoring for the service card. | + +### spec.landingPage.healthCheck + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `enabled` | bool | No | `false` | Whether health checks are performed. | +| `path` | string | No | `/health` | HTTP path to check. | +| `port` | *int32 | No | `spec.service.port` | Port to use for health checks. Range: 1-65535. | +| `intervalSeconds` | *int | No | `30` | Health check interval. Range: 10-300. | +| `timeoutSeconds` | *int | No | `5` | Per-check request timeout. Range: 1-30. | ## Status -The operator sets conditions on the NebariApp status to indicate readiness: +The operator writes status conditions and several status fields for downstream consumers (the webapi watcher, ArgoCD, etc.). + +### Conditions | Condition | Description | |-----------|-------------| | `RoutingReady` | HTTPRoute has been created and the Gateway is routing traffic. | | `TLSReady` | TLS certificate is provisioned and the HTTPS listener is configured. | -| `AuthReady` | SecurityPolicy is created and OIDC client is available. Only set when `auth.enabled=true`. | +| `AuthReady` | SecurityPolicy is created and the OIDC client is available. Only set when `auth.enabled=true`. | | `Ready` | Aggregate condition - all components are ready. | ### Condition Reasons @@ -110,12 +214,24 @@ The operator sets conditions on the NebariApp status to indicate readiness: | `Reconciling` | Reconciliation is in progress. | | `ReconcileSuccess` | Reconciliation completed successfully. | | `ValidationSuccess` | Validation passed successfully. | +| `Failed` | Reconciliation failed. | | `NamespaceNotOptedIn` | Namespace is missing the `nebari.dev/managed=true` label. | -| `ServiceNotFound` | The referenced Service does not exist in the namespace. | +| `ServiceNotFound` | The referenced Service does not exist. | | `SecretNotFound` | The referenced Secret does not exist. | | `GatewayNotFound` | The target Gateway does not exist. | +| `GatewayListenerConflict` | The per-app Gateway listener conflicts with another NebariApp on the same hostname. | | `CertificateNotReady` | The cert-manager Certificate is not yet ready. | -| `Failed` | Reconciliation failed. | + +### Status Fields + +| Field | Type | Description | +|-------|------|-------------| +| `observedGeneration` | int64 | Most recent `metadata.generation` observed by the controller. | +| `hostname` | string | Mirror of `spec.hostname` for easy reference. | +| `gatewayRef` | object `{name, namespace}` | The Gateway resource routing traffic to this app. | +| `clientSecretRef` | object `{name, namespace}` | The Secret containing OIDC client credentials. | +| `authConfigHash` | string | SHA-256 hash of the last successfully provisioned OIDC client config. Used to skip re-provisioning when the spec is unchanged. To force re-provisioning, set the `nebari.dev/force-reprovision` annotation; the operator removes it once the forced re-provision completes. | +| `serviceDiscovery` | object | URL-resolved view of `spec.landingPage` for the webapi/landing-page watcher. Includes `enabled`, `displayName`, `description`, `url`, `icon`, `category`, `priority`, `visibility`, `requiredGroups`. | ## Namespace Opt-In @@ -193,7 +309,6 @@ spec: enabled: {{ .enabled | default false }} provider: {{ .provider | default "keycloak" }} provisionClient: {{ .provisionClient | default true }} - redirectURI: {{ .redirectURI | default "/" }} {{- with .scopes }} scopes: {{- toYaml . | nindent 6 }} @@ -215,7 +330,6 @@ nebariapp: enabled: false provider: keycloak provisionClient: true - redirectURI: / scopes: - openid - profile diff --git a/examples/auth-fastapi/chart/templates/nebariapp.yaml b/examples/auth-fastapi/chart/templates/nebariapp.yaml index e23a501..0d99104 100644 --- a/examples/auth-fastapi/chart/templates/nebariapp.yaml +++ b/examples/auth-fastapi/chart/templates/nebariapp.yaml @@ -30,7 +30,9 @@ spec: enabled: {{ .enabled | default true }} provider: {{ .provider | default "keycloak" }} provisionClient: {{ .provisionClient | default true }} - redirectURI: {{ .redirectURI | default "/" }} + {{- with .redirectURI }} + redirectURI: {{ . }} + {{- end }} {{- with .scopes }} scopes: {{- toYaml . | nindent 6 }} diff --git a/examples/auth-fastapi/chart/values.yaml b/examples/auth-fastapi/chart/values.yaml index 96db1ad..946d6c9 100644 --- a/examples/auth-fastapi/chart/values.yaml +++ b/examples/auth-fastapi/chart/values.yaml @@ -48,7 +48,7 @@ nebariapp: enabled: true provider: keycloak provisionClient: true - redirectURI: / + # redirectURI: /oauth2/callback # Default (operator-supplied); set only to override scopes: - openid - profile diff --git a/examples/basic-nginx/chart/templates/nebariapp.yaml b/examples/basic-nginx/chart/templates/nebariapp.yaml index bcf6cab..0eda596 100644 --- a/examples/basic-nginx/chart/templates/nebariapp.yaml +++ b/examples/basic-nginx/chart/templates/nebariapp.yaml @@ -31,7 +31,9 @@ spec: enabled: {{ .enabled | default false }} provider: {{ .provider | default "keycloak" }} provisionClient: {{ .provisionClient | default true }} - redirectURI: {{ .redirectURI | default "/" }} + {{- with .redirectURI }} + redirectURI: {{ . }} + {{- end }} {{- with .scopes }} scopes: {{- toYaml . | nindent 6 }} diff --git a/examples/basic-nginx/chart/values.yaml b/examples/basic-nginx/chart/values.yaml index 0a602bb..3af5125 100644 --- a/examples/basic-nginx/chart/values.yaml +++ b/examples/basic-nginx/chart/values.yaml @@ -38,7 +38,7 @@ nebariapp: enabled: false provider: keycloak provisionClient: true - redirectURI: / + # redirectURI: /oauth2/callback # Default (operator-supplied); set only to override scopes: - openid - profile diff --git a/examples/kustomize-nginx/base/nebariapp.yaml b/examples/kustomize-nginx/base/nebariapp.yaml index 1336d09..fc5f0e5 100644 --- a/examples/kustomize-nginx/base/nebariapp.yaml +++ b/examples/kustomize-nginx/base/nebariapp.yaml @@ -21,7 +21,7 @@ spec: enabled: false provider: keycloak provisionClient: true - redirectURI: / + # redirectURI defaults to /oauth2/callback - override only if your app reserves that path scopes: - openid - profile diff --git a/examples/vanilla-yaml/nebariapp.yaml b/examples/vanilla-yaml/nebariapp.yaml index 351b3ab..529754b 100644 --- a/examples/vanilla-yaml/nebariapp.yaml +++ b/examples/vanilla-yaml/nebariapp.yaml @@ -21,7 +21,7 @@ spec: enabled: false provider: keycloak provisionClient: true - redirectURI: / + # redirectURI defaults to /oauth2/callback - override only if your app reserves that path scopes: - openid - profile diff --git a/examples/wrap-existing-chart/chart/templates/nebariapp.yaml b/examples/wrap-existing-chart/chart/templates/nebariapp.yaml index 2f585e3..66334f6 100644 --- a/examples/wrap-existing-chart/chart/templates/nebariapp.yaml +++ b/examples/wrap-existing-chart/chart/templates/nebariapp.yaml @@ -31,7 +31,9 @@ spec: enabled: {{ .enabled | default false }} provider: {{ .provider | default "keycloak" }} provisionClient: {{ .provisionClient | default true }} - redirectURI: {{ .redirectURI | default "/" }} + {{- with .redirectURI }} + redirectURI: {{ . }} + {{- end }} {{- with .scopes }} scopes: {{- toYaml . | nindent 6 }} diff --git a/examples/wrap-existing-chart/chart/values.yaml b/examples/wrap-existing-chart/chart/values.yaml index 25b2229..8db9dc3 100644 --- a/examples/wrap-existing-chart/chart/values.yaml +++ b/examples/wrap-existing-chart/chart/values.yaml @@ -16,7 +16,7 @@ nebariapp: enabled: false provider: keycloak provisionClient: true - redirectURI: / + # redirectURI: /oauth2/callback # Default (operator-supplied); set only to override scopes: - openid - profile From ad3835328c078ac91467ab582c5d0c2ae8b7da06 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 4 May 2026 14:37:01 +0200 Subject: [PATCH 2/3] Address PR review and fix wrap-existing-chart integration test The integration test failure on the prior commit traced to a pre-existing bug masked by alpha.5: the wrap-existing-chart helm test uses release name 'test-podinfo', which matches podinfo's chart name and triggers the upstream chart's name-deduplication. The actual rendered Service is named 'test-podinfo' (not 'test-podinfo-podinfo'), but the chart's helper hardcodes '-podinfo'. NebariApp pointed at a non-existent Service and the operator (correctly, in alpha.19) reported ServiceNotFound, blocking helm --wait until timeout. The same broken release name appears in two README walkthroughs, so end users following those would also fail. Rename the test/walkthrough release name to 'test-wrap' so the upstream dedup does not trigger; '-podinfo' resolves to 'test-wrap-podinfo' and matches what the upstream chart actually creates. Also addresses PR review feedback: - Drop redirectURI: /oauth2/callback from the CRD reference Full Example so it is not cargo-culted into pack manifests; replace with a comment. - Scope the auth-flow.md SecurityPolicy boundary section explicitly to the OIDC filter fields, and call out that spaClient, deviceFlowClient, keycloakConfig, and tokenExchange are first-class NebariApp fields documented in the CRD reference. - Clarify in the CRD reference that spec.auth.clientSecretRef is a string (Secret name), not the object reference used by status.clientSecretRef. - Note that auth.denyRedirect pairs naturally with auth.spaClient so pack authors discover it for the right use case. --- .github/workflows/test-integration.yaml | 8 ++++---- README.md | 7 ++++--- docs/auth-flow.md | 12 +++++++++--- docs/nebariapp-crd-reference.md | 4 ++-- examples/wrap-existing-chart/README.md | 4 ++-- 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 593dcc3..02f5e25 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -208,21 +208,21 @@ jobs: - name: "Deploy wrap-existing-chart (podinfo)" run: | helm dependency update examples/wrap-existing-chart/chart/ - helm install test-podinfo examples/wrap-existing-chart/chart/ \ + helm install test-wrap examples/wrap-existing-chart/chart/ \ --set nebariapp.enabled=true \ --set nebariapp.hostname=podinfo.nebari.local \ --wait --timeout 3m - name: "Verify wrap-existing-chart NebariApp" run: | - kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=test-podinfo --timeout=120s - kubectl wait --for=condition=Ready nebariapp/test-podinfo-my-pack --timeout=120s + kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=test-wrap --timeout=120s + kubectl wait --for=condition=Ready nebariapp/test-wrap-my-pack --timeout=120s kubectl get httproute -l app.kubernetes.io/managed-by=nebari-operator echo "wrap-existing-chart: NebariApp Ready" - name: "Cleanup wrap-existing-chart" if: always() - run: helm uninstall test-podinfo --wait || true + run: helm uninstall test-wrap --wait || true # ============================================================ # Debug on failure diff --git a/README.md b/README.md index 2053ce8..1e94c2b 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,8 @@ more specialized features. Each is documented in - **`auth.forwardAccessToken`** - send the user's access token to your app as `Authorization: Bearer ` so it can decode the JWT itself. - **`auth.denyRedirect`** - return 401 instead of redirecting to Keycloak when - matching headers are present (avoids PKCE races on SPA page loads). + matching headers are present. Most useful alongside `auth.spaClient` to avoid + PKCE races when an SPA fires several requests on page load. - **`auth.spaClient`** - provision a separate public Keycloak client for browser- based PKCE flows (React + `keycloak-js`, etc.). - **`auth.deviceFlowClient`** - provision a public client for the OAuth2 Device @@ -429,8 +430,8 @@ spec: helm dependency update examples/wrap-existing-chart/chart/ # Deploy standalone -helm install test-podinfo examples/wrap-existing-chart/chart/ -kubectl port-forward svc/test-podinfo-podinfo 9898:9898 +helm install test-wrap examples/wrap-existing-chart/chart/ +kubectl port-forward svc/test-wrap-podinfo 9898:9898 # Deploy on Nebari helm install my-pack examples/wrap-existing-chart/chart/ \ diff --git a/docs/auth-flow.md b/docs/auth-flow.md index 139e814..5c502cc 100644 --- a/docs/auth-flow.md +++ b/docs/auth-flow.md @@ -361,15 +361,21 @@ fields the **operator** understands - they go on `spec.auth` of the NebariApp re At runtime, the operator generates an Envoy Gateway `SecurityPolicy` from the NebariApp, and that SecurityPolicy has its own (much larger) set of OIDC tuning knobs. -When you read Envoy Gateway docs and see OIDC tuning fields, mentally place each -one in one of these buckets: +This section is specifically about the **Envoy Gateway SecurityPolicy OIDC +filter** fields. Other Keycloak-side or client-provisioning features (`spaClient`, +`deviceFlowClient`, `keycloakConfig`, `tokenExchange`) are first-class NebariApp +fields and are documented in +[nebariapp-crd-reference.md](nebariapp-crd-reference.md). + +For the OIDC filter fields specifically, mentally place each one in one of these +buckets: 1. **Surfaced on NebariApp.** The operator exposes the field as a NebariApp `spec.auth.*` field and copies it into the SecurityPolicy at reconcile time. Currently this includes `forwardAccessToken` (`auth.forwardAccessToken`) and redirect-deny rules (`auth.denyRedirect`). Set these on the NebariApp. -2. **Not surfaced on NebariApp.** Most fine-grained OIDC fields - including +2. **Not surfaced on NebariApp.** Most fine-grained OIDC filter fields - including `cookieNames`, `disableIdToken`, `disableAccessToken`, `passThroughAuthHeader`, custom logout URLs, and similar - are not exposed on NebariApp today. To use them, either: diff --git a/docs/nebariapp-crd-reference.md b/docs/nebariapp-crd-reference.md index 1d137c8..d1f3acb 100644 --- a/docs/nebariapp-crd-reference.md +++ b/docs/nebariapp-crd-reference.md @@ -38,7 +38,7 @@ spec: provisionClient: true enforceAtGateway: true forwardAccessToken: false - redirectURI: /oauth2/callback + # redirectURI defaults to /oauth2/callback - omit to use the operator default scopes: - openid - profile @@ -112,7 +112,7 @@ Used in both `spec.routing.routes[]` and `spec.routing.publicRoutes[]`. | `forwardAccessToken` | *bool | No | `false` | When `enforceAtGateway: true`, forward the user's OAuth access token to the upstream service via the `Authorization: Bearer ` header. Use when the app needs to read the JWT itself (e.g., to inspect the `groups` claim for per-user authorization). Without this, the gateway only stores the token in an encrypted session cookie that the backend cannot decode. | | `denyRedirect` | [][DenyRedirectHeader](#denyredirectheader) | No | - | Headers that, when matched, prevent the OIDC filter from redirecting to the IdP and instead return 401. Helps avoid PKCE race conditions when SPAs fire multiple parallel requests on page load. Only applies when `enforceAtGateway: true`. | | `redirectURI` | string | No | `"/oauth2/callback"` | OAuth2 callback path. The full URL is `https://`. | -| `clientSecretRef` | *string | No | - | Reference to a Secret containing `client-id` and `client-secret`. If omitted and `provisionClient` is true, the operator creates `-oidc-client` with keys: `client-id`, `client-secret`, and `issuer-url`. | +| `clientSecretRef` | string | No | - | Name (string) of a Secret in the same namespace containing keys `client-id` and `client-secret`. **Note:** the spec field is a plain string (the Secret name), not the `{name, namespace}` object reference used by `status.clientSecretRef`. If omitted and `provisionClient` is true, the operator creates a Secret named `-oidc-client` with keys: `client-id`, `client-secret`, and `issuer-url`. | | `scopes` | []string | No | `["openid", "profile", "email"]` | OIDC scopes to request during authentication. | | `groups` | []string | No | - | Groups that have access. When specified, only users in these groups are authorized. Case-sensitive. | | `issuerURL` | string | No | - | OIDC issuer URL. Required when `provider=generic-oidc`, ignored for `keycloak`. Example: `https://accounts.google.com`. | diff --git a/examples/wrap-existing-chart/README.md b/examples/wrap-existing-chart/README.md index 0b84049..fcd24a8 100644 --- a/examples/wrap-existing-chart/README.md +++ b/examples/wrap-existing-chart/README.md @@ -99,10 +99,10 @@ helm install my-pack ./chart/ \ ```bash helm dependency update ./chart/ -helm install test-podinfo ./chart/ +helm install test-wrap ./chart/ # Access via port-forward -kubectl port-forward svc/test-podinfo-podinfo 9898:9898 +kubectl port-forward svc/test-wrap-podinfo 9898:9898 # Open http://localhost:9898 ``` From a6cfe3bd4a566d32dd510fc04735a14b9decc294 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 4 May 2026 18:10:05 +0200 Subject: [PATCH 3/3] Fix wrap-existing-chart verify step pod selector Podinfo's chart only applies app.kubernetes.io/name=-podinfo to its pods, not app.kubernetes.io/instance. With the previous fix to the release name, helm install now succeeds, so this second pre-existing bug in the verify step (kubectl wait on a non-existent label selector) became the new failure point. Wait on the Deployment's Available condition instead of pods directly. The Deployment name is well-defined and the condition is the same thing helm install --wait already gates on, so the wait will return promptly on success. --- .github/workflows/test-integration.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 02f5e25..8058c38 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -215,7 +215,9 @@ jobs: - name: "Verify wrap-existing-chart NebariApp" run: | - kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=test-wrap --timeout=120s + # Podinfo's chart labels its pods with app.kubernetes.io/name=-podinfo + # and does not set app.kubernetes.io/instance, so wait on the Deployment instead. + kubectl wait --for=condition=available deployment/test-wrap-podinfo --timeout=120s kubectl wait --for=condition=Ready nebariapp/test-wrap-my-pack --timeout=120s kubectl get httproute -l app.kubernetes.io/managed-by=nebari-operator echo "wrap-existing-chart: NebariApp Ready"