Skip to content

Commit 45d446d

Browse files
authored
Add Network Security Group (NSG) support for Azure Virtual Networks (#14383)
## Description Adds Network Security Group (NSG) support for Azure Virtual Networks, enabling fine-grained network traffic control for subnets. Includes both a **shorthand API** for the common case and an **explicit API** for full control. ### Shorthand API (recommended for most users) Fluent methods on subnet builders that auto-create an NSG, auto-increment priority, and auto-generate rule names: ```csharp var subnet = vnet.AddSubnet("web", "10.0.1.0/24") .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp) .DenyInbound(from: "VirtualNetwork") .DenyInbound(from: "Internet"); ``` ### Explicit API (for full control) Create standalone NSG resources with explicit `AzureSecurityRule` objects: ```csharp var nsg = builder.AddNetworkSecurityGroup("web-nsg") .WithSecurityRule(new AzureSecurityRule { Name = "allow-https", Priority = 100, Direction = SecurityRuleDirection.Inbound, Access = SecurityRuleAccess.Allow, Protocol = SecurityRuleProtocol.Tcp, DestinationPortRange = "443" }); var subnet = vnet.AddSubnet("web-subnet", "10.0.1.0/24") .WithNetworkSecurityGroup(nsg); ``` ### New public APIs **Types:** - `AzureNetworkSecurityGroupResource` — standalone `AzureProvisioningResource` with its own bicep module, `Id` and `NameOutput` outputs, and `AddAsExistingResource` support - `AzureSecurityRule` — data class for rule configuration. `SourcePortRange`, `SourceAddressPrefix`, and `DestinationAddressPrefix` default to `"*"` to reduce verbosity **Extension methods on `IDistributedApplicationBuilder`:** - `AddNetworkSecurityGroup(name)` — creates a top-level NSG resource **Extension methods on `IResourceBuilder<AzureNetworkSecurityGroupResource>`:** - `WithSecurityRule(rule)` — adds a security rule (rejects duplicate names) **Extension methods on `IResourceBuilder<AzureSubnetResource>`:** - `WithNetworkSecurityGroup(nsg)` — associates an explicit NSG with a subnet - `AllowInbound(port, from, to, protocol, priority, name)` — shorthand allow inbound rule - `DenyInbound(...)` — shorthand deny inbound rule - `AllowOutbound(...)` — shorthand allow outbound rule - `DenyOutbound(...)` — shorthand deny outbound rule ### Key design decisions - **NSG is a standalone `AzureProvisioningResource`** — generates its own bicep module (not inline in the VNet module). Subnets reference the NSG via cross-module parameter (`param nsg_outputs_id string`) - **NSG is a top-level resource** (not a child of VNet), matching Azure's actual resource model - **Shorthand methods auto-create an implicit NSG** named `{subnet}-nsg` when no NSG is assigned. Calling `WithNetworkSecurityGroup` after shorthand methods throws `InvalidOperationException` to prevent silent rule loss - **Priority auto-increments** by 100 (100, 200, 300...) from the max existing priority - **Rule names auto-generate** from access/direction/port/source (e.g., `allow-inbound-443-AzureLoadBalancer`) - **Sensible defaults on `AzureSecurityRule`** — `SourcePortRange`, `SourceAddressPrefix`, `DestinationAddressPrefix` all default to `"*"`, reducing the common 10-line rule to ~5 required properties - **A single NSG can be shared across multiple subnets** - **Duplicate rule names** within an NSG are rejected with `ArgumentException`
1 parent a30335e commit 45d446d

File tree

22 files changed

+1537
-4
lines changed

22 files changed

+1537
-4
lines changed

playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
#pragma warning disable AZPROVISION001 // Azure.Provisioning.Network is experimental
5+
6+
using Azure.Provisioning.Network;
7+
48
var builder = DistributedApplication.CreateBuilder(args);
59

610
// Create a virtual network with two subnets:
711
// - One for the Container App Environment (with service delegation)
812
// - One for private endpoints
913
var vnet = builder.AddAzureVirtualNetwork("vnet");
1014

11-
var containerAppsSubnet = vnet.AddSubnet("container-apps", "10.0.0.0/23");
12-
var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/27");
15+
var containerAppsSubnet = vnet.AddSubnet("container-apps", "10.0.0.0/23")
16+
.AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp)
17+
.DenyInbound(from: "VirtualNetwork")
18+
.DenyInbound(from: "Internet");
1319

1420
// Create a NAT Gateway for deterministic outbound IP on the ACA subnet
1521
var natGateway = builder.AddNatGateway("nat");
1622
containerAppsSubnet.WithNatGateway(natGateway);
1723

24+
var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/27")
25+
.AllowInbound(port: "443", from: "VirtualNetwork", protocol: SecurityRuleProtocol.Tcp)
26+
.DenyInbound(from: "Internet");
27+
1828
// Configure the Container App Environment to use the VNet
1929
builder.AddAzureContainerAppEnvironment("env")
2030
.WithDelegatedSubnet(containerAppsSubnet);

playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,23 @@
55
"type": "azure.bicep.v0",
66
"path": "vnet.module.bicep",
77
"params": {
8-
"nat_outputs_id": "{nat.outputs.id}"
8+
"nat_outputs_id": "{nat.outputs.id}",
9+
"container_apps_nsg_outputs_id": "{container-apps-nsg.outputs.id}",
10+
"private_endpoints_nsg_outputs_id": "{private-endpoints-nsg.outputs.id}"
911
}
1012
},
13+
"container-apps-nsg": {
14+
"type": "azure.bicep.v0",
15+
"path": "container-apps-nsg.module.bicep"
16+
},
1117
"nat": {
1218
"type": "azure.bicep.v0",
1319
"path": "nat.module.bicep"
1420
},
21+
"private-endpoints-nsg": {
22+
"type": "azure.bicep.v0",
23+
"path": "private-endpoints-nsg.module.bicep"
24+
},
1525
"env-acr": {
1626
"type": "azure.bicep.v0",
1727
"path": "env-acr.module.bicep"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
@description('The location for the resource(s) to be deployed.')
2+
param location string = resourceGroup().location
3+
4+
resource container_apps_nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = {
5+
name: take('container_apps_nsg-${uniqueString(resourceGroup().id)}', 80)
6+
location: location
7+
tags: {
8+
'aspire-resource-name': 'container-apps-nsg'
9+
}
10+
}
11+
12+
resource container_apps_nsg_allow_inbound_443_AzureLoadBalancer 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = {
13+
name: 'allow-inbound-443-AzureLoadBalancer'
14+
properties: {
15+
access: 'Allow'
16+
destinationAddressPrefix: '*'
17+
destinationPortRange: '443'
18+
direction: 'Inbound'
19+
priority: 100
20+
protocol: 'Tcp'
21+
sourceAddressPrefix: 'AzureLoadBalancer'
22+
sourcePortRange: '*'
23+
}
24+
parent: container_apps_nsg
25+
}
26+
27+
resource container_apps_nsg_deny_inbound_VirtualNetwork 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = {
28+
name: 'deny-inbound-VirtualNetwork'
29+
properties: {
30+
access: 'Deny'
31+
destinationAddressPrefix: '*'
32+
destinationPortRange: '*'
33+
direction: 'Inbound'
34+
priority: 200
35+
protocol: '*'
36+
sourceAddressPrefix: 'VirtualNetwork'
37+
sourcePortRange: '*'
38+
}
39+
parent: container_apps_nsg
40+
}
41+
42+
resource container_apps_nsg_deny_inbound_Internet 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = {
43+
name: 'deny-inbound-Internet'
44+
properties: {
45+
access: 'Deny'
46+
destinationAddressPrefix: '*'
47+
destinationPortRange: '*'
48+
direction: 'Inbound'
49+
priority: 300
50+
protocol: '*'
51+
sourceAddressPrefix: 'Internet'
52+
sourcePortRange: '*'
53+
}
54+
parent: container_apps_nsg
55+
}
56+
57+
output id string = container_apps_nsg.id
58+
59+
output name string = container_apps_nsg.name
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
@description('The location for the resource(s) to be deployed.')
2+
param location string = resourceGroup().location
3+
4+
resource private_endpoints_nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = {
5+
name: take('private_endpoints_nsg-${uniqueString(resourceGroup().id)}', 80)
6+
location: location
7+
tags: {
8+
'aspire-resource-name': 'private-endpoints-nsg'
9+
}
10+
}
11+
12+
resource private_endpoints_nsg_allow_inbound_443_VirtualNetwork 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = {
13+
name: 'allow-inbound-443-VirtualNetwork'
14+
properties: {
15+
access: 'Allow'
16+
destinationAddressPrefix: '*'
17+
destinationPortRange: '443'
18+
direction: 'Inbound'
19+
priority: 100
20+
protocol: 'Tcp'
21+
sourceAddressPrefix: 'VirtualNetwork'
22+
sourcePortRange: '*'
23+
}
24+
parent: private_endpoints_nsg
25+
}
26+
27+
resource private_endpoints_nsg_deny_inbound_Internet 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = {
28+
name: 'deny-inbound-Internet'
29+
properties: {
30+
access: 'Deny'
31+
destinationAddressPrefix: '*'
32+
destinationPortRange: '*'
33+
direction: 'Inbound'
34+
priority: 200
35+
protocol: '*'
36+
sourceAddressPrefix: 'Internet'
37+
sourcePortRange: '*'
38+
}
39+
parent: private_endpoints_nsg
40+
}
41+
42+
output id string = private_endpoints_nsg.id
43+
44+
output name string = private_endpoints_nsg.name

playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ param location string = resourceGroup().location
33

44
param nat_outputs_id string
55

6+
param container_apps_nsg_outputs_id string
7+
8+
param private_endpoints_nsg_outputs_id string
9+
610
resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' = {
711
name: take('vnet-${uniqueString(resourceGroup().id)}', 64)
812
properties: {
@@ -33,6 +37,9 @@ resource container_apps 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' =
3337
natGateway: {
3438
id: nat_outputs_id
3539
}
40+
networkSecurityGroup: {
41+
id: container_apps_nsg_outputs_id
42+
}
3643
}
3744
parent: vnet
3845
}
@@ -41,6 +48,9 @@ resource private_endpoints 'Microsoft.Network/virtualNetworks/subnets@2025-05-01
4148
name: 'private-endpoints'
4249
properties: {
4350
addressPrefix: '10.0.2.0/27'
51+
networkSecurityGroup: {
52+
id: private_endpoints_nsg_outputs_id
53+
}
4454
}
4555
parent: vnet
4656
dependsOn: [
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.ApplicationModel;
5+
using Aspire.Hosting.Azure;
6+
using Azure.Provisioning;
7+
using Azure.Provisioning.Network;
8+
9+
namespace Aspire.Hosting;
10+
11+
/// <summary>
12+
/// Provides extension methods for adding Azure Network Security Group resources to the application model.
13+
/// </summary>
14+
public static class AzureNetworkSecurityGroupExtensions
15+
{
16+
/// <summary>
17+
/// Adds an Azure Network Security Group to the application model.
18+
/// </summary>
19+
/// <param name="builder">The builder for the distributed application.</param>
20+
/// <param name="name">The name of the Network Security Group resource.</param>
21+
/// <returns>A reference to the <see cref="IResourceBuilder{AzureNetworkSecurityGroupResource}"/>.</returns>
22+
/// <example>
23+
/// This example adds a Network Security Group with a security rule:
24+
/// <code>
25+
/// var nsg = builder.AddNetworkSecurityGroup("web-nsg")
26+
/// .WithSecurityRule(new AzureSecurityRule
27+
/// {
28+
/// Name = "allow-https",
29+
/// Priority = 100,
30+
/// Direction = SecurityRuleDirection.Inbound,
31+
/// Access = SecurityRuleAccess.Allow,
32+
/// Protocol = SecurityRuleProtocol.Tcp,
33+
/// DestinationPortRange = "443"
34+
/// });
35+
/// </code>
36+
/// </example>
37+
public static IResourceBuilder<AzureNetworkSecurityGroupResource> AddNetworkSecurityGroup(
38+
this IDistributedApplicationBuilder builder,
39+
[ResourceName] string name)
40+
{
41+
ArgumentNullException.ThrowIfNull(builder);
42+
ArgumentException.ThrowIfNullOrEmpty(name);
43+
44+
builder.AddAzureProvisioning();
45+
46+
var resource = new AzureNetworkSecurityGroupResource(name, ConfigureNetworkSecurityGroup);
47+
48+
if (builder.ExecutionContext.IsRunMode)
49+
{
50+
return builder.CreateResourceBuilder(resource);
51+
}
52+
53+
return builder.AddResource(resource);
54+
}
55+
56+
/// <summary>
57+
/// Adds a security rule to the Network Security Group.
58+
/// </summary>
59+
/// <param name="builder">The Network Security Group resource builder.</param>
60+
/// <param name="rule">The security rule configuration.</param>
61+
/// <returns>A reference to the <see cref="IResourceBuilder{AzureNetworkSecurityGroupResource}"/> for chaining.</returns>
62+
/// <example>
63+
/// This example adds multiple security rules to a Network Security Group:
64+
/// <code>
65+
/// var nsg = builder.AddNetworkSecurityGroup("web-nsg")
66+
/// .WithSecurityRule(new AzureSecurityRule
67+
/// {
68+
/// Name = "allow-https",
69+
/// Priority = 100,
70+
/// Direction = SecurityRuleDirection.Inbound,
71+
/// Access = SecurityRuleAccess.Allow,
72+
/// Protocol = SecurityRuleProtocol.Tcp,
73+
/// DestinationPortRange = "443"
74+
/// })
75+
/// .WithSecurityRule(new AzureSecurityRule
76+
/// {
77+
/// Name = "deny-all-inbound",
78+
/// Priority = 4096,
79+
/// Direction = SecurityRuleDirection.Inbound,
80+
/// Access = SecurityRuleAccess.Deny,
81+
/// Protocol = SecurityRuleProtocol.Asterisk,
82+
/// DestinationPortRange = "*"
83+
/// });
84+
/// </code>
85+
/// </example>
86+
public static IResourceBuilder<AzureNetworkSecurityGroupResource> WithSecurityRule(
87+
this IResourceBuilder<AzureNetworkSecurityGroupResource> builder,
88+
AzureSecurityRule rule)
89+
{
90+
ArgumentNullException.ThrowIfNull(builder);
91+
ArgumentNullException.ThrowIfNull(rule);
92+
ArgumentException.ThrowIfNullOrEmpty(rule.Name);
93+
94+
if (builder.Resource.SecurityRules.Any(existing => string.Equals(existing.Name, rule.Name, StringComparison.OrdinalIgnoreCase)))
95+
{
96+
throw new ArgumentException(
97+
$"A security rule named '{rule.Name}' already exists in Network Security Group '{builder.Resource.Name}'.",
98+
nameof(rule));
99+
}
100+
101+
builder.Resource.SecurityRules.Add(rule);
102+
return builder;
103+
}
104+
105+
private static void ConfigureNetworkSecurityGroup(AzureResourceInfrastructure infra)
106+
{
107+
var azureResource = (AzureNetworkSecurityGroupResource)infra.AspireResource;
108+
109+
var nsg = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra,
110+
(identifier, name) =>
111+
{
112+
var resource = NetworkSecurityGroup.FromExisting(identifier);
113+
resource.Name = name;
114+
return resource;
115+
},
116+
(infrastructure) =>
117+
{
118+
return new NetworkSecurityGroup(infrastructure.AspireResource.GetBicepIdentifier())
119+
{
120+
Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } }
121+
};
122+
});
123+
124+
foreach (var rule in azureResource.SecurityRules)
125+
{
126+
var ruleIdentifier = Infrastructure.NormalizeBicepIdentifier($"{nsg.BicepIdentifier}_{rule.Name}");
127+
var securityRule = new SecurityRule(ruleIdentifier)
128+
{
129+
Name = rule.Name,
130+
Priority = rule.Priority,
131+
Direction = rule.Direction,
132+
Access = rule.Access,
133+
Protocol = rule.Protocol,
134+
SourceAddressPrefix = rule.SourceAddressPrefix,
135+
SourcePortRange = rule.SourcePortRange,
136+
DestinationAddressPrefix = rule.DestinationAddressPrefix,
137+
DestinationPortRange = rule.DestinationPortRange,
138+
Parent = nsg,
139+
};
140+
infra.Add(securityRule);
141+
}
142+
143+
infra.Add(new ProvisioningOutput("id", typeof(string))
144+
{
145+
Value = nsg.Id
146+
});
147+
148+
infra.Add(new ProvisioningOutput("name", typeof(string))
149+
{
150+
Value = nsg.Name
151+
});
152+
}
153+
}

0 commit comments

Comments
 (0)