Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
34 changes: 34 additions & 0 deletions charts/openhands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,40 @@ Bitbucket Data Center is the self-hosted version of Bitbucket. The setup is diff
host: <your-bitbucket-data-center-host>
```

#### Enterprise SSO (SAML)

Enterprise SSO signs users in with a corporate SAML identity provider through the bundled Keycloak.

1. Register Keycloak with your identity provider using these SAML values:

- ACS URL `https://auth.openhands.example.com/realms/allhands/broker/enterprise_sso/endpoint`
- Entity ID `https://auth.openhands.example.com/realms/allhands`

2. Update site-values.yaml file:

```yaml
enterpriseSSO:
enabled: true
displayName: "Company SSO" # optional, defaults to "Company SSO"
idpMetadataUrl: "https://idp.example.com/saml/metadata"
# When idpMetadataUrl is provided, the chart automatically creates and keeps updated the
# enterprise_sso SAML identity provider in the bundled Keycloak on every pod start.
# The managed provider validates SAML signatures and trusts the assertion email for
# account linking, so only use metadata from an identity provider you trust.
# Leave idpMetadataUrl empty to configure the provider manually in the Keycloak admin
Comment thread
saurya marked this conversation as resolved.
# console instead. When disabling chart-managed SSO, retain idpMetadataUrl for that
# rollout so the chart can distinguish and disable the provider it manages without
# modifying a manually managed provider.
```

For manual setup, also add this identity-provider mapper to `enterprise_sso`:

- Name: `identity-provider`
- Mapper type: `hardcoded-attribute-idp-mapper`
- Attribute: `identity_provider`
- Value: `enterprise_sso:saml`
- Sync mode: `FORCE`

### LiteLLM configuration

> [!IMPORTANT]
Expand Down
5 changes: 5 additions & 0 deletions charts/openhands/templates/_env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,11 @@ the app UI and webhook users are matched by email. */}}
key: client-secret
{{- end }}

{{- if .Values.enterpriseSSO.enabled }}
- name: ENABLE_ENTERPRISE_SSO
value: "true"
{{- end }}

{{- if .Values.automationServiceKey.enabled }}
- name: AUTOMATIONS_SERVICE_KEY
valueFrom:
Expand Down
10 changes: 10 additions & 0 deletions charts/openhands/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ 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 }}
{{- if not (hasKey (.Values.env | default dict) "ENTERPRISE_SSO_DISPLAY_NAME") }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 hasKey guards give .Values.env precedence for the init container, which is correct and matches openhands.env. The side effect is that enterpriseSSO.displayName is only ever emitted when idpMetadataUrl is also set.

$ helm template ... --set enterpriseSSO.enabled=true \
    --set-string enterpriseSSO.displayName="Acme SSO" \
    --set-string enterpriseSSO.idpMetadataUrl=""
# "Acme SSO" appears 0 times in the rendered output

So in manual mode an operator can set displayName in values (or type it into the installer field, which config.yaml shows whenever the toggle is on, independent of the URL) and it is silently discarded — the name comes from whatever they typed into the Keycloak admin console instead. That's the correct runtime behaviour, since the chart isn't managing the provider, but nothing tells them. A sentence in the values.yaml comment saying displayName applies only when idpMetadataUrl is set would cover it.

- name: ENTERPRISE_SSO_DISPLAY_NAME
value: {{ .Values.enterpriseSSO.displayName | default "Company SSO" | 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 }}
Comment thread
saurya marked this conversation as resolved.
Comment thread
saurya marked this conversation as resolved.
{{- end }}
{{- end }}
volumeMounts:
- name: keycloak-config-script
mountPath: /scripts
Expand Down
152 changes: 149 additions & 3 deletions charts/openhands/templates/keycloak-config-script.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,59 @@ 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
Comment thread
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
}

echo "Waiting for Keycloak to be ready..."
until curl --output /dev/null --silent --head --fail $KEYCLOAK_SERVER_URL; do
echo '.'
Expand Down Expand Up @@ -231,4 +277,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\""
CT="-H \"Content-Type: application/json\""

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"})
}' > /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_api_call "curl -s -X PUT \"$KC_REALM/identity-provider/instances/enterprise_sso\" $AUTH $CT --data \"@/tmp/idp-enterprise-sso.json\"" || exit 1
echo " Updated identity provider: enterprise_sso"
else
keycloak_api_call "curl -s -X POST \"$KC_REALM/identity-provider/instances\" $AUTH $CT --data \"@/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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — the mapper existence check reads a bare RESPONSE and treats every non-array shape as "no mapper", so it falls through to POST. The provider and mapper writes below correctly use the status-aware keycloak_write, but this read still goes through keycloak_api_call, which only inspects the body for an error key and never sees the HTTP status.

Exercised the helper directly with the rendered script's own filter:

200 empty body             -> POST -> create
200 whitespace body        -> POST -> create
object instead of array    -> POST -> create
empty array                -> POST -> create
array with the mapper      -> PUT (id=abc)

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.

keycloak_get_optional already returns the status-checked body you want here. Swapping this call for it and rejecting a non-array RESPONSE before the select would make the read as safe as the writes. Note that keycloak_get_optional maps 404 to an empty RESPONSE, so the non-array guard needs to distinguish "absent" from "unparseable".

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_api_call "curl -s -X PUT \"$KC_REALM/identity-provider/instances/enterprise_sso/mappers/$EXISTING_MAPPER_ID\" $AUTH $CT --data \"@/tmp/mapper-enterprise-sso-update.json\"" || exit 1
echo " Updated mapper: enterprise_sso/identity-provider"
else
keycloak_api_call "curl -s -X POST \"$KC_REALM/identity-provider/instances/enterprise_sso/mappers\" $AUTH $CT --data \"@/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
Comment thread
saurya marked this conversation as resolved.
{{- else if .Values.enterpriseSSO.idpMetadataUrl }}
Comment thread
saurya marked this conversation as resolved.
Outdated
# A retained metadata URL marks this provider as chart-managed. Disable it
# when the feature toggle is turned off so direct kc_idp_hint requests cannot
# bypass the hidden login button.
(
refresh_access_token || exit 1
KC_REALM="$KEYCLOAK_SERVER_URL/admin/realms/$KEYCLOAK_REALM_NAME"
AUTH="-H \"Authorization: Bearer $ACCESS_TOKEN\""
CT="-H \"Content-Type: application/json\""
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
echo "$EXISTING_IDP" | jq '.enabled = false' > /tmp/idp-enterprise-sso-disabled.json || exit 1
keycloak_api_call "curl -s -X PUT \"$KC_REALM/identity-provider/instances/enterprise_sso\" $AUTH $CT --data \"@/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 }}
4 changes: 4 additions & 0 deletions charts/openhands/templates/validations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ are never rendered, so a server-less release must not trip them.
{{- if and .Values.enabled (not .Values.postgresql.enabled) (or (not .Values.externalDatabase.host) (not .Values.externalDatabase.username)) -}}
{{- fail "postgresql.enabled is false but the external database is not configured. Set externalDatabase.host and externalDatabase.username, or set postgresql.enabled=true to use the bundled database." -}}
{{- end -}}
{{- if and .Values.enabled .Values.enterpriseSSO.idpMetadataUrl (not (include "openhands.keycloakProvisionRealm" .)) -}}
{{- fail "enterpriseSSO.idpMetadataUrl requires chart-managed Keycloak realm provisioning. Set keycloak.enabled=true or keycloak.provisionRealm=true, or leave enterpriseSSO.idpMetadataUrl empty and manage the provider manually." -}}
{{- end -}}

{{/*
Data-loss guard for the bundled MinIO. Its chart runs the bucket job on
post-install AND post-upgrade, and purge: true makes that job delete the bucket
Expand Down
116 changes: 116 additions & 0 deletions charts/openhands/tests/enterprise_sso_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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'

- 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'

- it: disables a managed provider when the feature toggle is off
set:
enabled: true
keycloak.enabled: true
enterpriseSSO.enabled: false
enterpriseSSO.idpMetadataUrl: https://idp.example.com/saml/metadata
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
19 changes: 19 additions & 0 deletions charts/openhands/tests/validations_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,25 @@ tests:
asserts:
- hasDocuments:
count: 0
- it: fails when SSO metadata is set without chart-managed realm provisioning
set:
enterpriseSSO.enabled: true
enterpriseSSO.idpMetadataUrl: https://idp.example.com/saml/metadata
keycloak.enabled: false
keycloak.provisionRealm: false
asserts:
- failedTemplate:
errorMessage: "enterpriseSSO.idpMetadataUrl requires chart-managed Keycloak realm provisioning. Set keycloak.enabled=true or keycloak.provisionRealm=true, or leave enterpriseSSO.idpMetadataUrl empty and manage the provider manually."
- it: allows manually managed SSO with external Keycloak
set:
enterpriseSSO.enabled: true
enterpriseSSO.idpMetadataUrl: ""
keycloak.enabled: false
keycloak.provisionRealm: false
asserts:
- hasDocuments:
count: 0

# The bundled MinIO bucket job runs on post-upgrade too, so purge: true wipes
# conversation and session data on every ordinary upgrade of a persistent
# install. These cases pin the guard that rejects it.
Expand Down
Loading
Loading