From 5fc2db68dc3d36f84b7bbcba82cb2836f2db03fe Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Tue, 26 May 2026 19:14:01 -0400 Subject: [PATCH] feat: use managed identity for verification functions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../services/durable_verification_client.py | 47 ++++++++++---- .../test_durable_verification_client.py | 64 +++++++++++++++++-- apps/verification-functions/function_app.py | 2 +- infra/.terraform.lock.hcl | 25 ++++++++ infra/container-apps.tf | 9 +-- infra/entra.tf | 13 ++++ infra/functions.tf | 55 ++++++++++++++-- infra/outputs.tf | 5 ++ infra/provider.tf | 11 ++++ .../learn_to_cloud_shared/core/azure_auth.py | 6 +- .../src/learn_to_cloud_shared/core/config.py | 2 +- 11 files changed, 202 insertions(+), 37 deletions(-) create mode 100644 infra/entra.tf diff --git a/api/src/learn_to_cloud/services/durable_verification_client.py b/api/src/learn_to_cloud/services/durable_verification_client.py index 22dcbba2..2e36958f 100644 --- a/api/src/learn_to_cloud/services/durable_verification_client.py +++ b/api/src/learn_to_cloud/services/durable_verification_client.py @@ -6,6 +6,8 @@ from typing import Any import httpx +from azure.core.exceptions import AzureError +from learn_to_cloud_shared.core.azure_auth import get_token as get_azure_token from learn_to_cloud_shared.core.config import get_web_settings from learn_to_cloud_shared.verification_job_executor import PreparedVerificationJob @@ -22,6 +24,10 @@ class DurableVerificationStatusError(Exception): """Raised when Durable status cannot be fetched or parsed.""" +class DurableVerificationAuthError(Exception): + """Raised when a verification Function access token cannot be acquired.""" + + @dataclass(frozen=True, slots=True) class DurableStartResult: instance_id: str @@ -48,16 +54,12 @@ async def start_verification_orchestration( *definition* and ``github_username`` snapshot. """ settings = get_web_settings() - base_url = settings.verification_functions.base_url.rstrip("/") - function_key = settings.verification_functions.key + base_url, token_scope = _verification_endpoint_config(settings) - if not base_url or not function_key: - raise DurableVerificationConfigError( - "Verification Functions endpoint is not configured." - ) + token = await _get_verification_token(token_scope) url = f"{base_url}/api/verification/jobs/{prepared.id}/start" - headers = {"x-functions-key": function_key} + headers = {"Authorization": f"Bearer {token}"} body: dict[str, Any] = prepared.to_payload() timeout = settings.http.external_api_timeout @@ -93,16 +95,12 @@ async def get_verification_orchestration_status( ) -> DurableStatusResult: """Fetch Durable orchestration status through the Function app proxy.""" settings = get_web_settings() - base_url = settings.verification_functions.base_url.rstrip("/") - function_key = settings.verification_functions.key + base_url, token_scope = _verification_endpoint_config(settings) - if not base_url or not function_key: - raise DurableVerificationConfigError( - "Verification Functions endpoint is not configured." - ) + token = await _get_verification_token(token_scope) url = f"{base_url}/api/verification/jobs/{instance_id}/status" - headers = {"x-functions-key": function_key} + headers = {"Authorization": f"Bearer {token}"} timeout = settings.http.external_api_timeout try: @@ -134,3 +132,24 @@ async def get_verification_orchestration_status( output=payload.get("output"), custom_status=payload.get("customStatus"), ) + + +def _verification_endpoint_config(settings: Any) -> tuple[str, str]: + base_url = settings.verification_functions.base_url.rstrip("/") + token_scope = settings.verification_functions.token_scope + + if not base_url or not token_scope: + raise DurableVerificationConfigError( + "Verification Functions endpoint is not configured." + ) + + return base_url, token_scope + + +async def _get_verification_token(token_scope: str) -> str: + try: + return await get_azure_token(token_scope) + except AzureError as exc: + raise DurableVerificationAuthError( + "Verification Functions access token could not be acquired." + ) from exc diff --git a/api/tests/services/test_durable_verification_client.py b/api/tests/services/test_durable_verification_client.py index 6e36b165..eaeb1292 100644 --- a/api/tests/services/test_durable_verification_client.py +++ b/api/tests/services/test_durable_verification_client.py @@ -6,12 +6,14 @@ import httpx import pytest +from azure.core.exceptions import ClientAuthenticationError from learn_to_cloud_shared.testing.requirement_factories import ( github_profile_requirement, ) from learn_to_cloud_shared.verification_job_executor import PreparedVerificationJob from learn_to_cloud.services.durable_verification_client import ( + DurableVerificationAuthError, DurableVerificationConfigError, DurableVerificationStartError, DurableVerificationStatusError, @@ -25,10 +27,13 @@ def _settings( *, base_url: str = "http://localhost:7071/", - key: str = "function-key", + token_scope: str = "api://verification-functions/.default", ) -> SimpleNamespace: return SimpleNamespace( - verification_functions=SimpleNamespace(base_url=base_url, key=key), + verification_functions=SimpleNamespace( + base_url=base_url, + token_scope=token_scope, + ), http=SimpleNamespace(external_api_timeout=3.0), ) @@ -58,7 +63,7 @@ def _async_client(response: httpx.Response | Exception): return client, context_manager -async def test_starts_orchestration_with_function_key_header_and_payload(): +async def test_starts_orchestration_with_bearer_token_header_and_payload(): prepared = _prepared() client, context_manager = _async_client(httpx.Response(202, json={"id": "abc"})) @@ -67,6 +72,10 @@ async def test_starts_orchestration_with_function_key_header_and_payload(): "learn_to_cloud.services.durable_verification_client.get_web_settings", return_value=_settings(), ), + patch( + "learn_to_cloud.services.durable_verification_client.get_azure_token", + new=AsyncMock(return_value="access-token"), + ), patch( "learn_to_cloud.services.durable_verification_client.httpx.AsyncClient", return_value=context_manager, @@ -78,7 +87,7 @@ async def test_starts_orchestration_with_function_key_header_and_payload(): async_client.assert_called_once_with(timeout=3.0) client.post.assert_awaited_once_with( f"http://localhost:7071/api/verification/jobs/{prepared.id}/start", - headers={"x-functions-key": "function-key"}, + headers={"Authorization": "Bearer access-token"}, json=prepared.to_payload(), ) @@ -87,7 +96,7 @@ async def test_missing_config_fails_before_http_call(): with ( patch( "learn_to_cloud.services.durable_verification_client.get_web_settings", - return_value=_settings(base_url="", key=""), + return_value=_settings(base_url="", token_scope=""), ), patch( "learn_to_cloud.services.durable_verification_client.httpx.AsyncClient" @@ -99,6 +108,31 @@ async def test_missing_config_fails_before_http_call(): async_client.assert_not_called() +async def test_token_error_fails_before_http_call(): + with ( + patch( + "learn_to_cloud.services.durable_verification_client.get_web_settings", + return_value=_settings(), + ), + patch( + "learn_to_cloud.services.durable_verification_client.get_azure_token", + new=AsyncMock( + side_effect=ClientAuthenticationError("identity unavailable") + ), + ), + patch( + "learn_to_cloud.services.durable_verification_client.httpx.AsyncClient" + ) as async_client, + pytest.raises( + DurableVerificationAuthError, + match="token could not be acquired", + ), + ): + await start_verification_orchestration(_prepared()) + + async_client.assert_not_called() + + async def test_http_error_status_raises_start_error(): _, context_manager = _async_client(httpx.Response(500, json={"error": "boom"})) @@ -107,6 +141,10 @@ async def test_http_error_status_raises_start_error(): "learn_to_cloud.services.durable_verification_client.get_web_settings", return_value=_settings(), ), + patch( + "learn_to_cloud.services.durable_verification_client.get_azure_token", + new=AsyncMock(return_value="access-token"), + ), patch( "learn_to_cloud.services.durable_verification_client.httpx.AsyncClient", return_value=context_manager, @@ -125,6 +163,10 @@ async def test_transport_error_raises_start_error(): "learn_to_cloud.services.durable_verification_client.get_web_settings", return_value=_settings(), ), + patch( + "learn_to_cloud.services.durable_verification_client.get_azure_token", + new=AsyncMock(return_value="access-token"), + ), patch( "learn_to_cloud.services.durable_verification_client.httpx.AsyncClient", return_value=context_manager, @@ -134,7 +176,7 @@ async def test_transport_error_raises_start_error(): await start_verification_orchestration(_prepared()) -async def test_gets_orchestration_status_with_function_key_header(): +async def test_gets_orchestration_status_with_bearer_token_header(): instance_id = str(uuid4()) client, context_manager = _async_client( httpx.Response(200, json={"runtimeStatus": "Running", "customStatus": None}) @@ -145,6 +187,10 @@ async def test_gets_orchestration_status_with_function_key_header(): "learn_to_cloud.services.durable_verification_client.get_web_settings", return_value=_settings(), ), + patch( + "learn_to_cloud.services.durable_verification_client.get_azure_token", + new=AsyncMock(return_value="access-token"), + ), patch( "learn_to_cloud.services.durable_verification_client.httpx.AsyncClient", return_value=context_manager, @@ -156,7 +202,7 @@ async def test_gets_orchestration_status_with_function_key_header(): async_client.assert_called_once_with(timeout=3.0) client.get.assert_awaited_once_with( f"http://localhost:7071/api/verification/jobs/{instance_id}/status", - headers={"x-functions-key": "function-key"}, + headers={"Authorization": "Bearer access-token"}, ) @@ -168,6 +214,10 @@ async def test_status_without_runtime_status_raises_status_error(): "learn_to_cloud.services.durable_verification_client.get_web_settings", return_value=_settings(), ), + patch( + "learn_to_cloud.services.durable_verification_client.get_azure_token", + new=AsyncMock(return_value="access-token"), + ), patch( "learn_to_cloud.services.durable_verification_client.httpx.AsyncClient", return_value=context_manager, diff --git a/apps/verification-functions/function_app.py b/apps/verification-functions/function_app.py index 44efcd93..9f11c58d 100644 --- a/apps/verification-functions/function_app.py +++ b/apps/verification-functions/function_app.py @@ -78,7 +78,7 @@ def _configure_function_logging() -> None: configure_observability() -app = df.DFApp(http_auth_level=func.AuthLevel.FUNCTION) +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) _DEFAULT_ORCHESTRATOR_NAME = "verification_orchestrator" # Async-only submission types: phase 3+ verifications that involve LLM diff --git a/infra/.terraform.lock.hcl b/infra/.terraform.lock.hcl index 8d54ae8d..d5fc946b 100644 --- a/infra/.terraform.lock.hcl +++ b/infra/.terraform.lock.hcl @@ -6,6 +6,7 @@ provider "registry.terraform.io/azure/azapi" { constraints = "~> 2.0" hashes = [ "h1:HjiC0LNLnJ8/HNGVWJl7Vh80Iiuxskocz16osUmuuZw=", + "h1:ivmN39jnMJRZXlPuX8r5TTXM8lyF8ZocPQQgEUy61eM=", "h1:tF2SjvM2Vj+7MTbFXhssLYu1Uc9uK/cVWBYIH8heDqM=", "zh:0a4eee8c9362db6ca19d371eccf38701c8306be761182da87b624294f7e8f867", "zh:4df87bee5b8f4cce27461ae26132a599542583cdf035942b42b0259a514ab46e", @@ -22,10 +23,33 @@ provider "registry.terraform.io/azure/azapi" { ] } +provider "registry.terraform.io/hashicorp/azuread" { + version = "3.8.0" + constraints = "~> 3.0" + hashes = [ + "h1:CdXgQ3XArvzreMya5g6/8ODlJloqe3yu8m5bMeTF8Zg=", + "h1:E2YWNE3Qry4bQMlmmZ33X4hLY5hOGrEZrlRg4anI2uw=", + "h1:kIEmiknJFWKe44U9ePG5vXwoloKep0Dbi/mQ8uqCbw0=", + "zh:0d26cfbf9417acd1c2295ccd5b0052abeac85ad1c3f6422ff09bf6a1ce16f00d", + "zh:144d4ea92fed541a6376bc76ad65ba4738dfd7bcab4c9d6cc20d35001338d06d", + "zh:1c3e89cf19118fc07d7b04257251fc9897e722c16e0a0df7b07fcd261f8c12e7", + "zh:2061d2cb64d8167d0af37e6610d0dc051977bd1ccc0e5cdd5ab02525ee239f96", + "zh:75562fdc3b313b7e538907199bfa588a1fcbc40113b0f3b7bfb496fbc358a32f", + "zh:78bef022ae9b1b0c636b7dd32bcda13fb273023f3a888cc005f3aa20e365b417", + "zh:ad8dbee59843154f8e93b24db9939a4257d13c7c86331eb93f1691294bc4e31f", + "zh:b3d83d7ac57073631704336a188cb746c473f728fc7ccb76abecb520e83fdf65", + "zh:c0bf9e0be73843de9089597be2720e4093b3ba320fbad99ab86da47681e77949", + "zh:c9a4c27d2b0800d3f4ece19d66c1fa574f7cd4ff66277af8f120d65e8f03f48e", + "zh:cd9ad8c848e17d9824045c33132cc0e87aa4d58cdb7bee6c0f6c3f9bc27892d5", + "zh:fbcafc21cdd19451274b905f9ee8a5b758ca63cc231e7544c815642e4a399c6d", + ] +} + provider "registry.terraform.io/hashicorp/azurerm" { version = "4.72.0" constraints = "~> 4.0" hashes = [ + "h1:QYnPAHT/PYheOOZz52ucHqw/ZO9PxWyPLtO7UD/jSMg=", "h1:UGajAH+Widv37GVBc3uHCPpv/dhu//aV99fooqlvhG8=", "h1:wMG5mll7zno5x6MizV2bFQhzmLZD0amtgPmJFG2xznI=", "zh:073472587c3752e89738522814d2b4eb2fd69eb2cb19c5a5ead3c7d2eabdc279", @@ -49,6 +73,7 @@ provider "registry.terraform.io/hashicorp/random" { hashes = [ "h1:Eexl06+6J+s75uD46+WnZtpJZYRVUMB0AiuPBifK6Jc=", "h1:m2y2fw9SBQ6+e7pNhi3+qsh8bYNmqkL89BulzH7uK3U=", + "h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=", "zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4", "zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae", "zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57", diff --git a/infra/container-apps.tf b/infra/container-apps.tf index 8a60a434..6a407fce 100644 --- a/infra/container-apps.tf +++ b/infra/container-apps.tf @@ -82,11 +82,6 @@ resource "azurerm_container_app" "api" { key_vault_secret_id = "${azurerm_key_vault.main.vault_uri}secrets/labs-verification-secret" } - secret { - name = "verification-functions-key" - value = data.azurerm_function_app_host_keys.verification.default_function_key - } - ingress { external_enabled = true target_port = 8000 @@ -178,8 +173,8 @@ resource "azurerm_container_app" "api" { } env { - name = "VERIFICATION_FUNCTIONS__KEY" - secret_name = "verification-functions-key" + name = "VERIFICATION_FUNCTIONS__TOKEN_SCOPE" + value = local.verification_functions_auth_scope } env { diff --git a/infra/entra.tf b/infra/entra.tf new file mode 100644 index 00000000..548ea45d --- /dev/null +++ b/infra/entra.tf @@ -0,0 +1,13 @@ +resource "azuread_application" "verification_functions" { + display_name = local.verification_functions_auth_app_name + identifier_uris = [local.verification_functions_auth_audience] + sign_in_audience = "AzureADMyOrg" + + api { + requested_access_token_version = 2 + } +} + +resource "azuread_service_principal" "verification_functions" { + client_id = azuread_application.verification_functions.client_id +} diff --git a/infra/functions.tf b/infra/functions.tf index 31299fbd..245a4ca0 100644 --- a/infra/functions.tf +++ b/infra/functions.tf @@ -144,9 +144,56 @@ resource "azurerm_function_app_flex_consumption" "verification" { ] } -data "azurerm_function_app_host_keys" "verification" { - name = azurerm_function_app_flex_consumption.verification.name - resource_group_name = azurerm_resource_group.main.name +resource "azapi_resource" "verification_auth_settings" { + type = "Microsoft.Web/sites/config@2022-09-01" + name = "authsettingsV2" + parent_id = azurerm_function_app_flex_consumption.verification.id + + body = { + properties = { + platform = { + enabled = true + runtimeVersion = "~1" + } + globalValidation = { + requireAuthentication = true + unauthenticatedClientAction = "Return401" + } + httpSettings = { + requireHttps = true + } + identityProviders = { + azureActiveDirectory = { + enabled = true + registration = { + clientId = azuread_application.verification_functions.client_id + openIdIssuer = "https://login.microsoftonline.com/${data.azurerm_client_config.current.tenant_id}/v2.0" + } + validation = { + allowedAudiences = [ + azuread_application.verification_functions.client_id, + local.verification_functions_auth_audience, + ] + defaultAuthorizationPolicy = { + allowedApplications = [ + azurerm_user_assigned_identity.api.client_id, + ] + allowedPrincipals = { + identities = [ + azurerm_user_assigned_identity.api.principal_id, + ] + } + } + } + } + } + } + } - depends_on = [azurerm_function_app_flex_consumption.verification] + schema_validation_enabled = false + + depends_on = [ + azuread_service_principal.verification_functions, + azurerm_function_app_flex_consumption.verification, + ] } diff --git a/infra/outputs.tf b/infra/outputs.tf index 1788c09d..642d17fa 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -110,6 +110,11 @@ output "verification_functions_url" { value = "https://${azurerm_function_app_flex_consumption.verification.default_hostname}" } +output "verification_functions_auth_scope" { + description = "Microsoft Entra token scope used by the API Durable client" + value = local.verification_functions_auth_scope +} + output "verification_functions_identity_client_id" { description = "Client ID for the verification Functions managed identity" value = azurerm_user_assigned_identity.verification_functions.client_id diff --git a/infra/provider.tf b/infra/provider.tf index be586a39..7237a918 100644 --- a/infra/provider.tf +++ b/infra/provider.tf @@ -10,6 +10,10 @@ terraform { source = "Azure/azapi" version = "~> 2.0" } + azuread = { + source = "hashicorp/azuread" + version = "~> 3.0" + } random = { source = "hashicorp/random" version = "~> 3.6" @@ -35,6 +39,10 @@ provider "azurerm" { provider "azapi" {} +provider "azuread" { + tenant_id = data.azurerm_client_config.current.tenant_id +} + resource "random_string" "suffix" { length = 6 special = false @@ -63,6 +71,9 @@ locals { verification_functions_storage_account_prefix = substr(replace("stltcfunc${lower(var.environment)}", "-", ""), 0, 18) verification_functions_storage_account_name = "${local.verification_functions_storage_account_prefix}${local.suffix}" verification_functions_task_hub_name = "verification-${var.environment}" + verification_functions_auth_app_name = "ltc-verification-functions-${var.environment}" + verification_functions_auth_audience = "api://${local.verification_functions_auth_app_name}" + verification_functions_auth_scope = "${local.verification_functions_auth_audience}/.default" suffix = random_string.suffix.result resource_group_name = "rg-ltc-${var.environment}" tags = { diff --git a/packages/learn-to-cloud-shared/src/learn_to_cloud_shared/core/azure_auth.py b/packages/learn-to-cloud-shared/src/learn_to_cloud_shared/core/azure_auth.py index 2b988296..73517e51 100644 --- a/packages/learn-to-cloud-shared/src/learn_to_cloud_shared/core/azure_auth.py +++ b/packages/learn-to-cloud-shared/src/learn_to_cloud_shared/core/azure_auth.py @@ -34,10 +34,10 @@ async def get_credential() -> ManagedIdentityCredential: return _azure_credential -async def get_token() -> str: - """Get Azure AD token for PostgreSQL auth.""" +async def get_token(scope: str = AZURE_PG_SCOPE) -> str: + """Get Azure AD token for the requested scope.""" credential = await get_credential() - token = await credential.get_token(AZURE_PG_SCOPE) + token = await credential.get_token(scope) return token.token diff --git a/packages/learn-to-cloud-shared/src/learn_to_cloud_shared/core/config.py b/packages/learn-to-cloud-shared/src/learn_to_cloud_shared/core/config.py index 9d2ac47a..63a78a11 100644 --- a/packages/learn-to-cloud-shared/src/learn_to_cloud_shared/core/config.py +++ b/packages/learn-to-cloud-shared/src/learn_to_cloud_shared/core/config.py @@ -175,7 +175,7 @@ class VerificationFunctionsConfig(FrozenConfig): """Durable verification Functions starter config.""" base_url: str = "" - key: str = "" + token_scope: str = "" class RateLimitConfig(FrozenConfig):