diff --git a/charts/openhands/README.md b/charts/openhands/README.md index f917a9a5..3cd53187 100644 --- a/charts/openhands/README.md +++ b/charts/openhands/README.md @@ -282,6 +282,32 @@ Bitbucket Data Center is the self-hosted version of Bitbucket. The setup is diff 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 + # 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. + ``` + ### LiteLLM configuration > [!IMPORTANT] diff --git a/charts/openhands/templates/_env.yaml b/charts/openhands/templates/_env.yaml index 8da166d3..e274d90a 100644 --- a/charts/openhands/templates/_env.yaml +++ b/charts/openhands/templates/_env.yaml @@ -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: diff --git a/charts/openhands/templates/deployment.yaml b/charts/openhands/templates/deployment.yaml index 14dc7404..fedda34e 100644 --- a/charts/openhands/templates/deployment.yaml +++ b/charts/openhands/templates/deployment.yaml @@ -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 | default "Company SSO" | quote }} + - name: ENTERPRISE_SSO_IDP_METADATA_URL + value: {{ .Values.enterpriseSSO.idpMetadataUrl | quote }} + {{- end }} volumeMounts: - name: keycloak-config-script mountPath: /scripts diff --git a/charts/openhands/templates/keycloak-config-script.yaml b/charts/openhands/templates/keycloak-config-script.yaml index 1f00a507..62b2f9ca 100644 --- a/charts/openhands/templates/keycloak-config-script.yaml +++ b/charts/openhands/templates/keycloak-config-script.yaml @@ -19,6 +19,32 @@ data: exit 1 fi } + + refresh_access_token() { + ACCESS_TOKEN=$(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" | jq -r '.access_token // empty') + if [ -z "$ACCESS_TOKEN" ]; then + echo "ERROR: could not refresh the Keycloak admin access token." >&2 + return 1 + fi + } + + keycloak_get_optional() { + URL=$1 + STATUS=$(curl -sS -o /tmp/keycloak-get-response.json -w '%{http_code}' "$URL" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + RESPONSE=$(cat /tmp/keycloak-get-response.json) + 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 '.' @@ -231,4 +257,103 @@ 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. + ( + refresh_access_token + 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 + }') + 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") 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 + + keycloak_get_optional "$KC_REALM/identity-provider/instances/enterprise_sso" + 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\"" + 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 + keycloak_api_call "curl -s \"$KC_REALM/identity-provider/instances/enterprise_sso/mappers\" $AUTH" + EXISTING_MAPPER_ID=$(echo "$RESPONSE" | 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. + ( + refresh_access_token + 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" + 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 + 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 }} diff --git a/charts/openhands/templates/validations.yaml b/charts/openhands/templates/validations.yaml index fa6a6cf2..9896d4b4 100644 --- a/charts/openhands/templates/validations.yaml +++ b/charts/openhands/templates/validations.yaml @@ -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 diff --git a/charts/openhands/tests/enterprise_sso_test.yaml b/charts/openhands/tests/enterprise_sso_test.yaml new file mode 100644 index 00000000..7a14ba78 --- /dev/null +++ b/charts/openhands/tests/enterprise_sso_test.yaml @@ -0,0 +1,93 @@ +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' diff --git a/charts/openhands/tests/validations_test.yaml b/charts/openhands/tests/validations_test.yaml index 47037af4..0645eed3 100644 --- a/charts/openhands/tests/validations_test.yaml +++ b/charts/openhands/tests/validations_test.yaml @@ -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. diff --git a/charts/openhands/tests/values_schema_test.yaml b/charts/openhands/tests/values_schema_test.yaml index 5683e0f1..0b18b7fc 100644 --- a/charts/openhands/tests/values_schema_test.yaml +++ b/charts/openhands/tests/values_schema_test.yaml @@ -62,6 +62,25 @@ 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: rejects a bare HTTPS Enterprise SSO metadata URL + set: + enterpriseSSO.idpMetadataUrl: https:// + asserts: + - failedTemplate: + errorPattern: "enterpriseSSO/idpMetadataUrl" + - it: rejects whitespace in an Enterprise SSO metadata URL + set: + enterpriseSSO.idpMetadataUrl: "https://idp.example.com/saml metadata" + asserts: + - failedTemplate: + errorPattern: "enterpriseSSO/idpMetadataUrl" + - it: accepts valid values set: uvicorn.workers: 4 @@ -73,6 +92,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 laminar.clickhouse.diagnostics.retentionDays: 2 asserts: diff --git a/charts/openhands/values.schema.json b/charts/openhands/values.schema.json index db3d5b02..f118aaa7 100644 --- a/charts/openhands/values.schema.json +++ b/charts/openhands/values.schema.json @@ -49,6 +49,17 @@ "class": { "type": "string" } } }, + "enterpriseSSO": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "displayName": { "type": "string" }, + "idpMetadataUrl": { + "type": "string", + "pattern": "^$|^https://[^\\s]+$" + } + } + }, "sandbox": { "type": "object", "properties": { diff --git a/charts/openhands/values.yaml b/charts/openhands/values.yaml index 54d48fd0..c0e1efc6 100644 --- a/charts/openhands/values.yaml +++ b/charts/openhands/values.yaml @@ -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: "Company SSO" + idpMetadataUrl: "" # sha256 of all secret (password) config values, injected by KOTS. Changing any # secret-backed config changes this value, which changes the openhands pod diff --git a/replicated/config.yaml b/replicated/config.yaml index 5b837d40..b1568b65 100644 --- a/replicated/config.yaml +++ b/replicated/config.yaml @@ -991,6 +991,39 @@ 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. + type: text + default: "Company SSO" + 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//federationmetadata/2007-06/federationmetadata.xml + (Microsoft Entra ID) or https:///app//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 diff --git a/replicated/openhands.yaml b/replicated/openhands.yaml index 20d3df0d..57152d26 100644 --- a/replicated/openhands.yaml +++ b/replicated/openhands.yaml @@ -99,6 +99,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" }}' diff --git a/scripts/test_keycloak_realm_template.py b/scripts/test_keycloak_realm_template.py index 86984e85..208fa4c8 100644 --- a/scripts/test_keycloak_realm_template.py +++ b/scripts/test_keycloak_realm_template.py @@ -4,11 +4,12 @@ import json import re +import shutil +import subprocess from pathlib import Path import pytest - REPO_ROOT = Path(__file__).resolve().parents[1] REALM_TEMPLATE = ( REPO_ROOT @@ -21,6 +22,8 @@ REPO_ROOT / "charts" / "openhands" / "templates" / "keycloak-config-script.yaml" ) OPENHANDS_CHART = REPO_ROOT / "charts" / "openhands" +OPENHANDS_VALUES = OPENHANDS_CHART / "values.yaml" +OPENHANDS_VALUES_SCHEMA = OPENHANDS_CHART / "values.schema.json" REPLICATED_OPENHANDS = REPO_ROOT / "replicated" / "openhands.yaml" @@ -132,9 +135,6 @@ def test_keycloak_config_script_applies_sso_session_lifetimes() -> None: def test_sso_session_jq_filter_rewrites_realm_lifetimes() -> None: """Run the script's actual jq filter against the realm template.""" - import shutil - import subprocess - if shutil.which("jq") is None: pytest.skip("jq not available") @@ -203,8 +203,6 @@ def test_keycloak_config_script_includes_laminar_web_host_in_envsubst() -> None: def test_keycloak_identity_provider_socket_timeout() -> None: - import subprocess - result = subprocess.run( [ "helm", @@ -236,3 +234,164 @@ def test_replicated_keycloak_identity_provider_socket_timeout() -> None: r"\s+value: [\"']15000[\"']", replicated_values, ) + + +def enterprise_sso_idp_jq_filter(script_template: str) -> str: + match = re.search( + r'echo "\$IMPORT_RESPONSE" \| jq \\\n' + r'\s*--arg display "\$ENTERPRISE_SSO_DISPLAY_NAME" \\\n' + r"\s*'(?P\{.*?\})' > /tmp/idp-enterprise-sso\.json", + script_template, + re.DOTALL, + ) + assert match, "Could not find the enterprise SSO identity provider jq filter" + return match.group("filter") + + +def enterprise_sso_import_guard(script_template: str) -> str: + match = re.search( + r'if ! echo "\$IMPORT_RESPONSE" \| jq -e \\\n' + r"\s*'(?P[^']+)' \\\n", + script_template, + ) + assert match, "Could not find the enterprise SSO import response guard" + return match.group("filter") + + +def test_enterprise_sso_uses_one_canonical_values_key() -> None: + values = OPENHANDS_VALUES.read_text(encoding="utf-8") + schema = json.loads(OPENHANDS_VALUES_SCHEMA.read_text(encoding="utf-8")) + replicated = REPLICATED_OPENHANDS.read_text(encoding="utf-8") + + assert "enterpriseSso:" not in values + assert re.search(r"^enterpriseSSO:$", values, re.MULTILINE) + assert "enterpriseSso" not in schema["properties"] + assert set(schema["properties"]["enterpriseSSO"]["properties"]) == { + "enabled", + "displayName", + "idpMetadataUrl", + } + assert "enterpriseSso:" not in replicated + assert " enterpriseSSO:" in replicated + + +def test_enterprise_sso_metadata_url_requires_https() -> None: + schema = json.loads(OPENHANDS_VALUES_SCHEMA.read_text(encoding="utf-8")) + metadata_schema = schema["properties"]["enterpriseSSO"]["properties"][ + "idpMetadataUrl" + ] + assert metadata_schema["pattern"] == r"^$|^https://[^\s]+$" + + +def test_enterprise_sso_uses_keycloak_import_contract() -> None: + script = KEYCLOAK_CONFIG_SCRIPT.read_text(encoding="utf-8") + + assert re.search( + r'identity-provider/import-config".*?Content-Type: application/json', + script, + re.DOTALL, + ) + assert "--data-urlencode" not in script + assert 'providerId: "saml"' in script + assert "fromUrl: $from_url" in script + assert 'has("idpEntityId")' in script + assert 'has("singleSignOnServiceUrl")' in script + assert 'has("signingCertificate")' in script + assert '"validateSignature": "true"' in script + assert 'syncMode: "FORCE"' in script + + +def test_enterprise_sso_import_guard_requires_signing_certificate() -> None: + if shutil.which("jq") is None: + pytest.skip("jq not available") + + script = KEYCLOAK_CONFIG_SCRIPT.read_text(encoding="utf-8") + jq_filter = enterprise_sso_import_guard(script) + imported_config = { + "idpEntityId": "https://idp.example.com/entity", + "singleSignOnServiceUrl": "https://idp.example.com/sso", + } + + missing_certificate = subprocess.run( + ["jq", "-e", jq_filter], + input=json.dumps(imported_config), + capture_output=True, + text=True, + check=False, + ) + assert missing_certificate.returncode == 1 + + imported_config["signingCertificate"] = "certificate-data" + with_certificate = subprocess.run( + ["jq", "-e", jq_filter], + input=json.dumps(imported_config), + capture_output=True, + text=True, + check=False, + ) + assert with_certificate.returncode == 0 + + +def test_enterprise_sso_jq_filter_builds_keycloak_idp() -> None: + if shutil.which("jq") is None: + pytest.skip("jq not available") + + script = KEYCLOAK_CONFIG_SCRIPT.read_text(encoding="utf-8") + jq_filter = enterprise_sso_idp_jq_filter(script) + imported_config = { + "idpEntityId": "https://idp.example.com/entity", + "singleSignOnServiceUrl": "https://idp.example.com/sso", + "signingCertificate": "certificate-data", + } + display_name = 'Company "Platform" $(touch /tmp/should-not-run)' + + result = subprocess.run( + ["jq", "--arg", "display", display_name, jq_filter], + input=json.dumps(imported_config), + capture_output=True, + text=True, + check=True, + ) + identity_provider = json.loads(result.stdout) + + assert identity_provider["alias"] == "enterprise_sso" + assert identity_provider["displayName"] == display_name + assert identity_provider["config"]["idpEntityId"] == imported_config["idpEntityId"] + assert identity_provider["config"]["validateSignature"] == "true" + assert identity_provider["config"]["syncMode"] == "IMPORT" + + +def test_enterprise_sso_inputs_are_environment_data() -> None: + deployment = (OPENHANDS_CHART / "templates" / "deployment.yaml").read_text( + encoding="utf-8" + ) + script = KEYCLOAK_CONFIG_SCRIPT.read_text(encoding="utf-8") + replicated = REPLICATED_OPENHANDS.read_text(encoding="utf-8") + + assert "- name: ENTERPRISE_SSO_DISPLAY_NAME" in deployment + assert ( + 'value: {{ .Values.enterpriseSSO.displayName | default "Company SSO" | quote }}' + in deployment + ) + assert "- name: ENTERPRISE_SSO_IDP_METADATA_URL" in deployment + assert "value: {{ .Values.enterpriseSSO.idpMetadataUrl | quote }}" in deployment + assert "{{ .Values.enterpriseSSO.displayName" not in script + assert "{{ .Values.enterpriseSSO.idpMetadataUrl" not in script + assert '--arg display "$ENTERPRISE_SSO_DISPLAY_NAME"' in script + assert '--arg from_url "$ENTERPRISE_SSO_IDP_METADATA_URL"' in script + assert ( + 'displayName: repl{{ ConfigOption "enterprise_sso_display_name" | mustToJson }}' + in replicated + ) + assert ( + 'idpMetadataUrl: repl{{ ConfigOption "enterprise_sso_idp_metadata_url" | mustToJson }}' + in replicated + ) + + +def test_enterprise_sso_disable_path_reconciles_managed_provider() -> None: + script = KEYCLOAK_CONFIG_SCRIPT.read_text(encoding="utf-8") + + assert "else if .Values.enterpriseSSO.idpMetadataUrl" in script + assert "jq '.enabled = false'" in script + assert "Disabled managed identity provider: enterprise_sso" in script