feat(bp-openclaw): per-tenant Keycloak SSO + NewAPI as OpenAI-compatible LLM gateway (#915) (#917)

Wire bp-openclaw to the per-tenant Keycloak realm (OIDC SSO) and the
per-tenant NewAPI (OpenAI-compatible LLM endpoint, NOT direct OpenAI),
delivering C3 of umbrella epic #915.

Chart changes (bp-openclaw 0.1.0 → 0.2.0):
- Add canonical `oidc.{issuerURL,clientId,clientSecret.{name,key}}` block.
- Add canonical `llm.{baseURL,apiKey.{name,key},defaultModel}` block.
- Controller Deployment now emits OIDC_*, LLM_*, OPENAI_API_{BASE,KEY},
  LLM_DEFAULT_MODEL envs (legacy KEYCLOAK_*/NEWAPI_BASE_URL_DEFAULT
  retained for back-compat with current controller image).
- Per-user pods carry OPENAI_API_BASE / OPENAI_API_KEY / LLM_DEFAULT_MODEL
  alongside the identity-blind NEWAPI_BASE_URL / NEWAPI_KEY (ADR-0003
  §3.3 unchanged).
- Legacy `keycloak.*` / `newapi.*` keys remain accepted as fallbacks;
  helpers prefer canonical blocks but fall back to the legacy alias when
  the canonical block is unset (or still at placeholder).
- assertNoPlaceholders guard updated to check resolved canonical values.
- render-toggles.sh smoke test extended: asserts both canonical and
  legacy code-paths render and that all expected envs reach the
  rendered Deployment.

Orchestrator changes (catalyst-api smeTenantBPOpenClaw template):
- Emit per-tenant `oidc.issuerURL` = https://keycloak.<sub>.<parent>/realms/sme-<sub>
- Emit per-tenant `oidc.clientId` = openclaw, secret from
  openclaw-oidc-client-secret/OIDC_CLIENT_SECRET (rendered by
  bp-keycloak's post-install hook).
- Emit per-tenant `llm.baseURL` = https://api.<sub>.<parent>/v1 (alice's
  own NewAPI ingress, NOT the otech-wide newapi.<otech-fqdn>); apiKey
  from openclaw-newapi-controller-token/NEWAPI_KEY.
- Emit `llm.defaultModel: qwen3.6` — NewAPI uses this to select the
  backing channel; C4 of #915 wires Qwen3.6@BankDhofar at tenant-create.
- Legacy keycloak/newapi blocks still emitted for back-compat with
  bp-openclaw < 0.2.0.

Tests:
- New TestRenderSMETenantOverlay_OpenClawOIDCAndLLMBlocks asserts the
  rendered HelmRelease contains the canonical oidc + llm blocks with
  per-tenant values, and that llm.baseURL is the per-tenant
  api.<sub>.<parent>/v1 (NOT the otech-wide newapi).
- bp-openclaw render-toggles.sh extended (Case 2b/2c).

Co-authored-by: alierenbaysal <alierenbaysal@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-05 13:26:59 +04:00 committed by GitHub
parent dcf6cf70b4
commit 61c8d77b58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 439 additions and 54 deletions

View File

@ -73,13 +73,19 @@ The chart fails to render if any of these are unset (see
| Value | Example |
|---|---|
| `keycloak.realmURL` | `https://keycloak.acme.<otech-fqdn>/realms/acme` |
| `keycloak.clientSecretName` | `openclaw-oidc` (ExternalSecret with key `OIDC_CLIENT_SECRET`) |
| `oidc.issuerURL` | `https://keycloak.acme.<parent-domain>/realms/sme-acme` |
| `oidc.clientId` | `openclaw` |
| `oidc.clientSecret.name` | `openclaw-oidc-client-secret` (Secret with key `OIDC_CLIENT_SECRET`) |
| `llm.baseURL` | `https://api.acme.<parent-domain>/v1` (per-tenant NewAPI OpenAI-compatible endpoint) |
| `llm.apiKey.name` | `openclaw-newapi-controller-token` (Secret with key `NEWAPI_KEY`) |
| `llm.defaultModel` | `qwen3.6` (NewAPI maps this to a backing channel — e.g. Qwen3.6@BankDhofar) |
| `tenant.namespace` | `sme-acme` |
| `newapi.baseURL` | `https://newapi.<otech-fqdn>` |
| `controller.image.tag` | SHA-pinned tag (Inviolable Principle 4) |
| `perUserPod.image.tag` | SHA-pinned tag (Inviolable Principle 4) |
| `ingress.host` | `openclaw.acme.<otech-fqdn>` |
| `ingress.host` | `openclaw.acme.<parent-domain>` |
Legacy `keycloak.*` / `newapi.*` keys remain accepted for back-compat
(see umbrella epic #915).
---

View File

@ -1,7 +1,7 @@
apiVersion: v2
name: bp-openclaw
version: 0.1.0
appVersion: "0.1.0"
version: 0.2.0
appVersion: "0.2.0"
description: |
Catalyst Blueprint chart for the OpenClaw workspace controller pattern
— a multi-tenant controller deployment in an SME tenant namespace plus
@ -11,10 +11,11 @@ description: |
Deployment shape:
- One controller Deployment per SME tenant namespace. Authenticates
end users via the SME-vcluster Keycloak realm (OIDC) and validates
end users via the per-tenant Keycloak realm (OIDC) and validates
the JWT signature + sub claim before spawning a per-user pod.
- Per-user pods carry only two env vars (NEWAPI_BASE_URL, NEWAPI_KEY)
mounted from the per-user Secret labelled
- Per-user pods carry NEWAPI_BASE_URL + NEWAPI_KEY (plus their
OpenAI-compatible aliases OPENAI_API_BASE / OPENAI_API_KEY +
LLM_DEFAULT_MODEL) mounted from the per-user Secret labelled
`catalyst.openova.io/sme-user-uuid=<uuid>`. The runtime image is
identity-blind: no Keycloak code, no key-management code.
@ -25,8 +26,24 @@ description: |
SHA in the consuming overlay (per Inviolable Principle 4 — never use
floating tags in production manifests).
Pairs with bp-newapi (Sovereign-level metered LLM gateway), bp-keycloak
(SME-vcluster realm), bp-cert-manager (per-host TLS).
Pairs with bp-newapi (per-tenant OpenAI-compatible LLM gateway),
bp-keycloak (per-tenant realm), bp-cert-manager (per-host TLS).
Changelog
─────────
0.2.0 (2026-05-05, umbrella #915)
- Add canonical `oidc.{issuerURL,clientId,clientSecret}` block
(per-tenant Keycloak SSO).
- Add canonical `llm.{baseURL,apiKey,defaultModel}` block
(per-tenant NewAPI as OpenAI-compatible LLM gateway).
- Controller now emits OIDC_*, LLM_*, OPENAI_API_{BASE,KEY},
LLM_DEFAULT_MODEL envs (legacy KEYCLOAK_*/NEWAPI_BASE_URL_DEFAULT
retained for back-compat).
- Per-user pods now also carry OPENAI_API_BASE / OPENAI_API_KEY /
LLM_DEFAULT_MODEL.
- Legacy `keycloak.*` / `newapi.*` keys remain as fallbacks.
0.1.0 (2026-04, epic #795)
- Initial chart: controller Deployment + per-user pod ConfigMap.
type: application
keywords: [catalyst, blueprint, openclaw, sme, workspace-controller, per-user-pod, llm, agent]

View File

@ -71,6 +71,98 @@ matches the operator's intent.
{{- default .Release.Namespace .Values.tenant.namespace }}
{{- end }}
{{/*
─── OIDC + LLM resolution helpers (umbrella #915) ─────────────────────
The chart's canonical config blocks are `oidc.*` and `llm.*`; legacy
overlays may still set `keycloak.*` / `newapi.*`. Helpers prefer the
canonical value and fall back to the legacy alias when unset.
*/}}
{{/*
Resolution rule: when an overlay sets a legacy key (`keycloak.*` /
`newapi.*`) AND leaves the canonical block at its placeholder default,
the legacy key wins (back-compat). When the canonical block is
explicitly set to a non-placeholder value, it always wins.
*/}}
{{- define "bp-openclaw.oidc.issuerURL" -}}
{{- $oidc := .Values.oidc.issuerURL | default "" -}}
{{- $legacy := .Values.keycloak.realmURL | default "" -}}
{{- if and $legacy (or (eq $oidc "") (eq $oidc "https://keycloak.example.local/realms/example")) -}}
{{- $legacy -}}
{{- else if $oidc -}}
{{- $oidc -}}
{{- else -}}
{{- "https://keycloak.example.local/realms/example" -}}
{{- end -}}
{{- end }}
{{- define "bp-openclaw.oidc.clientId" -}}
{{- $oidc := .Values.oidc.clientId | default "" -}}
{{- $legacy := .Values.keycloak.clientID | default "" -}}
{{- if and $legacy (or (eq $oidc "") (eq $oidc "openclaw")) -}}
{{- $legacy -}}
{{- else if $oidc -}}
{{- $oidc -}}
{{- else -}}
{{- "openclaw" -}}
{{- end -}}
{{- end }}
{{- define "bp-openclaw.oidc.clientSecretName" -}}
{{- $oidc := "" -}}
{{- if .Values.oidc.clientSecret -}}
{{- $oidc = .Values.oidc.clientSecret.name | default "" -}}
{{- end -}}
{{- $legacy := .Values.keycloak.clientSecretName | default "" -}}
{{- if and $legacy (or (eq $oidc "") (eq $oidc "openclaw-oidc")) -}}
{{- $legacy -}}
{{- else if $oidc -}}
{{- $oidc -}}
{{- else -}}
{{- "openclaw-oidc" -}}
{{- end -}}
{{- end }}
{{- define "bp-openclaw.oidc.clientSecretKey" -}}
{{- if and .Values.oidc.clientSecret .Values.oidc.clientSecret.key -}}
{{- .Values.oidc.clientSecret.key -}}
{{- else -}}
{{- "OIDC_CLIENT_SECRET" -}}
{{- end -}}
{{- end }}
{{- define "bp-openclaw.llm.baseURL" -}}
{{- $llm := .Values.llm.baseURL | default "" -}}
{{- $legacy := .Values.newapi.baseURL | default "" -}}
{{- if and $legacy (or (eq $llm "") (eq $llm "https://newapi.example.local/v1")) -}}
{{- $legacy -}}
{{- else if $llm -}}
{{- $llm -}}
{{- else -}}
{{- "https://newapi.example.local/v1" -}}
{{- end -}}
{{- end }}
{{- define "bp-openclaw.llm.apiKeySecretName" -}}
{{- if and .Values.llm.apiKey .Values.llm.apiKey.name -}}
{{- .Values.llm.apiKey.name -}}
{{- else -}}
{{- "openclaw-llm-apikey" -}}
{{- end -}}
{{- end }}
{{- define "bp-openclaw.llm.apiKeySecretKey" -}}
{{- if and .Values.llm.apiKey .Values.llm.apiKey.key -}}
{{- .Values.llm.apiKey.key -}}
{{- else -}}
{{- "NEWAPI_KEY" -}}
{{- end -}}
{{- end }}
{{- define "bp-openclaw.llm.defaultModel" -}}
{{- default "qwen3.6" .Values.llm.defaultModel -}}
{{- end }}
{{/*
Placeholder-rejection assertions. The chart ships placeholder defaults
(see values.yaml) so `helm template` smoke-renders cleanly in CI
@ -94,11 +186,11 @@ HelmRelease.
{{- if eq .Values.perUserPod.image.tag "0.1.0-placeholder" }}
{{- fail "perUserPod.image.tag is still the placeholder — overlay must supply a SHA-pinned tag (Inviolable Principle 4)" }}
{{- end }}
{{- if eq .Values.keycloak.realmURL "https://keycloak.example.local/realms/example" }}
{{- fail "keycloak.realmURL is still the placeholder — overlay must supply the SME-vcluster Keycloak realm URL" }}
{{- if eq (include "bp-openclaw.oidc.issuerURL" .) "https://keycloak.example.local/realms/example" }}
{{- fail "oidc.issuerURL is still the placeholder — overlay must supply the per-tenant Keycloak realm URL" }}
{{- end }}
{{- if eq .Values.newapi.baseURL "https://newapi.example.local" }}
{{- fail "newapi.baseURL is still the placeholder — overlay must supply the NewAPI customer-facing hostname" }}
{{- if eq (include "bp-openclaw.llm.baseURL" .) "https://newapi.example.local/v1" }}
{{- fail "llm.baseURL is still the placeholder — overlay must supply the per-tenant NewAPI OpenAI-compatible endpoint" }}
{{- end }}
{{- if eq .Values.tenant.namespace "sme-example" }}
{{- fail "tenant.namespace is still the placeholder — overlay must supply the SME tenant namespace" }}

View File

@ -34,28 +34,69 @@ spec:
containerPort: {{ .Values.controller.port }}
protocol: TCP
env:
# ── Identity: SME-vcluster Keycloak realm ─────────────────
# ── Identity: per-tenant Keycloak (OIDC) ──────────────────
# Canonical OIDC_* envs (umbrella #915). Legacy KEYCLOAK_*
# envs are emitted alongside for back-compat with controller
# builds that haven't yet been recompiled to the new names.
- name: OIDC_ISSUER_URL
value: {{ include "bp-openclaw.oidc.issuerURL" . | quote }}
- name: OIDC_CLIENT_ID
value: {{ include "bp-openclaw.oidc.clientId" . | quote }}
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "bp-openclaw.oidc.clientSecretName" . }}
key: {{ include "bp-openclaw.oidc.clientSecretKey" . }}
- name: KEYCLOAK_REALM_URL
value: {{ .Values.keycloak.realmURL | quote }}
value: {{ include "bp-openclaw.oidc.issuerURL" . | quote }}
- name: KEYCLOAK_CLIENT_ID
value: {{ .Values.keycloak.clientID | quote }}
value: {{ include "bp-openclaw.oidc.clientId" . | quote }}
- name: KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.keycloak.clientSecretName }}
key: OIDC_CLIENT_SECRET
name: {{ include "bp-openclaw.oidc.clientSecretName" . }}
key: {{ include "bp-openclaw.oidc.clientSecretKey" . }}
# ── Tenant placement for per-user pods ────────────────────
- name: TENANT_NAMESPACE
value: {{ include "bp-openclaw.tenantNamespace" . | quote }}
# ── LLM gateway: per-tenant NewAPI (OpenAI-compatible) ────
# The controller knows the tenant's NewAPI /v1 endpoint and
# default model so it can run /readyz probes and any
# controller-side LLM call that pre-dates a user session.
# Per-user pods continue to read NEWAPI_BASE_URL/NEWAPI_KEY
# from the per-user `newapi-key-{uuid}` Secret per ADR-0003
# §3.3 — the controller does NOT inject its own service
# token into the user-facing pod.
- name: LLM_BASE_URL
value: {{ include "bp-openclaw.llm.baseURL" . | quote }}
- name: LLM_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "bp-openclaw.llm.apiKeySecretName" . }}
key: {{ include "bp-openclaw.llm.apiKeySecretKey" . }}
optional: true
- name: LLM_DEFAULT_MODEL
value: {{ include "bp-openclaw.llm.defaultModel" . | quote }}
# OpenAI-SDK-compatible aliases (drop-in for any controller
# subroutine that uses an off-the-shelf OpenAI client).
- name: OPENAI_API_BASE
value: {{ include "bp-openclaw.llm.baseURL" . | quote }}
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "bp-openclaw.llm.apiKeySecretName" . }}
key: {{ include "bp-openclaw.llm.apiKeySecretKey" . }}
optional: true
# ── NewAPI customer-facing base URL (fallback default) ───
# Authoritative value per ADR-0003 §3.3 lives on each
# `newapi-key-{uuid}` Secret as `base-url`. The controller
# reads that field; this env is the chart-render-time
# fallback for overlays that haven't yet been migrated.
- name: NEWAPI_BASE_URL_DEFAULT
value: {{ .Values.newapi.baseURL | quote }}
value: {{ include "bp-openclaw.llm.baseURL" . | quote }}
# ── Per-user pod template ConfigMap reference ─────────────
- name: POD_TEMPLATE_CONFIGMAP

View File

@ -47,6 +47,8 @@ data:
image: "{{ .Values.perUserPod.image.repository }}:{{ .Values.perUserPod.image.tag }}"
imagePullPolicy: {{ .Values.perUserPod.image.pullPolicy }}
env:
# NewAPI gateway — per-user Secret (ADR-0003 §3.3). The
# runtime is identity-blind: it reads only these env vars.
- name: NEWAPI_BASE_URL
valueFrom:
secretKeyRef:
@ -57,6 +59,24 @@ data:
secretKeyRef:
name: ${SECRET_NAME}
key: api-key
# OpenAI-compatible aliases (any pre-built OpenAI SDK in the
# runtime image picks these up without code changes).
- name: OPENAI_API_BASE
valueFrom:
secretKeyRef:
name: ${SECRET_NAME}
key: base-url
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: ${SECRET_NAME}
key: api-key
# Default model identifier sent by the runtime when the
# client doesn't override. NewAPI uses the model name to
# select a channel; for #915 DoD this is "qwen3.6" and C4
# wires the BankDhofar channel routing.
- name: LLM_DEFAULT_MODEL
value: {{ include "bp-openclaw.llm.defaultModel" . | quote }}
{{- range .Values.perUserPod.extraEnv }}
- {{- toYaml . | nindent 14 }}
{{- end }}

View File

@ -49,13 +49,15 @@ if ! grep -q "placeholder" "$TMP/assert.err"; then
fi
echo " PASS"
echo "[render-toggles] Case 2b: assertNoPlaceholders=true with all real values renders successfully"
echo "[render-toggles] Case 2b: assertNoPlaceholders=true with all real values (canonical oidc/llm blocks) renders successfully"
if ! helm template smoke-openclaw . \
--set "assertNoPlaceholders=true" \
--set "keycloak.realmURL=https://kc.acme.example/realms/acme" \
--set "keycloak.clientSecretName=openclaw-oidc" \
--set "oidc.issuerURL=https://kc.acme.example/realms/sme-acme" \
--set "oidc.clientSecret.name=openclaw-oidc-client-secret" \
--set "llm.baseURL=https://api.acme.example/v1" \
--set "llm.apiKey.name=openclaw-newapi-controller-token" \
--set "llm.defaultModel=qwen3.6" \
--set "tenant.namespace=sme-acme" \
--set "newapi.baseURL=https://newapi.example" \
--set "controller.image.tag=sha-deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" \
--set "perUserPod.image.tag=sha-cafef00dcafef00dcafef00dcafef00dcafef00d" \
--set "ingress.host=openclaw.acme.example" \
@ -64,6 +66,61 @@ if ! helm template smoke-openclaw . \
cat "$TMP/real.err" >&2
exit 1
fi
# Assert canonical envs are present on the controller.
for env in OIDC_ISSUER_URL OIDC_CLIENT_ID OIDC_CLIENT_SECRET \
LLM_BASE_URL LLM_API_KEY LLM_DEFAULT_MODEL \
OPENAI_API_BASE OPENAI_API_KEY \
KEYCLOAK_REALM_URL NEWAPI_BASE_URL_DEFAULT; do
if ! grep -q "name: ${env}" "$TMP/real.yaml"; then
echo "FAIL: controller env ${env} missing from rendered manifests" >&2
exit 1
fi
done
# Assert the per-user pod-template ConfigMap carries OPENAI_API_BASE +
# LLM_DEFAULT_MODEL so OpenAI-SDK-based runtimes work without code change.
if ! grep -q "OPENAI_API_BASE" "$TMP/real.yaml"; then
echo "FAIL: per-user pod template missing OPENAI_API_BASE env" >&2
exit 1
fi
if ! grep -q "LLM_DEFAULT_MODEL" "$TMP/real.yaml"; then
echo "FAIL: per-user pod template missing LLM_DEFAULT_MODEL env" >&2
exit 1
fi
# Assert OIDC issuer + LLM baseURL values reach the rendered Deployment
# verbatim from --set.
if ! grep -q "https://kc.acme.example/realms/sme-acme" "$TMP/real.yaml"; then
echo "FAIL: OIDC issuer URL not propagated to rendered Deployment" >&2
exit 1
fi
if ! grep -q "https://api.acme.example/v1" "$TMP/real.yaml"; then
echo "FAIL: LLM baseURL not propagated to rendered Deployment" >&2
exit 1
fi
echo " PASS"
echo "[render-toggles] Case 2c: legacy keycloak.* / newapi.* keys still work as fallbacks"
if ! helm template smoke-openclaw . \
--set "assertNoPlaceholders=true" \
--set "keycloak.realmURL=https://kc.legacy.example/realms/legacy" \
--set "keycloak.clientSecretName=openclaw-oidc-legacy" \
--set "newapi.baseURL=https://newapi.legacy.example/v1" \
--set "tenant.namespace=sme-legacy" \
--set "controller.image.tag=sha-deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" \
--set "perUserPod.image.tag=sha-cafef00dcafef00dcafef00dcafef00dcafef00d" \
--set "ingress.host=openclaw.legacy.example" \
> "$TMP/legacy.yaml" 2> "$TMP/legacy.err"; then
echo "FAIL: legacy-key render failed:" >&2
cat "$TMP/legacy.err" >&2
exit 1
fi
if ! grep -q "https://kc.legacy.example/realms/legacy" "$TMP/legacy.yaml"; then
echo "FAIL: legacy keycloak.realmURL did not reach rendered Deployment" >&2
exit 1
fi
if ! grep -q "https://newapi.legacy.example/v1" "$TMP/legacy.yaml"; then
echo "FAIL: legacy newapi.baseURL did not reach rendered Deployment" >&2
exit 1
fi
echo " PASS"
echo "[render-toggles] Case 3: RBAC — 'create' verb is NOT combined with resourceNames"

View File

@ -6,14 +6,20 @@
# rebuilding the Blueprint OCI artifact.
#
# Required at install time (overlay MUST set):
# - keycloak.realmURL (SME-vcluster Keycloak realm OIDC issuer)
# - keycloak.clientSecretName (ExternalSecret with OIDC client_secret)
# - oidc.issuerURL (per-tenant Keycloak realm OIDC issuer)
# - oidc.clientSecret.name (ExternalSecret with OIDC client_secret)
# - llm.baseURL (per-tenant NewAPI OpenAI-compatible endpoint)
# - llm.apiKey.name (Secret carrying NewAPI bearer token)
# - tenant.namespace (SME tenant namespace where per-user pods run)
# - ingress.host (controller public hostname)
# - newapi.baseURL (NewAPI customer-facing hostname)
# - controller.image.tag (SHA-pinned tag)
# - perUserPod.image.tag (SHA-pinned tag)
#
# Legacy keys `keycloak.*` and `newapi.*` remain accepted for back-compat
# (they fall back when the canonical `oidc.*` / `llm.*` block omits a
# field); new overlays SHOULD use the canonical blocks. See umbrella
# epic #915 (per-tenant SSO + per-tenant NewAPI gateway DoD).
#
# Placeholder defaults below let `helm template` smoke-render cleanly in
# CI without --set (mirroring the bp-self-sovereign-cutover pattern).
# Runtime use with the placeholder values is non-functional by design;
@ -80,32 +86,79 @@ controller:
timeoutSeconds: 5
failureThreshold: 3
# ─── Auth: SME-vcluster Keycloak (OIDC) ──────────────────────────────────
# ─── Auth: per-tenant Keycloak (OIDC) — canonical block ─────────────────
# The controller validates inbound end-user JWTs against the SME's own
# Keycloak realm. It NEVER uses the OTECH-ops realm, NEVER trusts a JWT
# from outside the SME vcluster.
keycloak:
# Required at install time: full realm issuer URL.
# e.g. "https://keycloak.<sme-domain>/realms/<sme-realm>"
# Placeholder default lets `helm template` smoke-render pass.
realmURL: "https://keycloak.example.local/realms/example"
# OIDC client registered in the SME's Keycloak realm for OpenClaw.
clientID: "openclaw"
# ExternalSecret name carrying the OIDC client_secret. Required key:
#
# Per umbrella epic #915 the orchestrator (catalyst-api smeTenantBPOpenClaw
# template) emits this block per-tenant: `oidc.issuerURL` resolves to
# https://keycloak.<tenant-domain>/realms/<tenant>, `oidc.clientId` to
# `openclaw`, and `oidc.clientSecret.name` to the per-tenant Keycloak
# client-secret (Secret rendered by bp-keycloak post-install).
oidc:
# Required at install time: full realm issuer URL (the OIDC `iss` claim
# the controller will validate JWTs against). Placeholder default lets
# `helm template` smoke-render pass.
issuerURL: "https://keycloak.example.local/realms/example"
# OIDC client registered in the per-tenant Keycloak realm.
clientId: "openclaw"
# OIDC client_secret. Loaded from a K8s Secret via secretKeyRef so the
# plaintext NEVER lands in the HelmRelease values block (Inviolable
# Principle 10 — credential hygiene). Required Secret key:
# OIDC_CLIENT_SECRET. Provisioned by the SME-tenant onboarding pipeline
# (epic #795 ticket #804). Placeholder default lets smoke render pass.
clientSecretName: "openclaw-oidc"
# (epic #795 ticket #804).
clientSecret:
# Secret name (defaults to legacy `keycloak.clientSecretName` if
# unset — see _helpers.tpl).
name: "openclaw-oidc"
key: "OIDC_CLIENT_SECRET"
# ─── NewAPI integration ──────────────────────────────────────────────────
# Per-user pods talk to NewAPI via its CUSTOMER-FACING hostname (egress
# through the OTECH ingress with TLS), NEVER the in-cluster admin URL.
# Per ADR-0003 §3.3 the per-user Secret carries `base-url` matching this
# value; we read the value from the Secret at pod-spawn time so this
# field acts as a fallback default for overlays that don't yet set
# `base-url` on the Secret.
# ─── LLM gateway: per-tenant NewAPI ─────────────────────────────────────
# OpenClaw uses the per-tenant NewAPI as its OpenAI-compatible LLM
# gateway (NOT direct OpenAI). The orchestrator points this at
# https://api.<tenant-domain>/v1; NewAPI then routes to the configured
# channel (e.g. Qwen3.6@BankDhofar — wired by C4 of #915).
#
# Per ADR-0003 §3.3 each end-user has a per-user Secret rendered by
# the unified-rbac hook carrying `base-url` + `api-key`; per-user pods
# read those at session-start. The chart-level `llm.*` block carries the
# CONTROLLER-side defaults (used for /readyz probes, fallback when the
# per-user Secret is missing the field, and for the OpenAI-compatible
# `LLM_DEFAULT_MODEL` env that NewAPI maps to a channel).
llm:
# Required at install time. The customer-facing OpenAI-compatible
# endpoint (i.e. NewAPI's /v1 surface). Placeholder default lets
# smoke render pass.
baseURL: "https://newapi.example.local/v1"
# Bearer token sourced from a K8s Secret (Inviolable Principle 10).
# Required Secret key: NEWAPI_KEY (or `api-key` for ADR-0003-aligned
# per-user secrets). Per-user pods continue to read the per-user
# `newapi-key-{uuid}` Secret per ADR-0003 §3.3; this controller-side
# secret is the controller's own service-token (used for /readyz
# probes and for any controller-side LLM call that pre-dates a user
# session).
apiKey:
name: "openclaw-llm-apikey"
key: "NEWAPI_KEY"
# The default OpenAI-compatible model identifier sent on /v1/chat/
# completions when the client doesn't override. NewAPI uses the
# `model` field to select a channel; for the umbrella #915 DoD this
# is "qwen3.6" — C4 wires the BankDhofar channel routing at
# tenant-create time.
defaultModel: "qwen3.6"
# ─── Legacy aliases (back-compat, deprecated) ───────────────────────────
# Old overlays may still set `keycloak.*` / `newapi.*`. The chart's
# helpers prefer `oidc.*` / `llm.*` and fall back to these when the
# canonical block is unset (or still at placeholder). Removed once every
# overlay has migrated.
keycloak:
realmURL: ""
clientID: ""
clientSecretName: ""
newapi:
# Required at install time. Placeholder default lets smoke render pass.
baseURL: "https://newapi.example.local"
baseURL: ""
# ─── Tenant namespace ────────────────────────────────────────────────────
# SME tenant namespace where:

View File

@ -837,8 +837,18 @@ spec:
issuer: letsencrypt-prod
`
const smeTenantBPOpenClaw = `# bp-openclaw (#803) workspace controller pre-wired to the SME
# Keycloak + the otech NewAPI gateway.
const smeTenantBPOpenClaw = `# bp-openclaw (#803, #915) workspace controller pre-wired to the
# per-tenant Keycloak realm (SSO) and the per-tenant NewAPI gateway
# (OpenAI-compatible LLM endpoint, NOT direct OpenAI).
#
# Per umbrella epic #915:
# - oidc.issuerURL per-tenant Keycloak realm (alice's users log in
# to OpenClaw via alice's own Keycloak).
# - llm.baseURL per-tenant NewAPI /v1 (alice's OpenClaw chats
# route through alice's NewAPI which proxies to the configured
# channel Qwen3.6@BankDhofar wired by C4).
# - llm.defaultModel "qwen3.6" placeholder; NewAPI maps the model
# name to a channel.
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
@ -858,15 +868,36 @@ spec:
- name: bp-keycloak
namespace: {{.Namespace}}
values:
# OIDC (per-tenant Keycloak SSO)
oidc:
issuerURL: https://keycloak.{{.Subdomain}}.{{.ParentDomain}}/realms/sme-{{.Subdomain}}
clientId: openclaw
clientSecret:
name: openclaw-oidc-client-secret
key: OIDC_CLIENT_SECRET
# LLM gateway (per-tenant NewAPI, OpenAI-compatible)
# newapi runs as a per-tenant HelmRelease (bp-newapi) alice has
# her own NewAPI at api.<sme-domain>; OpenClaw points its OpenAI
# client there. The per-user newapi-key-{uuid} Secret carries the
# end-user's bearer token (ADR-0003 §3.3); the controller-side
# service token below is used for /readyz probes and any
# controller-side LLM call that pre-dates a user session.
llm:
baseURL: https://api.{{.Subdomain}}.{{.ParentDomain}}/v1
apiKey:
name: openclaw-newapi-controller-token
key: NEWAPI_KEY
# NewAPI uses the model name to select a backing channel. C4
# provisions channel #1 = Qwen3.6@BankDhofar at tenant-create
# time so this default routes to the correct upstream.
defaultModel: qwen3.6
# Legacy aliases (back-compat with chart < 0.2.0)
keycloak:
realmURL: https://keycloak.{{.Subdomain}}.{{.ParentDomain}}/realms/sme-{{.Subdomain}}
clientID: openclaw
clientSecretName: openclaw-oidc-client-secret
newapi:
# newapi runs on the otech (Sovereign) ingress regardless of the
# SME's chosen parent zone there's exactly one NewAPI per
# Sovereign and it's anchored to OTECHFQDN.
baseURL: https://newapi.{{.OTECHFQDN}}
baseURL: https://api.{{.Subdomain}}.{{.ParentDomain}}/v1
tenant:
namespace: {{.Namespace}}
ingress:

View File

@ -525,6 +525,74 @@ func TestRenderSMETenantOverlay_NoVersionsDefaultsToStar(t *testing.T) {
}
}
// TestRenderSMETenantOverlay_OpenClawOIDCAndLLMBlocks asserts that the
// bp-openclaw HelmRelease emits the canonical oidc.{issuerURL,clientId,
// clientSecret} + llm.{baseURL,apiKey,defaultModel} blocks per umbrella
// epic openova-io/openova#915. These blocks pre-wire OpenClaw to:
// - per-tenant Keycloak (alice's users log in via alice's Keycloak)
// - per-tenant NewAPI as the OpenAI-compatible LLM gateway
// (alice's OpenClaw chats route through alice's NewAPI which
// proxies to the configured channel — Qwen3.6@BankDhofar wired
// by C4 of #915).
func TestRenderSMETenantOverlay_OpenClawOIDCAndLLMBlocks(t *testing.T) {
rec := store.SMETenantProvisionRecord{
SMETenantID: "t-alice",
Subdomain: "alice",
ParentDomain: "omantel.omani.works",
DomainMode: store.SMEDomainFreeSubdomain,
AdminEmail: "admin@alice.test",
CompanyName: "Alice Corp",
OTECHFQDN: "otech107.omani.works",
VClusterName: "vc-alice",
TenantNamespace: "sme-t-alice",
}
files, err := renderSMETenantOverlay(rec, SMETenantChartVersions{})
if err != nil {
t.Fatalf("render: %v", err)
}
body, ok := files["bp-openclaw.yaml"]
if !ok {
t.Fatalf("bp-openclaw.yaml missing from rendered overlay")
}
// OIDC block (canonical).
wantOIDC := []string{
" oidc:",
" issuerURL: https://keycloak.alice.omantel.omani.works/realms/sme-alice",
" clientId: openclaw",
" clientSecret:",
" name: openclaw-oidc-client-secret",
" key: OIDC_CLIENT_SECRET",
}
for _, line := range wantOIDC {
if !strings.Contains(body, line) {
t.Errorf("bp-openclaw oidc block missing line %q\n--- rendered ---\n%s", line, body)
}
}
// LLM block (canonical) — per-tenant NewAPI endpoint, NOT direct
// OpenAI; defaultModel is the placeholder NewAPI maps to the
// Qwen3.6@BankDhofar channel C4 wires at tenant-create time.
wantLLM := []string{
" llm:",
" baseURL: https://api.alice.omantel.omani.works/v1",
" apiKey:",
" name: openclaw-newapi-controller-token",
" key: NEWAPI_KEY",
" defaultModel: qwen3.6",
}
for _, line := range wantLLM {
if !strings.Contains(body, line) {
t.Errorf("bp-openclaw llm block missing line %q\n--- rendered ---\n%s", line, body)
}
}
// Per-tenant LLM endpoint MUST be the SME's own api.<sub>.<parent>,
// NEVER the otech-wide newapi.<otech-fqdn> (that would route every
// SME's traffic through one shared gateway, defeating per-tenant
// channel routing).
if strings.Contains(body, "https://newapi.otech107.omani.works") {
t.Errorf("bp-openclaw llm.baseURL must be per-tenant api.<sub>.<parent>, not otech-wide newapi: %s", body)
}
}
func TestStepsForState(t *testing.T) {
cases := []struct {
state store.SMETenantProvisionState