diff --git a/src/deploy-cromwell-on-azure/Deployer.cs b/src/deploy-cromwell-on-azure/Deployer.cs index d2bb58e7..b436eeec 100644 --- a/src/deploy-cromwell-on-azure/Deployer.cs +++ b/src/deploy-cromwell-on-azure/Deployer.cs @@ -179,6 +179,14 @@ private static async Task FetchResourceDataAsync(Func if (configuration.Update) { - resourceGroup = (await armSubscription.GetResourceGroupAsync(configuration.ResourceGroupName, cts.Token)).Value; + await EnsureResourceGroup(); configuration.RegionName = resourceGroup.Id.Location ?? (resourceGroup.HasData ? resourceGroup.Data.Location.Name : (await FetchResourceDataAsync(resourceGroup.GetAsync, cts.Token, resource => resourceGroup = resource)).Data.Location.Name); @@ -628,7 +636,7 @@ await Execute("Validating existing Azure resources...", async () => var vnetAndSubnet = await ValidateAndGetExistingVirtualNetworkAsync(); - if (string.IsNullOrWhiteSpace(configuration.ResourceGroupName)) + if (resourceGroup is null) { configuration.ResourceGroupName = Utility.RandomResourceName($"{configuration.MainIdentifierPrefix}-", 15); resourceGroup = await CreateResourceGroupAsync(); @@ -636,7 +644,7 @@ await Execute("Validating existing Azure resources...", async () => } else { - resourceGroup = (await armSubscription.GetResourceGroupAsync(configuration.ResourceGroupName, cts.Token)).Value; + await EnsureResourceGroup(); } if (!string.IsNullOrWhiteSpace(configuration.IdentityResourceId)) @@ -1718,8 +1726,8 @@ private Task AssignMIAsDataOwnerToStorageAccountAsync(UserAssignedIdentityResour $"Assigning '{RoleDefinitions.GetDisplayName(RoleDefinitions.Storage.StorageBlobDataOwner)}' role for user-managed identity to Storage Account resource scope..."); private Task AssignVmAsContributorToStorageAccountAsync(UserAssignedIdentityResource managedIdentity, StorageAccountResource storageAccount) - => AssignRoleToResourceAsync(managedIdentity, storageAccount, GetSubscriptionRoleDefinition(RoleDefinitions.General.Contributor), - $"Assigning '{RoleDefinitions.GetDisplayName(RoleDefinitions.General.Contributor)}' role for user-managed identity to Storage Account resource scope..."); + => AssignRoleToResourceAsync(managedIdentity, storageAccount, GetSubscriptionRoleDefinition(RoleDefinitions.Privileged.Contributor), + $"Assigning '{RoleDefinitions.GetDisplayName(RoleDefinitions.Privileged.Contributor)}' role for user-managed identity to Storage Account resource scope..."); private async Task AssignMeAsRbacClusterAdminToManagedClusterAsync(ContainerServiceManagedClusterResource managedCluster) { @@ -1873,8 +1881,8 @@ await UploadTextToStorageAccountAsync(GetBlobClient(storageAccount, Configuratio }); private Task AssignVmAsContributorToBatchAccountAsync(UserAssignedIdentityResource managedIdentity, BatchAccountResource batchAccount) - => AssignRoleToResourceAsync(managedIdentity, batchAccount, GetSubscriptionRoleDefinition(RoleDefinitions.General.Contributor), - $"Assigning '{RoleDefinitions.GetDisplayName(RoleDefinitions.General.Contributor)}' role for user-managed identity to Batch Account resource scope..."); + => AssignRoleToResourceAsync(managedIdentity, batchAccount, GetSubscriptionRoleDefinition(RoleDefinitions.Privileged.Contributor), + $"Assigning '{RoleDefinitions.GetDisplayName(RoleDefinitions.Privileged.Contributor)}' role for user-managed identity to Batch Account resource scope..."); private async Task CreatePostgreSqlServerAndDatabaseAsync(SubnetResource subnet, PrivateDnsZoneResource postgreSqlDnsZone) { @@ -1918,8 +1926,8 @@ await Execute( } private Task AssignVmAsContributorToAppInsightsAsync(UserAssignedIdentityResource managedIdentity, ArmResource appInsights) - => AssignRoleToResourceAsync(managedIdentity, appInsights, GetSubscriptionRoleDefinition(RoleDefinitions.General.Contributor), - $"Assigning '{RoleDefinitions.GetDisplayName(RoleDefinitions.General.Contributor)}' role for user-managed identity to App Insights resource scope..."); + => AssignRoleToResourceAsync(managedIdentity, appInsights, GetSubscriptionRoleDefinition(RoleDefinitions.Privileged.Contributor), + $"Assigning '{RoleDefinitions.GetDisplayName(RoleDefinitions.Privileged.Contributor)}' role for user-managed identity to App Insights resource scope..."); private ResourceIdentifier GetSubscriptionRoleDefinition(Guid roleDefinition) => AuthorizationRoleDefinitionResource.CreateResourceIdentifier(SubscriptionResource.CreateResourceIdentifier(configuration.SubscriptionId), new(roleDefinition.ToString("D"))); @@ -2405,8 +2413,9 @@ private async Task ValidateRegionNameAsync(string regionName) private async Task ValidateSubscriptionAndResourceGroupAsync(Configuration configuration) { - var ownerRoleId = RoleDefinitions.General.Owner.ToString("D"); - var contributorRoleId = RoleDefinitions.General.Contributor.ToString("D"); + var ownerRoleId = RoleDefinitions.Privileged.Owner.ToString("D"); + var contributorRoleId = RoleDefinitions.Privileged.Contributor.ToString("D"); + var userAccessAdministrator = RoleDefinitions.Privileged.UserAccessAdministrator.ToString("D"); bool rgExists; @@ -2428,15 +2437,37 @@ private async Task ValidateSubscriptionAndResourceGroupAsync(Configuration confi var currentPrincipalObjectId = new JwtSecurityTokenHandler().ReadJwtToken(token.Token).Claims.FirstOrDefault(c => c.Type == "oid").Value; var currentPrincipalSubscriptionRoleIds = armSubscription.GetRoleAssignments().GetAllAsync($"atScope() and assignedTo('{currentPrincipalObjectId}')", cancellationToken: cts.Token) - .SelectAwaitWithCancellation(async (b, c) => await FetchResourceDataAsync(t => b.GetAsync(cancellationToken: t), c)).Select(b => b.Data.RoleDefinitionId.Name); + .SelectAwaitWithCancellation(async (b, c) => b.HasData ? b : await FetchResourceDataAsync(t => b.GetAsync(cancellationToken: t), c)).Select(b => b.Data.RoleDefinitionId.Name); + + var isSubOwner = false; + var isSubContributor = false; + var isSubUAA = false; + bool HasSubscriptionAccess(string role) + { + if (ownerRoleId.Equals(role, StringComparison.OrdinalIgnoreCase)) + { + isSubOwner = true; + } + else if (contributorRoleId.Equals(role, StringComparison.OrdinalIgnoreCase)) + { + isSubContributor = true; + } + else if (userAccessAdministrator.Equals(role, StringComparison.OrdinalIgnoreCase)) + { + isSubUAA = true; + } + + return isSubOwner || (isSubContributor && isSubUAA); + } - if (!await currentPrincipalSubscriptionRoleIds.AnyAsync(role => ownerRoleId.Equals(role, StringComparison.OrdinalIgnoreCase) || contributorRoleId.Equals(role, StringComparison.OrdinalIgnoreCase), cts.Token)) + if (!await currentPrincipalSubscriptionRoleIds.AnyAsync(HasSubscriptionAccess, cts.Token)) { if (!rgExists) { throw new ValidationException($"Insufficient access to deploy. You must be: 1) Owner of the subscription, or 2) Contributor and User Access Administrator of the subscription, or 3) Owner of the resource group", displayExample: false); } + await EnsureResourceGroup(); var currentPrincipalRgRoleIds = resourceGroup.GetRoleAssignments().GetAllAsync($"atScope() and assignedTo('{currentPrincipalObjectId}')", cancellationToken: cts.Token) .SelectAwaitWithCancellation(async (b, c) => await FetchResourceDataAsync(t => b.GetAsync(cancellationToken: t), c)).Select(b => b.Data.RoleDefinitionId.Name); diff --git a/src/deploy-cromwell-on-azure/RoleDefinitions.cs b/src/deploy-cromwell-on-azure/RoleDefinitions.cs index b21a8097..cbcb3f64 100644 --- a/src/deploy-cromwell-on-azure/RoleDefinitions.cs +++ b/src/deploy-cromwell-on-azure/RoleDefinitions.cs @@ -15,11 +15,14 @@ private static readonly ImmutableDictionary _roleDef = ImmutableDictionary.Empty.AddRange( // Add in order of https://learn.microsoft.com/azure/role-based-access-control/built-in-roles, followed by any custom definitions (in a separate subclass). [ - // https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/general#contributor - new($"{nameof(General)}.{nameof(General.Contributor)}", new(new("b24988ac-6180-42a0-ab88-20f7382dd24c"), "Contributor")), + // https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/privileged#contributor + new($"{nameof(Privileged)}.{nameof(Privileged.Contributor)}", new(new("b24988ac-6180-42a0-ab88-20f7382dd24c"), "Contributor")), - // https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/general#owner - new($"{nameof(General)}.{nameof(General.Owner)}", new(new("8e3af657-a8ff-443c-a75c-2fe8c4bcb635"), "Grants full access to manage all resources, including the ability to assign roles in Azure RBAC.")), + // https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#owner + new($"{nameof(Privileged)}.{nameof(Privileged.Owner)}", new(new("8e3af657-a8ff-443c-a75c-2fe8c4bcb635"), "Grants full access to manage all resources, including the ability to assign roles in Azure RBAC.")), + + // https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#user-access-administrator + new($"{nameof(Privileged)}.{nameof(Privileged.UserAccessAdministrator)}", new(new("18d7d88d-d35e-4fb5-a5c3-7773c20a72d9"), "Lets you manage user access to Azure resources.")), // https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/networking#network-contributor new($"{nameof(Networking)}.{nameof(Networking.NetworkContributor)}", new(new("4d97b98b-1d4f-4787-a291-c67834d212e7"), "Network Contributor")), @@ -43,17 +46,22 @@ private static readonly ImmutableDictionary _roleDef private static readonly ImmutableDictionary _displayNames = _roleDefinitions.Values.ToImmutableDictionary(t => t.Name, t => t.DisplayName); - internal static class General + internal static class Privileged { /// /// Grants full access to manage all resources, but does not allow you to assign roles in Azure RBAC, manage assignments in Azure Blueprints, or share image galleries. /// - internal static Guid Contributor { get; } = _roleDefinitions[$"{nameof(General)}.{nameof(Contributor)}"].Name; + internal static Guid Contributor { get; } = _roleDefinitions[$"{nameof(Privileged)}.{nameof(Contributor)}"].Name; /// /// Grants full access to manage all resources, including the ability to assign roles in Azure RBAC. /// - internal static Guid Owner { get; } = _roleDefinitions[$"{nameof(General)}.{nameof(Owner)}"].Name; + internal static Guid Owner { get; } = _roleDefinitions[$"{nameof(Privileged)}.{nameof(Owner)}"].Name; + + /// + /// Lets you manage user access to Azure resources. + /// + internal static Guid UserAccessAdministrator { get; } = _roleDefinitions[$"{nameof(Privileged)}.{nameof(UserAccessAdministrator)}"].Name; } internal static class Networking