diff --git a/README.md b/README.md index 383532f..1ffd6b5 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,18 @@ var cosmos = builder.AddAzureCosmosDbNoSqlAccount("cosmos", acc => ac.Resource.WithDevelopmentDefaults(); ac.WithDevelopmentGlobalAccess(); // Adds your local principal to have access to everything in the account } - acc.AddDatabase("db", db => { }).AddContainer("cn", cn => + var db = acc.AddDatabase("db"); + var cn = db.AddContainer("cn", cn => { cn.PartitionKey = new CosmosDbSqlContainerPartitionKey("/id"); }); + + if (builder.ExecutionContext.IsPublishMode) { + // Add a Managed Identity to the Cosmos DB + ac.AddRoleAssignment(acc.Resource, id, CosmosDbSqlBuiltInRoles.Contributor); + //ac.AddRoleAssignment(db.Resource, id, CosmosDbSqlBuiltInRoles.Contributor); // Or the Database + //ac.AddRoleAssignment(cn, id, CosmosDbSqlBuiltInRoles.Contributor); // Or the Container + } }); ``` @@ -157,6 +165,7 @@ them I'm more than happy to accept PRs or look at implementing things myself, ju - 0.1.0 - Minimal to support the addition of Managed Identities as well as the Custom ID support. - 0.2.0 - Added Bicep generator for the creation of more complex resources. Added Cosmos DB and Role Assignment support. +- 0.2.1 - Adds convenience methods for Role Assignments - 0.2.X - More resources (see above). Tidy up/unify the APIs a little. - 0.3.0 - Add a tool to complement `azd` so that the below is not required. - 0.4.0 - Use above tool to also allow full customisation of the generated Bicep templates, down to the Container App Environment. diff --git a/src/Achieve.Aspire.AzureProvisioning/Bicep/CosmosDb/SqlRoleAssignmentResource.cs b/src/Achieve.Aspire.AzureProvisioning/Bicep/CosmosDb/SqlRoleAssignmentResource.cs index 5dcc4ea..6acd8a8 100644 --- a/src/Achieve.Aspire.AzureProvisioning/Bicep/CosmosDb/SqlRoleAssignmentResource.cs +++ b/src/Achieve.Aspire.AzureProvisioning/Bicep/CosmosDb/SqlRoleAssignmentResource.cs @@ -40,6 +40,16 @@ public CosmosDbSqlRoleAssignmentResource WithScope(CosmosDbSqlContainerResource return this; } + public CosmosDbSqlRoleAssignmentResource WithBuiltInRole(CosmosDbSqlBuiltInRole role) + { + return role switch + { + CosmosDbSqlBuiltInRole.Reader => WithReaderRole(), + CosmosDbSqlBuiltInRole.Contributor => WithContributorRole(), + _ => throw new ArgumentOutOfRangeException(nameof(role), "Invalid Built in Role") + }; + } + public CosmosDbSqlRoleAssignmentResource WithReaderRole() { RoleDefinitionId = GetBaseBuiltInRolePrefix().WithArgument(new BicepStringValue("00000000-0000-0000-0000-000000000001")); @@ -78,23 +88,12 @@ private BicepVariableValue GetDatabaseAccountReference() private BicepFunctionCallValue GetBaseBuiltInRolePrefix() { - // /subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.DocumentDB/databaseAccounts/${cosmosDbAccount.name}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002 return new BicepFunctionCallValue("resourceId", new BicepStringValue("Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions"), new BicepPropertyAccessValue(GetDatabaseAccountReference(), "name")); } public override void Construct() { - /*resource rvIdAccess 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-11-15' = { - parent: cosmosDbAccount - name: guid(cosmosDbAccount.id, 'rvIdAccess') - properties: { - roleDefinitionId: contributorRole - scope: cosmosDbAccount.id - principalId: '73f51bfc-7674-4256-8657-38cf695abe56' - } - }*/ - Body.Add(new BicepResourceProperty("parent", GetDatabaseAccountReference())); Body.Add(new BicepResourceProperty("name", new BicepFunctionCallValue("guid", new BicepPropertyAccessValue(GetDatabaseAccountReference(), "id"), new BicepStringValue(Name)))); var propertyBag = new BicepResourcePropertyBag("properties"); @@ -132,4 +131,10 @@ public override void Construct() propertyBag.AddProperty("principalId", PrincipalId); Body.Add(propertyBag); } +} + +public enum CosmosDbSqlBuiltInRole +{ + Reader, + Contributor } \ No newline at end of file diff --git a/src/Achieve.Aspire.AzureProvisioning/CosmosDb.cs b/src/Achieve.Aspire.AzureProvisioning/CosmosDb.cs index bdcb210..67f3f26 100644 --- a/src/Achieve.Aspire.AzureProvisioning/CosmosDb.cs +++ b/src/Achieve.Aspire.AzureProvisioning/CosmosDb.cs @@ -27,6 +27,14 @@ public static IResourceBuilder AddAzureCosmosDbNoSqlAccou { fileOutput.AddParameter(new BicepParameter(AzureBicepResource.KnownParameters.PrincipalId, BicepSupportedType.String)); } + + if (options.Principals.Count > 0) + { + foreach(var (paramName, bicepOutputReference) in options.Principals) + { + fileOutput.AddParameter(new BicepParameter(paramName, BicepSupportedType.String)); + } + } fileOutput.AddResource(accountResource); foreach (var database in options.Databases) @@ -38,6 +46,14 @@ public static IResourceBuilder AddAzureCosmosDbNoSqlAccou } } + if (options.RoleAssignments.Count > 0) + { + foreach (var roleAssignment in options.RoleAssignments) + { + fileOutput.AddResource(roleAssignment); + } + } + fileOutput.AddOutput(new BicepOutput(AzureCosmosDbResource.AccountEndpointOutput, BicepSupportedType.String, accountResource.Name + ".properties.documentEndpoint")); var resource = new AzureCosmosDbResource(name, fileOutput); @@ -46,6 +62,13 @@ public static IResourceBuilder AddAzureCosmosDbNoSqlAccou { resourceBuilder.WithParameter(AzureBicepResource.KnownParameters.PrincipalId); } + if (options.Principals.Count > 0) + { + foreach(var (paramName, bicepOutputReference) in options.Principals) + { + resourceBuilder.WithParameter(paramName, bicepOutputReference); + } + } return resourceBuilder.WithManifestPublishingCallback(resource.WriteToManifest); } @@ -72,10 +95,12 @@ public class CosmosDbAccountOptions(CosmosDbAccountResource resource) public bool EnablePassPrincipalId { get; set; } - public CosmosDbDatabaseOptions AddDatabase(string name, Action configure) + public List<(string, BicepOutputReference)> Principals { get; set; } = []; + + public CosmosDbDatabaseOptions AddDatabase(string name, Action? configure = null) { var database = new CosmosDbSqlDatabaseResource(Resource, name); - configure(database); + configure?.Invoke(database); Databases.Add(name, new CosmosDbDatabaseOptions(this, database)); return Databases[name]; } @@ -89,6 +114,31 @@ public CosmosDbAccountOptions WithDevelopmentGlobalAccess() .WithContributorRole()); return this; } + + /// + /// Add a Role Assignment to the Cosmos DB Account. + /// + /// Must be a , or . + /// Bicep Output Reference. Must be a Principal ID. + /// Built in role (currently) to assign. + /// + public CosmosDbAccountOptions WithRoleAssignment(BicepResource scope, BicepOutputReference output, CosmosDbSqlBuiltInRole role) + { + var paramName = output.Resource.Name + "Principal"; + if (Principals.All(p => p.Item1 != paramName)) + { + Principals.Add((paramName, output)); + } + var roleAssignment = new CosmosDbSqlRoleAssignmentResource(output.Resource.Name + "Ra_" + Helpers.StableIdentifier(output.Resource.Name + scope.Name + role)); + // Can't use WithScope as typed + roleAssignment.Scope = scope; + roleAssignment.WithBuiltInRole(role).PrincipalId = new BicepVariableValue(paramName); + RoleAssignments.Add(roleAssignment); + return this; + } + + public CosmosDbAccountOptions WithRoleAssignment(BicepResource scope, IResourceBuilder identity, CosmosDbSqlBuiltInRole role) => + WithRoleAssignment(scope, identity.GetOutput("PrincipalId"), role); } public class CosmosDbDatabaseOptions(CosmosDbAccountOptions parent, CosmosDbSqlDatabaseResource sqlDatabase) @@ -96,10 +146,10 @@ public class CosmosDbDatabaseOptions(CosmosDbAccountOptions parent, CosmosDbSqlD public CosmosDbSqlDatabaseResource Resource { get; set; } = sqlDatabase; public Dictionary Containers { get; set; } = []; - public CosmosDbSqlContainerResource AddContainer(string name, Action configure) + public CosmosDbSqlContainerResource AddContainer(string name, Action? configure = null) { var container = new CosmosDbSqlContainerResource(Resource, name); - configure(container); + configure?.Invoke(container); Containers.Add(name, container); return container; } diff --git a/tests/Achieve.Aspire.AzureProvisioning.Tests/CosmosDbTests.cs b/tests/Achieve.Aspire.AzureProvisioning.Tests/CosmosDbTests.cs index ec3bd98..3cadb4f 100644 --- a/tests/Achieve.Aspire.AzureProvisioning.Tests/CosmosDbTests.cs +++ b/tests/Achieve.Aspire.AzureProvisioning.Tests/CosmosDbTests.cs @@ -16,13 +16,11 @@ public async Task BasicCosmosDbGeneratesCorrectly() var id = builder.AddManagedIdentity("testid"); var cosmos = builder.AddAzureCosmosDbNoSqlAccount("cosmos", acc => { - acc.AddDatabase("db", db => - { - - }).AddContainer("cn", cn => - { - cn.PartitionKey = new CosmosDbSqlContainerPartitionKey("/id"); - }); + acc.AddDatabase("db") + .AddContainer("cn", cn => + { + cn.PartitionKey = new CosmosDbSqlContainerPartitionKey("/id"); + }); }); var cosmosManifestBicep = await ManifestUtils.GetManifestWithBicep(cosmos.Resource); @@ -106,4 +104,143 @@ public async Task BasicCosmosDbGeneratesCorrectly() """; Assert.Equal(expectedBicep, cosmosManifestBicep.BicepText); } + + [Fact] + public async Task CosmosAccountWithRbacGeneratesCorrectly() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var id = builder.AddManagedIdentity("testid"); + var cosmos = builder.AddAzureCosmosDbNoSqlAccount("cosmos", acc => + { + var db = acc.AddDatabase("db"); + var conn = db.AddContainer("cn", cn => + { + cn.PartitionKey = new CosmosDbSqlContainerPartitionKey("/id"); + }); + acc.WithDevelopmentGlobalAccess(); + acc.WithRoleAssignment(db.Resource, id, CosmosDbSqlBuiltInRole.Reader); + acc.WithRoleAssignment(conn, id, CosmosDbSqlBuiltInRole.Contributor); + }); + + + var cosmosManifestBicep = await ManifestUtils.GetManifestWithBicep(cosmos.Resource); + + var expectedManifest = """ + { + "type": "azure.bicep.v0", + "connectionString": "{cosmos.outputs.accountEndpoint}", + "path": "cosmos.achieve.bicep", + "params": { + "principalId": "", + "testidPrincipal": "{testid.outputs.PrincipalId}" + } + } + """; + Assert.Equal(expectedManifest, cosmosManifestBicep.ManifestNode.ToString()); + + var expected = """ + targetScope = 'resourceGroup' + + @description('The location of the resource group.') + param location string = resourceGroup().location + + param principalId string + + param testidPrincipal string + + resource cosmosDbAccount 'Microsoft.DocumentDB/databaseAccounts@2023-11-15' = { + name: 'cosmos${uniqueString(resourceGroup().id)}' + location: location + properties: { + databaseAccountOfferType: 'Standard' + backupPolicy: { + type: 'Continuous' + continuousModeProperties: { + tier: 'Continuous7Days' + } + } + capabilities: [ + { + name: 'EnableServerless' + } + ] + consistencyPolicy: { + defaultConsistencyLevel: 'Session' + } + locations: [ + { + failoverPriority: 0 + locationName: location + isZoneRedundant: false + } + ] + minimalTlsVersion: 'Tls12' + publicNetworkAccess: 'SecuredByPerimeter' + } + } + + resource db 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2023-11-15' = { + parent: cosmosDbAccount + name: 'db' + location: location + properties: { + resource: { + id: 'db' + } + } + } + + resource cn 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-11-15' = { + parent: db + name: 'cn' + location: location + properties: { + resource: { + id: 'cn' + partitionKey: { + kind: 'Hash' + paths: [ + '/id' + ] + } + } + } + } + + resource developmentAccess 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-11-15' = { + parent: cosmosDbAccount + name: guid(cosmosDbAccount.id,'developmentAccess') + properties: { + roleDefinitionId: resourceId('Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions',cosmosDbAccount.name,'00000000-0000-0000-0000-000000000002') + scope: cosmosDbAccount.id + principalId: principalId + } + } + + resource testidRa_B9899A8C 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-11-15' = { + parent: cosmosDbAccount + name: guid(cosmosDbAccount.id,'testidRa_B9899A8C') + properties: { + roleDefinitionId: resourceId('Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions',cosmosDbAccount.name,'00000000-0000-0000-0000-000000000001') + scope: '${cosmosDbAccount.id}/dbs/${db.name}' + principalId: testidPrincipal + } + } + + resource testidRa_BD6A548A 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-11-15' = { + parent: cosmosDbAccount + name: guid(cosmosDbAccount.id,'testidRa_BD6A548A') + properties: { + roleDefinitionId: resourceId('Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions',cosmosDbAccount.name,'00000000-0000-0000-0000-000000000002') + scope: '${cosmosDbAccount.id}/dbs/${db.name}/colls/${cn.name}' + principalId: testidPrincipal + } + } + + output accountEndpoint string = cosmosDbAccount.properties.documentEndpoint + + """; + + Assert.Equal(expected, cosmosManifestBicep.BicepText); + } } \ No newline at end of file