FAQ | This is a LIVE service | Changelog

Skip to content

feat: add CloudEvent validation for webhook handler proxy

Part of #50 (closed)

This MR implements the validation dance for using a EventGrid Topic Subscription using the CloudEvent 1.0 Schema.

Testing

Manual

The easiest way to test this is to run the webhook handler proxy locally by checking out the code and running poe entra. This will expose the service on your localhost port 8090. I used Tailscale Funnel to temporarily expose this port to the open internet, which provided me with a public accessible URL.

You would then need to go into the Azure Portal, find an EventGrid Topic (I used activate-account-development-azure-ad-topic) and create a subscription. Make you to select the correct event schema and configure a webhook endpoint pointing to your publicly accessible URL.

Screenshot_2025-08-18_at_13.03.19

If the subscription is created, then it did the validation and it all works. You can see in the logs of the application whether it worked.

Through terraform

Alternatively, you can also use Terraform to set things up for you. You can deploy this to development:

locals {
  # Future-proofing: if we add more webhook triggers, we can use this to tweak the container template.
  webhook = {
    image = "europe-west2-docker.pkg.dev/shared-code-meta-2daffe11/public/ucam-faas-python/entra-webhook/50-webhook-handler-proxy-cloud-event-validation:bf725fe0ca3b60d46983171a26f021e7c82487ba"
    args  = null
    env = [
      {
        name  = "ENTRA_APPLICATION_ID"
        value = local.azure_application_id
      },
      {
        name  = "ENTRA_TENANCY_ID"
        value = local.azure_tenant_id
      },
      {
        name  = "ENTRA_EXPECTED_PRINCIPAL_OBJECT_ID"
        value = data.azuread_service_principal.event_grid.object_id
      },
      {
        name  = "PUBSUB_PROJECT_ID"
        value = local.project
      },
      {
        name  = "PUBSUB_TOPIC_NAME"
        value = google_pubsub_topic.test_azure_webhook.name
      }
    ]
  }
}

resource "google_service_account" "test_azure_webhook_sa" {
  project    = local.project
  account_id = "test-azure-webhook"
}

resource "google_pubsub_topic" "test_azure_webhook" {
  name    = "test-azure-webhook"
  project = local.project
}

resource "google_pubsub_topic_iam_member" "test_azure_webhook_publisher" {
  topic  = google_pubsub_topic.test_azure_webhook.name
  role   = "roles/pubsub.publisher"
  member = google_service_account.test_azure_webhook_sa.member
}

resource "google_cloud_run_v2_service" "test_azure_webhook" {
  name     = "test-azure-webhook"
  location = local.region

  deletion_protection = false
  ingress             = "INGRESS_TRAFFIC_ALL"

  template {
    service_account = google_service_account.test_azure_webhook_sa.email

    containers {
      image = local.webhook.image
      args  = local.webhook.args != null ? local.webhook.args : null

      dynamic "env" {
        for_each = local.webhook.env
        content {
          name  = env.value["name"]
          value = env.value["value"]
          dynamic "value_source" {
            for_each = contains(keys(env.value), "value_source") ? env.value["value_source"] != null ? [env.value["value_source"]] : [] : []
            content {
              dynamic "secret_key_ref" {
                for_each = value_source.value["secret_key_ref"] != null ? [value_source.value["secret_key_ref"]] : []
                content {
                  secret  = secret_key_ref.value["secret"]
                  version = secret_key_ref.value["version"]
                }
              }
            }
          }
        }
      }

      resources {
        limits = {
          cpu    = 2
          memory = "1024Mi"
        }
      }

      liveness_probe {
        http_get {
          path = "/healthy"
        }
      }

      startup_probe {
        http_get {
          path = "/healthy"
        }
      }
    }

    scaling {
      min_instance_count = 0
      max_instance_count = 1
    }
  }
}

# Allow unauthenticated invocations of the Cloud Run service
resource "google_cloud_run_service_iam_binding" "default" {
  location = google_cloud_run_v2_service.test_azure_webhook.location
  service  = google_cloud_run_v2_service.test_azure_webhook.name
  role     = "roles/run.invoker"
  members = [
    "allUsers"
  ]
}

And then create the subscription like:

resource "azurerm_eventgrid_event_subscription" "entra_event_ce" {
  name  = "entra-event-subscription-ce"
  scope = azurerm_eventgrid_topic.azure_ad_topic.id

  event_delivery_schema = "CloudEventSchemaV1_0"

  webhook_endpoint {
    url                            = google_cloud_run_v2_service.test_azure_webhook.uri
    active_directory_tenant_id     = local.azure_tenant_id
    active_directory_app_id_or_uri = local.azure_application_id

    # Need to explicitly set these to avoid `terraform plan` trying to change them on every run.
    # See https://github.com/hashicorp/terraform-provider-azurerm/issues/10922
    max_events_per_batch              = 1
    preferred_batch_size_in_kilobytes = 64
  }
}

Once that's created, it means it validated the endpoint (if it's hanging, it's waiting for manual action and the code didn't work).

You can see an example of this here:

https://cloudlogging.app.goo.gl/R8E4RYdnFyS2g3a96

Edited by Sebastiaan ten Pas

Merge request reports

Loading