diff --git a/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs b/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs index e840a5fb7ac..94d315453e2 100644 --- a/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs +++ b/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs @@ -5,6 +5,7 @@ using Aspire.Hosting.Azure; using Azure.Provisioning; using Azure.Provisioning.Expressions; +using Azure.Provisioning.Primitives; using Azure.Provisioning.Sql; namespace Aspire.Hosting; @@ -205,14 +206,6 @@ private static void CreateSqlServer( { var resource = SqlServer.FromExisting(identifier); resource.Name = name; - resource.Administrators = new ServerExternalAdministrator() - { - AdministratorType = SqlAdministratorType.ActiveDirectory, - IsAzureADOnlyAuthenticationEnabled = true, - Sid = principalIdParameter, - Login = principalNameParameter, - TenantId = BicepFunction.GetSubscription().TenantId - }; return resource; }, (infrastructure) => @@ -234,6 +227,20 @@ private static void CreateSqlServer( }; }); + // If the resource is an existing resource, we model the administrator access + // for the managed identity as an "edge" between the parent SqlServer resource + // and a custom SqlServerAzureADAdministrator resource. + if (sqlServer.IsExistingResource) + { + var admin = new SqlServerAzureADAdministratorWorkaround($"{sqlServer.BicepIdentifier}_admin") + { + ParentOverride = sqlServer, + LoginOverride = principalNameParameter, + SidOverride = principalIdParameter + }; + infrastructure.Add(admin); + } + infrastructure.Add(new SqlFirewallRule("sqlFirewallRule_AllowAllAzureIps") { Parent = sqlServer, @@ -244,11 +251,15 @@ private static void CreateSqlServer( if (distributedApplicationBuilder.ExecutionContext.IsRunMode) { - // When in run mode we inject the users identity and we need to specify - // the principalType. - var principalTypeParameter = new ProvisioningParameter(AzureBicepResource.KnownParameters.PrincipalType, typeof(string)); - infrastructure.Add(principalTypeParameter); - sqlServer.Administrators.PrincipalType = principalTypeParameter; + // Avoid mutating properties on existing resources. + if (!sqlServer.IsExistingResource) + { + // When in run mode we inject the users identity and we need to specify + // the principalType. + var principalTypeParameter = new ProvisioningParameter(AzureBicepResource.KnownParameters.PrincipalType, typeof(string)); + infrastructure.Add(principalTypeParameter); + sqlServer.Administrators.PrincipalType = principalTypeParameter; + } infrastructure.Add(new SqlFirewallRule("sqlFirewallRule_AllowAllIps") { @@ -273,4 +284,80 @@ private static void CreateSqlServer( infrastructure.Add(new ProvisioningOutput("sqlServerFqdn", typeof(string)) { Value = sqlServer.FullyQualifiedDomainName }); } + + /// + /// Workaround for issue using SqlServerAzureADAdministrator. + /// See https://github.com/Azure/azure-sdk-for-net/issues/48364 for more information. + /// + private sealed class SqlServerAzureADAdministratorWorkaround(string bicepIdentifier) : SqlServerAzureADAdministrator(bicepIdentifier) + { + private BicepValue? _name; + private BicepValue? _login; + private BicepValue? _sid; + private ResourceReference? _parent; + + /// + /// Login name of the server administrator. + /// + public BicepValue LoginOverride + { + get + { + Initialize(); + return _login!; + } + set + { + Initialize(); + _login!.Assign(value); + } + } + + /// + /// SID (object ID) of the server administrator. + /// + public BicepValue SidOverride + { + get + { + Initialize(); + return _sid!; + } + set + { + Initialize(); + _sid!.Assign(value); + } + } + + /// + /// Parent resource of the server administrator. + /// + public SqlServer? ParentOverride + { + get + { + Initialize(); + return _parent!.Value; + } + set + { + Initialize(); + _parent!.Value = value; + } + } + + private static BicepValue GetNameDefaultValue() + { + return new StringLiteralExpression("ActiveDirectory"); + } + + protected override void DefineProvisionableProperties() + { + _name = DefineProperty("Name", ["name"], defaultValue: GetNameDefaultValue()); + _login = DefineProperty("Login", ["properties", "login"]); + _sid = DefineProperty("Sid", ["properties", "sid"]); + _parent = DefineResource("Parent", ["parent"], isOutput: false, isRequired: true); + } + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/ExistingAzureResourceTests.cs b/tests/Aspire.Hosting.Azure.Tests/ExistingAzureResourceTests.cs index 1ed5ce92dee..eb2a7c16a77 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ExistingAzureResourceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ExistingAzureResourceTests.cs @@ -1050,15 +1050,15 @@ param existingResourceName string resource sqlServer 'Microsoft.Sql/servers@2021-11-01' existing = { name: existingResourceName + } + + resource sqlServer_admin 'Microsoft.Sql/servers/administrators@2021-11-01' = { + name: 'ActiveDirectory' properties: { - administrators: { - administratorType: 'ActiveDirectory' - login: principalName - sid: principalId - tenantId: subscription().tenantId - azureADOnlyAuthentication: true - } + login: principalName + sid: principalId } + parent: sqlServer } resource sqlFirewallRule_AllowAllAzureIps 'Microsoft.Sql/servers/firewallRules@2021-11-01' = { @@ -1096,8 +1096,7 @@ public async Task SupportsExistingAzureSqlServerInRunMode() "params": { "existingResourceName": "{existingResourceName.value}", "principalId": "", - "principalName": "", - "principalType": "" + "principalName": "" } } """; @@ -1114,20 +1113,17 @@ param principalName string param existingResourceName string - param principalType string - resource sqlServer 'Microsoft.Sql/servers@2021-11-01' existing = { name: existingResourceName + } + + resource sqlServer_admin 'Microsoft.Sql/servers/administrators@2021-11-01' = { + name: 'ActiveDirectory' properties: { - administrators: { - administratorType: 'ActiveDirectory' - principalType: principalType - login: principalName - sid: principalId - tenantId: subscription().tenantId - azureADOnlyAuthentication: true - } + login: principalName + sid: principalId } + parent: sqlServer } resource sqlFirewallRule_AllowAllAzureIps 'Microsoft.Sql/servers/firewallRules@2021-11-01' = { @@ -1300,13 +1296,13 @@ public async Task SupportsExistingAzureApplicationInsightsWithResourceGroup() var expectedBicep = """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param existingResourceName string - + resource appInsights 'Microsoft.Insights/components@2020-02-02' existing = { name: existingResourceName } - + output appInsightsConnectionString string = appInsights.properties.ConnectionString """; @@ -1347,17 +1343,17 @@ public async Task SupportsExistingAzureOpenAIWithResourceGroup() var expectedBicep = """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param existingResourceName string - + param principalType string - + param principalId string - + resource openAI 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { name: existingResourceName } - + resource openAI_CognitiveServicesOpenAIContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(openAI.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a001fd3d-188f-4b5d-821b-7da978bf7442')) properties: { @@ -1367,7 +1363,7 @@ param principalId string } scope: openAI } - + resource mymodel 'Microsoft.CognitiveServices/accounts/deployments@2024-10-01' = { name: 'mymodel' properties: {