diff --git a/Directory.Packages.props b/Directory.Packages.props
index a3b5f9ab58b..f361a6fc491 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -49,7 +49,7 @@
-
+
@@ -180,7 +180,7 @@
-
+
diff --git a/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs b/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs
index da27cf443dd..15a086038e9 100644
--- a/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs
+++ b/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs
@@ -75,6 +75,8 @@ public static IResourceBuilder AddAzureOpenAI(this IDistrib
// We need to output name to externalize role assignments.
infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = cogServicesAccount.Name.ToBicepExpression() });
+ infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = cogServicesAccount.Id.ToBicepExpression() });
+
var resource = (AzureOpenAIResource)infrastructure.AspireResource;
CognitiveServicesAccountDeployment? dependency = null;
diff --git a/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs b/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs
index 733c6feda16..a129ae4d9ef 100644
--- a/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs
+++ b/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs
@@ -1,5 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
using Aspire.Hosting.Azure;
using Azure.Provisioning.CognitiveServices;
using Azure.Provisioning.Primitives;
@@ -13,7 +16,7 @@ namespace Aspire.Hosting.ApplicationModel;
/// Configures the underlying Azure resource using Azure.Provisioning.
public class AzureOpenAIResource(string name, Action configureInfrastructure)
: AzureProvisioningResource(name, configureInfrastructure),
- IResourceWithConnectionString
+ IResourceWithConnectionString, IAzureNspAssociationTarget
{
[Obsolete("Use AzureOpenAIDeploymentResource instead.")]
private readonly List _deployments = [];
@@ -34,6 +37,11 @@ public class AzureOpenAIResource(string name, Action
public BicepOutputReference NameOutputReference => new("name", this);
+ ///
+ /// Gets the "id" output reference for the resource.
+ ///
+ public BicepOutputReference Id => new("id", this);
+
///
/// Gets the connection URI expression for the Azure OpenAI endpoint.
///
diff --git a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs
index 0ccf61bc58b..0968b8a88ec 100644
--- a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs
+++ b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs
@@ -21,7 +21,8 @@ public class AzureCosmosDBResource(string name, Action Databases { get; } = [];
diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs
index 717f7818fbc..f04ad528171 100644
--- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs
+++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs
@@ -15,7 +15,7 @@ namespace Aspire.Hosting.Azure;
/// The name of the resource.
/// Callback to configure the Azure Event Hubs resource.
public class AzureEventHubsResource(string name, Action configureInfrastructure)
- : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IResourceWithEndpoints, IResourceWithAzureFunctionsConfig, IAzurePrivateEndpointTarget
+ : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IResourceWithEndpoints, IResourceWithAzureFunctionsConfig, IAzurePrivateEndpointTarget, IAzureNspAssociationTarget
{
private static readonly string[] s_eventHubClientNames =
[
diff --git a/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.cs b/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.cs
index e1371df42f2..5b3024da6ce 100644
--- a/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.cs
+++ b/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.cs
@@ -15,7 +15,7 @@ namespace Aspire.Hosting.Azure;
/// The name of the resource.
/// Callback to configure the Azure resources.
public class AzureKeyVaultResource(string name, Action configureInfrastructure)
- : AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithConnectionString, IAzureKeyVaultResource, IAzurePrivateEndpointTarget
+ : AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithConnectionString, IAzureKeyVaultResource, IAzurePrivateEndpointTarget, IAzureNspAssociationTarget
{
///
/// The secrets for this Key Vault.
diff --git a/src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityPerimeterExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityPerimeterExtensions.cs
new file mode 100644
index 00000000000..93c33f58f22
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityPerimeterExtensions.cs
@@ -0,0 +1,257 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Azure;
+using Azure.Core;
+using Azure.Provisioning;
+using Azure.Provisioning.Network;
+using Azure.Provisioning.Resources;
+
+namespace Aspire.Hosting;
+
+///
+/// Provides extension methods for adding Azure Network Security Perimeter resources to the application model.
+///
+public static class AzureNetworkSecurityPerimeterExtensions
+{
+ ///
+ /// Adds an Azure Network Security Perimeter to the application model.
+ ///
+ /// The builder for the distributed application.
+ /// The name of the Network Security Perimeter resource.
+ /// A reference to the .
+ ///
+ /// This example adds a Network Security Perimeter and associates a storage resource:
+ ///
+ /// var nsp = builder.AddNetworkSecurityPerimeter("my-nsp");
+ /// var storage = builder.AddAzureStorage("storage");
+ /// storage.WithNetworkSecurityPerimeter(nsp);
+ ///
+ ///
+ [AspireExport(Description = "Adds an Azure Network Security Perimeter resource to the application model.")]
+ public static IResourceBuilder AddNetworkSecurityPerimeter(
+ this IDistributedApplicationBuilder builder,
+ [ResourceName] string name)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ builder.AddAzureProvisioning();
+
+ var resource = new AzureNetworkSecurityPerimeterResource(name, ConfigureNetworkSecurityPerimeter);
+
+ if (builder.ExecutionContext.IsRunMode)
+ {
+ return builder.CreateResourceBuilder(resource);
+ }
+
+ return builder.AddResource(resource);
+ }
+
+ ///
+ /// Adds an access rule to the Network Security Perimeter.
+ ///
+ /// The Network Security Perimeter resource builder.
+ /// The access rule configuration.
+ /// A reference to the for chaining.
+ ///
+ /// This example adds inbound and outbound access rules:
+ ///
+ /// var nsp = builder.AddNetworkSecurityPerimeter("my-nsp")
+ /// .WithAccessRule(new AzureNspAccessRule
+ /// {
+ /// Name = "allow-my-ip",
+ /// Direction = NetworkSecurityPerimeterAccessRuleDirection.Inbound,
+ /// AddressPrefixes = { "203.0.113.0/24" }
+ /// })
+ /// .WithAccessRule(new AzureNspAccessRule
+ /// {
+ /// Name = "allow-outbound-fqdn",
+ /// Direction = NetworkSecurityPerimeterAccessRuleDirection.Outbound,
+ /// FullyQualifiedDomainNames = { "*.blob.core.windows.net" }
+ /// });
+ ///
+ ///
+ [AspireExport(Description = "Adds an access rule to an Azure Network Security Perimeter resource.")]
+ public static IResourceBuilder WithAccessRule(
+ this IResourceBuilder builder,
+ AzureNspAccessRule rule)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(rule);
+ ArgumentException.ThrowIfNullOrEmpty(rule.Name);
+
+ if (builder.Resource.AccessRules.Any(existing => string.Equals(existing.Name, rule.Name, StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new ArgumentException(
+ $"An access rule named '{rule.Name}' already exists in Network Security Perimeter '{builder.Resource.Name}'.",
+ nameof(rule));
+ }
+
+ builder.Resource.AccessRules.Add(rule);
+ return builder;
+ }
+
+ ///
+ /// Associates an Azure PaaS resource with a Network Security Perimeter.
+ ///
+ /// The target PaaS resource builder to associate.
+ /// The Network Security Perimeter to associate with.
+ ///
+ /// The access mode for the association. Defaults to .
+ /// Use to log violations without blocking traffic.
+ ///
+ ///
+ /// An optional name for the association. If not provided, defaults to "{resourceName}-assoc".
+ ///
+ /// A reference to the target resource builder for chaining.
+ ///
+ ///
+ /// In mode, resources within the
+ /// perimeter can communicate with each other, but public access is restricted to the rules defined
+ /// in the perimeter profile.
+ ///
+ ///
+ /// In mode, traffic that would
+ /// be blocked by the perimeter rules is logged but not denied. This is useful when onboarding
+ /// resources to identify required access rules before switching to enforced mode.
+ ///
+ ///
+ ///
+ /// This example associates storage and key vault resources with an NSP:
+ ///
+ /// var nsp = builder.AddNetworkSecurityPerimeter("my-nsp");
+ /// var storage = builder.AddAzureStorage("storage");
+ /// var keyVault = builder.AddAzureKeyVault("kv");
+ ///
+ /// storage.WithNetworkSecurityPerimeter(nsp);
+ /// keyVault.WithNetworkSecurityPerimeter(nsp, NetworkSecurityPerimeterAssociationAccessMode.Learning);
+ ///
+ ///
+ [AspireExport("associateWithNetworkSecurityPerimeter", Description = "Associates an Azure PaaS resource with a Network Security Perimeter.")]
+ public static IResourceBuilder WithNetworkSecurityPerimeter(
+ this IResourceBuilder target,
+ IResourceBuilder nsp,
+ NetworkSecurityPerimeterAssociationAccessMode accessMode = NetworkSecurityPerimeterAssociationAccessMode.Enforced,
+ string? associationName = null) where T : IResource, IAzureNspAssociationTarget
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(nsp);
+
+ associationName ??= $"{target.Resource.Name}-assoc";
+
+ if (nsp.Resource.Associations.Any(a => string.Equals(a.Name, associationName, StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new ArgumentException(
+ $"An association named '{associationName}' already exists in Network Security Perimeter '{nsp.Resource.Name}'.",
+ nameof(associationName));
+ }
+
+ nsp.Resource.Associations.Add(new AzureNetworkSecurityPerimeterResource.NspAssociationConfig(
+ associationName,
+ target.Resource.Id,
+ accessMode));
+
+ return target;
+ }
+
+ private static void ConfigureNetworkSecurityPerimeter(AzureResourceInfrastructure infra)
+ {
+ var azureResource = (AzureNetworkSecurityPerimeterResource)infra.AspireResource;
+
+ var nsp = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra,
+ (identifier, name) =>
+ {
+ var resource = NetworkSecurityPerimeter.FromExisting(identifier);
+ resource.Name = name;
+ return resource;
+ },
+ (infrastructure) =>
+ {
+ return new NetworkSecurityPerimeter(infrastructure.AspireResource.GetBicepIdentifier())
+ {
+ Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } }
+ };
+ });
+
+ // Create a default profile
+ var profileIdentifier = Infrastructure.NormalizeBicepIdentifier($"{nsp.BicepIdentifier}_profile");
+ var profile = new NetworkSecurityPerimeterProfile(profileIdentifier)
+ {
+ Name = "defaultProfile",
+ Parent = nsp,
+ };
+ infra.Add(profile);
+
+ // Add access rules to the profile
+ foreach (var rule in azureResource.AccessRules)
+ {
+ var ruleIdentifier = Infrastructure.NormalizeBicepIdentifier($"{profileIdentifier}_{rule.Name}");
+ var accessRule = new NetworkSecurityPerimeterAccessRule(ruleIdentifier)
+ {
+ Name = rule.Name,
+ Direction = rule.Direction,
+ Parent = profile,
+ };
+
+ foreach (var prefix in rule.AddressPrefixes)
+ {
+ accessRule.AddressPrefixes.Add(prefix);
+ }
+
+ foreach (var prefixReference in rule.AddressPrefixReferences)
+ {
+ accessRule.AddressPrefixes.Add(prefixReference.AsProvisioningParameter(infra));
+ }
+
+ foreach (var sub in rule.Subscriptions)
+ {
+ accessRule.Subscriptions.Add(new WritableSubResource { Id = new ResourceIdentifier(sub) });
+ }
+
+ foreach (var subReference in rule.SubscriptionReferences)
+ {
+ accessRule.Subscriptions.Add(new WritableSubResource { Id = subReference.AsProvisioningParameter(infra) });
+ }
+
+ foreach (var fqdn in rule.FullyQualifiedDomainNames)
+ {
+ accessRule.FullyQualifiedDomainNames.Add(fqdn);
+ }
+
+ foreach (var fqdnReference in rule.FullyQualifiedDomainNameReferences)
+ {
+ accessRule.FullyQualifiedDomainNames.Add(fqdnReference.AsProvisioningParameter(infra));
+ }
+
+ infra.Add(accessRule);
+ }
+
+ // Add resource associations
+ foreach (var association in azureResource.Associations)
+ {
+ var assocIdentifier = Infrastructure.NormalizeBicepIdentifier($"{nsp.BicepIdentifier}_{association.Name}");
+ var nspAssociation = new NetworkSecurityPerimeterAssociation(assocIdentifier)
+ {
+ Name = association.Name,
+ Parent = nsp,
+ AccessMode = association.AccessMode,
+ PrivateLinkResourceId = association.TargetResourceId.AsProvisioningParameter(infra),
+ ProfileId = profile.Id,
+ };
+
+ infra.Add(nspAssociation);
+ }
+
+ infra.Add(new ProvisioningOutput("id", typeof(string))
+ {
+ Value = nsp.Id
+ });
+
+ infra.Add(new ProvisioningOutput("name", typeof(string))
+ {
+ Value = nsp.Name
+ });
+ }
+}
diff --git a/src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityPerimeterResource.cs b/src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityPerimeterResource.cs
new file mode 100644
index 00000000000..b88b749ac57
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityPerimeterResource.cs
@@ -0,0 +1,70 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Azure.Provisioning.Network;
+using Azure.Provisioning.Primitives;
+
+namespace Aspire.Hosting.Azure;
+
+///
+/// Represents an Azure Network Security Perimeter resource.
+///
+///
+///
+/// A Network Security Perimeter groups PaaS resources (such as Storage, Key Vault, Cosmos DB, and SQL)
+/// into a logical security boundary. Resources within the perimeter can communicate with each other,
+/// while public access is controlled by the perimeter's access mode and rules.
+///
+///
+/// Use
+/// to configure specific properties.
+///
+///
+/// The name of the resource.
+/// Callback to configure the Azure Network Security Perimeter resource.
+public class AzureNetworkSecurityPerimeterResource(string name, Action configureInfrastructure)
+ : AzureProvisioningResource(name, configureInfrastructure)
+{
+ ///
+ /// Gets the "id" output reference from the Azure Network Security Perimeter resource.
+ ///
+ public BicepOutputReference Id => new("id", this);
+
+ ///
+ /// Gets the "name" output reference for the resource.
+ ///
+ public BicepOutputReference NameOutputReference => new("name", this);
+
+ internal List AccessRules { get; } = [];
+
+ internal List Associations { get; } = [];
+
+ ///
+ public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra)
+ {
+ var bicepIdentifier = this.GetBicepIdentifier();
+ var resources = infra.GetProvisionableResources();
+
+ var existing = resources.OfType().SingleOrDefault(r => r.BicepIdentifier == bicepIdentifier);
+
+ if (existing is not null)
+ {
+ return existing;
+ }
+
+ var nsp = NetworkSecurityPerimeter.FromExisting(bicepIdentifier);
+
+ if (!TryApplyExistingResourceAnnotation(this, infra, nsp))
+ {
+ nsp.Name = NameOutputReference.AsProvisioningParameter(infra);
+ }
+
+ infra.Add(nsp);
+ return nsp;
+ }
+
+ internal sealed record NspAssociationConfig(
+ string Name,
+ BicepOutputReference TargetResourceId,
+ NetworkSecurityPerimeterAssociationAccessMode AccessMode);
+}
diff --git a/src/Aspire.Hosting.Azure.Network/AzureNspAccessRule.cs b/src/Aspire.Hosting.Azure.Network/AzureNspAccessRule.cs
new file mode 100644
index 00000000000..7f30187e2f4
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.Network/AzureNspAccessRule.cs
@@ -0,0 +1,82 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Azure.Provisioning.Network;
+
+namespace Aspire.Hosting.Azure;
+
+///
+/// Represents an access rule configuration for an Azure Network Security Perimeter.
+///
+///
+/// Access rules control how traffic flows into and out of the network security perimeter.
+/// Inbound rules specify which external sources (IP ranges or subscriptions) can access
+/// resources within the perimeter. Outbound rules specify which external destinations (FQDNs)
+/// resources within the perimeter can communicate with.
+///
+[AspireDto]
+public sealed class AzureNspAccessRule
+{
+ ///
+ /// Gets or sets the name of the access rule. This name must be unique within the perimeter profile.
+ ///
+ public required string Name { get; set; }
+
+ ///
+ /// Gets or sets the direction of the rule.
+ ///
+ public required NetworkSecurityPerimeterAccessRuleDirection Direction { get; set; }
+
+ ///
+ /// Gets the list of inbound address prefixes (CIDR ranges) allowed by this rule.
+ ///
+ ///
+ /// Only applicable for rules.
+ ///
+ public List AddressPrefixes { get; } = [];
+
+ ///
+ /// Gets the list of inbound address prefixes (CIDR ranges) values allowed by this rule.
+ ///
+ ///
+ /// Only applicable for rules.
+ /// Values are resolved at deploy time and combined with .
+ ///
+ public List AddressPrefixReferences { get; } = [];
+
+ ///
+ /// Gets the list of subscription IDs allowed by this rule.
+ ///
+ ///
+ /// Only applicable for rules.
+ /// Subscription IDs should be in the format of a resource ID: /subscriptions/{subscriptionId}.
+ ///
+ public List Subscriptions { get; } = [];
+
+ ///
+ /// Gets the subscription resource ID values allowed by this rule.
+ ///
+ ///
+ /// Only applicable for rules.
+ /// Values are resolved at deploy time and combined with .
+ ///
+ public List SubscriptionReferences { get; } = [];
+
+ ///
+ /// Gets the list of fully qualified domain names (FQDNs) allowed by this rule.
+ ///
+ ///
+ /// Only applicable for rules.
+ ///
+ public List FullyQualifiedDomainNames { get; } = [];
+
+ ///
+ /// Gets the fully qualified domain name values allowed by this rule.
+ ///
+ ///
+ /// Only applicable for rules.
+ /// Values are resolved at deploy time and combined with .
+ ///
+ public List FullyQualifiedDomainNameReferences { get; } = [];
+}
diff --git a/src/Aspire.Hosting.Azure.Network/README.md b/src/Aspire.Hosting.Azure.Network/README.md
index 6dbe2aa538b..8051b420701 100644
--- a/src/Aspire.Hosting.Azure.Network/README.md
+++ b/src/Aspire.Hosting.Azure.Network/README.md
@@ -1,6 +1,6 @@
# Aspire.Hosting.Azure.Network library
-Provides extension methods and resource definitions for an Aspire AppHost to configure Azure Virtual Networks, Subnets, NAT Gateways, Public IP Addresses, Network Security Groups, and Private Endpoints.
+Provides extension methods and resource definitions for an Aspire AppHost to configure Azure Virtual Networks, Subnets, NAT Gateways, Public IP Addresses, Network Security Groups, Network Security Perimeters, and Private Endpoints.
## Getting started
@@ -116,6 +116,33 @@ var subnet = vnet.AddSubnet("web-subnet", "10.0.1.0/24")
A single NSG can be shared across multiple subnets.
+### Adding Network Security Perimeters
+
+A Network Security Perimeter (NSP) groups PaaS resources into a logical security boundary. Resources within the perimeter can communicate with each other, while public access is restricted by access rules:
+
+```csharp
+var nsp = builder.AddNetworkSecurityPerimeter("my-nsp")
+ .WithAccessRule(new AzureNspAccessRule
+ {
+ Name = "allow-my-ip",
+ Direction = NetworkSecurityPerimeterAccessRuleDirection.Inbound,
+ AddressPrefixes = { "203.0.113.0/24" }
+ });
+
+var storage = builder.AddAzureStorage("storage");
+var keyVault = builder.AddAzureKeyVault("kv");
+
+storage.WithNetworkSecurityPerimeter(nsp);
+keyVault.WithNetworkSecurityPerimeter(nsp);
+```
+
+Associations use `Enforced` access mode by default, which blocks non-compliant public traffic. Use `Learning` mode to log violations without blocking, which is useful when onboarding resources to identify required access rules:
+
+```csharp
+// Learning mode — logs violations without blocking traffic
+storage.WithNetworkSecurityPerimeter(nsp, NetworkSecurityPerimeterAssociationAccessMode.Learning);
+```
+
### Adding Private Endpoints
Create a private endpoint to securely connect to Azure resources over a private network:
@@ -155,6 +182,7 @@ storage.ConfigureInfrastructure(infra =>
* https://learn.microsoft.com/azure/virtual-network/
* https://learn.microsoft.com/azure/nat-gateway/
* https://learn.microsoft.com/azure/private-link/
+* https://learn.microsoft.com/azure/private-link/network-security-perimeter-concepts
## Feedback & contributing
diff --git a/src/Aspire.Hosting.Azure.OperationalInsights/AzureLogAnalyticsWorkspaceResource.cs b/src/Aspire.Hosting.Azure.OperationalInsights/AzureLogAnalyticsWorkspaceResource.cs
index a01986e278d..cbbf00df5cb 100644
--- a/src/Aspire.Hosting.Azure.OperationalInsights/AzureLogAnalyticsWorkspaceResource.cs
+++ b/src/Aspire.Hosting.Azure.OperationalInsights/AzureLogAnalyticsWorkspaceResource.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
using Azure.Provisioning.OperationalInsights;
using Azure.Provisioning.Primitives;
@@ -12,13 +14,18 @@ namespace Aspire.Hosting.Azure;
/// The resource name.
/// Callback to configure the Azure Log Analytics Workspace resource.
public class AzureLogAnalyticsWorkspaceResource(string name, Action configureInfrastructure)
- : AzureProvisioningResource(name, configureInfrastructure)
+ : AzureProvisioningResource(name, configureInfrastructure), IAzureNspAssociationTarget
{
///
/// Gets the "name" output reference for the resource.
///
public BicepOutputReference NameOutputReference => new("name", this);
+ ///
+ /// The identifier associated with the Azure Log Analytics Workspace resource.
+ ///
+ public BicepOutputReference Id => new("logAnalyticsWorkspaceId", this);
+
///
/// Gets the "logAnalyticsWorkspaceId" output reference for the Azure Log Analytics Workspace resource.
///
diff --git a/src/Aspire.Hosting.Azure.Search/AzureSearchResource.cs b/src/Aspire.Hosting.Azure.Search/AzureSearchResource.cs
index 01c1d91615c..00b727e1556 100644
--- a/src/Aspire.Hosting.Azure.Search/AzureSearchResource.cs
+++ b/src/Aspire.Hosting.Azure.Search/AzureSearchResource.cs
@@ -15,7 +15,7 @@ namespace Aspire.Hosting.Azure;
/// The name of the resource
/// Callback to configure the Azure AI Search resource.
public class AzureSearchResource(string name, Action configureInfrastructure)
- : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IAzurePrivateEndpointTarget
+ : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IAzurePrivateEndpointTarget, IAzureNspAssociationTarget
{
///
/// Gets the "connectionString" output reference from the Azure AI Search resource.
diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusResource.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusResource.cs
index b7549ecca2c..b7464da2e7a 100644
--- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusResource.cs
+++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusResource.cs
@@ -15,7 +15,7 @@ namespace Aspire.Hosting.Azure;
/// The name of the resource.
/// Callback to configure the Azure Service Bus resource.
public class AzureServiceBusResource(string name, Action configureInfrastructure)
- : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IResourceWithAzureFunctionsConfig, IResourceWithEndpoints, IAzurePrivateEndpointTarget
+ : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IResourceWithAzureFunctionsConfig, IResourceWithEndpoints, IAzurePrivateEndpointTarget, IAzureNspAssociationTarget
{
internal List Queues { get; } = [];
internal List Topics { get; } = [];
diff --git a/src/Aspire.Hosting.Azure.Sql/AzureSqlServerResource.cs b/src/Aspire.Hosting.Azure.Sql/AzureSqlServerResource.cs
index 5c01b66d442..7148a893d7c 100644
--- a/src/Aspire.Hosting.Azure.Sql/AzureSqlServerResource.cs
+++ b/src/Aspire.Hosting.Azure.Sql/AzureSqlServerResource.cs
@@ -27,7 +27,7 @@ namespace Aspire.Hosting.Azure;
/// Represents an Azure Sql Server resource.
///
[AspireExport(ExposeProperties = true)]
-public class AzureSqlServerResource : AzureProvisioningResource, IResourceWithConnectionString, IAzurePrivateEndpointTarget, IAzurePrivateEndpointTargetNotification
+public class AzureSqlServerResource : AzureProvisioningResource, IResourceWithConnectionString, IAzurePrivateEndpointTarget, IAzurePrivateEndpointTargetNotification, IAzureNspAssociationTarget
{
private const string AciSubnetDelegationServiceId = "Microsoft.ContainerInstance/containerGroups";
diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs
index f8b7c3901c1..78f029ec0a5 100644
--- a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs
+++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
using Aspire.Hosting.ApplicationModel;
using Azure.Provisioning.Primitives;
using Azure.Provisioning.Storage;
@@ -13,7 +15,7 @@ namespace Aspire.Hosting.Azure;
/// The name of the resource.
/// Callback to configure the Azure resources.
public class AzureStorageResource(string name, Action configureInfrastructure)
- : AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithAzureFunctionsConfig
+ : AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithAzureFunctionsConfig, IAzureNspAssociationTarget
{
internal const string BlobsConnectionKeyPrefix = "Aspire__Azure__Storage__Blobs";
internal const string QueuesConnectionKeyPrefix = "Aspire__Azure__Storage__Queues";
diff --git a/src/Aspire.Hosting.Azure/IAzureNspAssociationTarget.cs b/src/Aspire.Hosting.Azure/IAzureNspAssociationTarget.cs
new file mode 100644
index 00000000000..059501d5750
--- /dev/null
+++ b/src/Aspire.Hosting.Azure/IAzureNspAssociationTarget.cs
@@ -0,0 +1,24 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Azure;
+
+///
+/// Represents an Azure resource that can be associated with a Network Security Perimeter.
+///
+///
+/// Implement this interface on PaaS resources (such as Storage, Key Vault, Cosmos DB, SQL)
+/// that support Network Security Perimeter association via the
+/// Microsoft.Network/networkSecurityPerimeters/resourceAssociations resource type.
+///
+[Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")]
+public interface IAzureNspAssociationTarget : IResource
+{
+ ///
+ /// Gets the "id" output reference from the Azure resource.
+ ///
+ BicepOutputReference Id { get; }
+}
diff --git a/src/Aspire.Hosting.Foundry/FoundryExtensions.cs b/src/Aspire.Hosting.Foundry/FoundryExtensions.cs
index 1ec8ecdca5a..5adbef6d418 100644
--- a/src/Aspire.Hosting.Foundry/FoundryExtensions.cs
+++ b/src/Aspire.Hosting.Foundry/FoundryExtensions.cs
@@ -452,6 +452,8 @@ private static void ConfigureInfrastructure(AzureResourceInfrastructure infrastr
infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = cogServicesAccount.Name.ToBicepExpression() });
+ infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = cogServicesAccount.Id.ToBicepExpression() });
+
var resource = (FoundryResource)infrastructure.AspireResource;
if (resource.CapabilityHost != null)
diff --git a/src/Aspire.Hosting.Foundry/FoundryResource.cs b/src/Aspire.Hosting.Foundry/FoundryResource.cs
index a3d85f5e5b6..8aa6ff82877 100644
--- a/src/Aspire.Hosting.Foundry/FoundryResource.cs
+++ b/src/Aspire.Hosting.Foundry/FoundryResource.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Azure.Provisioning.CognitiveServices;
@@ -14,7 +16,7 @@ namespace Aspire.Hosting.Foundry;
/// The name of the resource.
/// Configures the underlying Azure resource using Azure.Provisioning.
public class FoundryResource(string name, Action configureInfrastructure) :
- AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithConnectionString
+ AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithConnectionString, IAzureNspAssociationTarget
{
internal Uri? EmulatorServiceUri { get; set; }
@@ -35,6 +37,11 @@ public class FoundryResource(string name, Action co
///
public BicepOutputReference NameOutputReference => new("name", this);
+ ///
+ /// Gets the "id" output reference for the resource.
+ ///
+ public BicepOutputReference Id => new("id", this);
+
///
/// Gets the connection URI expression for the Microsoft Foundry endpoint.
///
diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureLogAnalyticsWorkspaceExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureLogAnalyticsWorkspaceExtensionsTests.cs
index 618a618aed3..2d1a7943048 100644
--- a/tests/Aspire.Hosting.Azure.Tests/AzureLogAnalyticsWorkspaceExtensionsTests.cs
+++ b/tests/Aspire.Hosting.Azure.Tests/AzureLogAnalyticsWorkspaceExtensionsTests.cs
@@ -15,7 +15,7 @@ public async Task AddLogAnalyticsWorkspace()
var logAnalyticsWorkspace = builder.AddAzureLogAnalyticsWorkspace("logAnalyticsWorkspace");
Assert.Equal("logAnalyticsWorkspace", logAnalyticsWorkspace.Resource.Name);
- Assert.Equal("{logAnalyticsWorkspace.outputs.logAnalyticsWorkspaceId}", logAnalyticsWorkspace.Resource.WorkspaceId.ValueExpression);
+ Assert.Equal("{logAnalyticsWorkspace.outputs.logAnalyticsWorkspaceId}", logAnalyticsWorkspace.Resource.Id.ValueExpression);
var appInsightsManifest = await AzureManifestUtils.GetManifestWithBicep(logAnalyticsWorkspace.Resource);
var expectedManifest = """
diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityPerimeterExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityPerimeterExtensionsTests.cs
new file mode 100644
index 00000000000..0ab6d6d987e
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityPerimeterExtensionsTests.cs
@@ -0,0 +1,205 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+#pragma warning disable AZPROVISION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Utils;
+using Azure.Provisioning.Network;
+
+namespace Aspire.Hosting.Azure.Tests;
+
+public class AzureNetworkSecurityPerimeterExtensionsTests
+{
+ [Fact]
+ public void AddNetworkSecurityPerimeter_CreatesResource()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var nsp = builder.AddNetworkSecurityPerimeter("my-nsp");
+
+ Assert.NotNull(nsp);
+ Assert.Equal("my-nsp", nsp.Resource.Name);
+ Assert.IsType(nsp.Resource);
+ }
+
+ [Fact]
+ public void AddNetworkSecurityPerimeter_InRunMode_DoesNotAddToBuilder()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
+
+ var nsp = builder.AddNetworkSecurityPerimeter("my-nsp");
+
+ Assert.DoesNotContain(nsp.Resource, builder.Resources);
+ }
+
+ [Fact]
+ public async Task AddNetworkSecurityPerimeter_GeneratesCorrectBicep()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var nsp = builder.AddNetworkSecurityPerimeter("my-nsp");
+
+ var manifest = await AzureManifestUtils.GetManifestWithBicep(nsp.Resource);
+
+ await Verify(manifest.BicepText, extension: "bicep");
+ }
+
+ [Fact]
+ public async Task AddNetworkSecurityPerimeter_WithAccessRules_GeneratesCorrectBicep()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var nsp = builder.AddNetworkSecurityPerimeter("my-nsp")
+ .WithAccessRule(new AzureNspAccessRule
+ {
+ Name = "allow-my-ip",
+ Direction = NetworkSecurityPerimeterAccessRuleDirection.Inbound,
+ AddressPrefixes = { "203.0.113.0/24" }
+ })
+ .WithAccessRule(new AzureNspAccessRule
+ {
+ Name = "allow-outbound-fqdn",
+ Direction = NetworkSecurityPerimeterAccessRuleDirection.Outbound,
+ FullyQualifiedDomainNames = { "*.blob.core.windows.net" }
+ });
+
+ var manifest = await AzureManifestUtils.GetManifestWithBicep(nsp.Resource);
+
+ await Verify(manifest.BicepText, extension: "bicep");
+ }
+
+ [Fact]
+ public async Task AddNetworkSecurityPerimeter_WithSubscriptionRule_GeneratesCorrectBicep()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var nsp = builder.AddNetworkSecurityPerimeter("my-nsp")
+ .WithAccessRule(new AzureNspAccessRule
+ {
+ Name = "allow-subscription",
+ Direction = NetworkSecurityPerimeterAccessRuleDirection.Inbound,
+ Subscriptions = { "/subscriptions/00000000-0000-0000-0000-000000000001" }
+ });
+
+ var manifest = await AzureManifestUtils.GetManifestWithBicep(nsp.Resource);
+
+ await Verify(manifest.BicepText, extension: "bicep");
+ }
+
+ [Fact]
+ public async Task AddNetworkSecurityPerimeter_WithParameterBasedAccessRules_GeneratesCorrectBicep()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var inboundAddressPrefix = builder.AddParameter("inboundAddressPrefix");
+ var allowedSubscription = builder.AddParameter("allowedSubscription");
+ var outboundFqdn = builder.AddParameter("outboundFqdn");
+
+ var nsp = builder.AddNetworkSecurityPerimeter("my-nsp")
+ .WithAccessRule(new AzureNspAccessRule
+ {
+ Name = "allow-my-ip",
+ Direction = NetworkSecurityPerimeterAccessRuleDirection.Inbound,
+ AddressPrefixes = { "203.0.113.0/24" },
+ AddressPrefixReferences = { ReferenceExpression.Create($"{inboundAddressPrefix}") }
+ })
+ .WithAccessRule(new AzureNspAccessRule
+ {
+ Name = "allow-subscription",
+ Direction = NetworkSecurityPerimeterAccessRuleDirection.Inbound,
+ Subscriptions = { "/subscriptions/00000000-0000-0000-0000-000000000001" },
+ SubscriptionReferences = { ReferenceExpression.Create($"{allowedSubscription}") }
+ })
+ .WithAccessRule(new AzureNspAccessRule
+ {
+ Name = "allow-outbound-fqdn",
+ Direction = NetworkSecurityPerimeterAccessRuleDirection.Outbound,
+ FullyQualifiedDomainNames = { "*.blob.core.windows.net" },
+ FullyQualifiedDomainNameReferences = { ReferenceExpression.Create($"{outboundFqdn}") }
+ });
+
+ var manifest = await AzureManifestUtils.GetManifestWithBicep(nsp.Resource);
+
+ await Verify(manifest.BicepText, extension: "bicep");
+ }
+
+ [Fact]
+ public async Task AddNetworkSecurityPerimeter_WithStorageAssociation_GeneratesCorrectBicep()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var nsp = builder.AddNetworkSecurityPerimeter("my-nsp");
+ var storage = builder.AddAzureStorage("storage");
+
+ storage.WithNetworkSecurityPerimeter(nsp);
+
+ var manifest = await AzureManifestUtils.GetManifestWithBicep(nsp.Resource);
+
+ await Verify(manifest.BicepText, extension: "bicep");
+ }
+
+ [Fact]
+ public async Task AddNetworkSecurityPerimeter_WithMultipleAssociations_GeneratesCorrectBicep()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var nsp = builder.AddNetworkSecurityPerimeter("my-nsp")
+ .WithAccessRule(new AzureNspAccessRule
+ {
+ Name = "allow-my-ip",
+ Direction = NetworkSecurityPerimeterAccessRuleDirection.Inbound,
+ AddressPrefixes = { "203.0.113.0/24" }
+ });
+
+ var storage = builder.AddAzureStorage("storage");
+ var keyVault = builder.AddAzureKeyVault("kv");
+
+ storage.WithNetworkSecurityPerimeter(nsp);
+ keyVault.WithNetworkSecurityPerimeter(nsp, NetworkSecurityPerimeterAssociationAccessMode.Learning);
+
+ var manifest = await AzureManifestUtils.GetManifestWithBicep(nsp.Resource);
+
+ await Verify(manifest.BicepText, extension: "bicep");
+ }
+
+ [Fact]
+ public void WithAccessRule_DuplicateName_Throws()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var nsp = builder.AddNetworkSecurityPerimeter("my-nsp")
+ .WithAccessRule(new AzureNspAccessRule
+ {
+ Name = "allow-my-ip",
+ Direction = NetworkSecurityPerimeterAccessRuleDirection.Inbound,
+ AddressPrefixes = { "203.0.113.0/24" }
+ });
+
+ var exception = Assert.Throws(() => nsp.WithAccessRule(new AzureNspAccessRule
+ {
+ Name = "ALLOW-MY-IP",
+ Direction = NetworkSecurityPerimeterAccessRuleDirection.Inbound,
+ AddressPrefixes = { "10.0.0.0/8" }
+ }));
+
+ Assert.Contains("allow-my-ip", exception.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void AssociateWith_DuplicateAssociationName_Throws()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var nsp = builder.AddNetworkSecurityPerimeter("my-nsp");
+ var storage = builder.AddAzureStorage("storage");
+
+ storage.WithNetworkSecurityPerimeter(nsp);
+
+ var exception = Assert.Throws(() => storage.WithNetworkSecurityPerimeter(nsp));
+
+ Assert.Contains("storage-assoc", exception.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+}
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_GeneratesCorrectBicep.verified.bicep
new file mode 100644
index 00000000000..2dea4f39146
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_GeneratesCorrectBicep.verified.bicep
@@ -0,0 +1,19 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+resource my_nsp 'Microsoft.Network/networkSecurityPerimeters@2025-05-01' = {
+ name: take('mynsp${uniqueString(resourceGroup().id)}', 24)
+ location: location
+ tags: {
+ 'aspire-resource-name': 'my-nsp'
+ }
+}
+
+resource my_nsp_profile 'Microsoft.Network/networkSecurityPerimeters/profiles@2025-05-01' = {
+ name: 'defaultProfile'
+ parent: my_nsp
+}
+
+output id string = my_nsp.id
+
+output name string = my_nsp.name
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithAccessRules_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithAccessRules_GeneratesCorrectBicep.verified.bicep
new file mode 100644
index 00000000000..e262cd9f677
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithAccessRules_GeneratesCorrectBicep.verified.bicep
@@ -0,0 +1,41 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+resource my_nsp 'Microsoft.Network/networkSecurityPerimeters@2025-05-01' = {
+ name: take('mynsp${uniqueString(resourceGroup().id)}', 24)
+ location: location
+ tags: {
+ 'aspire-resource-name': 'my-nsp'
+ }
+}
+
+resource my_nsp_profile 'Microsoft.Network/networkSecurityPerimeters/profiles@2025-05-01' = {
+ name: 'defaultProfile'
+ parent: my_nsp
+}
+
+resource my_nsp_profile_allow_my_ip 'Microsoft.Network/networkSecurityPerimeters/profiles/accessRules@2025-05-01' = {
+ name: 'allow-my-ip'
+ properties: {
+ addressPrefixes: [
+ '203.0.113.0/24'
+ ]
+ direction: 'Inbound'
+ }
+ parent: my_nsp_profile
+}
+
+resource my_nsp_profile_allow_outbound_fqdn 'Microsoft.Network/networkSecurityPerimeters/profiles/accessRules@2025-05-01' = {
+ name: 'allow-outbound-fqdn'
+ properties: {
+ direction: 'Outbound'
+ fullyQualifiedDomainNames: [
+ '*.blob.core.windows.net'
+ ]
+ }
+ parent: my_nsp_profile
+}
+
+output id string = my_nsp.id
+
+output name string = my_nsp.name
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithMultipleAssociations_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithMultipleAssociations_GeneratesCorrectBicep.verified.bicep
new file mode 100644
index 00000000000..52da9697591
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithMultipleAssociations_GeneratesCorrectBicep.verified.bicep
@@ -0,0 +1,62 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+param storage_outputs_id string
+
+param kv_outputs_id string
+
+resource my_nsp 'Microsoft.Network/networkSecurityPerimeters@2025-05-01' = {
+ name: take('mynsp${uniqueString(resourceGroup().id)}', 24)
+ location: location
+ tags: {
+ 'aspire-resource-name': 'my-nsp'
+ }
+}
+
+resource my_nsp_profile 'Microsoft.Network/networkSecurityPerimeters/profiles@2025-05-01' = {
+ name: 'defaultProfile'
+ parent: my_nsp
+}
+
+resource my_nsp_profile_allow_my_ip 'Microsoft.Network/networkSecurityPerimeters/profiles/accessRules@2025-05-01' = {
+ name: 'allow-my-ip'
+ properties: {
+ addressPrefixes: [
+ '203.0.113.0/24'
+ ]
+ direction: 'Inbound'
+ }
+ parent: my_nsp_profile
+}
+
+resource my_nsp_storage_assoc 'Microsoft.Network/networkSecurityPerimeters/resourceAssociations@2025-05-01' = {
+ name: 'storage-assoc'
+ properties: {
+ accessMode: 'Enforced'
+ privateLinkResource: {
+ id: storage_outputs_id
+ }
+ profile: {
+ id: my_nsp_profile.id
+ }
+ }
+ parent: my_nsp
+}
+
+resource my_nsp_kv_assoc 'Microsoft.Network/networkSecurityPerimeters/resourceAssociations@2025-05-01' = {
+ name: 'kv-assoc'
+ properties: {
+ accessMode: 'Learning'
+ privateLinkResource: {
+ id: kv_outputs_id
+ }
+ profile: {
+ id: my_nsp_profile.id
+ }
+ }
+ parent: my_nsp
+}
+
+output id string = my_nsp.id
+
+output name string = my_nsp.name
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithParameterBasedAccessRules_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithParameterBasedAccessRules_GeneratesCorrectBicep.verified.bicep
new file mode 100644
index 00000000000..c2ddd41d20f
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithParameterBasedAccessRules_GeneratesCorrectBicep.verified.bicep
@@ -0,0 +1,65 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+param inboundaddressprefix_value string
+
+param allowedsubscription_value string
+
+param outboundfqdn_value string
+
+resource my_nsp 'Microsoft.Network/networkSecurityPerimeters@2025-05-01' = {
+ name: take('mynsp${uniqueString(resourceGroup().id)}', 24)
+ location: location
+ tags: {
+ 'aspire-resource-name': 'my-nsp'
+ }
+}
+
+resource my_nsp_profile 'Microsoft.Network/networkSecurityPerimeters/profiles@2025-05-01' = {
+ name: 'defaultProfile'
+ parent: my_nsp
+}
+
+resource my_nsp_profile_allow_my_ip 'Microsoft.Network/networkSecurityPerimeters/profiles/accessRules@2025-05-01' = {
+ name: 'allow-my-ip'
+ properties: {
+ addressPrefixes: [
+ '203.0.113.0/24'
+ inboundaddressprefix_value
+ ]
+ direction: 'Inbound'
+ }
+ parent: my_nsp_profile
+}
+
+resource my_nsp_profile_allow_subscription 'Microsoft.Network/networkSecurityPerimeters/profiles/accessRules@2025-05-01' = {
+ name: 'allow-subscription'
+ properties: {
+ direction: 'Inbound'
+ subscriptions: [
+ {
+ id: '/subscriptions/00000000-0000-0000-0000-000000000001'
+ }
+ {
+ id: allowedsubscription_value
+ }
+ ]
+ }
+ parent: my_nsp_profile
+}
+
+resource my_nsp_profile_allow_outbound_fqdn 'Microsoft.Network/networkSecurityPerimeters/profiles/accessRules@2025-05-01' = {
+ name: 'allow-outbound-fqdn'
+ properties: {
+ direction: 'Outbound'
+ fullyQualifiedDomainNames: [
+ '*.blob.core.windows.net'
+ outboundfqdn_value
+ ]
+ }
+ parent: my_nsp_profile
+}
+
+output id string = my_nsp.id
+
+output name string = my_nsp.name
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithStorageAssociation_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithStorageAssociation_GeneratesCorrectBicep.verified.bicep
new file mode 100644
index 00000000000..437382be81b
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithStorageAssociation_GeneratesCorrectBicep.verified.bicep
@@ -0,0 +1,35 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+param storage_outputs_id string
+
+resource my_nsp 'Microsoft.Network/networkSecurityPerimeters@2025-05-01' = {
+ name: take('mynsp${uniqueString(resourceGroup().id)}', 24)
+ location: location
+ tags: {
+ 'aspire-resource-name': 'my-nsp'
+ }
+}
+
+resource my_nsp_profile 'Microsoft.Network/networkSecurityPerimeters/profiles@2025-05-01' = {
+ name: 'defaultProfile'
+ parent: my_nsp
+}
+
+resource my_nsp_storage_assoc 'Microsoft.Network/networkSecurityPerimeters/resourceAssociations@2025-05-01' = {
+ name: 'storage-assoc'
+ properties: {
+ accessMode: 'Enforced'
+ privateLinkResource: {
+ id: storage_outputs_id
+ }
+ profile: {
+ id: my_nsp_profile.id
+ }
+ }
+ parent: my_nsp
+}
+
+output id string = my_nsp.id
+
+output name string = my_nsp.name
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithSubscriptionRule_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithSubscriptionRule_GeneratesCorrectBicep.verified.bicep
new file mode 100644
index 00000000000..09d646ba33d
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityPerimeterExtensionsTests.AddNetworkSecurityPerimeter_WithSubscriptionRule_GeneratesCorrectBicep.verified.bicep
@@ -0,0 +1,32 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+resource my_nsp 'Microsoft.Network/networkSecurityPerimeters@2025-05-01' = {
+ name: take('mynsp${uniqueString(resourceGroup().id)}', 24)
+ location: location
+ tags: {
+ 'aspire-resource-name': 'my-nsp'
+ }
+}
+
+resource my_nsp_profile 'Microsoft.Network/networkSecurityPerimeters/profiles@2025-05-01' = {
+ name: 'defaultProfile'
+ parent: my_nsp
+}
+
+resource my_nsp_profile_allow_subscription 'Microsoft.Network/networkSecurityPerimeters/profiles/accessRules@2025-05-01' = {
+ name: 'allow-subscription'
+ properties: {
+ direction: 'Inbound'
+ subscriptions: [
+ {
+ id: '/subscriptions/00000000-0000-0000-0000-000000000001'
+ }
+ ]
+ }
+ parent: my_nsp_profile
+}
+
+output id string = my_nsp.id
+
+output name string = my_nsp.name
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=False_useObsoleteApis=False.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=False_useObsoleteApis=False.verified.bicep
index 708188314a4..dc2213a27f2 100644
--- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=False_useObsoleteApis=False.verified.bicep
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=False_useObsoleteApis=False.verified.bicep
@@ -57,4 +57,6 @@ output connectionString string = 'Endpoint=${openai.properties.endpoint}'
output endpoint string = openai.properties.endpoint
-output name string = openai.name
\ No newline at end of file
+output name string = openai.name
+
+output id string = openai.id
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=False_useObsoleteApis=True.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=False_useObsoleteApis=True.verified.bicep
index 708188314a4..dc2213a27f2 100644
--- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=False_useObsoleteApis=True.verified.bicep
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=False_useObsoleteApis=True.verified.bicep
@@ -57,4 +57,6 @@ output connectionString string = 'Endpoint=${openai.properties.endpoint}'
output endpoint string = openai.properties.endpoint
-output name string = openai.name
\ No newline at end of file
+output name string = openai.name
+
+output id string = openai.id
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=True_useObsoleteApis=False.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=True_useObsoleteApis=False.verified.bicep
index 1fd922b65a0..0edd4fc7c02 100644
--- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=True_useObsoleteApis=False.verified.bicep
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=True_useObsoleteApis=False.verified.bicep
@@ -57,4 +57,6 @@ output connectionString string = 'Endpoint=${openai.properties.endpoint}'
output endpoint string = openai.properties.endpoint
-output name string = openai.name
\ No newline at end of file
+output name string = openai.name
+
+output id string = openai.id
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=True_useObsoleteApis=True.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=True_useObsoleteApis=True.verified.bicep
index 1fd922b65a0..0edd4fc7c02 100644
--- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=True_useObsoleteApis=True.verified.bicep
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureOpenAIExtensionsTests.AddAzureOpenAI_overrideLocalAuthDefault=True_useObsoleteApis=True.verified.bicep
@@ -57,4 +57,6 @@ output connectionString string = 'Endpoint=${openai.properties.endpoint}'
output endpoint string = openai.properties.endpoint
-output name string = openai.name
\ No newline at end of file
+output name string = openai.name
+
+output id string = openai.id
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureOpenAIWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureOpenAIWithResourceGroup.verified.bicep
index be02f6b588a..67b2abb9525 100644
--- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureOpenAIWithResourceGroup.verified.bicep
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureOpenAIWithResourceGroup.verified.bicep
@@ -27,4 +27,6 @@ output connectionString string = 'Endpoint=${openAI.properties.endpoint}'
output endpoint string = openAI.properties.endpoint
-output name string = openAI.name
\ No newline at end of file
+output name string = openAI.name
+
+output id string = openAI.id
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/FoundryExtensionsTests.AddFoundry_GeneratesValidBicep#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/FoundryExtensionsTests.AddFoundry_GeneratesValidBicep#00.verified.bicep
index 5eb1d61b16f..f059d2e9ea7 100644
--- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/FoundryExtensionsTests.AddFoundry_GeneratesValidBicep#00.verified.bicep
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/FoundryExtensionsTests.AddFoundry_GeneratesValidBicep#00.verified.bicep
@@ -89,4 +89,6 @@ output aiFoundryApiEndpoint string = foundry.properties.endpoints['AI Foundry AP
output endpoint string = foundry.properties.endpoint
-output name string = foundry.name
\ No newline at end of file
+output name string = foundry.name
+
+output id string = foundry.id
\ No newline at end of file