-
Notifications
You must be signed in to change notification settings - Fork 44
feat: configure Enterprise SSO for Replicated VM deployments #1116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 9 commits
675d17a
2d6279e
90ca0c2
349b924
f02db2e
233807f
a99f064
c1ba37d
b53f613
7c02209
ef0b03d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -74,6 +74,17 @@ spec: | |
| image: '{{.Values.image.repository}}:{{.Values.image.tag | default .Chart.AppVersion }}' | ||
| env: | ||
| {{- include "openhands.env" . | nindent 8 }} | ||
| {{- if and .Values.enterpriseSSO.enabled .Values.enterpriseSSO.idpMetadataUrl }} | ||
| {{- /* Keep these init-only; openhands.env already emits .Values.env overrides. */}} | ||
| {{- if not (hasKey (.Values.env | default dict) "ENTERPRISE_SSO_DISPLAY_NAME") }} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor, and I'm fine with the current answer — just flagging the consequence. These So in manual mode an operator can set |
||
| - name: ENTERPRISE_SSO_DISPLAY_NAME | ||
| value: {{ .Values.enterpriseSSO.displayName | quote }} | ||
| {{- end }} | ||
| {{- if not (hasKey (.Values.env | default dict) "ENTERPRISE_SSO_IDP_METADATA_URL") }} | ||
| - name: ENTERPRISE_SSO_IDP_METADATA_URL | ||
| value: {{ .Values.enterpriseSSO.idpMetadataUrl | quote }} | ||
|
saurya marked this conversation as resolved.
saurya marked this conversation as resolved.
|
||
| {{- end }} | ||
| {{- end }} | ||
| volumeMounts: | ||
| - name: keycloak-config-script | ||
| mountPath: /scripts | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,13 +12,81 @@ data: | |
|
|
||
| keycloak_api_call() { | ||
| COMMAND=$1 | ||
| export RESPONSE=$(eval $COMMAND) | ||
| ERROR=$(echo "$RESPONSE" | jq -r 'try if type == "array" then (.[0].error // .[0].errorMessage) else (.error // .errorMessage) end') | ||
| if ! RESPONSE=$(eval "$COMMAND"); then | ||
| echo "ERROR: Keycloak API request failed." >&2 | ||
| return 1 | ||
| fi | ||
| export RESPONSE | ||
| if ! ERROR=$(echo "$RESPONSE" | jq -r 'try if type == "array" then (.[0].error // .[0].errorMessage) else (.error // .errorMessage) end'); then | ||
| echo "ERROR: could not parse Keycloak API response: $RESPONSE" >&2 | ||
| return 1 | ||
| fi | ||
| if [ -n "$ERROR" ] && [ "null" != "$ERROR" ]; then | ||
| echo "Error from Keycloak: $RESPONSE" | ||
| exit 1 | ||
| return 1 | ||
|
saurya marked this conversation as resolved.
|
||
| fi | ||
| } | ||
|
|
||
| refresh_access_token() { | ||
| if ! TOKEN_RESPONSE=$(curl -sS -X POST "$KEYCLOAK_SERVER_URL/realms/master/protocol/openid-connect/token" \ | ||
| -H "Content-Type: application/x-www-form-urlencoded" \ | ||
| -d "client_id=admin-cli" \ | ||
| -d "grant_type=password" \ | ||
| -d "username=admin" \ | ||
| -d "password=$KEYCLOAK_ADMIN_PASSWORD"); then | ||
| echo "ERROR: could not refresh the Keycloak admin access token." >&2 | ||
| return 1 | ||
| fi | ||
| if ! ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token // empty'); then | ||
| echo "ERROR: could not parse the Keycloak admin access token response." >&2 | ||
| return 1 | ||
| fi | ||
| if [ -z "$ACCESS_TOKEN" ]; then | ||
| echo "ERROR: could not refresh the Keycloak admin access token." >&2 | ||
| return 1 | ||
| fi | ||
| } | ||
|
|
||
| keycloak_get_optional() { | ||
| URL=$1 | ||
| if ! STATUS=$(curl -sS -o /tmp/keycloak-get-response.json -w '%{http_code}' "$URL" \ | ||
| -H "Authorization: Bearer $ACCESS_TOKEN"); then | ||
| echo "ERROR: Keycloak GET $URL failed." >&2 | ||
| return 1 | ||
| fi | ||
| if ! RESPONSE=$(cat /tmp/keycloak-get-response.json); then | ||
| echo "ERROR: could not read the Keycloak GET response for $URL." >&2 | ||
| return 1 | ||
| fi | ||
| case "$STATUS" in | ||
| 200) return 0 ;; | ||
| 404) RESPONSE=""; return 0 ;; | ||
| *) echo "ERROR: Keycloak GET $URL returned HTTP $STATUS: $RESPONSE" >&2; return 1 ;; | ||
| esac | ||
| } | ||
|
|
||
| keycloak_write() { | ||
| METHOD=$1 | ||
| URL=$2 | ||
| DATA_FILE=$3 | ||
| if ! STATUS=$(curl -sS -o /tmp/keycloak-write-response.json -w '%{http_code}' \ | ||
| -X "$METHOD" "$URL" \ | ||
| -H "Authorization: Bearer $ACCESS_TOKEN" \ | ||
| -H "Content-Type: application/json" \ | ||
| --data "@$DATA_FILE"); then | ||
| echo "ERROR: Keycloak $METHOD $URL failed." >&2 | ||
| return 1 | ||
| fi | ||
| if ! RESPONSE=$(cat /tmp/keycloak-write-response.json); then | ||
| echo "ERROR: could not read the Keycloak $METHOD response for $URL." >&2 | ||
| return 1 | ||
| fi | ||
| case "$STATUS" in | ||
| 2??) return 0 ;; | ||
| *) echo "ERROR: Keycloak $METHOD $URL returned HTTP $STATUS: $RESPONSE" >&2; return 1 ;; | ||
| esac | ||
| } | ||
|
|
||
| echo "Waiting for Keycloak to be ready..." | ||
| until curl --output /dev/null --silent --head --fail $KEYCLOAK_SERVER_URL; do | ||
| echo '.' | ||
|
|
@@ -231,4 +299,104 @@ data: | |
| # from the beginning without side effects. | ||
| echo "Updated allhands realm configuration." | ||
| fi | ||
|
|
||
| {{- if and .Values.enterpriseSSO.enabled .Values.enterpriseSSO.idpMetadataUrl }} | ||
| # Enterprise SSO: upsert the enterprise_sso SAML identity provider from the | ||
| # operator-supplied IdP metadata URL, plus the identity_provider user | ||
| # attribute mapper the app relies on to detect SAML logins. Runs after the | ||
| # realm create/update block so the realm exists; runs in a subshell so a | ||
| # failure here (e.g. an unreachable metadata URL) warns but never blocks the | ||
| # app from starting. Every fallible operation exits explicitly because | ||
| # `set -e` is disabled inside a compound command followed by `||`. | ||
| ( | ||
| refresh_access_token || exit 1 | ||
| KC_REALM="$KEYCLOAK_SERVER_URL/admin/realms/$KEYCLOAK_REALM_NAME" | ||
| AUTH="-H \"Authorization: Bearer $ACCESS_TOKEN\"" | ||
|
|
||
| echo "Importing enterprise SSO IdP metadata from the configured URL..." | ||
| IMPORT_REQUEST=$(jq -n --arg from_url "$ENTERPRISE_SSO_IDP_METADATA_URL" '{ | ||
| providerId: "saml", | ||
| fromUrl: $from_url | ||
| }') || exit 1 | ||
| IMPORT_RESPONSE=$(curl -sS -X POST "$KC_REALM/identity-provider/import-config" \ | ||
| -H "Authorization: Bearer $ACCESS_TOKEN" \ | ||
| -H "Content-Type: application/json" \ | ||
| --data "$IMPORT_REQUEST") || exit 1 | ||
| if ! echo "$IMPORT_RESPONSE" | jq -e \ | ||
| 'type == "object" and has("idpEntityId") and has("singleSignOnServiceUrl") and has("signingCertificate")' \ | ||
| >/dev/null 2>&1; then | ||
| echo "ERROR: IdP metadata must include an entity ID, SSO service URL, and signing certificate: $IMPORT_RESPONSE" >&2 | ||
| echo "The enterprise_sso identity provider was not configured. Fix the metadata URL and redeploy to retry." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "$IMPORT_RESPONSE" | jq \ | ||
| --arg display "$ENTERPRISE_SSO_DISPLAY_NAME" \ | ||
| '{ | ||
| alias: "enterprise_sso", | ||
| providerId: "saml", | ||
| enabled: true, | ||
| displayName: $display, | ||
| updateProfileFirstLoginMode: "on", | ||
| trustEmail: true, | ||
| storeToken: false, | ||
| addReadTokenRoleOnCreate: false, | ||
| authenticateByDefault: false, | ||
| linkOnly: false, | ||
| hideOnLogin: true, | ||
| config: (. + { | ||
| "syncMode": "IMPORT", | ||
| "validateSignature": "true", | ||
| "openhandsManaged": "true" | ||
| }) | ||
| }' > /tmp/idp-enterprise-sso.json || exit 1 | ||
|
|
||
| keycloak_get_optional "$KC_REALM/identity-provider/instances/enterprise_sso" || exit 1 | ||
| EXISTING_IDP=$RESPONSE | ||
| if echo "$EXISTING_IDP" | jq -e '.internalId or .id' >/dev/null 2>&1; then | ||
| keycloak_write PUT "$KC_REALM/identity-provider/instances/enterprise_sso" /tmp/idp-enterprise-sso.json || exit 1 | ||
| echo " Updated identity provider: enterprise_sso" | ||
| else | ||
| keycloak_write POST "$KC_REALM/identity-provider/instances" /tmp/idp-enterprise-sso.json || exit 1 | ||
| echo " Created identity provider: enterprise_sso" | ||
| fi | ||
|
|
||
| jq -n '{ | ||
| name: "identity-provider", | ||
| identityProviderAlias: "enterprise_sso", | ||
| identityProviderMapper: "hardcoded-attribute-idp-mapper", | ||
| config: { | ||
| attribute: "identity_provider", | ||
| "attribute.value": "enterprise_sso:saml", | ||
| syncMode: "FORCE" | ||
| } | ||
| }' > /tmp/mapper-enterprise-sso.json || exit 1 | ||
| keycloak_api_call "curl -s \"$KC_REALM/identity-provider/instances/enterprise_sso/mappers\" $AUTH" || exit 1 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor — the mapper existence check reads a bare Exercised the helper directly with the rendered script's own filter: The first three are the interesting rows: a proxy returning an empty 200, or Keycloak answering with an object because the provider POST above silently didn't land, both read as "mapper absent" and re-POST. Keycloak permits duplicate mapper names on the same alias, so a flapping proxy accumulates mappers rather than converging — which undercuts the idempotency the block's own comment promises.
|
||
| EXISTING_MAPPER_ID=$(echo "$RESPONSE" | jq -r \ | ||
| '.[] | objects | select(.name == "identity-provider") | .id // empty') || exit 1 | ||
| if [ -n "$EXISTING_MAPPER_ID" ]; then | ||
| jq --arg id "$EXISTING_MAPPER_ID" '. + {id: $id}' /tmp/mapper-enterprise-sso.json > /tmp/mapper-enterprise-sso-update.json || exit 1 | ||
| keycloak_write PUT "$KC_REALM/identity-provider/instances/enterprise_sso/mappers/$EXISTING_MAPPER_ID" /tmp/mapper-enterprise-sso-update.json || exit 1 | ||
| echo " Updated mapper: enterprise_sso/identity-provider" | ||
| else | ||
| keycloak_write POST "$KC_REALM/identity-provider/instances/enterprise_sso/mappers" /tmp/mapper-enterprise-sso.json || exit 1 | ||
| echo " Created mapper: enterprise_sso/identity-provider" | ||
| fi | ||
| echo "enterprise_sso SAML identity provider configured." | ||
| ) || echo "WARNING: enterprise_sso auto-configuration failed (details above). Fix the SAML metadata URL and redeploy to retry; continuing startup." >&2 | ||
|
saurya marked this conversation as resolved.
|
||
| {{- else if not .Values.enterpriseSSO.enabled }} | ||
| # Reconcile on every toggle-off rollout, but only modify a provider stamped | ||
| # as chart-managed by the auto-configuration path. | ||
| ( | ||
| refresh_access_token || exit 1 | ||
| KC_REALM="$KEYCLOAK_SERVER_URL/admin/realms/$KEYCLOAK_REALM_NAME" | ||
| keycloak_get_optional "$KC_REALM/identity-provider/instances/enterprise_sso" || exit 1 | ||
| EXISTING_IDP=$RESPONSE | ||
| if echo "$EXISTING_IDP" | jq -e '.config.openhandsManaged == "true"' >/dev/null 2>&1; then | ||
| echo "$EXISTING_IDP" | jq '.enabled = false' > /tmp/idp-enterprise-sso-disabled.json || exit 1 | ||
| keycloak_write PUT "$KC_REALM/identity-provider/instances/enterprise_sso" /tmp/idp-enterprise-sso-disabled.json || exit 1 | ||
| echo "Disabled managed identity provider: enterprise_sso" | ||
| fi | ||
| ) || echo "WARNING: could not disable the managed enterprise_sso identity provider; continuing startup." >&2 | ||
| {{- end }} | ||
| {{- end }} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| suite: Enterprise SSO wiring | ||
| # The deployment checksums both Keycloak ConfigMaps and always references the | ||
| # LiteLLM script, so all four templates participate in these render tests. | ||
| templates: | ||
| - deployment.yaml | ||
| - keycloak-config-script.yaml | ||
| - keycloak-realm-template.yaml | ||
| - litellm-config-script.yaml | ||
| tests: | ||
| - it: advertises and auto-configures Enterprise SSO from metadata | ||
| set: | ||
| enabled: true | ||
| keycloak.enabled: true | ||
| databaseMigrations.waitForDatabase: false | ||
| databaseMigrations.createDatabases: false | ||
| databaseMigrations.migrate: false | ||
| enterpriseSSO.enabled: true | ||
| enterpriseSSO.displayName: Company SSO | ||
| enterpriseSSO.idpMetadataUrl: https://idp.example.com/saml/metadata | ||
| asserts: | ||
| - template: deployment.yaml | ||
| contains: | ||
| path: spec.template.spec.containers[0].env | ||
| content: | ||
| name: OH_WEB_CLIENT_PROVIDERS_CONFIGURED | ||
| value: '["enterprise_sso"]' | ||
| - template: deployment.yaml | ||
| contains: | ||
| path: spec.template.spec.containers[0].env | ||
| content: | ||
| name: ENABLE_ENTERPRISE_SSO | ||
| value: "true" | ||
| - template: deployment.yaml | ||
| equal: | ||
| path: spec.template.spec.initContainers[0].name | ||
| value: keycloak-config | ||
| - template: deployment.yaml | ||
| contains: | ||
| path: spec.template.spec.initContainers[0].env | ||
| content: | ||
| name: ENTERPRISE_SSO_DISPLAY_NAME | ||
| value: Company SSO | ||
| - template: deployment.yaml | ||
| contains: | ||
| path: spec.template.spec.initContainers[0].env | ||
| content: | ||
| name: ENTERPRISE_SSO_IDP_METADATA_URL | ||
| value: https://idp.example.com/saml/metadata | ||
| - template: keycloak-config-script.yaml | ||
| matchRegex: | ||
| path: data["keycloak-config.sh"] | ||
| pattern: 'Content-Type: application/json' | ||
| - template: keycloak-config-script.yaml | ||
| matchRegex: | ||
| path: data["keycloak-config.sh"] | ||
| pattern: 'validateSignature.*true' | ||
| - template: keycloak-config-script.yaml | ||
| notMatchRegex: | ||
| path: data["keycloak-config.sh"] | ||
| pattern: 'validateSignatures' | ||
| - template: keycloak-config-script.yaml | ||
| matchRegex: | ||
| path: data["keycloak-config.sh"] | ||
| pattern: 'openhandsManaged.*true' | ||
|
|
||
| - it: advertises Enterprise SSO without managing Keycloak when metadata is blank | ||
| set: | ||
| enabled: true | ||
| keycloak.enabled: true | ||
| enterpriseSSO.enabled: true | ||
| enterpriseSSO.idpMetadataUrl: "" | ||
| asserts: | ||
| - template: deployment.yaml | ||
| contains: | ||
| path: spec.template.spec.containers[0].env | ||
| content: | ||
| name: OH_WEB_CLIENT_PROVIDERS_CONFIGURED | ||
| value: '["enterprise_sso"]' | ||
| - template: keycloak-config-script.yaml | ||
| notMatchRegex: | ||
| path: data["keycloak-config.sh"] | ||
| pattern: 'ENTERPRISE_SSO_IDP_METADATA_URL' | ||
| - template: keycloak-config-script.yaml | ||
| notMatchRegex: | ||
| path: data["keycloak-config.sh"] | ||
| pattern: 'Disabled managed identity provider: enterprise_sso' | ||
|
|
||
| - it: disables a marked managed provider after the metadata URL is cleared | ||
| set: | ||
| enabled: true | ||
| keycloak.enabled: true | ||
| enterpriseSSO.enabled: false | ||
| enterpriseSSO.idpMetadataUrl: "" | ||
| asserts: | ||
| - template: deployment.yaml | ||
| notExists: | ||
| path: spec.template.spec.containers[0].env[?(@.name=="OH_WEB_CLIENT_PROVIDERS_CONFIGURED")] | ||
| - template: keycloak-config-script.yaml | ||
| matchRegex: | ||
| path: data["keycloak-config.sh"] | ||
| pattern: 'Disabled managed identity provider: enterprise_sso' | ||
|
|
||
| - it: gives operator env overrides precedence without duplicates | ||
| set: | ||
| enabled: true | ||
| keycloak.enabled: true | ||
| databaseMigrations.waitForDatabase: false | ||
| databaseMigrations.createDatabases: false | ||
| databaseMigrations.migrate: false | ||
| enterpriseSSO.enabled: true | ||
| enterpriseSSO.displayName: Company SSO | ||
| enterpriseSSO.idpMetadataUrl: https://idp.example.com/saml/metadata | ||
| env: | ||
| ENTERPRISE_SSO_DISPLAY_NAME: Operator SSO | ||
| ENTERPRISE_SSO_IDP_METADATA_URL: https://operator.example.com/saml/metadata | ||
| asserts: | ||
| - template: deployment.yaml | ||
| equal: | ||
| path: spec.template.spec.initContainers[0].env[?(@.name=="ENTERPRISE_SSO_DISPLAY_NAME")].value | ||
| value: Operator SSO | ||
| - template: deployment.yaml | ||
| equal: | ||
| path: spec.template.spec.initContainers[0].env[?(@.name=="ENTERPRISE_SSO_IDP_METADATA_URL")].value | ||
| value: https://operator.example.com/saml/metadata |
Uh oh!
There was an error while loading. Please reload this page.