From 872ac5f5edfcdf8fa6a95234ed3b99268d199605 Mon Sep 17 00:00:00 2001 From: Srinidhi Veeraraghavan Date: Mon, 8 Jun 2026 08:53:04 +0100 Subject: [PATCH 1/8] feat: allow new queue under an existing shared namespace --- .../azure/src/construct/event-handler/main.ts | 32 ++++++++++++- .../src/construct/event-handler/types.ts | 47 ++++++++++++++++--- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/packages/azure/src/construct/event-handler/main.ts b/packages/azure/src/construct/event-handler/main.ts index da944e97..e19fac83 100644 --- a/packages/azure/src/construct/event-handler/main.ts +++ b/packages/azure/src/construct/event-handler/main.ts @@ -98,11 +98,38 @@ export class AzureEventHandler extends AzureFunctionApp { ) } + /** + * @summary Resolve effective `useExisting` flags for the Service Bus namespace and queue. + * + * Per-resource flags (`namespace.useExisting`, `queue.useExisting`) take precedence over the + * deprecated top-level `serviceBus.useExisting`, which is treated as an alias that sets both. + * Throws if the invalid combination (namespace.useExisting=false + queue.useExisting=true) is + * requested — a queue cannot be looked up under a namespace the construct is about to create. + */ + protected resolveServiceBusUseExisting(): { namespace: boolean; queue: boolean } { + // TODO: remove `deprecatedServiceBusUseExisting` and the `?? deprecatedServiceBusUseExisting` + // fallbacks once all callers have migrated to the per-resource flags + // (see EventHandlerServiceBusProps.useExisting in types.ts). + const deprecatedServiceBusUseExisting = this.props.serviceBus?.useExisting + const namespaceUseExisting = this.props.serviceBus?.namespace?.useExisting + const queueUseExisting = this.props.serviceBus?.queue?.useExisting + const namespace = namespaceUseExisting ?? deprecatedServiceBusUseExisting ?? false + const queue = queueUseExisting ?? deprecatedServiceBusUseExisting ?? false + if (!namespace && queue) { + throw new Error( + `[${this.id}] invalid serviceBus configuration: queue.useExisting=true requires namespace.useExisting=true ` + + `(cannot resolve an existing queue under a namespace the construct is creating).` + ) + } + return { namespace, queue } + } + /** * @summary Method to create the Service Bus namespace */ protected createServiceBusNamespace() { - if (this.props.serviceBus?.useExisting && this.props.serviceBus?.namespace?.namespaceName) { + const useExistingFlags = this.resolveServiceBusUseExisting() + if (useExistingFlags.namespace && this.props.serviceBus?.namespace?.namespaceName) { this.serviceBus.namespace = getNamespaceOutput({ namespaceName: this.props.serviceBus.namespace.namespaceName, resourceGroupName: this.props.serviceBus.namespace.resourceGroupName, @@ -129,8 +156,9 @@ export class AzureEventHandler extends AzureFunctionApp { * @summary Method to create the Service Bus queue */ protected createServiceBusQueue() { + const useExistingFlags = this.resolveServiceBusUseExisting() if ( - this.props.serviceBus?.useExisting && + useExistingFlags.queue && this.props.serviceBus?.namespace?.namespaceName && this.props.serviceBus?.queue?.queueName ) { diff --git a/packages/azure/src/construct/event-handler/types.ts b/packages/azure/src/construct/event-handler/types.ts index 2671349c..34fb2cde 100644 --- a/packages/azure/src/construct/event-handler/types.ts +++ b/packages/azure/src/construct/event-handler/types.ts @@ -39,15 +39,50 @@ export interface EventHandlerEventGridSubscription { } /** - * Properties for configuring the Service Bus integration in the event handler + * Properties for configuring the Service Bus namespace inside the event handler. + * Wraps {@link ServiceBusNamespaceProps} with a `useExisting` flag controlling + * whether the construct creates the namespace or resolves an existing one. + * @category Interface + */ +export interface EventHandlerServiceBusNamespaceProps extends ServiceBusNamespaceProps { + /** When true, resolves an existing namespace via getNamespaceOutput instead of creating one */ + useExisting?: boolean +} + +/** + * Properties for configuring the Service Bus queue inside the event handler. + * Wraps {@link ServiceBusQueueProps} with a `useExisting` flag controlling + * whether the construct creates the queue or resolves an existing one. + * @category Interface + */ +export interface EventHandlerServiceBusQueueProps extends ServiceBusQueueProps { + /** When true, resolves an existing queue via getQueueOutput instead of creating one */ + useExisting?: boolean +} + +/** + * Properties for configuring the Service Bus integration in the event handler. + * + * `namespace.useExisting` and `queue.useExisting` are independent. The supported combinations are: + * - `namespace.useExisting=false, queue.useExisting=false` — create both (default) + * - `namespace.useExisting=true, queue.useExisting=false` — reuse an existing (e.g. shared) namespace, create a new queue under it + * - `namespace.useExisting=true, queue.useExisting=true` — reuse both (cross-stack reference to a queue someone else owns) + * - `namespace.useExisting=false, queue.useExisting=true` — invalid; construct throws at construct-time * @category Interface */ export interface EventHandlerServiceBusProps { - /** Service Bus namespace properties */ - namespace?: ServiceBusNamespaceProps - /** Service Bus queue properties */ - queue?: ServiceBusQueueProps - /** When true, resolves an existing Service Bus instead of creating a new one */ + /** Service Bus namespace properties (extends {@link ServiceBusNamespaceProps} with `useExisting`) */ + namespace?: EventHandlerServiceBusNamespaceProps + /** Service Bus queue properties (extends {@link ServiceBusQueueProps} with `useExisting`) */ + queue?: EventHandlerServiceBusQueueProps + /** + * Convenience alias that sets both `namespace.useExisting` and `queue.useExisting` to the same value. + * @deprecated Prefer `namespace.useExisting` and `queue.useExisting` individually. Retained as an alias + * so existing callers (e.g. WebhookEventHandler) continue to compile unchanged. + * TODO: remove once all callers have migrated to the per-resource flags + * as part of the Service Bus namespace consolidation rollout. Also remove the resolution + * fallback in `resolveServiceBusUseExisting()` in main.ts. + */ useExisting?: boolean } From 9cf6543e6e98ed70c6f2324da38ecae30d4e2a26 Mon Sep 17 00:00:00 2001 From: Srinidhi Veeraraghavan Date: Mon, 8 Jun 2026 08:58:14 +0100 Subject: [PATCH 2/8] feat: support queue resourceGroupName when namespace is external --- packages/azure/src/construct/event-handler/main.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/azure/src/construct/event-handler/main.ts b/packages/azure/src/construct/event-handler/main.ts index e19fac83..4aa95075 100644 --- a/packages/azure/src/construct/event-handler/main.ts +++ b/packages/azure/src/construct/event-handler/main.ts @@ -168,11 +168,17 @@ export class AzureEventHandler extends AzureFunctionApp { resourceGroupName: this.props.serviceBus.namespace.resourceGroupName, }) } else { + // Azure requires the queue's resource group to match its parent namespace's resource group. + // When the namespace is external, use the namespace's resource group from props; + // otherwise the construct is creating the namespace alongside the queue in the consumer stack's own resource group. + const namespaceResourceGroupName = useExistingFlags.namespace + ? (this.props.serviceBus?.namespace?.resourceGroupName ?? this.resourceGroup.name) + : this.resourceGroup.name this.serviceBus.queue = this.serviceBusManager.createServiceBusQueue(this.id, this, { ...this.props.serviceBus?.queue, queueName: this.props.serviceBus?.queue?.queueName ?? this.id, namespaceName: this.serviceBus.namespace.name, - resourceGroupName: this.resourceGroup.name, + resourceGroupName: namespaceResourceGroupName, }) } From da5280bb25d6d0a0e60ad9019a87178dc79b7249 Mon Sep 17 00:00:00 2001 From: Srinidhi Veeraraghavan Date: Mon, 8 Jun 2026 09:05:00 +0100 Subject: [PATCH 3/8] feat: update EventGrid subscription to key off queue.useExisting --- packages/azure/src/construct/event-handler/main.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/azure/src/construct/event-handler/main.ts b/packages/azure/src/construct/event-handler/main.ts index 4aa95075..950cb3d0 100644 --- a/packages/azure/src/construct/event-handler/main.ts +++ b/packages/azure/src/construct/event-handler/main.ts @@ -229,10 +229,15 @@ export class AzureEventHandler extends AzureFunctionApp { } /** - * @summary Method to create the EventGrid event subscription with Service Bus queue destination + * @summary Method to create the EventGrid event subscription with Service Bus queue destination. + * + * Skipped when the construct is reusing an existing queue (the producer/owner of that queue owns + * the subscription). When the construct creates a new queue — including the consolidation case + * of a new queue under an externally-managed namespace — the subscription is wired here. */ protected createEventGridEventSubscription() { - if (this.props.serviceBus?.useExisting || !this.eventGridEventSubscription.dlqStorageAccount) return + const useExistingFlags = this.resolveServiceBusUseExisting() + if (useExistingFlags.queue || !this.eventGridEventSubscription.dlqStorageAccount) return this.eventGridEventSubscription.eventSubscription = this.eventgridManager.createEventgridSubscription( this.id, From a6940f0cd05d27812b08893a0aff5e305bec3746 Mon Sep 17 00:00:00 2001 From: Srinidhi Veeraraghavan Date: Mon, 8 Jun 2026 09:09:52 +0100 Subject: [PATCH 4/8] feat: rename createServiceBusDiagnosticLog predicate to namespace.useExisting --- packages/azure/src/construct/event-handler/main.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/azure/src/construct/event-handler/main.ts b/packages/azure/src/construct/event-handler/main.ts index 950cb3d0..bc1e524e 100644 --- a/packages/azure/src/construct/event-handler/main.ts +++ b/packages/azure/src/construct/event-handler/main.ts @@ -260,10 +260,15 @@ export class AzureEventHandler extends AzureFunctionApp { } /** - * @summary Method to create diagnostic log settings for the Service Bus namespace + * @summary Method to create diagnostic log settings for the Service Bus namespace. + * + * Diagnostic settings live on the namespace, so the construct only registers them when it owns + * the namespace. When the namespace is external (e.g. provisioned by common-regional), its + * owner is responsible for diagnostic settings — registering again here would conflict. */ protected createServiceBusDiagnosticLog() { - if (this.props.serviceBus?.useExisting) return + const useExistingFlags = this.resolveServiceBusUseExisting() + if (useExistingFlags.namespace) return this.monitorManager.createMonitorDiagnosticSettings(this.id, this, { name: `${this.id}-servicebus`, From 9964e164fbe8677212705843a7e87ed6adf8dcdf Mon Sep 17 00:00:00 2001 From: Srinidhi Veeraraghavan Date: Mon, 8 Jun 2026 09:58:34 +0100 Subject: [PATCH 5/8] feat: replace RootManageSharedAccessKey connection string with scoped auth --- .../azure/src/construct/event-handler/main.ts | 82 +++++++++++++++++-- .../src/construct/event-handler/types.ts | 15 +++- .../azure/src/services/servicebus/main.ts | 21 +++++ .../azure/src/services/servicebus/types.ts | 8 ++ 4 files changed, 119 insertions(+), 7 deletions(-) diff --git a/packages/azure/src/construct/event-handler/main.ts b/packages/azure/src/construct/event-handler/main.ts index bc1e524e..e9cbb0f1 100644 --- a/packages/azure/src/construct/event-handler/main.ts +++ b/packages/azure/src/construct/event-handler/main.ts @@ -1,6 +1,12 @@ import { Provider } from '@pulumi/azure-native' import { getTopicOutput, GetTopicResult, Topic } from '@pulumi/azure-native/eventgrid/index.js' -import { getNamespaceOutput, getQueueOutput, listNamespaceKeysOutput } from '@pulumi/azure-native/servicebus/index.js' +import { + getNamespaceOutput, + getQueueOutput, + listNamespaceKeysOutput, + listQueueKeysOutput, +} from '@pulumi/azure-native/servicebus/index.js' +import { AccessRights } from '@pulumi/azure-native/types/enums/servicebus/index.js' import { Output } from '@pulumi/pulumi' import { AzureFunctionApp } from '../function-app/index.js' @@ -56,6 +62,7 @@ export class AzureEventHandler extends AzureFunctionApp { this.createEventGridSubscriptionDlqStorageContainer() this.createServiceBusNamespace() this.createServiceBusQueue() + this.createServiceBusQueueAuthorizationRule() this.createEventGrid() this.createEventGridEventSubscription() this.createServiceBusDiagnosticLog() @@ -188,6 +195,43 @@ export class AzureEventHandler extends AzureFunctionApp { }) } + /** + * @summary Provision a per-queue Listen+Send authorization rule. + * + * Skipped when the construct does not own the queue (`queue.useExisting=true`) — in that case + * the producer/owner of the queue is responsible for its auth rules and the function app's + * connection string falls back to the namespace-level root rule in {@link createFunctionAppSiteConfig}. + * + * Replaces the previous reliance on `RootManageSharedAccessKey`, which grants Listen+Send+Manage + * on every queue in the namespace. The new rule narrows the scope to this one queue while keeping + * Listen+Send so existing function code can both consume and publish through it (Manage is + * intentionally dropped — a runtime app should not create or delete queues). + */ + protected createServiceBusQueueAuthorizationRule() { + const useExistingFlags = this.resolveServiceBusUseExisting() + if (useExistingFlags.queue) return + + const namespaceResourceGroupName = useExistingFlags.namespace + ? (this.props.serviceBus?.namespace?.resourceGroupName ?? this.resourceGroup.name) + : this.resourceGroup.name + + this.serviceBus.queueAuthorizationRule = this.serviceBusManager.createServiceBusQueueAuthorizationRule( + this.id, + this, + { + authorizationRuleName: `${this.id}-listen-send`, + namespaceName: this.serviceBus.namespace.name, + queueName: this.serviceBus.queue.name, + resourceGroupName: namespaceResourceGroupName, + rights: [AccessRights.Listen, AccessRights.Send], + } + ) + + this.registerOutputs({ + serviceBusQueueAuthorizationRuleId: this.serviceBus.queueAuthorizationRule.id, + }) + } + /** * @summary Method to create or resolve an existing EventGrid topic */ @@ -321,16 +365,42 @@ export class AzureEventHandler extends AzureFunctionApp { this.appConnectionStrings = [ { name: 'EVENT_INGEST_SERVICE_BUS', - value: listNamespaceKeysOutput({ - resourceGroupName: this.props.serviceBus?.namespace?.resourceGroupName ?? this.resourceGroup.name, - namespaceName: this.serviceBus.namespace.name, - authorizationRuleName: 'RootManageSharedAccessKey', - }).primaryConnectionString, + value: this.resolveServiceBusConnectionString(), type: 'ServiceBus', }, ] } + /** + * @summary Resolve the connection string injected as `EVENT_INGEST_SERVICE_BUS` on the function app. + * + * - When the construct provisioned the queue itself, read from the per-queue Listen-scoped + * authorization rule created in {@link createServiceBusQueueAuthorizationRule}. Scope is + * limited to this one queue, which is correct for a shared (per-domain) namespace. + * - When the queue is external (legacy `WebhookEventHandler` cross-stack pattern), fall back to + * the namespace-level `RootManageSharedAccessKey` — preserves pre-existing behaviour for that + * caller shape since the construct does not own the queue and cannot provision a rule on it. + */ + protected resolveServiceBusConnectionString(): Output { + if (this.serviceBus.queueAuthorizationRule) { + const useExistingFlags = this.resolveServiceBusUseExisting() + const namespaceResourceGroupName = useExistingFlags.namespace + ? (this.props.serviceBus?.namespace?.resourceGroupName ?? this.resourceGroup.name) + : this.resourceGroup.name + return listQueueKeysOutput({ + resourceGroupName: namespaceResourceGroupName, + namespaceName: this.serviceBus.namespace.name, + queueName: this.serviceBus.queue.name, + authorizationRuleName: this.serviceBus.queueAuthorizationRule.name, + }).primaryConnectionString + } + return listNamespaceKeysOutput({ + resourceGroupName: this.props.serviceBus?.namespace?.resourceGroupName ?? this.resourceGroup.name, + namespaceName: this.serviceBus.namespace.name, + authorizationRuleName: 'RootManageSharedAccessKey', + }).primaryConnectionString + } + /** * @summary Override to extend the dashboard variables with service bus and event grid specifics */ diff --git a/packages/azure/src/construct/event-handler/types.ts b/packages/azure/src/construct/event-handler/types.ts index 34fb2cde..1f0dcbdb 100644 --- a/packages/azure/src/construct/event-handler/types.ts +++ b/packages/azure/src/construct/event-handler/types.ts @@ -1,5 +1,11 @@ import { EventSubscription } from '@pulumi/azure-native/eventgrid/index.js' -import { GetNamespaceResult, GetQueueResult, Namespace, Queue } from '@pulumi/azure-native/servicebus/index.js' +import { + GetNamespaceResult, + GetQueueResult, + Namespace, + Queue, + QueueAuthorizationRule, +} from '@pulumi/azure-native/servicebus/index.js' import { BlobContainer, StorageAccount } from '@pulumi/azure-native/storage/index.js' import { Input, Output } from '@pulumi/pulumi' @@ -95,6 +101,13 @@ export interface EventHandlerServiceBus { namespace: Namespace | Output /** The provisioned or resolved Service Bus queue */ queue: Queue | Output + /** + * Per-queue authorization rule (Listen+Send) used to build the function app's + * `EVENT_INGEST_SERVICE_BUS` connection string. Provisioned only when the + * construct owns the queue (`queue.useExisting=false`); undefined when the + * queue is external and the connection string falls back to the namespace-level rule. + */ + queueAuthorizationRule?: QueueAuthorizationRule } /** diff --git a/packages/azure/src/services/servicebus/main.ts b/packages/azure/src/services/servicebus/main.ts index e1027b67..627cc4ff 100644 --- a/packages/azure/src/services/servicebus/main.ts +++ b/packages/azure/src/services/servicebus/main.ts @@ -3,6 +3,7 @@ import { ManagedServiceIdentityType, Namespace, Queue, + QueueAuthorizationRule, SkuName, Subscription, Topic, @@ -14,6 +15,7 @@ import { CommonAzureConstruct } from '../../common/index.js' import { ResolveServicebusQueueProps, ServiceBusNamespaceProps, + ServiceBusQueueAuthorizationRuleProps, ServiceBusQueueProps, ServiceBusSubscriptionProps, ServiceBusTopicProps, @@ -148,6 +150,25 @@ export class AzureServiceBusManager { ) } + /** + * @summary Method to create a new service bus queue authorization rule + * @param id scoped id of the resource + * @param scope scope in which this resource is defined + * @param props service bus queue authorization rule properties + * @param resourceOptions Optional settings to control resource behaviour + * @see [Pulumi Azure Native Service Bus Queue Authorization Rule]{@link https://www.pulumi.com/registry/packages/azure-native/api-docs/servicebus/queueauthorizationrule/} + */ + public createServiceBusQueueAuthorizationRule( + id: string, + scope: CommonAzureConstruct, + props: ServiceBusQueueAuthorizationRuleProps, + resourceOptions?: ResourceOptions + ) { + if (!props) throw new Error(`Props undefined for ${id}`) + + return new QueueAuthorizationRule(`${id}-sqar`, { ...props }, { parent: scope, ...resourceOptions }) + } + /** * @summary Method to create a new service bus subscription * @param id scoped id of the resource diff --git a/packages/azure/src/services/servicebus/types.ts b/packages/azure/src/services/servicebus/types.ts index badb1174..daa4203a 100644 --- a/packages/azure/src/services/servicebus/types.ts +++ b/packages/azure/src/services/servicebus/types.ts @@ -2,6 +2,7 @@ import { GetQueueOutputArgs, NamespaceArgs, QueueArgs, + QueueAuthorizationRuleArgs, SubscriptionArgs, TopicArgs, } from '@pulumi/azure-native/servicebus/index.js' @@ -27,6 +28,13 @@ export interface ServiceBusTopicProps extends TopicArgs {} */ export interface ServiceBusQueueProps extends QueueArgs {} +/** + * Properties for creating a Service Bus queue authorization rule + * @see [Pulumi Azure Native Service Bus Queue Authorization Rule]{@link https://www.pulumi.com/registry/packages/azure-native/api-docs/servicebus/queueauthorizationrule/} + * @category Interface + */ +export interface ServiceBusQueueAuthorizationRuleProps extends QueueAuthorizationRuleArgs {} + /** * Properties for creating a Service Bus subscription * @see [Pulumi Azure Native Service Bus Subscription]{@link https://www.pulumi.com/registry/packages/azure-native/api-docs/servicebus/subscription/} From 025b32fa38f34abd86fdec5a4d8af22e4ab4049b Mon Sep 17 00:00:00 2001 From: Srinidhi Veeraraghavan Date: Mon, 8 Jun 2026 10:15:44 +0100 Subject: [PATCH 6/8] feat: update to docs and tests --- .../azure/src/construct/event-handler/main.ts | 62 +++++- .../src/construct/event-handler/types.ts | 5 +- .../event-handler-shared-namespace.json | 41 ++++ .../test/constructs/event-handler.test.ts | 190 ++++++++++++++++++ 4 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 packages/azure/test/common/config/event-handler-shared-namespace.json diff --git a/packages/azure/src/construct/event-handler/main.ts b/packages/azure/src/construct/event-handler/main.ts index e9cbb0f1..03dcd99e 100644 --- a/packages/azure/src/construct/event-handler/main.ts +++ b/packages/azure/src/construct/event-handler/main.ts @@ -14,8 +14,36 @@ import { AzureFunctionApp } from '../function-app/index.js' import { AzureEventHandlerProps, EventHandlerEventGridSubscription, EventHandlerServiceBus } from './types.js' /** - * Provides a construct to create and deploy an Azure EventGrid Event Handler with Service Bus integration + * Provides a construct to create and deploy an Azure EventGrid Event Handler with Service Bus integration. + * + * ## Service Bus configuration + * + * The construct manages a Service Bus namespace and queue. Each can independently be created by the + * construct or resolved from existing Azure resources via the `namespace.useExisting` and + * `queue.useExisting` flags on {@link EventHandlerServiceBusProps}: + * + * | `namespace.useExisting` | `queue.useExisting` | Behaviour | + * |---|---|---| + * | `false` | `false` | Create both — the default. Used by single-purpose stacks that own the whole Service Bus surface. | + * | `true` | `false` | Look up the namespace, create a new queue under it. Use this when one namespace is provisioned by a shared infrastructure stack and multiple event-handler stacks each provision their own queue under it. | + * | `true` | `true` | Look up both. Cross-stack pattern where the producer/owner of the queue is a different stack (e.g. `WebhookEventHandler` consuming a queue created by `WebhookGateway`). | + * | `false` | `true` | **Invalid** — construct-time error. You cannot resolve an existing queue under a namespace the construct is creating. | + * + * The top-level `serviceBus.useExisting` flag is retained as a deprecated alias that sets both + * per-resource flags to the same value, so existing callers continue to work unchanged. + * + * ## Authorization and the `EVENT_INGEST_SERVICE_BUS` connection string + * + * When the construct owns the queue (`queue.useExisting=false`), it provisions a per-queue + * authorization rule named `${id}-listen-send` with `Listen + Send` rights, and the function app's + * `EVENT_INGEST_SERVICE_BUS` connection string is sourced from that rule. This avoids granting the + * function app access to sibling queues when the namespace is shared. + * + * When the queue is external (`queue.useExisting=true`) the construct does not own auth rules on it + * and falls back to reading the namespace-level `RootManageSharedAccessKey` for the connection string. + * * @example + * // Minimal subclass (defaults — both namespace and queue created by the construct): * import { AzureEventHandler, AzureEventHandlerProps } from '@gradientedge/cdk-utils' * * class CustomConstruct extends AzureEventHandler { @@ -26,6 +54,30 @@ import { AzureEventHandlerProps, EventHandlerEventGridSubscription, EventHandler * this.initResources() * } * } + * + * @example + * // Shared-namespace pattern — reuse a namespace provisioned by another stack, create a new + * // queue under it. Useful when multiple event handlers should share a single Service Bus namespace. + * import * as pulumi from '@pulumi/pulumi' + * + * const sharedInfraStack = new pulumi.StackReference('shared-infra') + * const sharedNamespace = sharedInfraStack.getOutput('serviceBusNamespace') + * + * new CustomConstruct('my-event-handler', { + * ...baseProps, + * serviceBus: { + * namespace: { + * useExisting: true, + * namespaceName: sharedNamespace.apply((ns) => ns.name), + * resourceGroupName: sharedNamespace.apply((ns) => ns.resourceGroupName), + * }, + * queue: { + * useExisting: false, + * queueName: 'my-event-queue', + * }, + * }, + * }) + * * @category Construct */ export class AzureEventHandler extends AzureFunctionApp { @@ -276,8 +328,8 @@ export class AzureEventHandler extends AzureFunctionApp { * @summary Method to create the EventGrid event subscription with Service Bus queue destination. * * Skipped when the construct is reusing an existing queue (the producer/owner of that queue owns - * the subscription). When the construct creates a new queue — including the consolidation case - * of a new queue under an externally-managed namespace — the subscription is wired here. + * the subscription). When the construct creates a new queue — including the case where the queue + * is new but the parent namespace is externally-managed — the subscription is wired here. */ protected createEventGridEventSubscription() { const useExistingFlags = this.resolveServiceBusUseExisting() @@ -307,8 +359,8 @@ export class AzureEventHandler extends AzureFunctionApp { * @summary Method to create diagnostic log settings for the Service Bus namespace. * * Diagnostic settings live on the namespace, so the construct only registers them when it owns - * the namespace. When the namespace is external (e.g. provisioned by common-regional), its - * owner is responsible for diagnostic settings — registering again here would conflict. + * the namespace. When the namespace is external (e.g. provisioned by a shared infrastructure + * stack), its owner is responsible for diagnostic settings — registering again here would conflict. */ protected createServiceBusDiagnosticLog() { const useExistingFlags = this.resolveServiceBusUseExisting() diff --git a/packages/azure/src/construct/event-handler/types.ts b/packages/azure/src/construct/event-handler/types.ts index 1f0dcbdb..8439224c 100644 --- a/packages/azure/src/construct/event-handler/types.ts +++ b/packages/azure/src/construct/event-handler/types.ts @@ -85,9 +85,8 @@ export interface EventHandlerServiceBusProps { * Convenience alias that sets both `namespace.useExisting` and `queue.useExisting` to the same value. * @deprecated Prefer `namespace.useExisting` and `queue.useExisting` individually. Retained as an alias * so existing callers (e.g. WebhookEventHandler) continue to compile unchanged. - * TODO: remove once all callers have migrated to the per-resource flags - * as part of the Service Bus namespace consolidation rollout. Also remove the resolution - * fallback in `resolveServiceBusUseExisting()` in main.ts. + * TODO: remove once all callers have migrated to the per-resource flags. Also remove the + * resolution fallback in `resolveServiceBusUseExisting()` in main.ts. */ useExisting?: boolean } diff --git a/packages/azure/test/common/config/event-handler-shared-namespace.json b/packages/azure/test/common/config/event-handler-shared-namespace.json new file mode 100644 index 00000000..8d9bdeac --- /dev/null +++ b/packages/azure/test/common/config/event-handler-shared-namespace.json @@ -0,0 +1,41 @@ +{ + "eventGridTopic": { + "topicName": "test-event-handler-topic", + "location": "eastus", + "resourceGroupName": "test-rg-dev", + "useExistingTopic": false + }, + "eventGridEventSubscription": { + "eventSubscriptionName": "test-event-handler-subscription", + "scope": "/subscriptions/test-sub/resourceGroups/test-rg-dev/providers/Microsoft.EventGrid/topics/test-event-handler-topic-dev" + }, + "eventGridSubscription": { + "dlqStorageAccount": { + "accountName": "test-event-handler-dlq-sa", + "resourceGroupName": "test-rg-dev" + }, + "dlqStorageContainer": { + "containerName": "eventgrid-subscription-dlq-container", + "accountName": "test-event-handler-dlq-sa-dev", + "resourceGroupName": "test-rg-dev" + } + }, + "serviceBus": { + "namespace": { + "useExisting": true, + "namespaceName": "shared-domain-sb-ns", + "resourceGroupName": "shared-rg-dev", + "sku": { + "name": "Standard" + } + }, + "queue": { + "useExisting": false, + "queueName": "stack-owned-sb-queue" + } + }, + "commonLogAnalyticsWorkspace": { + "workspaceName": "test-workspace", + "resourceGroupName": "test-rg-dev" + } +} diff --git a/packages/azure/test/constructs/event-handler.test.ts b/packages/azure/test/constructs/event-handler.test.ts index be8869cb..10239778 100644 --- a/packages/azure/test/constructs/event-handler.test.ts +++ b/packages/azure/test/constructs/event-handler.test.ts @@ -244,6 +244,8 @@ pulumi.runtime.setMocks({ name = args.inputs.namespaceName } else if (args.type === 'azure-native:servicebus:Queue') { name = args.inputs.queueName + } else if (args.type === 'azure-native:servicebus:QueueAuthorizationRule') { + name = args.inputs.authorizationRuleName } else if (args.type === 'azure-native:eventgrid:Topic') { name = args.inputs.topicName } else if (args.type === 'azure-native:eventgrid:EventSubscription') { @@ -276,6 +278,12 @@ pulumi.runtime.setMocks({ secondaryConnectionString: 'mock-servicebus-secondary-connection-string', } } + if (args.token === 'azure-native:servicebus:listQueueKeys') { + return { + primaryConnectionString: 'mock-servicebus-queue-connection-string', + secondaryConnectionString: 'mock-servicebus-queue-secondary-connection-string', + } + } if (args.token === 'azure-native:eventgrid:getTopic') { return { id: 'existing-topic-id', @@ -583,6 +591,7 @@ class TestEventHandlerUseExistingConstruct extends AzureEventHandler { this.createEventGridSubscriptionDlqStorageContainer() this.createServiceBusNamespace() this.createServiceBusQueue() + this.createServiceBusQueueAuthorizationRule() this.createEventGrid() this.createEventGridEventSubscription() this.createServiceBusDiagnosticLog() @@ -673,3 +682,184 @@ describe('TestAzureEventHandlerFullConstruct', () => { expect(serviceBusConn.type).toEqual('ServiceBus') }) }) + +/* --- Tests for the per-resource useExisting flag combinations --- */ + +/* Combination 1: namespace.useExisting=false, queue.useExisting=false (default — covered by stack / stackFull above). + * - Existing tests cover: queue is created, EG subscription created, auth rule created, diag log created. + * + * Combination 2: namespace.useExisting=true, queue.useExisting=false (shared-namespace consolidation). + * - New tests below: namespace is looked up, queue is created in the namespace's RG, auth rule provisioned, EG subscription created, diag log skipped. + * + * Combination 3: namespace.useExisting=true, queue.useExisting=true (legacy cross-stack pattern — covered by stackUseExisting above via top-level useExisting alias). + * - Existing tests cover: both looked up, no EG subscription, no auth rule. + * + * Combination 4: namespace.useExisting=false, queue.useExisting=true (invalid — must throw). + * - New test below: constructor throws. + */ + +/* Combination 2 — shared namespace, owned queue */ + +const testStackSharedNamespaceProps: TestAzureStackProps = { + domainName: 'gradientedge.io', + extraContexts: [ + 'packages/azure/test/common/config/dummy.json', + 'packages/azure/test/common/config/event-handler-shared-namespace.json', + ], + location: AzureLocation.EastUS, + name: 'test-common-stack', + resourceGroupName: 'test-rg', + skipStageForARecords: false, + stage: 'dev', + stageContextPath: 'packages/azure/test/common/env', +} as TestAzureStackProps + +class TestEventHandlerSharedNamespaceConstruct extends AzureEventHandler { + declare props: AzureEventHandlerProps & TestAzureStackProps + + constructor(name: string, props: AzureEventHandlerProps & TestAzureStackProps) { + super(name, props) + this.props = props + this.eventGridEventSubscription = {} as EventHandlerEventGridSubscription + this.serviceBus = {} as EventHandlerServiceBus + this.initResources() + } + + public initResources() { + this.createResourceGroup() + this.resolveCommonLogAnalyticsWorkspace() + this.createEventGridSubscriptionDlqStorageAccount() + this.createEventGridSubscriptionDlqStorageContainer() + this.createServiceBusNamespace() + this.createServiceBusQueue() + this.createServiceBusQueueAuthorizationRule() + this.createEventGrid() + this.createEventGridEventSubscription() + this.createServiceBusDiagnosticLog() + } +} + +class TestCommonStackSharedNamespace extends CommonAzureStack { + declare props: AzureEventHandlerProps & TestAzureStackProps + declare construct: TestEventHandlerSharedNamespaceConstruct + + constructor(name: string, props: TestAzureStackProps) { + super(name, testStackSharedNamespaceProps) + this.construct = new TestEventHandlerSharedNamespaceConstruct(`${props.name}-shared-ns`, this.props) + } +} + +pulumi.runtime.setConfig('project:extraContexts', JSON.stringify(testStackSharedNamespaceProps.extraContexts)) +const stackSharedNamespace = new TestCommonStackSharedNamespace( + 'test-shared-namespace-stack', + testStackSharedNamespaceProps +) + +describe('TestAzureEventHandlerSharedNamespace', () => { + test('synthesises with namespace.useExisting=true and queue.useExisting=false', () => { + expect(stackSharedNamespace).toBeDefined() + expect(stackSharedNamespace.construct).toBeDefined() + expect(stackSharedNamespace.construct.props.serviceBus.namespace?.useExisting).toEqual(true) + expect(stackSharedNamespace.construct.props.serviceBus.queue?.useExisting).toEqual(false) + }) + + test('looks up the shared namespace via getNamespaceOutput', async () => { + await outputToPromise( + pulumi + .all([ + stackSharedNamespace.construct.serviceBus.namespace.id, + stackSharedNamespace.construct.serviceBus.namespace.name, + ]) + .apply(([id, name]) => { + expect(id).toEqual('existing-namespace-id') + expect(name).toEqual('shared-domain-sb-ns') + }) + ) + }) + + test('creates a new queue (not a lookup) under the shared namespace', async () => { + await outputToPromise( + stackSharedNamespace.construct.serviceBus.queue.id.apply(id => { + // Pulumi-generated id for created resources is `${name}-id`, not the 'existing-queue-id' lookup sentinel + expect(id).not.toEqual('existing-queue-id') + expect(id).toContain('-id') + }) + ) + }) + + test('provisions a per-queue Listen+Send authorization rule', async () => { + expect(stackSharedNamespace.construct.serviceBus.queueAuthorizationRule).toBeDefined() + await outputToPromise( + pulumi + .all([ + stackSharedNamespace.construct.serviceBus.queueAuthorizationRule!.name, + stackSharedNamespace.construct.serviceBus.queueAuthorizationRule!.rights, + ]) + .apply(([name, rights]) => { + expect(name).toContain('listen-send') + expect(rights).toEqual(['Listen', 'Send']) + }) + ) + }) + + test('creates the EventGrid event subscription (queue is owned)', () => { + expect(stackSharedNamespace.construct.eventGridEventSubscription.eventSubscription).toBeDefined() + }) +}) + +/* Verify the connection string flows from the queue-scoped rule. + * Uses stackFull (default flags — auth rule provisioned) since the code path is identical for + * any caller pattern where the construct owns the queue. */ +describe('TestAzureEventHandler EVENT_INGEST_SERVICE_BUS connection string source', () => { + test('connection string value comes from listQueueKeys (per-queue rule), not listNamespaceKeys', async () => { + await outputToPromise( + stackFull.construct.appConnectionStrings + .find((cs: { name: string }) => cs.name === 'EVENT_INGEST_SERVICE_BUS')! + .value.apply((value: string) => { + // mock returns 'mock-servicebus-queue-connection-string' for listQueueKeys + // and 'mock-servicebus-connection-string' for listNamespaceKeys; assert the queue one + expect(value).toEqual('mock-servicebus-queue-connection-string') + }) + ) + }) +}) + +/* Combination 4 — invalid (queue.useExisting=true requires namespace.useExisting=true) */ + +describe('TestAzureEventHandler invalid useExisting combination', () => { + test('throws when queue.useExisting=true and namespace.useExisting=false', () => { + const invalidProps = { + ...testStackProps, + serviceBus: { + namespace: { useExisting: false, namespaceName: 'irrelevant' }, + queue: { useExisting: true, queueName: 'irrelevant' }, + }, + } as unknown as AzureEventHandlerProps & TestAzureStackProps + + expect(() => { + new TestEventHandlerConstruct(`${invalidProps.name}-invalid-combo`, invalidProps) + }).toThrow(/queue\.useExisting=true requires namespace\.useExisting=true/) + }) +}) + +/* Deprecated top-level alias regression — `serviceBus.useExisting=true` must set both flags */ + +describe('TestAzureEventHandlerUseExistingConstruct (deprecated alias regression)', () => { + test('deprecated top-level useExisting=true still triggers namespace AND queue lookup', async () => { + // top-level useExisting=true is set in event-handler-use-existing.json; the existing + // tests already cover behaviour. Here we explicitly assert the resolver treats it + // as setting both per-resource flags by verifying the lookup sentinels are returned. + await outputToPromise( + pulumi + .all([stackUseExisting.construct.serviceBus.namespace.id, stackUseExisting.construct.serviceBus.queue.id]) + .apply(([nsId, queueId]) => { + expect(nsId).toEqual('existing-namespace-id') + expect(queueId).toEqual('existing-queue-id') + }) + ) + }) + + test('deprecated top-level useExisting=true skips per-queue auth rule provisioning', () => { + expect(stackUseExisting.construct.serviceBus.queueAuthorizationRule).toBeUndefined() + }) +}) From 8d6dd3147d9b92b9728618d9c41fc70e25726666 Mon Sep 17 00:00:00 2001 From: Srinidhi Veeraraghavan Date: Tue, 9 Jun 2026 14:38:44 +0100 Subject: [PATCH 7/8] feat: adding enum for AZURE_SERVICE_BUS_DATA_OWNER role --- packages/azure/src/services/authorisation/constants.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/azure/src/services/authorisation/constants.ts b/packages/azure/src/services/authorisation/constants.ts index 3f36dce8..3cb08c90 100644 --- a/packages/azure/src/services/authorisation/constants.ts +++ b/packages/azure/src/services/authorisation/constants.ts @@ -18,4 +18,6 @@ export enum RoleDefinitionId { STORAGE_BLOB_DATA_CONTRIBUTOR = '/providers/Microsoft.Authorization/roleDefinitions/ba92f5b4-2d11-453d-a403-e96b0029c9fe', /** Read, write, and delete Azure Storage table data */ STORAGE_TABLE_DATA_CONTRIBUTOR = '/providers/Microsoft.Authorization/roleDefinitions/0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3', + /** Full data-plane + management access on Service Bus namespaces (create queues/topics, listKeys on auth rules, send/receive) */ + AZURE_SERVICE_BUS_DATA_OWNER = '/providers/Microsoft.Authorization/roleDefinitions/090c5cfd-751d-490a-894a-3ce6f1109419', } From a65174fdd76b9cce585e0bfc44e56d9498edd418 Mon Sep 17 00:00:00 2001 From: Srinidhi Veeraraghavan Date: Tue, 9 Jun 2026 14:40:39 +0100 Subject: [PATCH 8/8] feat: support existing Service Bus namespace with owned queue in event-handler --- .changeset/dirty-pillows-brush.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/dirty-pillows-brush.md diff --git a/.changeset/dirty-pillows-brush.md b/.changeset/dirty-pillows-brush.md new file mode 100644 index 00000000..b3f1b4ca --- /dev/null +++ b/.changeset/dirty-pillows-brush.md @@ -0,0 +1,6 @@ +--- +'@gradientedge/cdk-utils-azure': minor +'@gradientedge/cdk-utils': minor +--- + +feat: support existing Service Bus namespace with owned queue in event-handler