From e4a5342d1503d5a1699acd783e75fd2ceeee617a Mon Sep 17 00:00:00 2001 From: oysand Date: Wed, 5 Aug 2026 19:35:53 +0200 Subject: [PATCH 1/3] Authenticate integration tests via Keycloak --- .github/workflows/run_integration_tests.yml | 23 +- README.md | 56 ++- pyproject.toml | 3 - robotics_integration_tests/armada.py | 4 +- robotics_integration_tests/conftest.py | 155 ++++--- .../custom_containers/flotilla_backend.py | 11 +- .../custom_containers/isar.py | 10 +- .../custom_containers/keycloak.py | 159 +++++++ .../custom_containers/migrations_runner.py | 46 +- .../custom_containers/sara.py | 18 +- .../migrations_runner/entrypoint.sh | 14 +- .../custom_realms/robotics-realm.json | 434 ++++++++++++++++++ .../settings/settings.py | 71 ++- .../utilities/authentication.py | 115 ++++- .../utilities/authentication_assertions.py | 188 ++++++++ .../utilities/flotilla_backend_api.py | 135 ++---- .../utilities/keyvault.py | 137 ------ .../utilities/sara_backend_api.py | 2 +- uv.lock | 80 ---- 19 files changed, 1179 insertions(+), 482 deletions(-) create mode 100644 robotics_integration_tests/custom_containers/keycloak.py create mode 100644 robotics_integration_tests/custom_realms/robotics-realm.json create mode 100644 robotics_integration_tests/utilities/authentication_assertions.py delete mode 100644 robotics_integration_tests/utilities/keyvault.py diff --git a/.github/workflows/run_integration_tests.yml b/.github/workflows/run_integration_tests.yml index f08afda..0af8b4a 100644 --- a/.github/workflows/run_integration_tests.yml +++ b/.github/workflows/run_integration_tests.yml @@ -10,8 +10,14 @@ on: default: "latest" secrets: INTEGRATION_TEST_AZURE_CLIENT_SECRET: - required: true - description: "Azure client secret for the service principal used in integration tests with access to keyvault" + required: false + description: | + Azure client secret for the service principal with read access to the + integration-test key vault, which now holds only the MQTT credentials. + No longer used for authentication between the services: those tokens come + from a local Keycloak realm. Kept optional rather than removed because + this workflow is consumed at @main by every robotics repo and each one + declares it; removing it outright would break them all at once. workflow_dispatch: inputs: @@ -25,6 +31,8 @@ permissions: contents: read env: + # Only MQTT credentials are still read from the key vault; service-to-service + # authentication runs against a local Keycloak realm. KEYVAULT_NAME: FlotillaTestsKv AZURE_TENANT_ID: 3aa4a235-b6e2-48d5-9195-7fcf05b459b0 AZURE_CLIENT_ID: 17d7c036-e4ff-4df6-87fd-0d648a36a727 @@ -53,15 +61,12 @@ jobs: run: | echo "Retrieving secrets from Key Vault: $KEYVAULT_NAME" - # You can list the secret names here + # MQTT is username/password, validated by the broker against the hashed + # passwd_file committed in equinor/flotilla, so these remain real secrets. secrets=( - INTEGRATION-TESTS-CLIENT-SECRET FLOTILLA-MQTT-PASSWORD - FLOTILLA-AZURE-CLIENT-SECRET FLOTILLA-BROKER-SERVER-KEY - ISAR-AZURE-CLIENT-SECRET ISAR-MQTT-PASSWORD - SARA-AZURE-CLIENT-SECRET SARA-MQTT-PASSWORD ) @@ -120,13 +125,9 @@ jobs: GIT_REPOSITORY_FOR_MIGRATIONS_REF: ${{ steps.images.outputs.git_ref }} SARA_GIT_REPOSITORY_FOR_MIGRATIONS_REF: ${{ steps.images.outputs.git_ref }} - INTEGRATION_TESTS_CLIENT_SECRET: ${{ env.INTEGRATION_TESTS_CLIENT_SECRET }} FLOTILLA_MQTT_PASSWORD: ${{ env.FLOTILLA_MQTT_PASSWORD }} - FLOTILLA_AZURE_CLIENT_SECRET: ${{ env.FLOTILLA_AZURE_CLIENT_SECRET }} FLOTILLA_BROKER_SERVER_KEY: ${{ env.FLOTILLA_BROKER_SERVER_KEY }} - ISAR_AZURE_CLIENT_SECRET: ${{ env.ISAR_AZURE_CLIENT_SECRET }} ISAR_MQTT_PASSWORD: ${{ env.ISAR_MQTT_PASSWORD }} - SARA_AZURE_CLIENT_SECRET: ${{ env.SARA_AZURE_CLIENT_SECRET }} SARA_MQTT_PASSWORD: ${{ env.SARA_MQTT_PASSWORD }} run: uv run --frozen pytest -s -n auto robotics_integration_tests diff --git a/README.md b/README.md index a0edf0c..bda3548 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,48 @@ The following components are currently included in the integration tests: - PostgreSQL database - Azure Blob Storage (emulated with Azurite) - [ISAR Robot](https://github.com/equinor/isar-robot) (your friendly neighbourhood mocked robot which provides the answers you need) +- [SARA](https://github.com/equinor/sara) (storage and analysis of robot acquired data) +- A local Keycloak realm (see [Authentication](#authentication)) + +## Authentication +The tests run with authentication **enabled and genuinely exercised**, but without Microsoft +Entra ID. A [Keycloak](https://www.keycloak.org/) container is the OpenID Connect issuer for the +whole stack: Flotilla and SARA run with `ASPNETCORE_ENVIRONMENT=IntegrationTest`, whose +`appsettings.IntegrationTest.json` sets `Authentication:Provider=Oidc`; ISAR is pointed at the +realm with `ISAR_OPENID_CONFIG_URL`; Flotilla acquires its downstream tokens from the realm too; +and the test process mints its own from the same issuer. + +No app registrations, tenant or client secrets are needed. The fixtures assert that each service +rejects unauthenticated callers before any test runs, and the mission tests attempt unauthorised +interference mid-flight and then assert the mission completed unaffected. + +MQTT is the exception: username/password validated by the broker against the hashed +`passwd_file` committed in `equinor/flotilla`, so those remain real secrets. + +### The realm +`robotics_integration_tests/custom_realms/robotics-realm.json` is imported at startup. It is also +what a developer mounts to run flotilla or sara against Keycloak locally, so a local run and a CI +run exercise the same clients, scopes and roles. + +Unlike Entra, Keycloak does not derive the audience from the requested scope: each API has a +client scope — `isar-api`, `sara-api`, `flotilla-api`, `pointilla-api` — carrying a single +audience mapper onto `isar-test`, `sara-test` and so on. **Request exactly one API scope per +token**; two audience mappers make Keycloak emit `aud` as an array, which ISAR rejects. + +Keycloak has no ad-hoc token endpoint, so a role set is chosen by picking the service account +that holds it: + +| Client | Roles | +| --- | --- | +| `integration-tests` | every role the three services require | +| `integration-tests-limited-role` | `Role.User.HUA` only — recognised, but insufficient | +| `integration-tests-no-role` | none | +| `flotilla-test` | used by Flotilla for its downstream ISAR/SARA calls | + +Three protocol mappers exist only to satisfy `fastapi-azure-auth`'s Entra-shaped token model, +which ISAR uses: a hardcoded `ver`, a hardcoded `nbf` (Keycloak emits none, the library requires +it) and a flat `roles` claim, since the default nested `realm_access.roles` maps to neither +ISAR's `User.roles` nor .NET's `ClaimTypes.Role`. ## Run the integration tests through remote workflow call To run the integration tests in a remote repository, this [workflow](./.github/workflows/run_integration_tests.yml) has been set up. @@ -55,6 +97,9 @@ This snippet will enable you to run the integration tests manually and automatic INTEGRATION_TEST_AZURE_CLIENT_SECRET ``` +This secret now only grants read access to the MQTT credentials in the key vault. It is kept +because every consuming repository declares it. + The input `lane` determines which image tag should be applied to the internally developed packages like Flotilla and ISAR. If input is set as `lane=dev` the newest development images (corresponding to newest push to main branch) will be used while `lane=latest` will use the newest release. ## Local development @@ -63,20 +108,17 @@ Clone the repository and install dependencies with [uv](https://docs.astral.sh/u uv sync ``` -Ensure the following secrets are populated in your local environment, either as environment variables or in a `.env` file in the repository root directory. +Ensure the following secrets are populated in your local environment, either as environment variables or in a `.env` file in the repository root directory. These are the MQTT credentials; no +Azure app registration secrets are needed, and you do **not** need to be logged in with `az`. ``` -INTEGRATION_TESTS_CLIENT_SECRET -FLOTILLA_AZURE_CLIENT_SECRET FLOTILLA_BROKER_SERVER_KEY FLOTILLA_MQTT_PASSWORD -ISAR_AZURE_CLIENT_SECRET ISAR_MQTT_PASSWORD -SARA_AZURE_CLIENT_SECRET SARA_MQTT_PASSWORD ``` -They may all be found in the integration test [keyvault](https://portal.azure.com/#@StatoilSRM.onmicrosoft.com/resource/subscriptions/c389567b-2dd0-41fa-a5da-d86b81f80bda/resourceGroups/FlotillaIntegrationTests/providers/Microsoft.KeyVault/vaults/FlotillaTestsKv/overview). +They may be found in the integration test [keyvault](https://portal.azure.com/#@StatoilSRM.onmicrosoft.com/resource/subscriptions/c389567b-2dd0-41fa-a5da-d86b81f80bda/resourceGroups/FlotillaIntegrationTests/providers/Microsoft.KeyVault/vaults/FlotillaTestsKv/overview). You may now run the tests with @@ -118,4 +160,4 @@ Two things worth knowing: - `isar-robot`'s `uv.lock` pins `isar` from PyPI, so the locally built `isar` wheel is installed over the released one. -The mosquitto broker is always the published image. +The mosquitto broker and Keycloak are always the published images. diff --git a/pyproject.toml b/pyproject.toml index 62b4e21..f2df299 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,13 +11,10 @@ dependencies = [ "pytest", "testcontainers[postgres,azurite,mqtt]", "azure-storage-blob", - "azure-identity", - "azure-keyvault-secrets", "pydantic", "pydantic-settings", "loguru", "python-dotenv", - "msal", "requests" ] diff --git a/robotics_integration_tests/armada.py b/robotics_integration_tests/armada.py index 82f2a5d..833b8e7 100644 --- a/robotics_integration_tests/armada.py +++ b/robotics_integration_tests/armada.py @@ -10,6 +10,7 @@ ) from robotics_integration_tests.custom_containers.isar import IsarRobot from robotics_integration_tests.custom_containers.mosquitto import FlotillaBroker +from robotics_integration_tests.custom_containers.keycloak import Keycloak from robotics_integration_tests.custom_containers.postgres import ( FlotillaDatabase, SaraDatabase, @@ -17,13 +18,12 @@ from robotics_integration_tests.custom_containers.teams_webhook_receiver import ( TeamsWebhookReceiver, ) -from robotics_integration_tests.utilities.keyvault import Keyvault class Armada: def __init__(self) -> None: self.network: Network | None = None - self.keyvault: Keyvault | None = None + self.keycloak: Keycloak | None = None self.test_id: str = "" self.flotilla_database: FlotillaDatabase | None = None self.flotilla_broker: FlotillaBroker | None = None diff --git a/robotics_integration_tests/conftest.py b/robotics_integration_tests/conftest.py index 7668dca..358dd09 100644 --- a/robotics_integration_tests/conftest.py +++ b/robotics_integration_tests/conftest.py @@ -43,6 +43,10 @@ create_flotilla_broker_container, FlotillaBroker, ) +from robotics_integration_tests.custom_containers.keycloak import ( + Keycloak, + create_keycloak_container, +) from robotics_integration_tests.custom_containers.postgres import ( SaraDatabase, create_postgres_container, @@ -61,13 +65,20 @@ StreamLoggingDockerContainer, ) from robotics_integration_tests.settings.settings import settings +from robotics_integration_tests.utilities.authentication import ( + configure_issuer, + reset_issuer, +) +from robotics_integration_tests.utilities.authentication_assertions import ( + assert_authentication_is_enforced, + isar_url, +) from robotics_integration_tests.utilities.flotilla_backend_api import ( setup_robot_in_flotilla, wait_for_backend_to_be_responsive, populate_database_with_minimum_models, wait_for_database_to_be_populated, ) -from robotics_integration_tests.utilities.keyvault import Keyvault, ScopedKeyvault from robotics_integration_tests.utilities.sara_backend_api import ( wait_for_sara_to_be_responsive, ) @@ -89,6 +100,7 @@ def _pull_latest_images() -> None: native_images = [ settings.POSTGRESQL_IMAGE, settings.AZURITE_IMAGE, + settings.KEYCLOAK_IMAGE, ] for image in amd64_images: @@ -127,28 +139,44 @@ def test_id(): @pytest.fixture -def keyvault(test_id: str): - scoped_keyvault: ScopedKeyvault = ScopedKeyvault( - prefix=test_id, - keyvault_name=settings.KEYVAULT_NAME, - client_secret=settings.FLOTILLA_AZURE_CLIENT_SECRET, - client_id=settings.FLOTILLA_AZURE_CLIENT_ID, - tenant_id=settings.AZURE_TENANT_ID, - ) +def network(): + with Network() as network: + yield network - yield scoped_keyvault - scoped_keyvault.cleanup() +@pytest.fixture +def keycloak(network: Network, test_id: str): + """Local OpenID Connect issuer standing in for Azure Entra ID. + Every service in the stack validates its access tokens against this + container, and the test process mints its own tokens from it, so the suite + needs no app registrations, no tenant and no client secrets. -@pytest.fixture -def network(): - with Network() as network: - yield network + The realm is imported from custom_realms/, the same file a developer mounts + to run flotilla or sara against Keycloak locally, so a local run and a CI run + exercise the same clients, scopes and roles. + """ + container, issuer = create_keycloak_container( + network=network, + alias=settings.KEYCLOAK_ALIAS, + port=settings.KEYCLOAK_PORT, + test_id=test_id, + ) + with container: + wait_for_port_mapping_to_be_available(container=container, port=issuer.port) + issuer.wait_until_ready() + + # The API helpers build auth headers from module functions with no access + # to fixtures, so the host URL is handed over globally. + configure_issuer(issuer.host_url) + try: + yield issuer + finally: + reset_issuer() @pytest.fixture -def flotilla_database(network: Network, keyvault: Keyvault, test_id: str): +def flotilla_database(network: Network, test_id: str): with create_postgres_container(network, test_id=test_id) as database: wait_for_port_mapping_to_be_available(container=database, port=5432) logger.info( @@ -174,11 +202,6 @@ def flotilla_database(network: Network, keyvault: Keyvault, test_id: str): logger.info("Migrations completed successfully (container exited cleanly)") - keyvault.set_secret( - secret_name="flotilla-database-connection-string", - secret_value=connection_string, - ) - yield FlotillaDatabase( database=database, connection_string=connection_string, @@ -187,7 +210,7 @@ def flotilla_database(network: Network, keyvault: Keyvault, test_id: str): @pytest.fixture -def sara_database(network: Network, keyvault: Keyvault, test_id: str): +def sara_database(network: Network, test_id: str): with create_sara_postgres_container(network, test_id=test_id) as database: wait_for_port_mapping_to_be_available(container=database, port=5432) logger.info( @@ -213,11 +236,6 @@ def sara_database(network: Network, keyvault: Keyvault, test_id: str): logger.info("Sara migrations completed successfully (container exited cleanly)") - keyvault.set_secret( - secret_name="sara-database-connection-string", - secret_value=connection_string, - ) - yield SaraDatabase( database=database, connection_string=connection_string, @@ -226,7 +244,7 @@ def sara_database(network: Network, keyvault: Keyvault, test_id: str): @pytest.fixture -def armada_storage(network: Network, keyvault: Keyvault, test_id: str): +def armada_storage(network: Network, test_id: str): with ExitStack() as stack: azurite_containers: Dict[str, AzuriteStorageContainer] = {} @@ -259,16 +277,6 @@ def armada_storage(network: Network, keyvault: Keyvault, test_id: str): docker_connection_string=docker_connection_string, host_connection_string=host_connection_string, ) - if azurite_container_alias == settings.SARA_RAW_STORAGE_CONTAINER: - keyvault.set_secret( - secret_name="AZURE-STORAGE-CONNECTION-STRING-DATA", - secret_value=docker_connection_string, - ) - elif azurite_container_alias == settings.SARA_ANON_STORAGE_CONTAINER: - keyvault.set_secret( - secret_name="AZURE-STORAGE-CONNECTION-STRING-METADATA", - secret_value=docker_connection_string, - ) ensure_blob_containers(host_connection_string, "hua", "kaa", "nls", "test") @@ -304,15 +312,14 @@ def teams_webhook_receiver(network: Network, test_id: str): test_id=test_id, ) with container: - wait_for_port_mapping_to_be_available( - container=container, port=receiver.port - ) + wait_for_port_mapping_to_be_available(container=container, port=receiver.port) yield receiver @pytest.fixture def flotilla_backend( network: Network, + keycloak: Keycloak, flotilla_database: FlotillaDatabase, teams_webhook_receiver: TeamsWebhookReceiver, test_id: str, @@ -333,6 +340,9 @@ def flotilla_backend( backend_url: str = f"http://localhost:{flotilla_backend.get_exposed_port(8000)}" wait_for_backend_to_be_responsive(backend_url=backend_url) + # Confirm the backend rejects unauthenticated callers. Every other + # assertion here would pass just as happily against an unsecured stack. + assert_authentication_is_enforced(f"{backend_url}/robots") populate_database_with_minimum_models(backend_url=backend_url) wait_for_database_to_be_populated(backend_url=backend_url) @@ -348,6 +358,7 @@ def flotilla_backend( @pytest.fixture def sara( network: Network, + keycloak: Keycloak, sara_database: SaraDatabase, armada_storage: ArmadaStorage, test_id: str, @@ -370,6 +381,7 @@ def sara( sara_url: str = f"http://localhost:{sara_container.get_exposed_port(8100)}" wait_for_sara_to_be_responsive(sara_url=sara_url) + assert_authentication_is_enforced(f"{sara_url}/api/analysis") yield Sara( sara=sara_container, @@ -382,9 +394,9 @@ def sara( @pytest.fixture def armada_without_robots( - keyvault: Keyvault, network: Network, test_id: str, + keycloak: Keycloak, flotilla_broker: FlotillaBroker, flotilla_database: FlotillaDatabase, flotilla_backend: FlotillaBackend, @@ -395,9 +407,9 @@ def armada_without_robots( ): armada: Armada = Armada() - armada.keyvault = keyvault armada.network = network armada.test_id = test_id + armada.keycloak = keycloak armada.sara_database = sara_database armada.sara = sara armada.flotilla_database = flotilla_database @@ -409,21 +421,45 @@ def armada_without_robots( yield armada +def _blob_connection_strings(armada: Armada) -> tuple[str, str]: + """In-network Azurite connection strings for ISAR's data and metadata stores. + + These used to be passed through the real Key Vault; they are now read + straight off the Azurite fixture. + """ + containers = armada.armada_storage.azurite_containers + return ( + containers[settings.SARA_RAW_STORAGE_CONTAINER].docker_connection_string, + containers[settings.SARA_ANON_STORAGE_CONTAINER].docker_connection_string, + ) + + +def _assert_robots_require_authentication(armada: Armada) -> None: + """Confirm every ISAR robot rejects unauthenticated callers. + + Mirrors the check applied to flotilla and sara when their containers start. + An unauthenticated POST is refused before the handler runs, so this cannot + disturb the robot's state machine. + """ + for robot in armada.robots.values(): + assert_authentication_is_enforced( + isar_url(robot, "/schedule/stop-mission"), method="POST" + ) + + @pytest.fixture def armada_with_single_successful_robot(armada_without_robots: Armada): armada: Armada = armada_without_robots + blob_conn_data, blob_conn_metadata = _blob_connection_strings(armada) with create_isar_robot_container( network=armada.network, + openid_config_url=armada.keycloak.internal_openid_config_url, image=settings.ISAR_ROBOT_IMAGE, name=settings.ISAR_ROBOT_NAME, port=settings.ISAR_ROBOT_PORT, alias=settings.ISAR_ROBOT_ALIAS, - blob_storage_connection_string_data=armada.keyvault.get_secret( - "AZURE-STORAGE-CONNECTION-STRING-DATA" - ).value, - blob_storage_connection_string_metadata=armada.keyvault.get_secret( - "AZURE-STORAGE-CONNECTION-STRING-METADATA" - ).value, + blob_storage_connection_string_data=blob_conn_data, + blob_storage_connection_string_metadata=blob_conn_metadata, test_id=armada.test_id, ) as isar_robot: @@ -440,6 +476,7 @@ def armada_with_single_successful_robot(armada_without_robots: Armada): alias=settings.ISAR_ROBOT_ALIAS, installation_code=installation_code_for_robot, ) + _assert_robots_require_authentication(armada) armada.log_startup_info() yield armada @@ -447,19 +484,17 @@ def armada_with_single_successful_robot(armada_without_robots: Armada): @pytest.fixture def armada_with_single_failing_robot(armada_without_robots: Armada): armada: Armada = armada_without_robots + blob_conn_data, blob_conn_metadata = _blob_connection_strings(armada) with create_isar_robot_container( network=armada.network, + openid_config_url=armada.keycloak.internal_openid_config_url, image=settings.ISAR_ROBOT_IMAGE, name=settings.ISAR_ROBOT_NAME, port=settings.ISAR_ROBOT_PORT, alias=settings.ISAR_ROBOT_ALIAS, - blob_storage_connection_string_data=armada.keyvault.get_secret( - "AZURE-STORAGE-CONNECTION-STRING-DATA" - ).value, - blob_storage_connection_string_metadata=armada.keyvault.get_secret( - "AZURE-STORAGE-CONNECTION-STRING-METADATA" - ).value, + blob_storage_connection_string_data=blob_conn_data, + blob_storage_connection_string_metadata=blob_conn_metadata, should_fail_normal_task=True, test_id=armada.test_id, ) as isar_robot: @@ -477,6 +512,7 @@ def armada_with_single_failing_robot(armada_without_robots: Armada): alias=settings.ISAR_ROBOT_ALIAS, installation_code=installation_code_for_robot, ) + _assert_robots_require_authentication(armada) armada.log_startup_info() yield armada @@ -500,12 +536,7 @@ def armada_with_multiple_robots(armada_without_robots: Armada): 4. MissionFailThenLost – mission fails, fails to return home """ armada: Armada = armada_without_robots - blob_conn_data: str = armada.keyvault.get_secret( - "AZURE-STORAGE-CONNECTION-STRING-DATA" - ).value - blob_conn_metadata: str = armada.keyvault.get_secret( - "AZURE-STORAGE-CONNECTION-STRING-METADATA" - ).value + blob_conn_data, blob_conn_metadata = _blob_connection_strings(armada) robot_configs = [ { @@ -543,6 +574,7 @@ def armada_with_multiple_robots(armada_without_robots: Armada): container = stack.enter_context( create_isar_robot_container( network=armada.network, + openid_config_url=armada.keycloak.internal_openid_config_url, image=settings.ISAR_ROBOT_IMAGE, name=cfg["name"], port=settings.ISAR_ROBOT_PORT, @@ -575,6 +607,7 @@ def armada_with_multiple_robots(armada_without_robots: Armada): installation_code=installation_code, ) + _assert_robots_require_authentication(armada) armada.log_startup_info() yield armada diff --git a/robotics_integration_tests/custom_containers/flotilla_backend.py b/robotics_integration_tests/custom_containers/flotilla_backend.py index 61ada1b..086cd33 100644 --- a/robotics_integration_tests/custom_containers/flotilla_backend.py +++ b/robotics_integration_tests/custom_containers/flotilla_backend.py @@ -42,12 +42,11 @@ def create_flotilla_backend_container( .with_env("Mqtt__Host", settings.FLOTILLA_BROKER_ALIAS) .with_env("Mqtt__Port", settings.FLOTILLA_BROKER_PORT) .with_env("Mqtt__Password", settings.FLOTILLA_MQTT_PASSWORD) + # Selects appsettings.IntegrationTest.json, which sets + # Authentication:Provider to Oidc, points token validation at the Keycloak + # realm, and turns off Key Vault and Redis. .with_env("ASPNETCORE_ENVIRONMENT", settings.ASPNETCORE_ENVIRONMENT) - .with_env("AZURE_CLIENT_SECRET", settings.FLOTILLA_AZURE_CLIENT_SECRET) - .with_env("AZURE_CLIENT_ID", settings.FLOTILLA_AZURE_CLIENT_ID) - .with_env("AZURE_TENANT_ID", settings.AZURE_TENANT_ID) - .with_env("KeyVault__VaultUri", settings.KEYVAULT_URI) .with_env("Database__PostgreSqlConnectionString", database_connection_string) - .with_env("AzureAd__ClientSecret", settings.FLOTILLA_AZURE_CLIENT_SECRET) - .with_env("TeamsNotification__WebhookUrl", teams_notification_webhook_url)) + .with_env("TeamsNotification__WebhookUrl", teams_notification_webhook_url) + ) return container diff --git a/robotics_integration_tests/custom_containers/isar.py b/robotics_integration_tests/custom_containers/isar.py index d88dac4..f7016fa 100644 --- a/robotics_integration_tests/custom_containers/isar.py +++ b/robotics_integration_tests/custom_containers/isar.py @@ -28,6 +28,7 @@ def __init__( def create_isar_robot_container( network: Network, + openid_config_url: str, image: str = "ghcr.io/equinor/isar-robot:latest", name: str = "isar_robot", port: int = 3000, @@ -58,9 +59,12 @@ def create_isar_robot_container( .with_kwargs(platform="linux/amd64") .with_env("ISAR_MQTT_HOST", settings.FLOTILLA_BROKER_ALIAS) .with_env("ISAR_MQTT_PASSWORD", settings.ISAR_MQTT_PASSWORD) - .with_env("AZURE_CLIENT_SECRET", settings.ISAR_AZURE_CLIENT_SECRET) - .with_env("ISAR_AZURE_CLIENT_ID", settings.ISAR_AZURE_CLIENT_ID) - .with_env("ISAR_AZURE_TENANT_ID", settings.ISAR_AZURE_TENANT_ID) + # Validate access tokens against the local Keycloak realm rather than + # Entra ID. ISAR_AZURE_CLIENT_ID is the expected `aud`; note that settings.py + # lets a bare AZURE_CLIENT_ID override it, so that variable must not be + # set here. + .with_env("ISAR_OPENID_CONFIG_URL", openid_config_url) + .with_env("ISAR_AZURE_CLIENT_ID", settings.ISAR_AUDIENCE) .with_env("ISAR_STORAGE_BLOB_ENABLED", "true") .with_env("ISAR_BLOB_STORAGE_ACCOUNT_DATA", settings.AZURITE_ACCOUNT) .with_env( diff --git a/robotics_integration_tests/custom_containers/keycloak.py b/robotics_integration_tests/custom_containers/keycloak.py new file mode 100644 index 0000000..1be860f --- /dev/null +++ b/robotics_integration_tests/custom_containers/keycloak.py @@ -0,0 +1,159 @@ +import time +from pathlib import Path + +import requests +from docker.models.networks import Network +from robotics_integration_tests.custom_containers.stream_logging_docker_container import ( + StreamLoggingDockerContainer, +) +from robotics_integration_tests.settings.settings import settings + +_REALM_DIR = Path(__file__).resolve().parent.parent / "custom_realms" + +REALM = "robotics" + +# Confidential clients declared in custom_realms/robotics-realm.json. The secrets +# are fixture values, not secrets. Constants rather than settings on purpose: a +# setting named INTEGRATION_TESTS_CLIENT_SECRET would be silently overridden by the +# stale Entra value of that name still sitting in .env files and repo secrets. +INTEGRATION_TESTS_CLIENT = "integration-tests" +INTEGRATION_TESTS_SECRET = "integration-tests-secret" + +# Holds Role.User.HUA and nothing else: a recognised principal that still lacks +# Flotilla admin rights and ISAR's Mission.Control. +LIMITED_ROLE_CLIENT = "integration-tests-limited-role" +LIMITED_ROLE_SECRET = "integration-tests-limited-role-secret" + +# Holds no roles at all. Kept distinct from the client above because Flotilla +# answers 403 for an insufficient role but 401 for a token with no roles. +NO_ROLE_CLIENT = "integration-tests-no-role" +NO_ROLE_SECRET = "integration-tests-no-role-secret" + + +class Keycloak: + """Local OpenID Connect issuer replacing Azure Entra ID for the test run. + + Services reach it under ``alias`` on the Docker network, the pytest process on + the published host port. Tokens carry the in-network issuer either way. + """ + + def __init__( + self, + container: StreamLoggingDockerContainer, + port: int, + alias: str, + ) -> None: + self.container = container + self.port = port + self.alias = alias + + @property + def internal_url(self) -> str: + """Issuer URL as seen by other containers on the same Docker network.""" + return f"http://{self.alias}:{self.port}/realms/{REALM}" + + @property + def internal_openid_config_url(self) -> str: + """Discovery document URL to hand to the services.""" + return f"{self.internal_url}/.well-known/openid-configuration" + + @property + def host_url(self) -> str: + """Realm URL reachable from the test host.""" + port = self.container.get_exposed_port(self.port) + return f"http://localhost:{port}/realms/{REALM}" + + @property + def token_url(self) -> str: + return f"{self.host_url}/protocol/openid-connect/token" + + def wait_until_ready(self, timeout: int = 180) -> None: + """Block until the realm can actually mint a token. + + The discovery document starts answering while the realm's clients are still + importing, and a token requested in that window comes back 401. + """ + deadline = time.monotonic() + timeout + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + response = requests.get( + f"{self.host_url}/.well-known/openid-configuration", timeout=5 + ) + if response.ok: + self.get_token(scope=f"{settings.ISAR_SCOPE}") + return + except requests.RequestException as error: # pragma: no cover - timing + last_error = error + time.sleep(0.5) + raise TimeoutError( + f"keycloak did not become ready within {timeout}s: {last_error}" + ) + + def get_token( + self, + scope: str, + client_id: str = INTEGRATION_TESTS_CLIENT, + client_secret: str = INTEGRATION_TESTS_SECRET, + ) -> str: + """Mint a token through the client credentials flow. + + ``scope`` names a client scope, each carrying a single audience mapper. + Request exactly one: with two, Keycloak emits ``aud`` as an array, which + ISAR rejects. + """ + response = requests.post( + self.token_url, + data={ + "grant_type": "client_credentials", + "scope": scope, + "client_id": client_id, + "client_secret": client_secret, + }, + timeout=10, + ) + response.raise_for_status() + return response.json()["access_token"] + + def get_token_without_roles(self, scope: str) -> str: + """Mint a token with a correct audience but no roles. + + Keycloak has no equivalent of an ad-hoc "issue me a token with these + claims" endpoint, so a role set is chosen by picking the service account + that holds it. This one holds none. + """ + return self.get_token( + scope=scope, + client_id=NO_ROLE_CLIENT, + client_secret=NO_ROLE_SECRET, + ) + + +def create_keycloak_container( + network: Network, + name: str = "keycloak", + port: int = 8080, + alias: str = "keycloak", + test_id: str = "", +) -> tuple[StreamLoggingDockerContainer, Keycloak]: + """Start Keycloak with the robotics realm imported.""" + container: StreamLoggingDockerContainer = ( + StreamLoggingDockerContainer(image=settings.KEYCLOAK_IMAGE) + .with_name(f"{name}-{test_id}") + .with_exposed_ports(port) + .with_network(network) + .with_network_aliases(alias) + .with_command("start-dev --import-realm") + # Admin console credentials. The console is not used by the tests; Keycloak + # requires a bootstrap admin to start. + .with_env("KC_BOOTSTRAP_ADMIN_USERNAME", "admin") + .with_env("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin") + # Tokens are minted from the host but validated inside the network, so the + # issuer must be the in-network name in both cases. + .with_env("KC_HOSTNAME", f"http://{alias}:{port}") + .with_env("KC_HOSTNAME_BACKCHANNEL_DYNAMIC", "false") + .with_volume_mapping(str(_REALM_DIR), "/opt/keycloak/data/import", mode="ro") + ) + + keycloak = Keycloak(container=container, port=port, alias=alias) + return container, keycloak diff --git a/robotics_integration_tests/custom_containers/migrations_runner.py b/robotics_integration_tests/custom_containers/migrations_runner.py index b2999f4..7dd4622 100644 --- a/robotics_integration_tests/custom_containers/migrations_runner.py +++ b/robotics_integration_tests/custom_containers/migrations_runner.py @@ -53,8 +53,35 @@ def _with_migrations_source( ).with_env("LOCAL_REPO_PATH", _LOCAL_REPO_MOUNT) +def _with_design_time_database_config( + container: StreamLoggingDockerContainer, postgres_connection_string: str +) -> StreamLoggingDockerContainer: + """Supply the connection string to EF's design-time context factory. + + ``dotnet ef database update --connection`` overrides the connection used for + the migration itself, but EF still instantiates ``DesignTimeContextFactory``, + which reads its own configuration and falls back to **Azure Key Vault** when + no connection string is present. Passing it as configuration short-circuits + that fallback, which is what removes the Key Vault dependency here. + + Flotilla and sara read different keys for this (``PostgreSqlConnectionString`` + vs ``postgresConnectionString``), so both are set. + """ + return ( + container.with_env( + "Database__postgresConnectionString", postgres_connection_string + ) + .with_env("Database__PostgreSqlConnectionString", postgres_connection_string) + .with_env("Database__ConnectionString", postgres_connection_string) + .with_env("Database__AllowedAuthMethods__0", "ConnectionString") + ) + + def create_migrations_runner_container( - network: Network, postgres_connection_string: str, name: str = "flotilla_migrations", test_id: str = "" + network: Network, + postgres_connection_string: str, + name: str = "flotilla_migrations", + test_id: str = "", ) -> StreamLoggingDockerContainer: migrations_runner_image: str = build_image_once( path=str(Path(settings.RELATIVE_PATH_TO_DOCKERFILE).resolve(strict=True)), @@ -66,24 +93,25 @@ def create_migrations_runner_container( .with_name(f"{name}-{test_id}") .with_network(network) .with_env("DATABASE_URL", postgres_connection_string) - .with_env("AZURE_CLIENT_SECRET", settings.FLOTILLA_AZURE_CLIENT_SECRET) - .with_env("AZURE_CLIENT_ID", settings.FLOTILLA_AZURE_CLIENT_ID) - .with_env("AZURE_TENANT_ID", settings.AZURE_TENANT_ID) .with_env("GIT_REPO", settings.GIT_REPOSITORY_FOR_MIGRATIONS) .with_env("GIT_REF", settings.GIT_REPOSITORY_FOR_MIGRATIONS_REF) .with_env("EF_PROJECT_PATH", settings.BACKEND_PROJECT_FILE_FOLDER) .with_env("EF_STARTUP_PATH", settings.BACKEND_PROJECT_FILE_FOLDER) ) - return _with_migrations_source( + container = _with_migrations_source( container, source_dir=settings.FLOTILLA_MIGRATIONS_SOURCE_DIR, project_folder=settings.BACKEND_PROJECT_FILE_FOLDER, setting_name="FLOTILLA_MIGRATIONS_SOURCE_DIR", ) + return _with_design_time_database_config(container, postgres_connection_string) def create_sara_migrations_runner_container( - network: Network, postgres_connection_string: str, name: str = "sara_migrations", test_id: str = "" + network: Network, + postgres_connection_string: str, + name: str = "sara_migrations", + test_id: str = "", ) -> StreamLoggingDockerContainer: sara_migrations_runner_image: str = build_image_once( path=str(Path(settings.RELATIVE_PATH_TO_DOCKERFILE).resolve(strict=True)), @@ -95,17 +123,15 @@ def create_sara_migrations_runner_container( .with_name(f"{name}-{test_id}") .with_network(network) .with_env("DATABASE_URL", postgres_connection_string) - .with_env("AZURE_CLIENT_SECRET", settings.SARA_AZURE_CLIENT_SECRET) - .with_env("AZURE_CLIENT_ID", settings.SARA_AZURE_CLIENT_ID) - .with_env("AZURE_TENANT_ID", settings.SARA_AZURE_TENANT_ID) .with_env("GIT_REPO", settings.SARA_GIT_REPOSITORY_FOR_MIGRATIONS) .with_env("GIT_REF", settings.SARA_GIT_REPOSITORY_FOR_MIGRATIONS_REF) .with_env("EF_PROJECT_PATH", settings.SARA_BACKEND_PROJECT_FILE_FOLDER) .with_env("EF_STARTUP_PATH", settings.SARA_BACKEND_PROJECT_FILE_FOLDER) ) - return _with_migrations_source( + container = _with_migrations_source( container, source_dir=settings.SARA_MIGRATIONS_SOURCE_DIR, project_folder=settings.SARA_BACKEND_PROJECT_FILE_FOLDER, setting_name="SARA_MIGRATIONS_SOURCE_DIR", ) + return _with_design_time_database_config(container, postgres_connection_string) diff --git a/robotics_integration_tests/custom_containers/sara.py b/robotics_integration_tests/custom_containers/sara.py index eb46bfa..f26566f 100644 --- a/robotics_integration_tests/custom_containers/sara.py +++ b/robotics_integration_tests/custom_containers/sara.py @@ -43,22 +43,12 @@ def create_sara_container( .with_env("Mqtt__Port", settings.FLOTILLA_BROKER_PORT) .with_env("Mqtt__Password", settings.SARA_MQTT_PASSWORD) .with_env("Mqtt__Username", "sara") + # Selects appsettings.IntegrationTest.json, which sets + # Authentication:Provider to Oidc, points token validation at the Keycloak + # realm, turns off Key Vault, and pins the database to the ConnectionString + # auth path. .with_env("ASPNETCORE_ENVIRONMENT", settings.ASPNETCORE_ENVIRONMENT) - # Pin the integration-test container to the ConnectionString auth path. - # sara's appsettings.Development.json now lists AppRegIdentity first - # (equinor/sara#396), which would otherwise wire EF Core against the - # dev Azure PostgreSQL server (unreachable from the test docker - # network). Mirrors the migration-workflow override in - # .github/workflows/run_dotnet_migrations.yml (PR #73). - .with_env("Database__AllowedAuthMethods__0", "ConnectionString") - .with_env("AZURE_CLIENT_SECRET", settings.SARA_AZURE_CLIENT_SECRET) - .with_env("AZURE_CLIENT_ID", settings.SARA_AZURE_CLIENT_ID) - .with_env("AZURE_TENANT_ID", settings.SARA_AZURE_TENANT_ID) - .with_env("KeyVault__VaultUri", settings.KEYVAULT_URI) .with_env("Database__postgresConnectionString", database_connection_string) - .with_env("AzureAd__ClientSecret", settings.SARA_AZURE_CLIENT_SECRET) - .with_env("AzureAd__ClientId", settings.SARA_AZURE_CLIENT_ID) - .with_env("AzureAd__TenantId", settings.SARA_AZURE_TENANT_ID) .with_env("Storage__RawStorageAccount", settings.AZURITE_ACCOUNT) .with_env( f"BlobStorage__{settings.AZURITE_ACCOUNT}__ConnectionString", diff --git a/robotics_integration_tests/custom_images/migrations_runner/entrypoint.sh b/robotics_integration_tests/custom_images/migrations_runner/entrypoint.sh index 81e10e7..2c4a2a5 100644 --- a/robotics_integration_tests/custom_images/migrations_runner/entrypoint.sh +++ b/robotics_integration_tests/custom_images/migrations_runner/entrypoint.sh @@ -12,10 +12,16 @@ EF_STARTUP_PATH="${EF_STARTUP_PATH:-$EF_PROJECT_PATH}" EF_CONTEXT="${EF_CONTEXT:-}" WAIT_FOR_DB_TIMEOUT="${WAIT_FOR_DB_TIMEOUT:-60}" -# ---------- Secrets required by EF migrations to build ---------- -: "${AZURE_CLIENT_SECRET:?AZURE_CLIENT_SECRET must be set at runtime}" -: "${AZURE_CLIENT_ID:?AZURE_CLIENT_ID must be set at runtime}" -: "${AZURE_TENANT_ID:?AZURE_TENANT_ID must be set at runtime}" +# ---------- Optional inputs ---------- +# The EF design-time context factories in flotilla and sara fall back to reading +# the connection string from Azure Key Vault when it is absent from config. The +# caller passes it in as configuration instead (Database__* below), so no Azure +# credentials are needed here. These are kept only for callers that still rely on +# the Key Vault path. +AZURE_CLIENT_SECRET="${AZURE_CLIENT_SECRET:-}" +AZURE_CLIENT_ID="${AZURE_CLIENT_ID:-}" +AZURE_TENANT_ID="${AZURE_TENANT_ID:-}" +export AZURE_CLIENT_SECRET AZURE_CLIENT_ID AZURE_TENANT_ID rm -rf /work/repo mkdir -p /work/repo diff --git a/robotics_integration_tests/custom_realms/robotics-realm.json b/robotics_integration_tests/custom_realms/robotics-realm.json new file mode 100644 index 0000000..19a05cd --- /dev/null +++ b/robotics_integration_tests/custom_realms/robotics-realm.json @@ -0,0 +1,434 @@ +{ + "realm": "robotics", + "enabled": true, + "displayName": "Equinor Robotics (local)", + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "accessTokenLifespan": 1800, + "ssoSessionIdleTimeout": 7200, + "ssoSessionMaxLifespan": 36000, + "roles": { + "realm": [ + { + "name": "Role.Admin", + "description": "Flotilla super admin (AccessRoleService.cs SUPER_ADMIN_ROLE_NAME)" + }, + { + "name": "Role.User.HUA", + "description": "Per-installation user role, Huldra" + }, + { + "name": "Role.User.KAA", + "description": "Per-installation user role, Kaarsto" + }, + { + "name": "Role.User.NLS", + "description": "Per-installation user role, Nyhamna" + }, + { + "name": "Mission.Control", + "description": "ISAR REQUIRED_ROLE (isar settings.py REQUIRED_ROLE)" + } + ] + }, + "clientScopes": [ + { + "name": "entra-compat", + "description": "Claims that Entra ID emits and the services depend on, but that Keycloak does not emit by default. See keycloak-plan.md for why each one is here.", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "ver", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "ver", + "claim.value": "2.0", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "false", + "userinfo.token.claim": "false", + "access.tokenResponse.claim": "false" + } + }, + { + "name": "nbf", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "nbf", + "claim.value": "1", + "jsonType.label": "long", + "access.token.claim": "true", + "id.token.claim": "false", + "userinfo.token.claim": "false", + "access.tokenResponse.claim": "false" + } + }, + { + "name": "roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "claim.name": "roles", + "jsonType.label": "String", + "multivalued": "true", + "access.token.claim": "true", + "id.token.claim": "false", + "userinfo.token.claim": "false" + } + }, + { + "name": "tid", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "tid", + "claim.value": "robotics-local-tenant", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "false", + "userinfo.token.claim": "false" + } + }, + { + "name": "oid", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "id", + "claim.name": "oid", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "false", + "userinfo.token.claim": "false" + } + } + ] + }, + { + "name": "flotilla-api", + "description": "Requests an access token whose aud is flotilla-test", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true" + }, + "protocolMappers": [ + { + "name": "flotilla-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "flotilla-test", + "access.token.claim": "true", + "id.token.claim": "false" + } + } + ] + }, + { + "name": "isar-api", + "description": "Requests an access token whose aud is isar-test", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true" + }, + "protocolMappers": [ + { + "name": "isar-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "isar-test", + "access.token.claim": "true", + "id.token.claim": "false" + } + } + ] + }, + { + "name": "sara-api", + "description": "Requests an access token whose aud is sara-test", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true" + }, + "protocolMappers": [ + { + "name": "sara-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "sara-test", + "access.token.claim": "true", + "id.token.claim": "false" + } + } + ] + }, + { + "name": "pointilla-api", + "description": "Requests an access token whose aud is pointilla-test", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true" + }, + "protocolMappers": [ + { + "name": "pointilla-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "pointilla-test", + "access.token.claim": "true", + "id.token.claim": "false" + } + } + ] + } + ], + "clients": [ + { + "clientId": "isar-test", + "name": "ISAR (resource)", + "description": "Audience target only. ISAR validates aud == isar-test.", + "enabled": true, + "publicClient": false, + "serviceAccountsEnabled": false, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "secret": "isar-test-secret", + "defaultClientScopes": [], + "optionalClientScopes": [] + }, + { + "clientId": "sara-test", + "name": "SARA (resource)", + "enabled": true, + "publicClient": false, + "serviceAccountsEnabled": false, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "secret": "sara-test-secret", + "defaultClientScopes": [], + "optionalClientScopes": [] + }, + { + "clientId": "pointilla-test", + "name": "Pointilla (resource)", + "enabled": true, + "publicClient": false, + "serviceAccountsEnabled": false, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "secret": "pointilla-test-secret", + "defaultClientScopes": [], + "optionalClientScopes": [] + }, + { + "clientId": "flotilla-test", + "name": "Flotilla (resource + downstream caller)", + "description": "Both an audience target and a confidential client: Flotilla acquires ISAR/SARA/Pointilla tokens through GenericOidcAuthorizationHeaderProvider.", + "enabled": true, + "publicClient": false, + "serviceAccountsEnabled": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "secret": "flotilla-test-secret", + "defaultClientScopes": [ + "entra-compat" + ], + "optionalClientScopes": [ + "flotilla-api", + "isar-api", + "sara-api", + "pointilla-api" + ] + }, + { + "clientId": "integration-tests", + "name": "Integration tests", + "description": "The pytest process. Holds every role the three services require.", + "enabled": true, + "publicClient": false, + "serviceAccountsEnabled": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "secret": "integration-tests-secret", + "defaultClientScopes": [ + "entra-compat" + ], + "optionalClientScopes": [ + "flotilla-api", + "isar-api", + "sara-api", + "pointilla-api" + ] + }, + { + "clientId": "integration-tests-no-role", + "name": "Integration tests (no roles)", + "description": "Negative tests: a valid token with a correct audience but no roles at all.", + "enabled": true, + "publicClient": false, + "serviceAccountsEnabled": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "secret": "integration-tests-no-role-secret", + "defaultClientScopes": [ + "entra-compat" + ], + "optionalClientScopes": [ + "flotilla-api", + "isar-api", + "sara-api", + "pointilla-api" + ] + }, + { + "clientId": "integration-tests-limited-role", + "name": "Integration tests (insufficient role)", + "description": "Negative tests: holds Role.User.HUA only. Recognised but insufficient, so Flotilla answers 403 where a token with no roles at all gets 401.", + "enabled": true, + "publicClient": false, + "serviceAccountsEnabled": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "secret": "integration-tests-limited-role-secret", + "defaultClientScopes": [ + "entra-compat" + ], + "optionalClientScopes": [ + "flotilla-api", + "isar-api", + "sara-api", + "pointilla-api" + ] + }, + { + "clientId": "flotilla-frontend", + "name": "Flotilla frontend (local development)", + "description": "Declared ahead of the frontend work so that picking it up needs no realm migration. Nothing uses this yet. Add profile/email client scopes when that work is picked up; a realm declaring its own clientScopes does not inherit Keycloak's built-ins.", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "redirectUris": [ + "http://localhost:3001/*" + ], + "webOrigins": [ + "http://localhost:3001" + ], + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "defaultClientScopes": [ + "entra-compat" + ], + "optionalClientScopes": [ + "flotilla-api", + "sara-api" + ] + }, + { + "clientId": "sara-frontend", + "name": "SARA frontend (local development)", + "description": "Declared ahead of the frontend work. Nothing uses this yet. Add profile/email client scopes when that work is picked up; a realm declaring its own clientScopes does not inherit Keycloak's built-ins.", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "redirectUris": [ + "http://localhost:8099/*" + ], + "webOrigins": [ + "http://localhost:8099" + ], + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "defaultClientScopes": [ + "entra-compat" + ], + "optionalClientScopes": [ + "sara-api" + ] + } + ], + "users": [ + { + "username": "service-account-integration-tests", + "enabled": true, + "serviceAccountClientId": "integration-tests", + "realmRoles": [ + "Role.Admin", + "Role.User.HUA", + "Role.User.KAA", + "Role.User.NLS", + "Mission.Control" + ] + }, + { + "username": "service-account-flotilla-test", + "enabled": true, + "serviceAccountClientId": "flotilla-test", + "realmRoles": [ + "Role.Admin", + "Role.User.HUA", + "Role.User.KAA", + "Role.User.NLS", + "Mission.Control" + ] + }, + { + "username": "service-account-integration-tests-no-role", + "enabled": true, + "serviceAccountClientId": "integration-tests-no-role", + "realmRoles": [] + }, + { + "username": "service-account-integration-tests-limited-role", + "enabled": true, + "serviceAccountClientId": "integration-tests-limited-role", + "realmRoles": [ + "Role.User.HUA" + ] + }, + { + "username": "dev", + "enabled": true, + "email": "dev@example.com", + "emailVerified": true, + "firstName": "Local", + "lastName": "Developer", + "credentials": [ + { + "type": "password", + "value": "dev", + "temporary": false + } + ], + "realmRoles": [ + "Role.Admin", + "Role.User.HUA", + "Role.User.KAA", + "Role.User.NLS", + "Mission.Control" + ] + } + ] +} diff --git a/robotics_integration_tests/settings/settings.py b/robotics_integration_tests/settings/settings.py index ab0ed92..bcdd774 100644 --- a/robotics_integration_tests/settings/settings.py +++ b/robotics_integration_tests/settings/settings.py @@ -1,4 +1,4 @@ -from typing import Optional, List +from typing import List from dotenv import load_dotenv from pydantic import Field, computed_field @@ -6,34 +6,37 @@ class Settings(BaseSettings): - # Integration test app registration - INTEGRATION_TESTS_CLIENT_ID: str = Field( - default="17d7c036-e4ff-4df6-87fd-0d648a36a727" - ) - INTEGRATION_TESTS_TENANT_ID: str = Field( - default="3aa4a235-b6e2-48d5-9195-7fcf05b459b0" - ) - INTEGRATION_TESTS_CLIENT_SECRET: Optional[str] = Field(default="") - - # Keyvault configuration (using Flotilla service principle) - KEYVAULT_NAME: str = Field(default="FlotillaTestsKv") - - @computed_field - @property - def KEYVAULT_URI(self) -> str: - return f"https://{self.KEYVAULT_NAME.lower()}.vault.azure.net" + # Local Keycloak realm standing in for Azure Entra ID. See + # custom_realms/robotics-realm.json for the clients, scopes and roles. + KEYCLOAK_IMAGE: str = Field(default="quay.io/keycloak/keycloak:26.4") + KEYCLOAK_ALIAS: str = Field(default="keycloak") + KEYCLOAK_PORT: int = Field(default=8080) + + # The clients live in custom_containers/keycloak.py, not here: an + # INTEGRATION_TESTS_CLIENT_SECRET setting would be silently overridden by the + # stale Entra value of that name still sitting in .env files and repo secrets. + + # Audiences validated by each service. Plain readable names rather than Entra + # app registration GUIDs. + FLOTILLA_AUDIENCE: str = Field(default="flotilla-test") + ISAR_AUDIENCE: str = Field(default="isar-test") + SARA_AUDIENCE: str = Field(default="sara-test") + + # Client scopes requested when minting a token; each carries a single audience + # mapper onto the matching *_AUDIENCE above. Request exactly one per token -- + # two make Keycloak emit `aud` as an array, which ISAR rejects. + FLOTILLA_SCOPE: str = Field(default="flotilla-api") + ISAR_SCOPE: str = Field(default="isar-api") + SARA_SCOPE: str = Field(default="sara-api") # Flotilla Backend environment MQTT_HOST: str = Field(default="broker") - FLOTILLA_MQTT_PASSWORD: Optional[str] = Field(default="") - ASPNETCORE_ENVIRONMENT: str = Field(default="Development") - FLOTILLA_AZURE_CLIENT_SECRET: Optional[str] = Field(default="") - FLOTILLA_AZURE_CLIENT_ID: Optional[str] = Field( - default="ea4c7b92-47b3-45fb-bd25-a8070f0c495c" - ) - AZURE_TENANT_ID: Optional[str] = Field( - default="3aa4a235-b6e2-48d5-9195-7fcf05b459b0" - ) + # MQTT is username/password, validated by the broker against the hashed + # passwd_file committed in equinor/flotilla, so these remain real secrets. + FLOTILLA_MQTT_PASSWORD: str = Field(default="") + # Selects appsettings.IntegrationTest.json in flotilla and sara, which sets + # Authentication:Provider=Oidc and disables Key Vault and Redis. + ASPNETCORE_ENVIRONMENT: str = Field(default="IntegrationTest") FLOTILLA_BACKEND_NAME: str = Field(default="flotilla_backend") FLOTILLA_BACKEND_ALIAS: str = Field(default="flotilla_backend") FLOTILLA_BACKEND_IMAGE: str = Field( @@ -42,7 +45,8 @@ def KEYVAULT_URI(self) -> str: FLOTILLA_BACKEND_PORT: int = Field(default=8000) # MQTT Broker environment - FLOTILLA_BROKER_SERVER_KEY: Optional[str] = Field(default="") + # TLS private key for the test broker; see the note on FLOTILLA_MQTT_PASSWORD. + FLOTILLA_BROKER_SERVER_KEY: str = Field(default="") FLOTILLA_BROKER_NAME: str = Field(default="flotilla_broker") FLOTILLA_BROKER_ALIAS: str = Field(default="broker") FLOTILLA_BROKER_IMAGE: str = Field(default="ghcr.io/equinor/flotilla-broker:latest") @@ -86,14 +90,7 @@ def KEYVAULT_URI(self) -> str: ) # ISAR Robot environment - ISAR_AZURE_CLIENT_SECRET: Optional[str] = Field(default="") - ISAR_MQTT_PASSWORD: Optional[str] = Field(default="") - ISAR_AZURE_CLIENT_ID: Optional[str] = Field( - default="fd384acd-5c1b-4c44-a1ac-d41d720ed0fe" - ) - ISAR_AZURE_TENANT_ID: Optional[str] = Field( - default="3aa4a235-b6e2-48d5-9195-7fcf05b459b0" - ) + ISAR_MQTT_PASSWORD: str = Field(default="") ISAR_ROBOT_NAME: str = Field(default="Placebot") ISAR_ROBOT_ALIAS: str = Field(default="isar_robot") ISAR_ROBOT_IMAGE: str = Field(default="ghcr.io/equinor/isar-robot:latest") @@ -103,10 +100,6 @@ def KEYVAULT_URI(self) -> str: SARA_RAW_STORAGE_CONTAINER: str = Field(default="sara-raw") SARA_ANON_STORAGE_CONTAINER: str = Field(default="sara-anon") SARA_VIS_STORAGE_CONTAINER: str = Field(default="sara-vis") - SARA_AZURE_CLIENT_SECRET: str = Field(default="") - - SARA_AZURE_CLIENT_ID: str = Field(default="dd7e115a-037e-4846-99c4-07561158a9cd") - SARA_AZURE_TENANT_ID: str = Field(default="3aa4a235-b6e2-48d5-9195-7fcf05b459b0") SARA_IMAGE: str = Field(default="ghcr.io/equinor/sara:latest") SARA_NAME: str = Field(default="sara") SARA_PORT: int = Field(default=8100) diff --git a/robotics_integration_tests/utilities/authentication.py b/robotics_integration_tests/utilities/authentication.py index 640f83e..87c4f6b 100644 --- a/robotics_integration_tests/utilities/authentication.py +++ b/robotics_integration_tests/utilities/authentication.py @@ -1,20 +1,105 @@ -import msal +"""Access tokens for the integration tests. -from robotics_integration_tests.settings.settings import settings +Tokens come from the local Keycloak realm started by the ``keycloak`` fixture, not +from Azure Entra ID, so the suite needs no app registrations and no client secrets. +Keycloak does not derive ``aud`` from the requested scope the way Entra does: each +API has a client scope carrying a single audience mapper, so ``flotilla-api`` yields +``aud="flotilla-test"``. Request exactly one per token -- two audience mappers make +Keycloak emit ``aud`` as an array, which ISAR rejects. +""" -def retrieve_access_token_for_integration_tests_app(resource_client_id: str) -> str: - app = msal.ConfidentialClientApplication( - client_id=settings.INTEGRATION_TESTS_CLIENT_ID, - client_credential=settings.INTEGRATION_TESTS_CLIENT_SECRET, - authority=f"https://login.microsoftonline.com/{settings.INTEGRATION_TESTS_TENANT_ID}", - ) - result = app.acquire_token_for_client( - scopes=[f"api://{resource_client_id}/.default"] - ) - if "access_token" in result: - return result["access_token"] - else: +from typing import Optional + +import requests + +from robotics_integration_tests.custom_containers.keycloak import ( + INTEGRATION_TESTS_CLIENT, + INTEGRATION_TESTS_SECRET, + LIMITED_ROLE_CLIENT, + LIMITED_ROLE_SECRET, + NO_ROLE_CLIENT, + NO_ROLE_SECRET, +) + +# Host-side realm URL, set by the `keycloak` fixture. A module-level global because +# the API helpers build their auth headers from plain module functions with no access +# to fixtures; each xdist worker is a separate process with its own container. +_issuer_url: Optional[str] = None + + +def configure_issuer(host_url: str) -> None: + """Point token acquisition at a running issuer.""" + global _issuer_url + _issuer_url = host_url + + +def reset_issuer() -> None: + """Forget the issuer so a stale URL cannot leak into a later test.""" + global _issuer_url + _issuer_url = None + + +def _require_issuer_url() -> str: + if _issuer_url is None: raise RuntimeError( - f"Unable to retrieve access token for integration tests app: {result}" + "The issuer has not been configured. Depend on the `keycloak` " + "fixture, which calls configure_issuer()." ) + return _issuer_url + + +def _request_token(scope: str, client_id: str, client_secret: str) -> str: + response = requests.post( + f"{_require_issuer_url()}/protocol/openid-connect/token", + data={ + "grant_type": "client_credentials", + "scope": scope, + "client_id": client_id, + "client_secret": client_secret, + }, + timeout=10, + ) + response.raise_for_status() + result = response.json() + if "access_token" not in result: + raise RuntimeError(f"Unable to retrieve access token for {scope}: {result}") + return result["access_token"] + + +def retrieve_access_token_for_integration_tests_app(scope: str) -> str: + """Acquire a token for the given API scope, with every role the services need.""" + return _request_token( + scope=scope, + client_id=INTEGRATION_TESTS_CLIENT, + client_secret=INTEGRATION_TESTS_SECRET, + ) + + +def retrieve_access_token_without_roles(scope: str) -> str: + """Acquire a token with a correct audience but no roles. + + Keycloak has no ad-hoc token endpoint, so a role set is chosen by picking the + service account that holds it. This one holds none. + + Used by the negative authentication tests to prove that authorisation is + genuinely enforced rather than accidentally bypassed. + """ + return _request_token( + scope=scope, + client_id=NO_ROLE_CLIENT, + client_secret=NO_ROLE_SECRET, + ) + + +def retrieve_access_token_with_insufficient_role(scope: str) -> str: + """Acquire a token whose roles do not grant access to the endpoint under test. + + Holds Role.User.HUA and nothing else, so Flotilla recognises the principal + and refuses it with 403, and ISAR refuses it for lacking Mission.Control. + """ + return _request_token( + scope=scope, + client_id=LIMITED_ROLE_CLIENT, + client_secret=LIMITED_ROLE_SECRET, + ) diff --git a/robotics_integration_tests/utilities/authentication_assertions.py b/robotics_integration_tests/utilities/authentication_assertions.py new file mode 100644 index 0000000..8013d26 --- /dev/null +++ b/robotics_integration_tests/utilities/authentication_assertions.py @@ -0,0 +1,188 @@ +"""Assertions that authentication is genuinely enforced. + +Woven into real mission flows rather than run standalone: each service's own suite +already covers its endpoints far more cheaply. What none of them can cover is +whether authentication is switched on in the deployed configuration, and whether a +token minted for one service is accepted by another. +""" + +from http import HTTPStatus +from typing import Dict, Optional + +import requests +from loguru import logger + +from robotics_integration_tests.armada import Armada +from robotics_integration_tests.custom_containers.isar import IsarRobot +from robotics_integration_tests.settings.settings import settings +from robotics_integration_tests.utilities.authentication import ( + retrieve_access_token_for_integration_tests_app, + retrieve_access_token_with_insufficient_role, +) + +_TIMEOUT = 30 + + +def isar_url(robot: IsarRobot, path: str) -> str: + """Host-reachable URL for one of an ISAR robot's endpoints.""" + port = robot.container.get_exposed_port(robot.port) + return f"http://localhost:{port}/{path.lstrip('/')}" + + +def _request(method: str, url: str, token: Optional[str] = None) -> requests.Response: + headers: Dict[str, str] = {"Authorization": f"Bearer {token}"} if token else {} + return requests.request(method, url, headers=headers, timeout=_TIMEOUT) + + +def assert_authentication_is_enforced(url: str, method: str = "GET") -> None: + """Fail unless an unauthenticated request is rejected. + + Called from the container fixtures so that it holds for every test. A stack + that has silently come up with authentication disabled would otherwise pass + the entire suite, since every other assertion is about mission behaviour. + """ + response = _request(method, url) + + if response.status_code not in ( + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + raise AssertionError( + f"Authentication is NOT enforced: {method} {url} returned " + f"{response.status_code} without a token, expected 401 or 403.\n" + "The stack is running unsecured, which makes every other assertion " + "in this suite meaningless. Check that the service is running with " + f"ASPNETCORE_ENVIRONMENT={settings.ASPNETCORE_ENVIRONMENT} (or, for " + "ISAR, that ISAR_AUTHENTICATION_ENABLED has not been turned off)." + ) + + logger.debug(f"Authentication enforced on {method} {url}") + + +def assert_token_for_another_service_is_rejected( + method: str, url: str, scope: str, description: str +) -> None: + """A correctly signed token for a *different* service must be rejected. + + The token is valid in every respect except its audience -- it carries every + role -- so this exercises audience validation rather than signature checking + or authorisation. The audience follows from the requested scope: each API + scope in the realm carries one audience mapper. + """ + token: str = retrieve_access_token_for_integration_tests_app(scope) + response = _request(method, url, token=token) + + assert response.status_code == HTTPStatus.UNAUTHORIZED, ( + f"{description}: {method} {url} accepted a token issued for " + f"'{audience}' and returned {response.status_code}, expected 401. " + "Services must not accept tokens minted for a different audience." + ) + + +def assert_missing_role_is_rejected( + method: str, + url: str, + scope: str, + expected_status: HTTPStatus, + description: str, +) -> None: + """A token with the right audience but insufficient roles must be rejected. + + The token holds Role.User.HUA and nothing else. That matters: Flotilla + answers 403 for a recognised principal with an insufficient role, but 401 + for a token carrying no roles at all, so a token with an empty role set + would prove something weaker than intended. + """ + token: str = retrieve_access_token_with_insufficient_role(scope) + response = _request(method, url, token=token) + + assert response.status_code == expected_status, ( + f"{description}: {method} {url} returned {response.status_code} for a " + f"token holding only Role.User.HUA, expected {int(expected_status)}." + ) + + +def assert_cannot_interfere_with_running_mission( + armada: Armada, robot: IsarRobot +) -> None: + """An unauthorised caller must not be able to disturb a running mission. + + Every token here is correctly signed by the same issuer the services trust, + so a failure means audience or role validation is broken rather than the + signature chain. + + The caller is expected to go on asserting that the mission reached its + normal outcome; that is what turns this from an endpoint probe into a + non-interference test. + """ + backend_url: str = armada.flotilla_backend.backend_url + stop_mission_url: str = isar_url(robot, "/schedule/stop-mission") + + # A SARA token must not open Flotilla. + assert_token_for_another_service_is_rejected( + method="GET", + url=f"{backend_url}/robots", + scope=settings.SARA_SCOPE, + description="Flotilla accepted a SARA token", + ) + + # Correct audience, but a role that grants no access to this installation. + assert_missing_role_is_rejected( + method="GET", + url=f"{backend_url}/robots", + scope=settings.FLOTILLA_SCOPE, + expected_status=HTTPStatus.FORBIDDEN, + description="Flotilla granted access without a sufficient role", + ) + + # A Flotilla token must not let anyone stop the robot directly. + assert_token_for_another_service_is_rejected( + method="POST", + url=stop_mission_url, + scope=settings.FLOTILLA_SCOPE, + description="ISAR accepted a Flotilla token", + ) + + # Correct audience, but missing ISAR's REQUIRED_ROLE. ISAR answers 401 rather + # than 403 here: validate_has_role raises fastapi-azure-auth's + # InvalidAuthHttp, which subclasses UnauthorizedHttp. + assert_missing_role_is_rejected( + method="POST", + url=stop_mission_url, + scope=settings.ISAR_SCOPE, + expected_status=HTTPStatus.UNAUTHORIZED, + description="ISAR accepted a token without Mission.Control", + ) + + logger.info("Unauthorised attempts to interfere with the mission were rejected") + + +def assert_cannot_pause_mission(armada: Armada, robot: IsarRobot) -> None: + """An unauthorised caller must not be able to pause a running mission.""" + backend_url: str = armada.flotilla_backend.backend_url + pause_url: str = f"{backend_url}/robots/{robot.robot_id}/pause" + + assert_token_for_another_service_is_rejected( + method="POST", + url=pause_url, + scope=settings.SARA_SCOPE, + description="Flotilla accepted a SARA token for pause", + ) + + assert_missing_role_is_rejected( + method="POST", + url=pause_url, + scope=settings.FLOTILLA_SCOPE, + expected_status=HTTPStatus.FORBIDDEN, + description="Flotilla allowed pause without a sufficient role", + ) + + # Straight at the robot, bypassing Flotilla entirely. + assert_token_for_another_service_is_rejected( + method="POST", + url=isar_url(robot, "/schedule/pause-mission"), + scope=settings.FLOTILLA_SCOPE, + description="ISAR accepted a Flotilla token for pause", + ) + + logger.info("Unauthorised attempts to pause the mission were rejected") diff --git a/robotics_integration_tests/utilities/flotilla_backend_api.py b/robotics_integration_tests/utilities/flotilla_backend_api.py index 1bae67a..0715f15 100644 --- a/robotics_integration_tests/utilities/flotilla_backend_api.py +++ b/robotics_integration_tests/utilities/flotilla_backend_api.py @@ -14,7 +14,7 @@ def _add_headers() -> Dict[str, str]: access_token: str = retrieve_access_token_for_integration_tests_app( - settings.FLOTILLA_AZURE_CLIENT_ID + settings.FLOTILLA_SCOPE ) headers = {"Authorization": f"Bearer {access_token}"} return headers @@ -48,91 +48,52 @@ def set_current_inspection_area_for_robot( headers=_add_headers(), ) + def get_dummy_mission_payload_with_installation( installation_code: str, ) -> Dict: return { - "tasks": [ - { - "tagId": "57-2030DC", - "description": "Center of valve", - "robotPose": { - "position": { - "x": 82.03125, - "y": 289.04297, - "z": 29.402 - }, - "orientation": { - "x": 0, - "y": 1, - "z": 0, - "w": 1.5707964 - } - }, - "targetPosition": { - "x": 112.252, - "y": 284.027, - "z": 29.779 - }, - "analysisTypes": [], - "sensorType": "Image", - }, - { - "tagId": "13-2036PT", - "description": "STID Tag Position", - "robotPose": { - "position": { - "x": 94.74, - "y": 296.157, - "z": 29.401 - }, - "orientation": { - "x": 0, - "y": 1, - "z": 0, - "w": 3.1415927 - } - }, - "targetPosition": { - "x": 295.62, - "y": 95.589, - "z": 29.767 - }, - "analysisTypes": [], - "sensorType": "Image", - }, - { - "tagId": "65-2015OH", - "description": "STID Tag Position", - "robotPose": { - "position": { - "x": 103.048, - "y": 298.45, - "z": 29.401 - }, - "orientation": { - "x": 0, - "y": 1, - "z": 0, - "w": 1.5707964 - } - }, - "targetPosition": { - "x": 299.888, - "y": 103.245, - "z": 29.7 - }, - "analysisTypes": [], - "sensorType": "Image", + "tasks": [ + { + "tagId": "57-2030DC", + "description": "Center of valve", + "robotPose": { + "position": {"x": 82.03125, "y": 289.04297, "z": 29.402}, + "orientation": {"x": 0, "y": 1, "z": 0, "w": 1.5707964}, + }, + "targetPosition": {"x": 112.252, "y": 284.027, "z": 29.779}, + "analysisTypes": [], + "sensorType": "Image", + }, + { + "tagId": "13-2036PT", + "description": "STID Tag Position", + "robotPose": { + "position": {"x": 94.74, "y": 296.157, "z": 29.401}, + "orientation": {"x": 0, "y": 1, "z": 0, "w": 3.1415927}, + }, + "targetPosition": {"x": 295.62, "y": 95.589, "z": 29.767}, + "analysisTypes": [], + "sensorType": "Image", + }, + { + "tagId": "65-2015OH", + "description": "STID Tag Position", + "robotPose": { + "position": {"x": 103.048, "y": 298.45, "z": 29.401}, + "orientation": {"x": 0, "y": 1, "z": 0, "w": 1.5707964}, + }, + "targetPosition": {"x": 299.888, "y": 103.245, "z": 29.7}, + "analysisTypes": [], + "sensorType": "Image", + }, + ], + "name": "Three valves on Mezzanine Deck", + "installationCode": installation_code, } - ], - "name": "Three valves on Mezzanine Deck", - "installationCode": installation_code, -} -def create_mission( - backend_url: str, payload: Dict -) -> Dict: + +def create_mission(backend_url: str, payload: Dict) -> Dict: try: response: Dict = call_create_mission( backend_url=backend_url, @@ -143,9 +104,8 @@ def create_mission( logger.exception(f"Failed to create mission") raise e -def call_create_mission( - backend_url: str, payload: Dict -) -> Dict: + +def call_create_mission(backend_url: str, payload: Dict) -> Dict: url: str = f"{backend_url}/missions/definitions" response: Response = requests.post( url, @@ -168,9 +128,8 @@ def call_create_mission( response.raise_for_status() return response.json() -def schedule_mission( - backend_url: str, robot_id: str, mission_id: str -) -> Dict: + +def schedule_mission(backend_url: str, robot_id: str, mission_id: str) -> Dict: try: response: Dict = call_schedule_mission( backend_url=backend_url, @@ -183,9 +142,7 @@ def schedule_mission( raise e -def call_schedule_mission( - backend_url: str, robot_id: str, mission_id: str -) -> Dict: +def call_schedule_mission(backend_url: str, robot_id: str, mission_id: str) -> Dict: payload: Dict = { "robotId": robot_id, } diff --git a/robotics_integration_tests/utilities/keyvault.py b/robotics_integration_tests/utilities/keyvault.py deleted file mode 100644 index 8f670d1..0000000 --- a/robotics_integration_tests/utilities/keyvault.py +++ /dev/null @@ -1,137 +0,0 @@ -from typing import List, Union - -from azure.core.exceptions import ( - ClientAuthenticationError, - HttpResponseError, - ResourceNotFoundError, -) -from azure.identity import ClientSecretCredential, DefaultAzureCredential -from azure.keyvault.secrets import KeyVaultSecret, SecretClient -from loguru import logger - - -class Keyvault: - def __init__( - self, - keyvault_name: str, - client_id: str = None, - client_secret: str = None, - tenant_id: str = None, - ): - self.name = keyvault_name - self.url = "https://" + keyvault_name + ".vault.azure.net" - self.client_id = client_id - self.client_secret = client_secret - self.tenant_id = tenant_id - self.client: SecretClient = None - - def get_secret(self, secret_name: str) -> KeyVaultSecret: - secret_client: SecretClient = self.get_secret_client() - try: - secret: KeyVaultSecret = secret_client.get_secret(name=secret_name) - except ResourceNotFoundError: - logger.exception( - f"Secret {secret_name} was not found in keyvault {self.name}" - ) - raise - except HttpResponseError: - logger.error( - "An error occurred while retrieving the secret '%s' from keyvault '%s'.", - secret_name, - self.name, - exc_info=True, - ) - raise - - return secret - - def set_secret(self, secret_name: str, secret_value) -> None: - secret_client: SecretClient = self.get_secret_client() - try: - secret_client.set_secret(name=secret_name, value=secret_value) - logger.info(f"Secret {secret_name} was set in keyvault {self.name}") - except HttpResponseError: - logger.exception( - f"An error occurred while setting secret {secret_name} in keyvault {self.name}" - ) - raise - - def delete_secret(self, secret_name: str) -> None: - secret_client: SecretClient = self.get_secret_client() - try: - secret_client.begin_delete_secret(name=secret_name) - logger.info(f"Secret {secret_name} was deleted from keyvault {self.name}") - except ResourceNotFoundError: - logger.warning( - f"Secret {secret_name} not found in keyvault {self.name} during cleanup" - ) - except HttpResponseError: - logger.warning( - f"Failed to delete secret {secret_name} from keyvault {self.name}", - exc_info=True, - ) - - def get_secret_client(self) -> SecretClient: - if self.client is None: - try: - credential: Union[ClientSecretCredential, DefaultAzureCredential] - if self.client_id and self.client_secret and self.tenant_id: - credential = ClientSecretCredential( - tenant_id=self.tenant_id, - client_id=self.client_id, - client_secret=self.client_secret, - ) - else: - credential = DefaultAzureCredential() - except ClientAuthenticationError: - logger.error( - "Failed to authenticate to Azure while connecting to KeyVault", - exc_info=True, - ) - raise - - self.client = SecretClient(vault_url=self.url, credential=credential) - return self.client - - -class ScopedKeyvault(Keyvault): - """A keyvault wrapper that namespaces all secret names with a unique prefix. - - This allows multiple test instances to use the same Azure Key Vault - concurrently without overwriting each other's secrets. Secrets are - automatically cleaned up when ``cleanup`` is called. - """ - - def __init__( - self, - prefix: str, - keyvault_name: str, - client_id: str = None, - client_secret: str = None, - tenant_id: str = None, - ): - super().__init__( - keyvault_name=keyvault_name, - client_id=client_id, - client_secret=client_secret, - tenant_id=tenant_id, - ) - self.prefix = prefix - self._created_secrets: List[str] = [] - - def _scoped_name(self, secret_name: str) -> str: - return f"{self.prefix}-{secret_name}" - - def get_secret(self, secret_name: str) -> KeyVaultSecret: - return super().get_secret(self._scoped_name(secret_name)) - - def set_secret(self, secret_name: str, secret_value) -> None: - scoped = self._scoped_name(secret_name) - super().set_secret(secret_name=scoped, secret_value=secret_value) - self._created_secrets.append(scoped) - - def cleanup(self) -> None: - """Delete all secrets created by this scoped instance.""" - for secret_name in self._created_secrets: - self.delete_secret(secret_name) - self._created_secrets.clear() diff --git a/robotics_integration_tests/utilities/sara_backend_api.py b/robotics_integration_tests/utilities/sara_backend_api.py index e9386f6..333484c 100644 --- a/robotics_integration_tests/utilities/sara_backend_api.py +++ b/robotics_integration_tests/utilities/sara_backend_api.py @@ -17,7 +17,7 @@ def _add_headers() -> Dict[str, str]: access_token: str = retrieve_access_token_for_integration_tests_app( - settings.SARA_AZURE_CLIENT_ID + settings.SARA_SCOPE ) headers = {"Authorization": f"Bearer {access_token}"} return headers diff --git a/uv.lock b/uv.lock index b572415..9201702 100644 --- a/uv.lock +++ b/uv.lock @@ -2,10 +2,6 @@ version = 1 revision = 3 requires-python = ">=3.14" -[options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. -exclude-newer-span = "P3D" - [[package]] name = "annotated-types" version = "0.8.0" @@ -28,36 +24,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, ] -[[package]] -name = "azure-identity" -version = "1.25.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "azure-core" }, - { name = "cryptography" }, - { name = "msal" }, - { name = "msal-extensions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, -] - -[[package]] -name = "azure-keyvault-secrets" -version = "4.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "azure-core" }, - { name = "isodate" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b8/03c7b4edd1e3355ad5fffb70e68af70cd09542963f45cf3a2aa9fb3930b5/azure_keyvault_secrets-4.11.0.tar.gz", hash = "sha256:ac14727b9159cca353173ec5a454d8d7b192a6f2f5e7eb540f9fbcf914fa0ca0", size = 112291, upload-time = "2026-04-17T00:52:12.422Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/76/41c513917c0e8dc68de0c33578c6a0668b4a09eb4dc3e2782d382dc2947f/azure_keyvault_secrets-4.11.0-py3-none-any.whl", hash = "sha256:d8543b710423569bd00ada2a2533fe83d52d8e1fe026e1c47f41a3bc0fc73ef5", size = 103148, upload-time = "2026-04-17T00:52:13.796Z" }, -] - [[package]] name = "azure-storage-blob" version = "12.30.0" @@ -332,32 +298,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] -[[package]] -name = "msal" -version = "1.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" }, -] - -[[package]] -name = "msal-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "msal" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, -] - [[package]] name = "mypy-extensions" version = "1.1.0" @@ -491,20 +431,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "pyjwt" -version = "2.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - [[package]] name = "pytest" version = "9.1.1" @@ -595,11 +521,8 @@ name = "robotics-integration-tests" version = "1.0.0" source = { editable = "." } dependencies = [ - { name = "azure-identity" }, - { name = "azure-keyvault-secrets" }, { name = "azure-storage-blob" }, { name = "loguru" }, - { name = "msal" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pytest" }, @@ -620,12 +543,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "azure-identity" }, - { name = "azure-keyvault-secrets" }, { name = "azure-storage-blob" }, { name = "black", marker = "extra == 'dev'" }, { name = "loguru" }, - { name = "msal" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pytest" }, From 9e50a1aae9f6b40a38ab3eea47b163dfe1257e39 Mon Sep 17 00:00:00 2001 From: oysand Date: Wed, 5 Aug 2026 19:36:06 +0200 Subject: [PATCH 2/3] Assert auth cannot be bypassed mid-mission --- .../test_multiple_robots_parallel_missions.py | 11 +++++++++++ ...mission_pausing_and_resuming_successful.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/robotics_integration_tests/test_multiple_robots_parallel_missions.py b/robotics_integration_tests/test_multiple_robots_parallel_missions.py index 55e7e51..d2af431 100644 --- a/robotics_integration_tests/test_multiple_robots_parallel_missions.py +++ b/robotics_integration_tests/test_multiple_robots_parallel_missions.py @@ -4,6 +4,9 @@ from robotics_integration_tests.armada import Armada from robotics_integration_tests.custom_containers.isar import IsarRobot +from robotics_integration_tests.utilities.authentication_assertions import ( + assert_cannot_interfere_with_running_mission, +) from robotics_integration_tests.utilities.teams_notifications import ( wait_for_all_teams_notifications, ) @@ -82,6 +85,14 @@ def test_multiple_robots_with_different_outcomes( f"on robot {robot_name}" ) + # With four missions in flight, confirm an unauthorised caller can neither read + # through Flotilla nor stop a robot directly. Every token is correctly signed, + # so this exercises audience and role validation. The status assertions below + # then prove none of it had any effect. + assert_cannot_interfere_with_running_mission( + armada=armada, robot=armada.robots["MissionOkThenHome"] + ) + wait_for_all_mission_run_statuses( backend_url=backend_url, mission_run_expectations={ diff --git a/robotics_integration_tests/test_simple_mission_pausing_and_resuming_successful.py b/robotics_integration_tests/test_simple_mission_pausing_and_resuming_successful.py index b01a695..ae5cf21 100644 --- a/robotics_integration_tests/test_simple_mission_pausing_and_resuming_successful.py +++ b/robotics_integration_tests/test_simple_mission_pausing_and_resuming_successful.py @@ -4,6 +4,9 @@ from robotics_integration_tests.armada import Armada from robotics_integration_tests.settings.settings import settings +from robotics_integration_tests.utilities.authentication_assertions import ( + assert_cannot_pause_mission, +) from robotics_integration_tests.utilities.blob_storage import ( wait_until_all_expected_files_uploaded, ) @@ -12,6 +15,7 @@ resume_mission, create_mission, get_dummy_mission_payload_with_installation, + get_mission_run_by_id, schedule_mission, wait_for_mission_run_status, wait_for_robot_status, @@ -44,6 +48,21 @@ def test_simple_mission_pausing_and_resuming_successfully( expected_status="InProgress", ) + # An unauthorised caller must not be able to pause the mission, whether it + # goes through Flotilla or straight at the robot. + assert_cannot_pause_mission(armada=armada, robot=robot) + + # Assert "not Paused" rather than "is Ongoing" to avoid racing normal mission + # progress; the property under test is that the rejected calls had no effect. + status_after_unauthorised_attempts: str = get_mission_run_by_id( + backend_url=armada.flotilla_backend.backend_url, + mission_run_id=mission_run_id, + ).get("status") + assert status_after_unauthorised_attempts != "Paused", ( + "An unauthorised caller managed to pause the mission run " + f"{mission_run_id}" + ) + pause_mission( backend_url=armada.flotilla_backend.backend_url, robot_id=robot.robot_id ) From 212456c3c28160d3ae69b6c80bb155b48af3f08a Mon Sep 17 00:00:00 2001 From: oysand Date: Wed, 5 Aug 2026 19:36:06 +0200 Subject: [PATCH 3/3] Verify locally built images carry auth config --- scripts/build_local_images.sh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/scripts/build_local_images.sh b/scripts/build_local_images.sh index cded66e..d18026a 100755 --- a/scripts/build_local_images.sh +++ b/scripts/build_local_images.sh @@ -148,6 +148,26 @@ CMD ["isar-start"] OVERLAY docker build --platform "$PLATFORM" -t "$ISAR_ROBOT_IMAGE" "$TMP_DIR/overlay" +# --------------------------------------------------------------------------- +# Verify. Without these, a bad image only reveals itself minutes into the suite, +# as a timeout with no obvious cause. +# --------------------------------------------------------------------------- + +log "Verifying images" + +verify_dotnet_settings() { + local image="$1" + if docker run --rm --platform "$PLATFORM" --entrypoint sh "$image" \ + -c 'test -f /app/appsettings.IntegrationTest.json'; then + echo " OK $image has appsettings.IntegrationTest.json" + else + die "$image is missing /app/appsettings.IntegrationTest.json" + fi +} + +verify_dotnet_settings "$FLOTILLA_IMAGE" +verify_dotnet_settings "$SARA_IMAGE" + docker run --rm --platform "$PLATFORM" \ --entrypoint /app/.venv/bin/python "$ISAR_ROBOT_IMAGE" -c ' import pathlib, sys @@ -165,12 +185,18 @@ if count == 0: "RobotRetrieveInspectionException" ) +if "OPENID_CONFIG_URL" not in type(settings).model_fields: + problems.append( + "isar does not expose OPENID_CONFIG_URL; the local isar overlay did not take" + ) + for problem in problems: print(" FAIL " + problem) if problems: sys.exit(1) print(f" OK isar-robot has {count} example_data files") +print(" OK isar exposes OPENID_CONFIG_URL") ' || die "isar-robot image verification failed" # ---------------------------------------------------------------------------