diff --git a/pyproject.toml b/pyproject.toml index 790fff190a..a1b0761ccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,7 @@ reportMissingImports = false # Ignore missing stubs in imported modules [tool.pytest.ini_options] # Run pytest with all our checkers, and don't spam us with massive tracebacks on error -addopts = '--tb=native -vv --doctest-modules --doctest-glob="*.rst"' +addopts = '--tb=native -vv --doctest-modules --doctest-glob="*.rst" --ignore=tests/system_tests/services' # https://iscinumpy.gitlab.io/post/bound-version-constraints/#watch-for-warnings filterwarnings = ["error", "ignore::DeprecationWarning"] # Doctest python code in docs, python code in src docstrings, test functions in tests diff --git a/tests/system_tests/compose.yaml b/tests/system_tests/compose.yaml index 6082515434..3b116938f5 100644 --- a/tests/system_tests/compose.yaml +++ b/tests/system_tests/compose.yaml @@ -35,10 +35,6 @@ services: - KC_BOOTSTRAP_ADMIN_USERNAME=admin - KC_HOSTNAME=http://localhost:8081 command: ["start-dev"] - volumes: - - ./services/keycloak_config/:/tmp/config/ - post_start: - - command: bash /tmp/config/startup.sh ports: - 8081:8080 healthcheck: @@ -50,6 +46,23 @@ services: security_opt: - label=disable + configure-services: + image: ghcr.io/astral-sh/uv:python3.13-alpine + network_mode: host + volumes: + - ./services/startup.py:/startup.py:ro + environment: + - KEYCLOAK_SERVER=http://localhost:8081 + - KC_BOOTSTRAP_ADMIN_PASSWORD=admin + - KC_BOOTSTRAP_ADMIN_USERNAME=admin + - KEYCLOAK_REALM=master + command: ["uv", "run", "/startup.py"] + depends_on: + keycloak: + condition: service_healthy + security_opt: + - label=disable + tiled: image: ghcr.io/bluesky/tiled:0.2.12 network_mode: host # Port 8407 diff --git a/tests/system_tests/services/keycloak_config/mappers-template.json b/tests/system_tests/services/keycloak_config/mappers-template.json deleted file mode 100644 index 27be3fb865..0000000000 --- a/tests/system_tests/services/keycloak_config/mappers-template.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "protocolMappers": [ - { - "name": "username", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "aggregate.attrs": "false", - "introspection.token.claim": "true", - "multivalued": "false", - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", - "lightweight.claim": "false", - "access.token.claim": "true", - "claim.name": "fedid", - "jsonType.label": "String" - } - }, - { - "name": "audience-mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "config": { - "introspection.token.claim": "true", - "access.token.claim": "true", - "included.custom.audience": "__AUDIENCE__" - } - } - ] -} diff --git a/tests/system_tests/services/keycloak_config/service-account-beamline.json b/tests/system_tests/services/keycloak_config/service-account-beamline.json deleted file mode 100644 index f736156f84..0000000000 --- a/tests/system_tests/services/keycloak_config/service-account-beamline.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "protocolMappers": [ - { - "name": "beamline", - "protocol": "openid-connect", - "protocolMapper": "oidc-hardcoded-claim-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "claim.value": "adsim", - "userinfo.token.claim": "true", - "id.token.claim": "true", - "lightweight.claim": "false", - "access.token.claim": "true", - "claim.name": "beamline", - "jsonType.label": "String", - "access.tokenResponse.claim": "false" - } - }, - { - "name": "tiled", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "consentRequired": false, - "config": { - "id.token.claim": "false", - "lightweight.claim": "false", - "access.token.claim": "true", - "introspection.token.claim": "true", - "included.custom.audience": "tiled-writer" - } - } - ] -} diff --git a/tests/system_tests/services/keycloak_config/service-account-user.json b/tests/system_tests/services/keycloak_config/service-account-user.json deleted file mode 100644 index 6196af5f19..0000000000 --- a/tests/system_tests/services/keycloak_config/service-account-user.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "protocolMappers": [ - { - "name": "fedid", - "protocol": "openid-connect", - "protocolMapper": "oidc-hardcoded-claim-mapper", - "config": { - "introspection.token.claim": "true", - "claim.value": "__CLAIM_VALUE__", - "userinfo.token.claim": "true", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "fedid", - "jsonType.label": "String" - } - }, - { - "name": "audience-mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "config": { - "introspection.token.claim": "true", - "access.token.claim": "true", - "included.custom.audience": "__AUDIENCE__" - } - } - ] -} diff --git a/tests/system_tests/services/keycloak_config/startup.sh b/tests/system_tests/services/keycloak_config/startup.sh deleted file mode 100644 index 32b22f3e4d..0000000000 --- a/tests/system_tests/services/keycloak_config/startup.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/bin/bash -export PATH=$PATH:/opt/keycloak/bin - -# --- Config --- -export KC_CLI_PASSWORD="admin" -SERVER="http://localhost:8080" -GENERAL_TEMPLATE="/tmp/config/mappers-template.json" -BEAMLINE_SERVICE_TEMPLATE="/tmp/config/service-account-beamline.json" -USER_SERVICE_TEMPLATE="/tmp/config/service-account-user.json" -REALM="master" - -# Wait for Keycloak -sleep 5 -until kcadm.sh config credentials --server $SERVER --realm $REALM --user admin; do sleep 3; done - -# Cleanup logic -for type in "Allowed Protocol Mapper Types" "Allowed Client Scopes"; do - for id in $(kcadm.sh get components -q name="$type" --fields id --format csv --noquotes); do - kcadm.sh delete components/$id - done -done - - -USERS=("alice:alice" "bob:bob") - -for entry in "${USERS[@]}"; do - # Split the string into username and password - username="${entry%%:*}" - password="${entry##*:}" - # Create the user - kcadm.sh create users -r "$REALM" -s username="$username" -s enabled=true - # Set the password - kcadm.sh set-password -r "$REALM" --username "$username" --new-password "$password" - echo "User '$username' created successfully." -done - -kcreg.sh config credentials --server $SERVER --realm $REALM --user admin - -# --- Client Creation Function --- -# Args: client_id, audience, type, fedid, extra_flags -create_client() { - local client_id=$1 - local aud=$2 - local type=$3 - local fedid=$4 - shift 4 # The rest are Keycloak attributes (-s key=value) - - if kcreg.sh get "$client_id" >/dev/null 2>&1; then - echo ">> Skipping $client_id (exists)" - return - fi - - echo ">> Creating $client_id..." - local tmpfile=$(mktemp) - - if [[ "$type" == "BEAMLINE_SERVICE_ACCOUNT" ]]; then - cp $BEAMLINE_SERVICE_TEMPLATE "$tmpfile" - elif [[ "$type" == "USER_SERVICE_ACCOUNT" ]]; then - sed "s/__AUDIENCE__/$aud/g; s/__CLAIM_VALUE__/$fedid/g" "$USER_SERVICE_TEMPLATE" > "$tmpfile" - else - # Use sed to replace placeholders in the JSON template - sed "s/__AUDIENCE__/$aud/g;" "$GENERAL_TEMPLATE" > "$tmpfile" - fi - - kcreg.sh create -x -s clientId="$client_id" -f "$tmpfile" "$@" - rm "$tmpfile" -} - -# --- Create Clients --- - -# ixx CLI -create_client "ixx-cli-blueapi" "ixx-blueapi" "" "" \ - -s standardFlowEnabled=false -s publicClient=true -s 'redirectUris=["/*"]' \ - -s 'attributes={"frontchannel.logout.session.required":"true","oauth2.device.authorization.grant.enabled":"true","use.refresh.tokens":"true","backchannel.logout.session.required":"true"}' - -# ixx BlueAPI -create_client "ixx-blueapi" "ixx-blueapi" "" "" \ - -s standardFlowEnabled=true -s secret="blueapi-secret" -s rootUrl="http://localhost:4180" \ - -s 'redirectUris=["http://localhost:4180/*"]' \ - -s 'attributes={"frontchannel.logout.session.required":"true","use.refresh.tokens":"true"}' - -# Tiled -create_client "tiled" "tiled" "" "" \ - -s standardFlowEnabled=true -s secret="tiled-secret" -s rootUrl="http://localhost:4181" \ - -s 'redirectUris=["http://localhost:4181/*"]' - -# Tiled CLI -create_client "tiled-cli" "tiled" "" ""\ - -s standardFlowEnabled=false -s publicClient=true -s 'redirectUris=["/*"]' \ - -s 'attributes={"frontchannel.logout.session.required":"true","oauth2.device.authorization.grant.enabled":"true","use.refresh.tokens":"true","backchannel.logout.session.required":"true"}' - -# Service account tiled-writer -create_client "tiled-writer" "" "BEAMLINE_SERVICE_ACCOUNT" "" \ - -s secret="secret" -s standardFlowEnabled=false -s serviceAccountsEnabled=true -s 'redirectUris=["/*"]' - -# System Test admin -create_client "system-test-blueapi-admin" "ixx-blueapi" "USER_SERVICE_ACCOUNT" "admin" \ - -s secret="secret" -s standardFlowEnabled=false -s serviceAccountsEnabled=true -s 'redirectUris=["/*"]' - -# System Test alice -create_client "system-test-blueapi-alice" "ixx-blueapi" "USER_SERVICE_ACCOUNT" "alice"\ - -s secret="secret" -s standardFlowEnabled=false -s serviceAccountsEnabled=true -s 'redirectUris=["/*"]' - -# System Test bob -create_client "system-test-blueapi-bob" "ixx-blueapi" "USER_SERVICE_ACCOUNT" "bob"\ - -s secret="secret" -s standardFlowEnabled=false -s serviceAccountsEnabled=true -s 'redirectUris=["/*"]' diff --git a/tests/system_tests/services/startup.py b/tests/system_tests/services/startup.py new file mode 100644 index 0000000000..d1072c0124 --- /dev/null +++ b/tests/system_tests/services/startup.py @@ -0,0 +1,263 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "mantelo==2.2.1", +# ] +# /// +"""Configure the local Keycloak instance used by the system tests.""" + +import os +from collections.abc import Callable +from functools import wraps +from typing import Any + +from mantelo import KeycloakAdmin + +SERVER = os.environ.get("KEYCLOAK_SERVER") +REALM = os.environ.get("KEYCLOAK_REALM") +ADMIN_USERNAME = os.environ.get("KC_BOOTSTRAP_ADMIN_USERNAME") +ADMIN_PASSWORD = os.environ.get("KC_BOOTSTRAP_ADMIN_PASSWORD") + +USERS = {"alice": "alice", "bob": "bob"} + +admin = KeycloakAdmin.from_username_password( + server_url=SERVER, + realm_name=REALM, + client_id="admin-cli", + username=ADMIN_USERNAME, + password=ADMIN_PASSWORD, +) + + +def protocol_mapper( + name: str, mapper_type: str, config: dict[str, str] +) -> dict[str, Any]: + return { + "name": name, + "protocol": "openid-connect", + "protocolMapper": mapper_type, + "consentRequired": False, + "config": config, + } + + +def audience_mapper( + audience: str, + name: str = "audience-mapper", + extra_config: dict[str, str] | None = None, +) -> dict[str, Any]: + return protocol_mapper( + name, + "oidc-audience-mapper", + { + "introspection.token.claim": "true", + "access.token.claim": "true", + "included.custom.audience": audience, + **(extra_config or {}), + }, + ) + + +def hardcoded_claim_mapper( + name: str, claim_value: str, extra_config: dict[str, str] | None = None +) -> dict[str, Any]: + return protocol_mapper( + name, + "oidc-hardcoded-claim-mapper", + { + "introspection.token.claim": "true", + "claim.value": claim_value, + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": name, + "jsonType.label": "String", + **(extra_config or {}), + }, + ) + + +def general_mappers(audience: str) -> dict[str, Any]: + return { + "protocolMappers": [ + protocol_mapper( + "username", + "oidc-usermodel-attribute-mapper", + { + "aggregate.attrs": "false", + "introspection.token.claim": "true", + "multivalued": "false", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "fedid", + "jsonType.label": "String", + }, + ), + audience_mapper(audience), + ] + } + + +def beamline_service_account_mappers() -> dict[str, Any]: + return { + "protocolMappers": [ + hardcoded_claim_mapper( + "beamline", + "adsim", + extra_config={ + "lightweight.claim": "false", + "access.tokenResponse.claim": "false", + }, + ), + audience_mapper( + "tiled-writer", + name="tiled", + extra_config={"id.token.claim": "false", "lightweight.claim": "false"}, + ), + ] + } + + +def user_service_account_mappers(audience: str, fedid: str) -> dict[str, Any]: + return { + "protocolMappers": [ + hardcoded_claim_mapper("fedid", fedid), + audience_mapper(audience), + ] + } + + +def cleanup_components() -> None: + for component_type in ("Allowed Protocol Mapper Types", "Allowed Client Scopes"): + for component in admin.components.get(name=component_type): + admin.components(component["id"]).delete() + + +def create_users() -> None: + for username, password in USERS.items(): + response, _ = admin.users.as_raw().post({"username": username, "enabled": True}) + user_id = response.headers["Location"].rsplit("/", 1)[-1] + admin.users(user_id).reset_password.put( + {"type": "password", "value": password, "temporary": False} + ) + print(f"User '{username}' created successfully.") + + +def create_client( + build_payload: Callable[..., dict[str, Any]], +) -> Callable[..., None]: + """Wrap a payload builder with the shared create-if-absent flow.""" + + @wraps(build_payload) + def wrapper(client_id: str, **kwargs: Any) -> None: + if admin.clients.get(clientId=client_id): + print(f">> Skipping {client_id} (exists)") + return + + print(f">> Creating {client_id}...") + payload = build_payload(**kwargs) + payload["clientId"] = client_id + admin.clients.post(payload) + + return wrapper + + +@create_client +def create_cli_client( + aud: str = "", + attributes: dict[str, str] | None = None, +) -> dict[str, Any]: + payload = general_mappers(aud) + payload.update( + standardFlowEnabled=False, + publicClient=True, + redirectUris=["/*"], + attributes={ + "frontchannel.logout.session.required": "true", + "oauth2.device.authorization.grant.enabled": "true", + "use.refresh.tokens": "true", + "backchannel.logout.session.required": "true", + }, + ) + return payload + + +@create_client +def create_web_client( + aud: str, + secret: str, + root_url: str, + attributes: dict[str, str] | None = None, +) -> dict[str, Any]: + payload = general_mappers(aud) + payload.update( + standardFlowEnabled=True, + secret=secret, + rootUrl=root_url, + redirectUris=[f"{root_url}/*"], + **({"attributes": attributes} if attributes else {}), + ) + return payload + + +@create_client +def create_beamline_service_account_client(secret: str = "secret") -> dict[str, Any]: + payload = beamline_service_account_mappers() + payload.update( + secret=secret, + standardFlowEnabled=False, + serviceAccountsEnabled=True, + redirectUris=["/*"], + ) + return payload + + +@create_client +def create_user_service_account_client( + fedid: str, aud: str = "ixx-blueapi", secret: str = "secret" +) -> dict[str, Any]: + payload = user_service_account_mappers(aud, fedid) + payload.update( + secret=secret, + standardFlowEnabled=False, + serviceAccountsEnabled=True, + redirectUris=["/*"], + ) + return payload + + +def create_clients() -> None: + create_cli_client(client_id="ixx-cli-blueapi", aud="ixx-blueapi") + create_cli_client(client_id="tiled-cli", aud="tiled") + create_web_client( + client_id="ixx-blueapi", + aud="ixx-blueapi", + secret="blueapi-secret", + root_url="http://localhost:4180", + attributes={ + "frontchannel.logout.session.required": "true", + "use.refresh.tokens": "true", + }, + ) + create_web_client( + client_id="tiled", + aud="tiled", + secret="tiled-secret", + root_url="http://localhost:4181", + ) + create_beamline_service_account_client(client_id="tiled-writer") + + # A system-test service account for the admin user plus each of USERS. + for fedid in ("admin", *USERS): + create_user_service_account_client( + client_id=f"system-test-blueapi-{fedid}", fedid=fedid + ) + + +if __name__ == "__main__": + cleanup_components() + create_users() + create_clients()