Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
23 changes: 23 additions & 0 deletions charts/openhands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,29 @@ 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.
# Leave it empty to configure the provider manually in the Keycloak admin console instead.
# When disabling chart-managed SSO, retain idpMetadataUrl for that rollout so the chart
# also disables the managed Keycloak provider.
```

### LiteLLM configuration

> [!IMPORTANT]
Expand Down
6 changes: 6 additions & 0 deletions charts/openhands/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ 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 }}
- name: ENTERPRISE_SSO_DISPLAY_NAME
value: {{ .Values.enterpriseSSO.displayName | quote }}
- name: ENTERPRISE_SSO_IDP_METADATA_URL
value: {{ .Values.enterpriseSSO.idpMetadataUrl | quote }}
{{- end }}
volumeMounts:
- name: keycloak-config-script
mountPath: /scripts
Expand Down
98 changes: 98 additions & 0 deletions charts/openhands/templates/keycloak-config-script.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -231,4 +231,102 @@ 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.
(
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",
alias: "enterprise_sso",
fromUrl: $from_url
}')
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")
if ! echo "$IMPORT_RESPONSE" | jq -e \
'type == "object" and has("idpEntityId") and has("singleSignOnServiceUrl")' \
>/dev/null 2>&1; then
echo "ERROR: could not import IdP metadata: $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: (if $display == "" then "Company SSO" else $display end),
updateProfileFirstLoginMode: "on",
trustEmail: true,
storeToken: false,
addReadTokenRoleOnCreate: false,
authenticateByDefault: false,
linkOnly: false,
hideOnLogin: true,
config: (. + {"syncMode": "IMPORT", "validateSignature": "true"})
}' > /tmp/idp-enterprise-sso.json

EXISTING_IDP=$(curl -s "$KC_REALM/identity-provider/instances/enterprise_sso" \
-H "Authorization: Bearer $ACCESS_TOKEN")
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\""
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\""
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
EXISTING_MAPPER_ID=$(curl -s "$KC_REALM/identity-provider/instances/enterprise_sso/mappers" \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq -r \
'.[] | objects | select(.name == "identity-provider") | .id // empty')
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
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\""
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\""
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
{{- else if .Values.enterpriseSSO.idpMetadataUrl }}
# 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.
(
KC_REALM="$KEYCLOAK_SERVER_URL/admin/realms/$KEYCLOAK_REALM_NAME"
AUTH="-H \"Authorization: Bearer $ACCESS_TOKEN\""
CT="-H \"Content-Type: application/json\""
EXISTING_IDP=$(curl -s "$KC_REALM/identity-provider/instances/enterprise_sso" \
-H "Authorization: Bearer $ACCESS_TOKEN")
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
keycloak_api_call "curl -s -X PUT \"$KC_REALM/identity-provider/instances/enterprise_sso\" $AUTH $CT --data \"@/tmp/idp-enterprise-sso-disabled.json\""
echo "Disabled managed identity provider: enterprise_sso"
fi
) || echo "WARNING: could not disable the managed enterprise_sso identity provider; continuing startup." >&2
{{- end }}
{{- end }}
68 changes: 68 additions & 0 deletions charts/openhands/tests/enterprise_sso_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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
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: 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'
8 changes: 8 additions & 0 deletions charts/openhands/tests/values_schema_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ tests:
asserts:
- failedTemplate:
errorPattern: "filestore/region.*want string"
- it: rejects an insecure Enterprise SSO metadata URL
set:
enterpriseSSO.idpMetadataUrl: http://idp.example.com/metadata
asserts:
- failedTemplate:
errorPattern: "enterpriseSSO/idpMetadataUrl"

- it: accepts valid values
set:
uvicorn.workers: 4
Expand All @@ -61,6 +68,7 @@ tests:
filestore.endpoint: https://minio.example.com
filestore.existingSecret: s3-creds
filestore.accessKeyIdKey: access-key
enterpriseSSO.idpMetadataUrl: https://idp.example.com/metadata
filestore.secretAccessKeyKey: secret-key
asserts:
- notFailedTemplate: {}
11 changes: 11 additions & 0 deletions charts/openhands/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@
"class": { "type": "string" }
}
},
"enterpriseSSO": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"displayName": { "type": "string" },
"idpMetadataUrl": {
"type": "string",
"pattern": "^$|^https://"
}
}
},
"sandbox": {
"type": "object",
"properties": {
Expand Down
5 changes: 5 additions & 0 deletions charts/openhands/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,13 @@ bitbucket:
auth:
existingSecret: bitbucket-app

# Enterprise SSO (SAML). Enabling this advertises the login provider. When an
# HTTPS metadata URL is also set, the keycloak-config init container manages the
# enterprise_sso identity provider on every pod start.
enterpriseSSO:
enabled: false
displayName: ""
idpMetadataUrl: ""

# sha256 of all secret (password) config values, injected by KOTS. Changing any
# secret-backed config changes this value, which changes the openhands pod
Expand Down
32 changes: 32 additions & 0 deletions replicated/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,38 @@ spec:
when: 'repl{{ ConfigOptionEquals "gitlab_auth_enabled" "1" }}'
required: true

- name: enterprise_sso_authentication
title: Enterprise SSO (SAML) Authentication
description: Let users sign in with a corporate SAML identity provider through the bundled Keycloak. Provide a metadata URL below for automatic setup, or manually configure an identity provider with alias enterprise_sso in the allhands realm.
items:
- name: enterprise_sso_enabled
title: Enable Enterprise SSO Authentication
help_text: Show the Enterprise SSO button on the OpenHands login page. The button redirects users to the identity provider whose alias is enterprise_sso in the bundled Keycloak (realm allhands). Provide a metadata URL below for automatic setup, or configure that identity provider manually before enabling this option.
type: bool
default: "0"
- name: enterprise_sso_display_name
title: Identity Provider Display Name
help_text: Name shown for this identity provider in Keycloak. Defaults to "Company SSO" when left blank.
type: text
when: 'repl{{ ConfigOptionEquals "enterprise_sso_enabled" "1" }}'
- name: enterprise_sso_idp_metadata_url
title: SAML Metadata URL
help_text: >-
HTTPS URL of your identity provider's SAML metadata. For example,
https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml
(Microsoft Entra ID) or https://<okta-domain>/app/<app-id>/sso/saml/metadata (Okta).
When provided, the installer automatically creates and keeps up to date the
enterprise_sso SAML identity provider in the bundled Keycloak (realm allhands),
with signature validation and trust-email on and the attribute mapper OpenHands
uses to recognize SAML logins. Leave blank to create and manage the identity
provider manually in the Keycloak admin console.
type: text
when: 'repl{{ ConfigOptionEquals "enterprise_sso_enabled" "1" }}'
validation:
regex:
pattern: '^$|^https://[^[:space:]]+$'
message: 'Must be blank or an HTTPS metadata URL.'

- name: slack_configuration
title: Enable Slack
description: Enable Slack
Expand Down
14 changes: 14 additions & 0 deletions replicated/openhands.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ spec:
# integration card never renders. Mirror jira_data_center_enabled here.
OH_WEB_CLIENT_FEATURE_FLAGS_ENABLE_JIRA_DC: 'repl{{ ConfigOptionEquals "jira_data_center_enabled" "1" }}'
OH_WEB_CLIENT_FEATURE_FLAGS_ENABLE_JIRA: 'repl{{ ConfigOptionEquals "jira_cloud_enabled" "1" }}'
# ENABLE_ENTERPRISE_SSO is only presence-checked by the app server and the
# web-client config injector: any non-empty value (even the string "false")
# turns the login button on. Render "true" when enabled and nothing when
# disabled so the button stays hidden.
ENABLE_ENTERPRISE_SSO: 'repl{{ if ConfigOptionEquals "enterprise_sso_enabled" "1" }}truerepl{{ end }}'
LOG_LEVEL: 'repl{{ ConfigOption "log_level" }}'
# The chart's fallback for LAMINAR_WEB_HOST is laminar.<ingress.host>, but
# Replicated installs expose Laminar at the analytics hostname. Set it
Expand Down Expand Up @@ -99,6 +104,15 @@ spec:
integrations:
resolverLabel: 'repl{{ ConfigOption "openhands_resolver_label" }}'

# Enterprise SSO auto-configuration. When the toggle is on and a metadata URL
# is provided, the chart's keycloak-config init container upserts the
# enterprise_sso SAML identity provider in Keycloak on every pod start.
# Leave idpMetadataUrl empty to manage the provider manually.
enterpriseSSO:
enabled: repl{{ ConfigOptionEquals "enterprise_sso_enabled" "1" }}
displayName: repl{{ ConfigOption "enterprise_sso_display_name" | mustToJson }}
idpMetadataUrl: repl{{ ConfigOption "enterprise_sso_idp_metadata_url" | mustToJson }}

ingress:
enabled: true
host: '{{repl ConfigOption "computed_app_hostname" }}'
Expand Down
Loading
Loading