Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
33 changes: 33 additions & 0 deletions charts/openhands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,39 @@ 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, trusts the assertion email for account
# linking, and stores an ownership marker in Keycloak. Turning enabled off disables only
# a provider with that marker, even if idpMetadataUrl is cleared in the same rollout.
# Leave idpMetadataUrl empty to configure the provider manually in the Keycloak admin
Comment thread
saurya marked this conversation as resolved.
# console instead; the chart does not disable providers without its ownership marker.
```

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
11 changes: 11 additions & 0 deletions charts/openhands/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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") }}

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 | 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
174 changes: 171 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,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
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
}

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 '.'
Expand Down Expand Up @@ -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

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_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
Comment thread
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 }}
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
124 changes: 124 additions & 0 deletions charts/openhands/tests/enterprise_sso_test.yaml
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
Loading
Loading