();
18 | return View(exceptionHandlerPathFeature.Error);
19 | }
20 | }
21 | }
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Features/ListPets/Index.cshtml:
--------------------------------------------------------------------------------
1 | @model SimpleMvcApp.Features.ListPets.ListPetsViewModel
2 |
3 | @{
4 | ViewBag.Title = "Pets";
5 | Layout = "_Layout";
6 | }
7 |
8 | Pets
9 |
10 | @foreach (var pet in Model.Pets)
11 | {
12 | - @pet.Name
13 | }
14 |
15 |
16 |
17 | No pets, or can't see your pet?
18 | Add one @Html.ActionLink("here", "Index", "NewPet")
19 |
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Features/ListPets/ListPetsController.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Tasks;
2 | using Microsoft.AspNetCore.Mvc;
3 | using Microsoft.Extensions.Logging;
4 | using Microsoft.Identity.Web;
5 | using SimpleMvcApp.Features.NewPet;
6 | using SimpleMvcApp.Services;
7 |
8 | namespace SimpleMvcApp.Features.ListPets
9 | {
10 | [Route("/pets/list")]
11 | public class ListPetsController : Controller
12 | {
13 | private readonly ILogger _logger;
14 | private readonly PetsClient _client;
15 |
16 | public ListPetsController(ILogger logger, PetsClient client)
17 | {
18 | _logger = logger;
19 | _client = client;
20 | }
21 |
22 | [Route("")]
23 | public async Task Index()
24 | {
25 | return View(new ListPetsViewModel { Pets = await _client.GetAll() });
26 | }
27 | }
28 | }
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Features/ListPets/ListPetsViewModel.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using SimpleMvcApp.Services;
3 |
4 | namespace SimpleMvcApp.Features.ListPets
5 | {
6 | public class ListPetsViewModel
7 | {
8 | public ReferenceItem[] Pets { get; set; }
9 | }
10 | }
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Features/NewPet/Index.cshtml:
--------------------------------------------------------------------------------
1 | @model SimpleMvcApp.Features.NewPet.NewPetCommand
2 |
3 | @{
4 | ViewBag.Title = "New Pet";
5 | Layout = "_Layout";
6 | }
7 |
8 | New Pet
9 |
10 |
11 | @using (Html.BeginForm("New", "NewPet", FormMethod.Post))
12 | {
13 | @Html.LabelFor(x => x.Name)
14 | @Html.TextBoxFor(x => x.Name)
15 |
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Features/NewPet/NewPetCommand.cs:
--------------------------------------------------------------------------------
1 | using System.ComponentModel.DataAnnotations;
2 |
3 | namespace SimpleMvcApp.Features.NewPet
4 | {
5 | public class NewPetCommand
6 | {
7 | [Required]
8 | [StringLength(100, MinimumLength = 3)]
9 | public string Name { get; set; }
10 | }
11 | }
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Features/NewPet/NewPetController.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading.Tasks;
3 | using Microsoft.AspNetCore.Mvc;
4 | using Microsoft.Extensions.Logging;
5 | using SimpleMvcApp.Services;
6 |
7 | namespace SimpleMvcApp.Features.NewPet
8 | {
9 | [Route("/pets/new")]
10 | public class NewPetController : Controller
11 | {
12 | private readonly ILogger _logger;
13 | private readonly PetsClient _client;
14 |
15 | public NewPetController(ILogger logger, PetsClient client)
16 | {
17 | _logger = logger;
18 | _client = client;
19 | }
20 |
21 | [Route("")]
22 | public IActionResult Index()
23 | {
24 | return View(new NewPetCommand());
25 | }
26 |
27 | [Route("")]
28 | [HttpPost]
29 | public async Task New(NewPetCommand newPetCommand)
30 | {
31 | if (!ModelState.IsValid)
32 | {
33 | throw new ArgumentException("Invalid request");
34 | }
35 | await _client.New(newPetCommand);
36 | return RedirectToAction("Index", "ListPets");
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Features/Shared/_Layout.cshtml:
--------------------------------------------------------------------------------
1 | @using Microsoft.AspNetCore.Hosting
2 | @inject IWebHostEnvironment hostEnvironment
3 |
4 |
5 |
6 |
7 |
8 |
9 | @ViewData["Title"] - Simple Pets
10 | @Html.Raw(JavaScriptSnippet.FullScript)
11 |
12 |
13 |
14 |
15 |
16 |
17 | @RenderBody()
18 |
19 |
20 |
21 |
22 | @await RenderSectionAsync("Scripts", required: false)
23 |
24 |
25 |
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Features/Shared/_ValidationScriptsPartial.cshtml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Features/_ViewImports.cshtml:
--------------------------------------------------------------------------------
1 | @using SimpleMvcApp
2 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
3 | @inject Microsoft.ApplicationInsights.AspNetCore.JavaScriptSnippet JavaScriptSnippet
4 |
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Features/_ViewStart.cshtml:
--------------------------------------------------------------------------------
1 | @{
2 | Layout = "_Layout";
3 | }
4 |
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Infrastructure/ApiSettings.cs:
--------------------------------------------------------------------------------
1 | namespace SimpleMvcApp.Infrastructure
2 | {
3 | // ReSharper disable once ClassNeverInstantiated.Global
4 | public class ApiSettings
5 | {
6 | public string Url { get; set; }
7 | public string SubscriptionKey { get; set; }
8 | public string Scope { get; set; }
9 | }
10 | }
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Infrastructure/HttpClientEx.cs:
--------------------------------------------------------------------------------
1 | using System.Net.Http;
2 | using System.Text.Json;
3 | using System.Threading.Tasks;
4 |
5 | namespace SimpleMvcApp.Infrastructure
6 | {
7 | public static class HttpClientEx
8 | {
9 | public static async Task AsJsonAsync(this Task responseMessage)
10 | {
11 | var response = await responseMessage;
12 | response.EnsureSuccessStatusCode();
13 | return JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(), new JsonSerializerOptions()
14 | {
15 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase
16 | });
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Program.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Threading.Tasks;
5 | using Microsoft.AspNetCore.Hosting;
6 | using Microsoft.Extensions.Configuration;
7 | using Microsoft.Extensions.Hosting;
8 | using Microsoft.Extensions.Logging;
9 |
10 | namespace SimpleMvcApp
11 | {
12 | public class Program
13 | {
14 | public static void Main(string[] args)
15 | {
16 | CreateHostBuilder(args).Build().Run();
17 | }
18 |
19 | public static IHostBuilder CreateHostBuilder(string[] args) =>
20 | Host.CreateDefaultBuilder(args)
21 | .ConfigureLogging(builder => builder.AddAzureWebAppDiagnostics())
22 | .ConfigureWebHostDefaults(webBuilder =>
23 | {
24 | webBuilder.UseStartup();
25 | });
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "profiles": {
3 | "SimpleMvcApp": {
4 | "commandName": "Project",
5 | "dotnetRunMessages": "true",
6 | "launchBrowser": true,
7 | "applicationUrl": "https://sampleapp.localtest.me:4430;",
8 | "environmentVariables": {
9 | "ASPNETCORE_ENVIRONMENT": "Development"
10 | }
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Services/PetsClient.cs:
--------------------------------------------------------------------------------
1 | using System.Net.Http;
2 | using System.Net.Http.Headers;
3 | using System.Text;
4 | using System.Text.Json;
5 | using System.Threading.Tasks;
6 | using Microsoft.Extensions.Logging;
7 | using Microsoft.Extensions.Options;
8 | using Microsoft.Identity.Web;
9 | using SimpleMvcApp.Features.ListPets;
10 | using SimpleMvcApp.Features.NewPet;
11 | using SimpleMvcApp.Infrastructure;
12 |
13 | namespace SimpleMvcApp.Services
14 | {
15 | public class PetsClient
16 | {
17 | public readonly string ServiceName = nameof(PetsClient);
18 |
19 | private readonly HttpClient _client;
20 | private readonly ILogger _logger;
21 | private readonly ITokenAcquisition _tokenAcquisition;
22 | private readonly IOptions _settings;
23 |
24 | public PetsClient(
25 | HttpClient client,
26 | ILogger logger,
27 | ITokenAcquisition tokenAcquisition,
28 | IOptions settings)
29 | {
30 | _client = client;
31 | _logger = logger;
32 | _tokenAcquisition = tokenAcquisition;
33 | _settings = settings;
34 | }
35 |
36 | public async Task GetAll()
37 | {
38 | _logger.LogDebug("Fetching all pets");
39 | var token = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { _settings.Value.Scope });
40 | var req = new HttpRequestMessage(HttpMethod.Get, "pets");
41 | req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
42 | req.Headers.Add("Ocp-Apim-Subscription-Key", _settings.Value.SubscriptionKey);
43 | return await _client.SendAsync(req).AsJsonAsync();
44 | }
45 |
46 | public async Task New(NewPetCommand newPetCommand)
47 | {
48 | _logger.LogDebug("Adding new pet");
49 | var token = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { _settings.Value.Scope });
50 | var req = new HttpRequestMessage(HttpMethod.Post, "pets");
51 | req.Content = new StringContent(JsonSerializer.Serialize(newPetCommand), Encoding.UTF8, "application/json");
52 | req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
53 | req.Headers.Add("Ocp-Apim-Subscription-Key", _settings.Value.SubscriptionKey);
54 | await _client.SendAsync(req);
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Services/ReferenceItem.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace SimpleMvcApp.Services
4 | {
5 | public class ReferenceItem
6 | {
7 | public Guid Id { get; set; }
8 | public string Name { get; set; }
9 | }
10 | }
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/SimpleMvcApp.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net7.0
5 | QuickStartSampleWebApp
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | <_ContentIncludedByDefault Remove="Views\Shared\Error.cshtml" />
20 | <_ContentIncludedByDefault Remove="Views\Shared\_Layout.cshtml" />
21 | <_ContentIncludedByDefault Remove="Views\Shared\_ValidationScriptsPartial.cshtml" />
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/Startup.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Net;
5 | using System.Net.Http;
6 | using System.Text.Json.Serialization;
7 | using JetBrains.Annotations;
8 | using Microsoft.AspNetCore.Authentication.OpenIdConnect;
9 | using Microsoft.AspNetCore.Authorization;
10 | using Microsoft.AspNetCore.Builder;
11 | using Microsoft.AspNetCore.Hosting;
12 | using Microsoft.AspNetCore.HttpsPolicy;
13 | using Microsoft.AspNetCore.Mvc.Authorization;
14 | using Microsoft.AspNetCore.Mvc.Razor;
15 | using Microsoft.Extensions.Configuration;
16 | using Microsoft.Extensions.DependencyInjection;
17 | using Microsoft.Extensions.Hosting;
18 | using Microsoft.Identity.Web;
19 | using SimpleMvcApp.Infrastructure;
20 | using SimpleMvcApp.Services;
21 |
22 | [assembly: AspMvcViewLocationFormat(@"~\Features\{1}\{0}.cshtml")]
23 | [assembly: AspMvcViewLocationFormat(@"~\Features\{0}.cshtml")]
24 | [assembly: AspMvcViewLocationFormat(@"~\Features\Shared\{0}.cshtml")]
25 |
26 | [assembly: AspMvcAreaViewLocationFormat(@"~\Areas\{2}\{1}\{0}.cshtml")]
27 | [assembly: AspMvcAreaViewLocationFormat(@"~\Areas\{2}\Features\{1}\{0}.cshtml")]
28 | [assembly: AspMvcAreaViewLocationFormat(@"~\Areas\{2}\{0}.cshtml")]
29 | [assembly: AspMvcAreaViewLocationFormat(@"~\Areas\{2}\Shared\{0}.cshtml")]
30 |
31 | namespace SimpleMvcApp
32 | {
33 | public class Startup
34 | {
35 | public Startup(IConfiguration configuration, IHostEnvironment environment)
36 | {
37 | Configuration = configuration;
38 | Environment = environment;
39 | }
40 |
41 | public IConfiguration Configuration { get; }
42 | public IHostEnvironment Environment { get; }
43 |
44 | // This method gets called by the runtime. Use this method to add services to the container.
45 | public void ConfigureServices(IServiceCollection services)
46 | {
47 | services.AddApplicationInsightsTelemetry();
48 | services.AddHealthChecks();
49 |
50 | var intermediateSettings = new ApiSettings();
51 | var configurationSection = Configuration.GetSection("ApiSettings");
52 | configurationSection.Bind(intermediateSettings);
53 | services.Configure(configurationSection);
54 |
55 | services.AddControllersWithViews(options =>
56 | {
57 | var policy = new AuthorizationPolicyBuilder()
58 | .RequireAuthenticatedUser()
59 | .Build();
60 | options.Filters.Add(new AuthorizeFilter(policy));
61 | options.Filters.Add(new AuthorizeForScopesAttribute { Scopes = new[] { intermediateSettings.Scope } });
62 | });
63 |
64 | services.AddRazorPages(); //.AddRazorRuntimeCompilation();
65 | services.Configure(x =>
66 | {
67 | x.ViewLocationExpanders.Add(new FeatureLocationExpander());
68 | });
69 |
70 | services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
71 | .AddMicrosoftIdentityWebApp(Configuration.GetSection("AzureAd"))
72 | .EnableTokenAcquisitionToCallDownstreamApi(new[] { intermediateSettings.Scope })
73 | .AddInMemoryTokenCaches()
74 | ;
75 |
76 | var serviceClient = services.AddHttpClient(c =>
77 | {
78 | c.BaseAddress = new Uri(intermediateSettings.Url);
79 | });
80 |
81 | if (Environment.IsDevelopment())
82 | {
83 | serviceClient.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler()
84 | {
85 | ServerCertificateCustomValidationCallback = (a, b, c, d) => true
86 | });
87 | }
88 | }
89 |
90 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
91 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
92 | {
93 | if (env.IsDevelopment())
94 | {
95 | app.UseDeveloperExceptionPage();
96 | }
97 | else
98 | {
99 | app.UseExceptionHandler("/Home/Error");
100 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
101 | app.UseHsts();
102 | }
103 |
104 | app.UseHttpsRedirection();
105 | app.UseStaticFiles();
106 |
107 | app.UseRouting();
108 |
109 | app.UseAuthentication();
110 | app.UseAuthorization();
111 |
112 | app.UseEndpoints(endpoints =>
113 | {
114 | endpoints.MapHealthChecks("/health");
115 | endpoints.MapControllerRoute(
116 | name: "default",
117 | pattern: "{controller=home}/{action=index}/{id?}");
118 | });
119 | }
120 | }
121 | }
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/appsettings.Development.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Information",
5 | "Microsoft": "Warning",
6 | "Microsoft.Hosting.Lifetime": "Information"
7 | }
8 | },
9 | "ApiSettings": {
10 | "Url": "https://api.sampleapp.localtest.me:4431",
11 | "Scope": "api://30c96387-e02f-49e3-878c-17c2cbf34a35/Pets.Manage"
12 | },
13 | "AzureAD": {
14 | "ClientId": "8f566f8d-2de9-404e-b802-d5bff33c6d27"
15 | }
16 | }
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Information",
5 | "Microsoft": "Warning",
6 | "Microsoft.Hosting.Lifetime": "Information"
7 | },
8 | "ApplicationInsights": {
9 | "LogLevel": {
10 | "Default": "Information"
11 | }
12 | }
13 | },
14 | "AllowedHosts": "*",
15 | "AzureAD" : {
16 | "TenantId" : "49f24cca-11a6-424d-b2e2-0650053986cc",
17 | "Instance": "https://login.microsoftonline.com/"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/wwwroot/css/site.css:
--------------------------------------------------------------------------------
1 | /* Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
2 | for details on configuring this project to bundle and minify static web assets. */
3 |
4 | a.navbar-brand {
5 | white-space: normal;
6 | text-align: center;
7 | word-break: break-all;
8 | }
9 |
10 | /* Provide sufficient contrast against white background */
11 | a {
12 | color: #0366d6;
13 | }
14 |
15 | .btn-primary {
16 | color: #fff;
17 | background-color: #1b6ec2;
18 | border-color: #1861ac;
19 | }
20 |
21 | .nav-pills .nav-link.active, .nav-pills .show > .nav-link {
22 | color: #fff;
23 | background-color: #1b6ec2;
24 | border-color: #1861ac;
25 | }
26 |
27 | /* Sticky footer styles
28 | -------------------------------------------------- */
29 | html {
30 | font-size: 14px;
31 | }
32 | @media (min-width: 768px) {
33 | html {
34 | font-size: 16px;
35 | }
36 | }
37 |
38 | .border-top {
39 | border-top: 1px solid #e5e5e5;
40 | }
41 | .border-bottom {
42 | border-bottom: 1px solid #e5e5e5;
43 | }
44 |
45 | .box-shadow {
46 | box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
47 | }
48 |
49 | button.accept-policy {
50 | font-size: 1rem;
51 | line-height: inherit;
52 | }
53 |
54 | /* Sticky footer styles
55 | -------------------------------------------------- */
56 | html {
57 | position: relative;
58 | min-height: 100%;
59 | }
60 |
61 | body {
62 | /* Margin bottom by footer height */
63 | margin-bottom: 60px;
64 | }
65 | .footer {
66 | position: absolute;
67 | bottom: 0;
68 | width: 100%;
69 | white-space: nowrap;
70 | line-height: 60px; /* Vertically center the text there */
71 | }
72 |
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/wwwroot/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/graemefoster/QuickStart/cbe312b29ce2792d47f11f6ca38e4ee0436a0992/Application/WebApp/SimpleMvcApp/wwwroot/favicon.ico
--------------------------------------------------------------------------------
/Application/WebApp/SimpleMvcApp/wwwroot/js/site.js:
--------------------------------------------------------------------------------
1 | // Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
2 | // for details on configuring this project to bundle and minify static web assets.
3 |
4 | // Write your JavaScript code.
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 graemefoster
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Platform/Tier1/inner.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'resourceGroup'
2 |
3 | param resourcePrefix string
4 | param apimPublishedEmail string
5 | param databaseAdministratorName string
6 | param databaseAdministratorObjectId string
7 | param environmentName string
8 | param hasSlot bool
9 | param location string = resourceGroup().location
10 |
11 | var apimName = '${resourcePrefix}-${environmentName}-apim'
12 | var databaseServerName = '${resourcePrefix}-${environmentName}-sqlserver'
13 |
14 | resource LogAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2021-06-01' = {
15 | name: '${resourcePrefix}-${environmentName}-loga'
16 | location: location
17 | properties: {
18 | sku: {
19 | name: 'PerGB2018'
20 | }
21 | workspaceCapping: {
22 | dailyQuotaGb: 1
23 | }
24 | }
25 | }
26 |
27 | resource QuickStartServerFarm 'Microsoft.Web/serverfarms@2021-01-15' = {
28 | name: '${resourcePrefix}-${environmentName}-asp'
29 | location: location
30 | sku: {
31 | name: hasSlot ? 'S1' : 'F1'
32 | }
33 | }
34 |
35 | resource AppServicePlanDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
36 | name: 'aspDiagnostics'
37 | scope: QuickStartServerFarm
38 | properties: {
39 | workspaceId: LogAnalyticsWorkspace.id
40 | metrics: [
41 | {
42 | category: 'AllMetrics'
43 | enabled: true
44 | retentionPolicy: {
45 | days: 3
46 | enabled: true
47 | }
48 | }
49 | ]
50 | }
51 | }
52 |
53 | resource ContainerAppsAppInsights 'Microsoft.Insights/components@2020-02-02-preview' = {
54 | name: '${resourcePrefix}-${environmentName}-ctrapps-appi'
55 | location: location
56 | kind: 'web'
57 | properties: {
58 | Application_Type: 'web'
59 | Flow_Type: 'Bluefield'
60 | Request_Source: 'rest'
61 | WorkspaceResourceId: LogAnalyticsWorkspace.id
62 | }
63 | }
64 |
65 | resource ContainerAppsEnvironment 'Microsoft.App/managedEnvironments@2022-06-01-preview' = {
66 | name: '${resourcePrefix}-${environmentName}-ctrapps'
67 | location: location
68 | properties: {
69 | appLogsConfiguration: {
70 | destination: 'log-analytics'
71 | logAnalyticsConfiguration: {
72 | customerId: LogAnalyticsWorkspace.properties.customerId
73 | sharedKey: LogAnalyticsWorkspace.listKeys().primarySharedKey
74 | }
75 | }
76 | }
77 | }
78 |
79 | resource SqlDatabaseServer 'Microsoft.Sql/servers@2021-02-01-preview' = {
80 | name: databaseServerName
81 | location: location
82 | properties: {
83 | minimalTlsVersion: '1.2'
84 | administrators: {
85 | azureADOnlyAuthentication: true
86 | administratorType: 'ActiveDirectory'
87 | login: databaseAdministratorName
88 | principalType: 'Application'
89 | tenantId: subscription().tenantId
90 | sid: databaseAdministratorObjectId
91 | }
92 | }
93 | }
94 |
95 | resource SqlFirewallAllowAzureServices 'Microsoft.Sql/servers/firewallRules@2021-02-01-preview' = {
96 | parent: SqlDatabaseServer
97 | name: 'AllowAllAzureServices'
98 | properties: {
99 | startIpAddress: '0.0.0.0'
100 | endIpAddress: '0.0.0.0'
101 | }
102 | }
103 |
104 | resource Apim 'Microsoft.ApiManagement/service@2021-04-01-preview' = {
105 | location: location
106 | name: apimName
107 | sku: {
108 | capacity:0
109 | name: 'Consumption'
110 | }
111 | properties: {
112 | publisherEmail: apimPublishedEmail
113 | publisherName: 'ApimPublisher'
114 | }
115 | }
116 |
117 | output serverFarmId string = QuickStartServerFarm.id
118 | output databaseServerName string = databaseServerName
119 | output logAnalyticsWorkspaceId string = LogAnalyticsWorkspace.id
120 | output containerEnvironmentId string = ContainerAppsEnvironment.id
121 | output apimHostname string = Apim.properties.hostnameConfigurations[0].hostName
122 | output apimId string = Apim.id
123 |
--------------------------------------------------------------------------------
/Platform/Tier1/main.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'resourceGroup'
2 |
3 | param resourcePrefix string
4 | param databaseAdministratorName string
5 | param databaseAdministratorObjectId string
6 | param environmentName string
7 | param apimPublisherEmail string
8 | param location string = resourceGroup().location
9 | param platformRgName string
10 | param singleResourceGroupDeployment bool
11 |
12 | var hasSlot = environmentName != 'test'
13 |
14 | var uniqueness = uniqueString(resourceGroup().name)
15 |
16 | module PlatformDeployment './inner.bicep' = {
17 | name: '${deployment().name}-inner'
18 | scope: resourceGroup(platformRgName)
19 | params: {
20 | location: location
21 | resourcePrefix: resourcePrefix
22 | databaseAdministratorName: databaseAdministratorName
23 | databaseAdministratorObjectId: databaseAdministratorObjectId
24 | environmentName: environmentName
25 | hasSlot: hasSlot
26 | apimPublishedEmail: apimPublisherEmail
27 | }
28 | }
29 |
30 | output platformResourceGroupName string = platformRgName
31 | output serverFarmId string = PlatformDeployment.outputs.serverFarmId
32 | output databaseServerName string = PlatformDeployment.outputs.databaseServerName
33 | output logAnalyticsWorkspaceId string = PlatformDeployment.outputs.logAnalyticsWorkspaceId
34 | output containerEnvironmentId string = PlatformDeployment.outputs.containerEnvironmentId
35 | output apimHostname string = PlatformDeployment.outputs.apimHostname
36 | output resourcePrefix string = resourcePrefix
37 | output databaseAdministratorName string = databaseAdministratorName
38 | output environmentName string = environmentName
39 | output singleResourceGroupDeployment bool = singleResourceGroupDeployment
40 | output uniqueness string =uniqueness
41 |
--------------------------------------------------------------------------------
/Platform/Tier2/api/database.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'resourceGroup'
2 |
3 | param resourcePrefix string
4 | param databaseServerName string
5 | param environmentName string
6 | param logAnalyticsWorkspaceId string
7 | param location string = resourceGroup().location
8 |
9 | var databaseName = '${resourcePrefix}-${environmentName}-sqldb'
10 |
11 | resource SqlDatabaseTest 'Microsoft.Sql/servers/databases@2021-02-01-preview' = {
12 | name: '${databaseServerName}/${databaseName}'
13 | location: location
14 | sku: {
15 | tier: 'Basic'
16 | name: 'Basic'
17 | capacity: 5
18 | }
19 | }
20 |
21 | resource DatabaseDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
22 | name: 'databaseDiagnostics'
23 | scope: SqlDatabaseTest
24 | properties: {
25 | workspaceId: logAnalyticsWorkspaceId
26 | metrics: [
27 | {
28 | category: 'Basic'
29 | enabled: true
30 | retentionPolicy: {
31 | days: 3
32 | enabled: true
33 | }
34 | }
35 | ]
36 | logs: [
37 | {
38 | categoryGroup: 'allLogs'
39 | enabled: true
40 | retentionPolicy: {
41 | days: 3
42 | enabled: true
43 | }
44 | }
45 | ]
46 | }
47 | }
48 |
49 | output apiDatabaseName string = databaseName
50 | output apiDatabaseConnectionString string = 'Server=tcp:${databaseServerName}${environment().suffixes.sqlServerHostname},1433;Database=${databaseName};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30; Authentication=Active Directory Default'
51 |
--------------------------------------------------------------------------------
/Platform/Tier2/api/main.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'resourceGroup'
2 |
3 | param resourcePrefix string
4 | param environmentName string
5 | param platformResourceGroupName string
6 | param logAnalyticsWorkspaceId string
7 | param serverFarmId string
8 | param databaseServerName string
9 | param uniqueness string
10 | param apiAadClientId string
11 | param aadTenantId string
12 | param appFqdn string
13 | param spaFqdn string
14 | param appSlotFqdn string
15 | param spaSlotFqdn string
16 |
17 | param location string = resourceGroup().location
18 |
19 | var apiName = '${resourcePrefix}-${uniqueness}-${environmentName}-api'
20 | var apiMsiName = '${resourcePrefix}-${uniqueness}-${environmentName}-msi'
21 | var cors0 = 'https://${appFqdn}'
22 | var cors1 = 'https://${appSlotFqdn}'
23 | var cors2 = 'https://${spaFqdn}'
24 | var cors3 = 'https://${spaSlotFqdn}'
25 | var deploySlot = environmentName != 'test'
26 |
27 | module database 'database.bicep' = {
28 | name: '${deployment().name}-db'
29 | scope: resourceGroup(platformResourceGroupName)
30 | params: {
31 | databaseServerName: databaseServerName
32 | environmentName: environmentName
33 | resourcePrefix: resourcePrefix
34 | logAnalyticsWorkspaceId: logAnalyticsWorkspaceId
35 | location: location
36 | }
37 | }
38 |
39 | resource ManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = {
40 | location: location
41 | name: apiMsiName
42 | }
43 |
44 | resource WebAppAppInsights 'Microsoft.Insights/components@2020-02-02' = {
45 | name: '${apiName}-appi'
46 | location: location
47 | kind: 'Web'
48 | properties: {
49 | Application_Type: 'web'
50 | WorkspaceResourceId: logAnalyticsWorkspaceId
51 | }
52 | }
53 |
54 | var settings = [
55 | {
56 | name: 'WEBSITE_RUN_FROM_PACKAGE'
57 | value: '1'
58 | }
59 | { name: 'ASPNETCORE_ENVIRONMENT'
60 | value: 'Production'
61 | }
62 | {
63 | name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
64 | value: WebAppAppInsights.properties.ConnectionString
65 | }
66 | {
67 | name: 'ApplicationInsightsAgent_EXTENSION_VERSION'
68 | value: '~2'
69 | }
70 | {
71 | name: 'XDT_MicrosoftApplicationInsights_Mode'
72 | value: 'recommended'
73 | }
74 | {
75 | name: 'InstrumentationEngine_EXTENSION_VERSION'
76 | value: '~1'
77 | }
78 | {
79 | name: 'XDT_MicrosoftApplicationInsights_BaseExtensions'
80 | value: '~1'
81 | }
82 | {
83 | name: 'ApiSettings__UserAssignedClientId'
84 | value: ManagedIdentity.properties.clientId
85 | }
86 | {
87 | name: 'AzureAD__ClientId'
88 | value: apiAadClientId
89 | }
90 | {
91 | name: 'AzureAD__TenantId'
92 | value: aadTenantId
93 | }
94 | {
95 | name: 'ApiSettings__ConnectionString'
96 | value: '${database.outputs.apiDatabaseConnectionString};User Id=${ManagedIdentity.properties.clientId}'
97 | }
98 | { name: 'ApiSettings__Cors__0'
99 | value: cors0
100 | }
101 | { name: 'ApiSettings__Cors__1'
102 | value: cors1
103 | }
104 | { name: 'ApiSettings__Cors__2'
105 | value: cors2
106 | }
107 | { name: 'ApiSettings__Cors__3'
108 | value: cors3
109 | }
110 | ]
111 |
112 | resource WebApi 'Microsoft.Web/sites@2021-01-15' = {
113 | name: apiName
114 | location: location
115 | identity: {
116 | type: 'UserAssigned'
117 | userAssignedIdentities: {
118 | '${ManagedIdentity.id}': {}
119 | }
120 | }
121 | properties: {
122 | httpsOnly: true
123 | serverFarmId: serverFarmId
124 | siteConfig: {
125 | minTlsVersion: '1.2'
126 | netFrameworkVersion: 'v7.0'
127 | appSettings: settings
128 | }
129 | }
130 | }
131 |
132 | resource WebAppInsightsHealthCheck 'Microsoft.Insights/webtests@2018-05-01-preview' = {
133 | location: location
134 | name: 'webapi-ping-test'
135 | kind: 'ping'
136 | //Must have tag pointing to App Insights
137 | tags: {
138 | 'hidden-link:${WebAppAppInsights.id}': 'Resource'
139 | }
140 | properties: {
141 | Kind: 'ping'
142 | Frequency: 300
143 | Name: 'webapi-ping-test'
144 | SyntheticMonitorId: 'webapi-ping-test'
145 | Enabled: true
146 | Timeout: 10
147 | Configuration: {
148 | WebTest: ''
149 | }
150 | Locations: [
151 | {
152 | Id: 'emea-au-syd-edge'
153 | }
154 | {
155 | Id: 'apac-sg-sin-azr'
156 | }
157 | ]
158 | }
159 | }
160 |
161 | resource WebApiGreen 'Microsoft.Web/sites/slots@2021-01-15' = if (deploySlot) {
162 | parent: WebApi
163 | name: 'green'
164 | location: location
165 | identity: {
166 | type: 'SystemAssigned'
167 | }
168 | properties: {
169 | httpsOnly: true
170 | serverFarmId: serverFarmId
171 | siteConfig: {
172 | minTlsVersion: '1.2'
173 | netFrameworkVersion: 'v7.0'
174 | appSettings: settings
175 | }
176 | }
177 | }
178 |
179 | output appName string = WebApi.name
180 | output appHostname string = WebApi.properties.hostNames[0]
181 | output appSlotHostname string = deploySlot ? WebApiGreen.properties.hostNames[0] : ''
182 | output managedIdentityName string = ManagedIdentity.name
183 | output managedIdentityAppId string = ManagedIdentity.properties.clientId
184 | output appInsightsKey string = WebAppAppInsights.properties.InstrumentationKey
185 | output databaseConnectionString string = database.outputs.apiDatabaseConnectionString
186 |
--------------------------------------------------------------------------------
/Platform/Tier2/app/main.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'resourceGroup'
2 |
3 | param resourcePrefix string
4 | param environmentName string
5 | param serverFarmId string
6 | param logAnalyticsWorkspaceId string
7 | param containerAppFqdn string
8 | param apiAadClientId string
9 | param apiHostName string
10 | param appAadClientId string
11 | param aadTenantId string
12 |
13 | param location string = resourceGroup().location
14 | param uniqueness string
15 |
16 | @secure()
17 | param appClientSecret string
18 |
19 | var subscriptionSecretName = 'ApiSubscriptionKey'
20 | var deploySlot = environmentName != 'test'
21 |
22 | var appHostname = '${resourcePrefix}-${uniqueness}-${environmentName}-webapp'
23 | var appKeyVaultName = '${resourcePrefix}-app-${environmentName}-kv'
24 |
25 | @description('This is the built-in Key Vault Administrator role. See https://docs.microsoft.com/azure/role-based-access-control/built-in-roles#key-vault-administrator')
26 | resource keyVaultSecretsUserRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = {
27 | scope: subscription()
28 | name: '4633458b-17de-408a-b874-0445c86b69e6'
29 | }
30 |
31 | resource AppKeyVault 'Microsoft.KeyVault/vaults@2021-06-01-preview' = {
32 | name: appKeyVaultName
33 | location: location
34 | properties: {
35 | sku: {
36 | family: 'A'
37 | name: 'standard'
38 | }
39 | tenantId: subscription().tenantId
40 | enableRbacAuthorization: true
41 | }
42 |
43 | resource secret 'secrets' = {
44 | name: 'ApplicationClientSecret'
45 | properties: {
46 | value: appClientSecret
47 | }
48 | }
49 | }
50 |
51 | resource KeyVaultDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
52 | name: 'aspDiagnostics'
53 | scope: AppKeyVault
54 | properties: {
55 | workspaceId: logAnalyticsWorkspaceId
56 | metrics: [
57 | {
58 | category: 'AllMetrics'
59 | enabled: true
60 | retentionPolicy: {
61 | days: 3
62 | enabled: true
63 | }
64 | }
65 | ]
66 | logs: [
67 | {
68 | categoryGroup: 'allLogs'
69 | enabled: true
70 | retentionPolicy: {
71 | days: 3
72 | enabled: true
73 | }
74 | }
75 | ]
76 | }
77 | }
78 |
79 | resource WebAppAppInsights 'Microsoft.Insights/components@2020-02-02' = {
80 | name: '${appHostname}-appi'
81 | location: location
82 | kind: 'Web'
83 | properties: {
84 | Application_Type: 'web'
85 | WorkspaceResourceId: logAnalyticsWorkspaceId
86 | }
87 | }
88 |
89 | var settings = [
90 | {
91 | name: 'WEBSITE_RUN_FROM_PACKAGE'
92 | value: '1'
93 | }
94 | {
95 | name: 'ASPNETCORE_ENVIRONMENT'
96 | value: environmentName
97 | }
98 | {
99 | name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
100 | value: WebAppAppInsights.properties.ConnectionString
101 | }
102 | {
103 | name: 'ApplicationInsightsAgent_EXTENSION_VERSION'
104 | value: '~2'
105 | }
106 | {
107 | name: 'XDT_MicrosoftApplicationInsights_Mode'
108 | value: 'recommended'
109 | }
110 | {
111 | name: 'InstrumentationEngine_EXTENSION_VERSION'
112 | value: '~1'
113 | }
114 | {
115 | name: 'XDT_MicrosoftApplicationInsights_BaseExtensions'
116 | value: '~1'
117 | }
118 | {
119 | name: 'ApiSettings__MicroServiceUrl'
120 | value: 'https://${containerAppFqdn}'
121 | }
122 | {
123 | name: 'ApiSettings__SubscriptionKey'
124 | value: '@Microsoft.KeyVault(VaultName=${AppKeyVault.name};SecretName=${subscriptionSecretName})'
125 | }
126 | {
127 | name: 'ApiSettings__URL'
128 | value: 'https://${apiHostName}'
129 | }
130 | {
131 | name: 'ApiSettings__Scope'
132 | value: 'api://${apiAadClientId}/Pets.Manage'
133 | }
134 | {
135 | name: 'AzureAD__ClientId'
136 | value: appAadClientId
137 | }
138 | {
139 | name: 'AzureAD__TenantId'
140 | value: aadTenantId
141 | }
142 | {
143 | name: 'AzureAD__ClientSecret'
144 | value: '@Microsoft.KeyVault(VaultName=${appKeyVaultName};SecretName=ApplicationClientSecret)'
145 | }
146 | ]
147 |
148 | resource WebApp 'Microsoft.Web/sites@2021-01-15' = {
149 | name: appHostname
150 | location: location
151 | identity: {
152 | type: 'SystemAssigned'
153 | }
154 | properties: {
155 | httpsOnly: true
156 | serverFarmId: serverFarmId
157 | siteConfig: {
158 | minTlsVersion: '1.2'
159 | netFrameworkVersion: 'v7.0'
160 | appSettings: settings
161 | }
162 | }
163 | }
164 |
165 |
166 | resource WebAppAppInsightsHealthCheck 'Microsoft.Insights/webtests@2018-05-01-preview' = {
167 | location: location
168 | name: 'webapp-ping-test'
169 | kind: 'ping'
170 | //Must have tag pointing to App Insights
171 | tags: {
172 | 'hidden-link:${WebAppAppInsights.id}' : 'Resource'
173 | }
174 | properties: {
175 | Kind: 'ping'
176 | Frequency: 300
177 | Name: 'webapp-ping-test'
178 | SyntheticMonitorId: 'webapp-ping-test'
179 | Enabled: true
180 | Timeout: 10
181 | Configuration: {
182 | WebTest: ''
183 | }
184 | //Locations here: https://docs.microsoft.com/en-us/azure/azure-monitor/app/monitor-web-app-availability
185 | Locations: [
186 | {
187 | Id: 'emea-au-syd-edge' //australia east
188 | }
189 | {
190 | Id: 'apac-sg-sin-azr' //south-east asia
191 | }
192 | {
193 | Id: 'emea-nl-ams-azr' //west-europe
194 | }
195 | {
196 | Id: 'us-va-ash-azr' //east-us
197 | }
198 | {
199 | Id: 'us-ca-sjc-azr' //west-us
200 | }
201 | ]
202 | }
203 | }
204 |
205 | resource KeyVaultAuth 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
206 | name: guid('${appHostname}-read-${appKeyVaultName}')
207 | scope: AppKeyVault
208 | properties: {
209 | roleDefinitionId: keyVaultSecretsUserRoleDefinition.id
210 | principalId: WebApp.identity.principalId
211 | principalType: 'ServicePrincipal'
212 | }
213 | }
214 |
215 | resource WebAppGreen 'Microsoft.Web/sites/slots@2021-01-15' = if(deploySlot) {
216 | parent: WebApp
217 | name: 'green'
218 | location: location
219 | identity: {
220 | type: 'SystemAssigned'
221 | }
222 | properties: {
223 | httpsOnly: true
224 | serverFarmId: serverFarmId
225 | siteConfig: {
226 | minTlsVersion: '1.2'
227 | netFrameworkVersion: 'v7.0'
228 | appSettings: settings
229 | }
230 | }
231 | }
232 |
233 | resource GreenKeyVaultAuth 'Microsoft.Authorization/roleAssignments@2022-04-01' = if(deploySlot) {
234 | name: guid('${appHostname}.green-read-${appKeyVaultName}')
235 | scope: AppKeyVault
236 | properties: {
237 | roleDefinitionId: keyVaultSecretsUserRoleDefinition.id
238 | principalId: (deploySlot == true) ? WebAppGreen.identity.principalId : 'Not deploying'
239 | principalType: 'ServicePrincipal'
240 | }
241 | }
242 |
243 | output appName string = WebApp.name
244 | output appHostname string = WebApp.properties.hostNames[0]
245 | output appSlotHostname string = deploySlot ? WebAppGreen.properties.hostNames[0] : ''
246 | output appInsightsKey string = WebAppAppInsights.properties.InstrumentationKey
247 | output appKeyVaultName string = appKeyVaultName
248 | output apiKeySecretName string = subscriptionSecretName
249 |
--------------------------------------------------------------------------------
/Platform/Tier2/main.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'subscription'
2 |
3 | param resourcePrefix string
4 | param environmentName string
5 | param location string
6 | param platformResourceGroupName string
7 | param singleResourceGroupDeployment bool
8 | param databaseServerName string
9 | param logAnalyticsWorkspaceId string
10 | param containerEnvironmentId string
11 | param serverFarmId string
12 | param apimHostname string
13 | param uniqueness string
14 | param appClientId string
15 | param apiClientId string
16 | param aadTenantId string
17 |
18 | @secure()
19 | param appClientSecret string
20 |
21 | var apiRgName = '${resourcePrefix}-${environmentName}-api-rg'
22 |
23 | var microserviceRgName = '${resourcePrefix}-${environmentName}-microservice-rg'
24 | module microserviceResourceGroup '../rg.bicep' = if (!singleResourceGroupDeployment) {
25 | name: '${deployment().name}-microservicerg'
26 | scope: subscription()
27 | params: {
28 | resourceGroupName: microserviceRgName
29 | location: location
30 | }
31 | }
32 |
33 | module MicroServiceDeployment './microservice/main.bicep' = {
34 | name: '${deployment().name}-microservice'
35 | scope: resourceGroup(singleResourceGroupDeployment ? platformResourceGroupName : microserviceRgName)
36 | params: {
37 | location: location
38 | environmentId: containerEnvironmentId
39 | containerAppName: 'pet-ownership'
40 | containerImage: 'ghcr.io/graemefoster/sample-microservice:latest'
41 | }
42 | }
43 |
44 | var appRgName = '${resourcePrefix}-${environmentName}-app-rg'
45 | module appResourceGroup '../rg.bicep' = if (!singleResourceGroupDeployment) {
46 | name: '${deployment().name}-apprg'
47 | scope: subscription()
48 | params: {
49 | resourceGroupName: appRgName
50 | location: location
51 | }
52 | }
53 |
54 | module AppDeployment './app/main.bicep' = {
55 | name: '${deployment().name}-app'
56 | scope: resourceGroup(singleResourceGroupDeployment ? platformResourceGroupName : appRgName)
57 | params: {
58 | environmentName: environmentName
59 | resourcePrefix: resourcePrefix
60 | location: location
61 | logAnalyticsWorkspaceId: logAnalyticsWorkspaceId
62 | serverFarmId: serverFarmId
63 | containerAppFqdn: MicroServiceDeployment.outputs.containerAppFqdn
64 | apiHostName: apimHostname
65 | uniqueness: uniqueness
66 | apiAadClientId: apiClientId
67 | appAadClientId: appClientId
68 | aadTenantId: aadTenantId
69 | appClientSecret: appClientSecret
70 | }
71 | }
72 |
73 | var spaRgName = '${resourcePrefix}-${environmentName}-spa-rg'
74 | module spaResourceGroup '../rg.bicep' = if (!singleResourceGroupDeployment) {
75 | name: '${deployment().name}-sparg'
76 | scope: subscription()
77 | params: {
78 | resourceGroupName: spaRgName
79 | location: location
80 | }
81 | }
82 |
83 | module SpaDeployment './spa/main.bicep' = {
84 | name: '${deployment().name}-spa'
85 | scope: resourceGroup(singleResourceGroupDeployment ? platformResourceGroupName : spaRgName)
86 | params: {
87 | environmentName: environmentName
88 | resourcePrefix: resourcePrefix
89 | location: location
90 | logAnalyticsWorkspaceId: logAnalyticsWorkspaceId
91 | serverFarmId: serverFarmId
92 | uniqueness: uniqueness
93 | apiAadClientId: apiClientId
94 | apiHostname: apimHostname
95 | appAadClientId: appClientId
96 | aadTenantId: aadTenantId
97 | containerAppFqdn: MicroServiceDeployment.outputs.containerAppFqdn
98 | }
99 | }
100 |
101 |
102 | module apiResourceGroup '../rg.bicep' = if (!singleResourceGroupDeployment) {
103 | name: '${deployment().name}-apimrg'
104 | scope: subscription()
105 | params: {
106 | resourceGroupName: apiRgName
107 | location: location
108 | }
109 | }
110 |
111 | module ApiDeployment './api/main.bicep' = {
112 | name: '${deployment().name}-api'
113 | scope: resourceGroup(singleResourceGroupDeployment ? platformResourceGroupName : apiRgName)
114 | params: {
115 | environmentName: environmentName
116 | resourcePrefix: resourcePrefix
117 | platformResourceGroupName: platformResourceGroupName
118 | databaseServerName: databaseServerName
119 | logAnalyticsWorkspaceId: logAnalyticsWorkspaceId
120 | serverFarmId: serverFarmId
121 | location: location
122 | uniqueness: uniqueness
123 | apiAadClientId: apiClientId
124 | aadTenantId: aadTenantId
125 | appFqdn: AppDeployment.outputs.appHostname
126 | appSlotFqdn: empty(AppDeployment.outputs.appSlotHostname) ? AppDeployment.outputs.appHostname : AppDeployment.outputs.appSlotHostname
127 | spaFqdn: SpaDeployment.outputs.appHostname
128 | spaSlotFqdn: empty(SpaDeployment.outputs.appSlotHostname) ? SpaDeployment.outputs.appHostname : SpaDeployment.outputs.appSlotHostname
129 | }
130 | }
131 |
132 |
133 | output appName string = AppDeployment.outputs.appName
134 | output apiName string = ApiDeployment.outputs.appName
135 | output spaName string = SpaDeployment.outputs.appName
136 |
137 | output appFqdn string = AppDeployment.outputs.appHostname
138 | output spaFqdn string = SpaDeployment.outputs.appHostname
139 | output apiFqdn string = ApiDeployment.outputs.appHostname
140 |
141 | output appSlotFqdn string = AppDeployment.outputs.appSlotHostname
142 | output spaSlotFqdn string = SpaDeployment.outputs.appSlotHostname
143 | output apiSlotFqdn string = ApiDeployment.outputs.appSlotHostname
144 |
145 | output microserviceFqdn string = MicroServiceDeployment.outputs.containerAppFqdn
146 | output containerAppName string = MicroServiceDeployment.outputs.containerAppName
147 | output containerAppResourceGroup string = MicroServiceDeployment.outputs.containerAppResourceGroup
148 |
149 | output appResourceGroupName string = singleResourceGroupDeployment ? platformResourceGroupName : appRgName
150 | output appKeyVaultName string = AppDeployment.outputs.appKeyVaultName
151 | output appApiKeySecretName string = AppDeployment.outputs.apiKeySecretName
152 |
153 | output spaResourceGroupName string = singleResourceGroupDeployment ? platformResourceGroupName : spaRgName
154 | output spaKeyVaultName string = SpaDeployment.outputs.appKeyVaultName
155 | output spaApiKeySecretName string = SpaDeployment.outputs.apiKeySecretName
156 |
157 | output databaseConnectionString string = ApiDeployment.outputs.databaseConnectionString
158 | output managedIdentityAppId string = ApiDeployment.outputs.managedIdentityAppId
159 | output managedIdentityName string = ApiDeployment.outputs.managedIdentityName
160 |
161 | output apiResourceGroupName string = singleResourceGroupDeployment ? platformResourceGroupName : apiRgName
162 |
--------------------------------------------------------------------------------
/Platform/Tier2/microservice/main.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'resourceGroup'
2 |
3 | param containerAppName string
4 | param containerImage string
5 | param environmentId string
6 | param location string = resourceGroup().location
7 |
8 | resource containerApp 'Microsoft.App/containerApps@2022-06-01-preview' = {
9 | name: containerAppName
10 | location: location
11 | properties: {
12 | environmentId: environmentId
13 | configuration: {
14 | secrets: []
15 | registries: []
16 | ingress: {
17 | external: true
18 | targetPort: 3000
19 | transport: 'auto'
20 | traffic: [
21 | {
22 | latestRevision: true
23 | weight: 100
24 | }
25 | ]
26 | }
27 | activeRevisionsMode: 'Multiple'
28 | }
29 | template: {
30 | containers: [
31 | {
32 | image: containerImage
33 | name: containerAppName
34 | }
35 | ]
36 | scale: {
37 | minReplicas: 1
38 | }
39 | }
40 | }
41 | }
42 |
43 | output containerAppFqdn string = containerApp.properties.configuration.ingress.fqdn
44 | output containerAppName string = containerApp.name
45 | output containerAppResourceGroup string = resourceGroup().name
46 |
--------------------------------------------------------------------------------
/Platform/Tier2/spa/main.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'resourceGroup'
2 |
3 | param resourcePrefix string
4 | param serverFarmId string
5 | param environmentName string
6 | param logAnalyticsWorkspaceId string
7 | param containerAppFqdn string
8 | param apiHostname string
9 | param apiAadClientId string
10 | param appAadClientId string
11 | param aadTenantId string
12 | param location string = resourceGroup().location
13 | param uniqueness string
14 |
15 | var appHostname = '${resourcePrefix}-${uniqueness}-${environmentName}-spa'
16 | var appKeyVaultName = '${resourcePrefix}-spa-${environmentName}-kv'
17 | var deploySlot = environmentName != 'test'
18 | var subscriptionSecretName = 'ApiSubscriptionKey'
19 |
20 | @description('This is the built-in Key Vault Administrator role. See https://docs.microsoft.com/azure/role-based-access-control/built-in-roles#key-vault-administrator')
21 | resource keyVaultSecretsUserRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = {
22 | scope: subscription()
23 | name: '4633458b-17de-408a-b874-0445c86b69e6'
24 | }
25 |
26 | resource AppKeyVault 'Microsoft.KeyVault/vaults@2021-06-01-preview' = {
27 | name: appKeyVaultName
28 | location: location
29 | properties: {
30 | sku: {
31 | family: 'A'
32 | name: 'standard'
33 | }
34 | tenantId: subscription().tenantId
35 | enableRbacAuthorization: true
36 | }
37 | }
38 |
39 | resource KeyVaultDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
40 | name: 'aspDiagnostics'
41 | scope: AppKeyVault
42 | properties: {
43 | workspaceId: logAnalyticsWorkspaceId
44 | metrics: [
45 | {
46 | category: 'AllMetrics'
47 | enabled: true
48 | retentionPolicy: {
49 | days: 3
50 | enabled: true
51 | }
52 | }
53 | ]
54 | logs: [
55 | {
56 | categoryGroup: 'allLogs'
57 | enabled: true
58 | retentionPolicy: {
59 | days: 3
60 | enabled: true
61 | }
62 | }
63 | ]
64 | }
65 | }
66 |
67 | resource WebAppAppInsights 'Microsoft.Insights/components@2020-02-02' = {
68 | name: '${appHostname}-appi'
69 | location: location
70 | kind: 'Web'
71 | properties: {
72 | Application_Type: 'web'
73 | WorkspaceResourceId: logAnalyticsWorkspaceId
74 | }
75 | }
76 |
77 | var settings = [
78 | {
79 | name: 'APPINSIGHTS_INSTRUMENTATIONKEY'
80 | value: WebAppAppInsights.properties.InstrumentationKey
81 | }
82 | {
83 | name: 'ApplicationInsightsAgent_EXTENSION_VERSION'
84 | value: '~2'
85 | }
86 | {
87 | name: 'XDT_MicrosoftApplicationInsights_Mode'
88 | value: 'recommended'
89 | }
90 | {
91 | name: 'InstrumentationEngine_EXTENSION_VERSION'
92 | value: '~1'
93 | }
94 | {
95 | name: 'XDT_MicrosoftApplicationInsights_BaseExtensions'
96 | value: '~1'
97 | }
98 | {
99 | name: 'ApiSettings__MicroServiceUrl'
100 | value: 'https://${containerAppFqdn}'
101 | }
102 | {
103 | name: 'ApiSettings__SubscriptionKey'
104 | value: '@Microsoft.KeyVault(VaultName=${AppKeyVault.name};SecretName=${subscriptionSecretName})'
105 | }
106 | {
107 | name: 'ApiSettings__URL'
108 | value: 'https://${apiHostname}'
109 | }
110 | {
111 | name: 'ApiSettings__Scope'
112 | value: 'api://${apiAadClientId}/Pets.Manage'
113 | }
114 | {
115 | name: 'AzureAD__ClientId'
116 | value: appAadClientId
117 | }
118 | {
119 | name: 'AzureAD__TenantId'
120 | value: aadTenantId
121 | }
122 | ]
123 |
124 | resource WebApp 'Microsoft.Web/sites@2021-01-15' = {
125 | name: appHostname
126 | location: location
127 | identity: {
128 | type: 'SystemAssigned'
129 | }
130 | properties: {
131 | httpsOnly: true
132 | serverFarmId: serverFarmId
133 | siteConfig: {
134 | minTlsVersion: '1.2'
135 | nodeVersion: 'node|18-lts'
136 | appSettings: settings
137 | }
138 | }
139 | }
140 |
141 | resource WebAppGreen 'Microsoft.Web/sites/slots@2021-01-15' = if (deploySlot) {
142 | parent: WebApp
143 | name: 'green'
144 | location: location
145 | identity: {
146 | type: 'SystemAssigned'
147 | }
148 | properties: {
149 | httpsOnly: true
150 | serverFarmId: serverFarmId
151 | siteConfig: {
152 | minTlsVersion: '1.2'
153 | nodeVersion: 'node|16-lts'
154 | appSettings: settings
155 | }
156 | }
157 | }
158 |
159 | resource KeyVaultAuth 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
160 | name: guid('${appHostname}-read-${appKeyVaultName}')
161 | scope: AppKeyVault
162 | properties: {
163 | roleDefinitionId: keyVaultSecretsUserRoleDefinition.id
164 | principalId: WebApp.identity.principalId
165 | principalType: 'ServicePrincipal'
166 | }
167 | }
168 |
169 | resource GreenKeyVaultAuth 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (deploySlot) {
170 | name: guid('${appHostname}.green-read-${appKeyVaultName}')
171 | scope: AppKeyVault
172 | properties: {
173 | roleDefinitionId: keyVaultSecretsUserRoleDefinition.id
174 | principalId: (deploySlot == true) ? WebAppGreen.identity.principalId : 'Not deploying'
175 | principalType: 'ServicePrincipal'
176 | }
177 | }
178 |
179 | output appName string = WebApp.name
180 | output appHostname string = WebApp.properties.hostNames[0]
181 | output appSlotHostname string = deploySlot ? WebAppGreen.properties.hostNames[0] : ''
182 | output appInsightsKey string = WebAppAppInsights.properties.InstrumentationKey
183 | output appKeyVaultName string = appKeyVaultName
184 | output apiKeySecretName string = subscriptionSecretName
185 |
--------------------------------------------------------------------------------
/Platform/Tier3/Apim/main.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'resourceGroup'
2 |
3 | param resourcePrefix string
4 | param environmentName string
5 |
6 | param apiFqdn string
7 | param logAnalyticsWorkspaceId string
8 |
9 | param appFqdn string
10 | param spaFqdn string
11 | param appSlotFqdn string
12 | param spaSlotFqdn string
13 |
14 | param appConsumerKeyVaultResourceGroup string
15 | param appConsumerKeyVaultName string
16 | param appConsumerSecretName string
17 |
18 | param spaConsumerKeyVaultResourceGroup string
19 | param spaConsumerKeyVaultName string
20 | param spaConsumerSecretName string
21 |
22 | param location string = resourceGroup().location
23 |
24 | var apimServiceName = '${resourcePrefix}-${environmentName}-apim'
25 | var productName = 'PetsProduct'
26 | var apimApiName = 'pets'
27 |
28 | var cors0 = 'https://${appFqdn}'
29 | var cors1 = 'https://${appSlotFqdn}'
30 | var cors2 = 'https://${spaFqdn}'
31 | var cors3 = 'https://${spaSlotFqdn}'
32 |
33 | resource ApimApiAppInsights 'Microsoft.Insights/components@2020-02-02' = {
34 | name: '${apimServiceName}-${apimApiName}-appi'
35 | location: location
36 | kind: 'Api'
37 | properties: {
38 | Application_Type: 'web'
39 | WorkspaceResourceId: logAnalyticsWorkspaceId
40 | }
41 | }
42 |
43 | resource ApiAppInsights 'Microsoft.ApiManagement/service/loggers@2021-04-01-preview' = {
44 | name: '${apimServiceName}/${apimApiName}-logger'
45 | properties: {
46 | loggerType: 'applicationInsights'
47 | resourceId: ApimApiAppInsights.id
48 | credentials: {
49 | instrumentationKey: ApimApiAppInsights.properties.InstrumentationKey
50 | }
51 | }
52 | }
53 |
54 | var backendName = '${apimApiName}backend'
55 | resource ApiBackend 'Microsoft.ApiManagement/service/backends@2022-04-01-preview' = {
56 | name: '${apimServiceName}/${backendName}'
57 | properties: {
58 | protocol: 'http'
59 | url: 'https://${apiFqdn}/pets'
60 | }
61 | }
62 |
63 | resource Api 'Microsoft.ApiManagement/service/apis@2021-04-01-preview' = {
64 | name: '${apimServiceName}/${apimApiName}'
65 | properties: {
66 | protocols: [
67 | 'https'
68 | ]
69 | path: 'Pets'
70 | apiType: 'http'
71 | description: 'Pets Api backed by app-service'
72 | subscriptionRequired: true
73 | displayName: 'PetsApi'
74 | serviceUrl: 'https://${apiFqdn}/pets'
75 | }
76 |
77 | resource Policy 'policies@2021-04-01-preview' = {
78 | name: 'policy'
79 | properties: {
80 | format: 'xml'
81 | value: '${cors0}${cors1}${cors2}${cors3}GETPOST'
82 | }
83 | }
84 |
85 | resource ApiOperation 'operations@2021-04-01-preview' = {
86 | name: 'GetPets'
87 | properties: {
88 | displayName: 'Get Pets'
89 | method: 'GET'
90 | urlTemplate: '/'
91 | }
92 | }
93 |
94 | resource NewPetApiOperation 'operations@2021-04-01-preview' = {
95 | name: 'NewPets'
96 | properties: {
97 | displayName: 'New Pet'
98 | method: 'POST'
99 | urlTemplate: '/'
100 | }
101 | }
102 |
103 | resource ApiAppInsightsLogging 'diagnostics@2021-04-01-preview' = {
104 | name: 'applicationinsights'
105 | properties: {
106 | loggerId: ApiAppInsights.id
107 | httpCorrelationProtocol: 'W3C'
108 | }
109 | }
110 | }
111 |
112 | resource PetsApiProduct 'Microsoft.ApiManagement/service/products@2021-04-01-preview' = {
113 | name: '${apimServiceName}/${productName}'
114 | properties: {
115 | displayName: 'Access to the Pets Product'
116 | approvalRequired: true
117 | subscriptionRequired: true
118 | state: 'published'
119 | }
120 |
121 | resource PetsApi 'apis@2021-04-01-preview' = {
122 | name: apimApiName
123 | }
124 | }
125 |
126 | resource PetsApiSubscription 'Microsoft.ApiManagement/service/subscriptions@2021-04-01-preview' = {
127 | name: '${apimServiceName}/PetsSubscription'
128 | properties: {
129 | displayName: 'Pets Subscription'
130 | scope: '/products/${PetsApiProduct.id}'
131 | }
132 | }
133 |
134 | module appConsumerApiKeySecret 'subscription-secret.bicep' = {
135 | name: '${deployment().name}-app-secret'
136 | scope: subscription()
137 | params: {
138 | consumerKeyVaultName: appConsumerKeyVaultName
139 | consumerKeyVaultResourceGroupName: appConsumerKeyVaultResourceGroup
140 | secretName: appConsumerSecretName
141 | subscriptionPrimaryKey: PetsApiSubscription.listSecrets().primaryKey
142 | }
143 | }
144 |
145 | module spaConsumerApiKeySecret 'subscription-secret.bicep' = {
146 | name: '${deployment().name}-spa-secret'
147 | scope: subscription()
148 | params: {
149 | consumerKeyVaultName: spaConsumerKeyVaultName
150 | consumerKeyVaultResourceGroupName: spaConsumerKeyVaultResourceGroup
151 | secretName: spaConsumerSecretName
152 | subscriptionPrimaryKey: PetsApiSubscription.listSecrets().primaryKey
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/Platform/Tier3/Apim/subscription-secret-inr.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'resourceGroup'
2 | param consumerKeyVaultName string
3 | @secure()
4 | param subscriptionPrimaryKey string
5 | param secretName string
6 |
7 | resource ApimProductKeySecret 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = {
8 | name: '${consumerKeyVaultName}/${secretName}'
9 | properties: {
10 | value: subscriptionPrimaryKey
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Platform/Tier3/Apim/subscription-secret.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'subscription'
2 |
3 | param consumerKeyVaultName string
4 | param consumerKeyVaultResourceGroupName string
5 |
6 | @secure()
7 | param subscriptionPrimaryKey string
8 |
9 | param secretName string
10 |
11 | module SubSecret 'subscription-secret-inr.bicep' = {
12 | name: '${deployment().name}-inr'
13 | scope: resourceGroup(consumerKeyVaultResourceGroupName)
14 | params: {
15 | consumerKeyVaultName: consumerKeyVaultName
16 | secretName: secretName
17 | subscriptionPrimaryKey: subscriptionPrimaryKey
18 | }
19 | }
20 |
21 |
--------------------------------------------------------------------------------
/Platform/Tier3/main.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'subscription'
2 |
3 | param apimResourceGroupName string
4 | param appResourceGroupName string
5 | param spaResourceGroupName string
6 | param apiFqdn string
7 | param appFqdn string
8 | param spaFqdn string
9 | param appSlotFqdn string
10 | param spaSlotFqdn string
11 | param environmentName string
12 | param resourcePrefix string
13 |
14 | param appConsumerKeyVaultName string
15 | param appConsumerSecretName string
16 | param spaConsumerKeyVaultName string
17 | param spaConsumerSecretName string
18 | param logAnalyticsWorkspaceId string
19 |
20 | param location string = deployment().location
21 |
22 | module ApimConfiguration './Apim/main.bicep' = {
23 | name: '${deployment().name}-apim'
24 | scope: resourceGroup(apimResourceGroupName)
25 | params: {
26 | logAnalyticsWorkspaceId: logAnalyticsWorkspaceId
27 | apiFqdn: apiFqdn
28 | appFqdn: appFqdn
29 | appSlotFqdn: empty(appSlotFqdn) ? appFqdn : appSlotFqdn
30 | spaFqdn: spaFqdn
31 | spaSlotFqdn: empty(spaSlotFqdn) ? spaFqdn : spaSlotFqdn
32 | environmentName: environmentName
33 | resourcePrefix: resourcePrefix
34 | location: location
35 | appConsumerKeyVaultResourceGroup: appResourceGroupName
36 | appConsumerKeyVaultName: appConsumerKeyVaultName
37 | appConsumerSecretName: appConsumerSecretName
38 | spaConsumerKeyVaultResourceGroup: spaResourceGroupName
39 | spaConsumerKeyVaultName: spaConsumerKeyVaultName
40 | spaConsumerSecretName: spaConsumerSecretName
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Platform/deploy-quickstart-apim.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'subscription'
2 |
3 | param location string = deployment().location
4 |
5 | param resourcePrefix string
6 | param environmentName string
7 |
8 | //assume outputs exist.
9 | resource AppsMetadata 'Microsoft.Resources/deployments@2022-09-01' existing = {
10 | name: '${resourcePrefix}-${environmentName}-apps'
11 | }
12 | //assume outputs exist.
13 | resource PlatformMetadata 'Microsoft.Resources/deployments@2022-09-01' existing = {
14 | name: '${resourcePrefix}-${environmentName}-platform'
15 | }
16 |
17 | module config 'Tier3/main.bicep' = {
18 | name: '${deployment().name}-config'
19 | scope: subscription()
20 | params: {
21 | environmentName: PlatformMetadata.properties.outputs.environmentName.value
22 | resourcePrefix: PlatformMetadata.properties.outputs.resourcePrefix.value
23 | apimResourceGroupName: PlatformMetadata.properties.outputs.platformResourceGroupName.value
24 | logAnalyticsWorkspaceId: PlatformMetadata.properties.outputs.logAnalyticsWorkspaceId.value
25 | appFqdn: AppsMetadata.properties.outputs.appFqdn.value
26 | spaFqdn: AppsMetadata.properties.outputs.spaFqdn.value
27 | appSlotFqdn: AppsMetadata.properties.outputs.appSlotFqdn.value
28 | spaSlotFqdn: AppsMetadata.properties.outputs.spaSlotFqdn.value
29 | apiFqdn: AppsMetadata.properties.outputs.apiFqdn.value
30 | location: location
31 |
32 | appConsumerKeyVaultName: AppsMetadata.properties.outputs.appKeyVaultName.value
33 | appConsumerSecretName: AppsMetadata.properties.outputs.appApiKeySecretName.value
34 | appResourceGroupName: AppsMetadata.properties.outputs.appResourceGroupName.value
35 |
36 | spaConsumerKeyVaultName: AppsMetadata.properties.outputs.spaKeyVaultName.value
37 | spaConsumerSecretName: AppsMetadata.properties.outputs.spaApiKeySecretName.value
38 | spaResourceGroupName: AppsMetadata.properties.outputs.spaResourceGroupName.value
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Platform/deploy-quickstart-apps.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'subscription'
2 |
3 | param location string = deployment().location
4 | param resourcePrefix string
5 | param environmentName string
6 | param appClientId string
7 | param apiClientId string
8 | param aadTenantId string
9 |
10 | @secure()
11 | param appClientSecret string
12 |
13 | //fetch platform information. Assumption that this is in a well known location
14 | resource PlatformMetadata 'Microsoft.Resources/deployments@2022-09-01' existing = {
15 | name: '${resourcePrefix}-${environmentName}-platform'
16 | }
17 |
18 | module inr './Tier2/main.bicep' = {
19 | name: '${deployment().name}-apps'
20 | params: {
21 | environmentName: PlatformMetadata.properties.outputs.environmentName.value
22 | resourcePrefix: PlatformMetadata.properties.outputs.resourcePrefix.value
23 | platformResourceGroupName: PlatformMetadata.properties.outputs.platformResourceGroupName.value
24 | singleResourceGroupDeployment: PlatformMetadata.properties.outputs.singleResourceGroupDeployment.value
25 | apimHostname: PlatformMetadata.properties.outputs.apimHostname.value
26 | containerEnvironmentId: PlatformMetadata.properties.outputs.containerEnvironmentId.value
27 | databaseServerName: PlatformMetadata.properties.outputs.databaseServerName.value
28 | logAnalyticsWorkspaceId: PlatformMetadata.properties.outputs.logAnalyticsWorkspaceId.value
29 | serverFarmId: PlatformMetadata.properties.outputs.serverFarmId.value
30 | location: location
31 | uniqueness: PlatformMetadata.properties.outputs.uniqueness.value
32 | appClientId: appClientId
33 | apiClientId: apiClientId
34 | appClientSecret: appClientSecret
35 | aadTenantId: aadTenantId
36 | }
37 | }
38 |
39 | output appName string = inr.outputs.appName
40 | output apiName string = inr.outputs.apiName
41 | output spaName string = inr.outputs.spaName
42 | output appFqdn string = inr.outputs.appFqdn
43 | output spaFqdn string = inr.outputs.spaFqdn
44 | output apiFqdn string = inr.outputs.apiFqdn
45 | output appSlotFqdn string = inr.outputs.appSlotFqdn
46 | output spaSlotFqdn string = inr.outputs.spaSlotFqdn
47 | output apiSlotFqdn string = inr.outputs.apiSlotFqdn
48 | output microserviceFqdn string = inr.outputs.microserviceFqdn
49 | output containerAppName string = inr.outputs.containerAppName
50 | output containerAppResourceGroup string = inr.outputs.containerAppResourceGroup
51 | output appResourceGroupName string = inr.outputs.appResourceGroupName
52 | output apiResourceGroupName string = inr.outputs.apiResourceGroupName
53 | output appKeyVaultName string = inr.outputs.appKeyVaultName
54 | output appApiKeySecretName string = inr.outputs.appApiKeySecretName
55 | output spaResourceGroupName string = inr.outputs.spaResourceGroupName
56 | output spaKeyVaultName string = inr.outputs.spaKeyVaultName
57 | output spaApiKeySecretName string = inr.outputs.spaApiKeySecretName
58 | output databaseConnectionString string = inr.outputs.databaseConnectionString
59 | output managedIdentityAppId string = inr.outputs.managedIdentityAppId
60 | output managedIdentityName string = inr.outputs.managedIdentityName
61 |
--------------------------------------------------------------------------------
/Platform/deploy-quickstart-platform.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'subscription'
2 |
3 | @description('A prefix to add to all resources to keep them unique')
4 | @minLength(3)
5 | @maxLength(6)
6 | param resourcePrefix string
7 |
8 | @description('AAD Service Principal name to set as the database administrator. This principal will be used to deploy databases to the server.')
9 | param databaseAdministratorName string
10 |
11 | @description('AAD Object Id of the Service Principal used as the database administrator.')
12 | param databaseAdministratorObjectId string
13 |
14 | @description('Used to construct app / api / keyvault names. Suggestions include test, prod, nonprod')
15 | param environmentName string
16 |
17 | @description('Publisher email used for the apim service')
18 | param apimPublisherEmail string
19 |
20 | param singleResourceGroup bool = true
21 |
22 | param location string = deployment().location
23 |
24 | var platformRgName = singleResourceGroup ? '${resourcePrefix}-${environmentName}-rg' : '${resourcePrefix}-platform-${environmentName}-rg'
25 | var deploymentName = 'platform-${resourcePrefix}-${environmentName}'
26 |
27 | resource platformResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = {
28 | name: platformRgName
29 | location: location
30 | }
31 |
32 | module PlatformDeployment './Tier1/main.bicep' = {
33 | name: deploymentName
34 | scope: platformResourceGroup
35 | params: {
36 | location: location
37 | resourcePrefix: resourcePrefix
38 | databaseAdministratorName: databaseAdministratorName
39 | databaseAdministratorObjectId: databaseAdministratorObjectId
40 | environmentName: environmentName
41 | apimPublisherEmail: apimPublisherEmail
42 | platformRgName: platformResourceGroup.name
43 | singleResourceGroupDeployment: singleResourceGroup
44 | }
45 | }
46 |
47 | output platformResourceGroupName string = platformRgName
48 | output serverFarmId string = PlatformDeployment.outputs.serverFarmId
49 | output databaseServerName string = PlatformDeployment.outputs.databaseServerName
50 | output logAnalyticsWorkspaceId string = PlatformDeployment.outputs.logAnalyticsWorkspaceId
51 | output containerEnvironmentId string = PlatformDeployment.outputs.containerEnvironmentId
52 | output apimHostname string = PlatformDeployment.outputs.apimHostname
53 | output resourcePrefix string = PlatformDeployment.outputs.resourcePrefix
54 | output databaseAdministratorName string = PlatformDeployment.outputs.databaseAdministratorName
55 | output environmentName string = PlatformDeployment.outputs.environmentName
56 | output singleResourceGroupDeployment bool = PlatformDeployment.outputs.singleResourceGroupDeployment
57 | output uniqueness string = PlatformDeployment.outputs.uniqueness
58 |
--------------------------------------------------------------------------------
/Platform/rg.bicep:
--------------------------------------------------------------------------------
1 | targetScope = 'subscription'
2 |
3 | param resourceGroupName string
4 | param location string = deployment().location
5 |
6 | resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = {
7 | name: resourceGroupName
8 | location: location
9 | }
10 |
11 | output rgName string = rg.name
12 |
--------------------------------------------------------------------------------
/Platform/setup-aad.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # This script sets up 2 AAD applications to control access to the Applications / API.
4 | # One is configured for an OIDC flow to sign the user into the app.
5 | # The 2nd configures authorisation for the API.
6 |
7 | # Your CI/CD will need permission to create AAD App Registrations else it won't work.
8 | RESOURCE_PREFIX=$1
9 | ENVIRONMENT_NAME=$2
10 | UNIQUENESS=$3
11 |
12 | WEBSITE_HOST_NAME="$RESOURCE_PREFIX-$UNIQUENESS-$ENVIRONMENT_NAME-webapp.azurewebsites.net"
13 | WEBSITE_SLOT_HOST_NAME="$RESOURCE_PREFIX-$UNIQUENESS-$ENVIRONMENT_NAME-webapp0-green.azurewebsites.net"
14 |
15 | WEB_API_HOST_NAME="$RESOURCE_PREFIX-$UNIQUENESS-$ENVIRONMENT_NAME-api.azurewebsites.net"
16 |
17 | SPA_HOST_NAME="$RESOURCE_PREFIX-$UNIQUENESS-$ENVIRONMENT_NAME-spa.azurewebsites.net"
18 | SPA_SLOT_HOST_NAME="$RESOURCE_PREFIX-$UNIQUENESS-$ENVIRONMENT_NAME-spa-green.azurewebsites.net"
19 |
20 | # Build the application representing the API.
21 | read -r -d '' API_ROLES << EOM
22 | [{
23 | "allowedMemberTypes": [
24 | "User"
25 | ],
26 | "id" : "a0bdae44-5469-4395-bba4-e0158a0ebc54",
27 | "description": "Readers can read pets",
28 | "displayName": "Reader",
29 | "isEnabled": "true",
30 | "value": "reader"
31 | },
32 | {
33 | "allowedMemberTypes": [
34 | "User"
35 | ],
36 | "id" : "d8eb2b97-42e6-47af-bab7-8f964e8d3a29",
37 | "description": "Admins can create pets",
38 | "displayName": "Admin",
39 | "isEnabled": "true",
40 | "value": "admin"
41 | }
42 | ]
43 | EOM
44 |
45 | AAD_API_APPLICATION_ID=$(az ad app create --display-name "$WEB_API_HOST_NAME" --app-roles "$API_ROLES" --query "appId" -o tsv | tr -d '\r')
46 | _=$(az ad app update --id $AAD_API_APPLICATION_ID --identifier-uris "api://${AAD_API_APPLICATION_ID}")
47 | echo "Created / retrieved API Application Id ${AAD_API_APPLICATION_ID}"
48 | echo "apiClientId=${AAD_API_APPLICATION_ID}" >> $GITHUB_OUTPUT
49 |
50 |
51 | ###Remove api permissions: disable default exposed scope first (https://learn.microsoft.com/en-us/azure/healthcare-apis/register-application-cli-rest)
52 | # az ad app no longer adds a default 'user_impersonation' scope .
53 | # default_scope=$(az ad app show --id $AAD_API_APPLICATION_ID | jq '.oauth2Permissions[0].isEnabled = false' | jq -r '.oauth2Permissions')
54 | # az ad app update --id $AAD_API_APPLICATION_ID --set oauth2Permissions="$default_scope"
55 |
56 | #Create a scope we can prompt the user for
57 | read -r -d '' API_SCOPES << EOM
58 | {
59 | "oauth2PermissionScopes": [
60 | {
61 | "adminConsentDescription": "Allows the app to see and create pets",
62 | "adminConsentDisplayName": "Pets",
63 | "id": "922d92cd-454b-4544-afd8-99f9a6ed9a44",
64 | "isEnabled": true,
65 | "type": "User",
66 | "userConsentDescription": "Allows the app to see and create pets",
67 | "userConsentDisplayName": "See pets",
68 | "value": "Pets.Manage"
69 | }
70 | ],
71 | "requestedAccessTokenVersion": 2
72 | }
73 | EOM
74 | az ad app update --id "$AAD_API_APPLICATION_ID" --set api="$API_SCOPES"
75 | echo "Set Scopes on API"
76 |
77 | #Create a service principal so we can request permissions against this in our directory
78 | _=$(az ad sp create --id $AAD_API_APPLICATION_ID)
79 | echo "Created service principal to represent API in directory"
80 |
81 |
82 |
83 | # Build the application representing the website.
84 | # All we want to do here is sign someone in. The application behaves like a SPA using a separate API for resource access.
85 | read -r -d '' REQUIRED_WEBSITE_RESOURCE_ACCESS << EOM
86 | [{
87 | "resourceAppId": "00000003-0000-0000-c000-000000000000",
88 | "resourceAccess": [
89 | {
90 | "id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d",
91 | "type": "Scope"
92 | }
93 | ]
94 | },
95 | {
96 | "resourceAppId": "$AAD_API_APPLICATION_ID",
97 | "resourceAccess": [
98 | {
99 | "id": "922d92cd-454b-4544-afd8-99f9a6ed9a44",
100 | "type": "Scope"
101 | },
102 | ]
103 | }]
104 | EOM
105 |
106 | AAD_WEBSITE_APPLICATION_ID=$(az ad app create --display-name $WEBSITE_HOST_NAME --required-resource-access "$REQUIRED_WEBSITE_RESOURCE_ACCESS" --query "appId" -o tsv | tr -d '\r')
107 | _=$(az ad app update --id $AAD_WEBSITE_APPLICATION_ID --identifier-uris "api://${AAD_WEBSITE_APPLICATION_ID}")
108 | AAD_WEBSITE_OBJECT_ID=$(az ad app show --id $AAD_WEBSITE_APPLICATION_ID --query "id" -o tsv | tr -d '\r')
109 | echo "Created / retrieved Web Application Id ${AAD_WEBSITE_APPLICATION_ID}. ObjectId ${AAD_WEBSITE_OBJECT_ID}"
110 | echo "applicationClientId=${AAD_WEBSITE_APPLICATION_ID}" >> $GITHUB_OUTPUT
111 |
112 | #https://github.com/Azure/azure-cli/issues/9501
113 | echo "Calling REST Api to update redirects for web and public client"
114 | if [ "$ENVIRONMENT_NAME" = "Development" ]; then
115 | LOCAL_REDIRECT=", \"http://localhost:3000\""
116 | else
117 | LOCAL_REDIRECT=""
118 | fi
119 |
120 | read -r -d '' CLIENT_SPA_REDIRECTS << EOM
121 | {
122 | "spa" : {
123 | "redirectUris" : [ "https://${SPA_HOST_NAME}/", "https://${SPA_SLOT_HOST_NAME}/" $LOCAL_REDIRECT ]
124 | }
125 | }
126 | EOM
127 |
128 | echo $CLIENT_SPA_REDIRECTS
129 |
130 | az rest --method PATCH \
131 | --uri "https://graph.microsoft.com/v1.0/applications/${AAD_WEBSITE_OBJECT_ID}" \
132 | --headers 'Content-Type=application/json' \
133 | --body "$CLIENT_SPA_REDIRECTS"
134 |
135 | echo "Patched SPA redirects"
136 |
137 | if [ "$ENVIRONMENT_NAME" = "Development" ]; then
138 | LOCAL_REDIRECT=", \"https://sampleapp.localtest.me:4430/signin-oidc\""
139 | else
140 | LOCAL_REDIRECT=""
141 | fi
142 |
143 | read -r -d '' CLIENT_WEB_REDIRECTS << EOM
144 | {
145 | "web" : {
146 | "redirectUris" : [ "https://${WEBSITE_HOST_NAME}/signin-oidc", "https://${WEBSITE_SLOT_HOST_NAME}/signin-oidc" $LOCAL_REDIRECT ]
147 | }
148 | }
149 | EOM
150 |
151 | az rest --method PATCH \
152 | --uri "https://graph.microsoft.com/v1.0/applications/${AAD_WEBSITE_OBJECT_ID}" \
153 | --headers 'Content-Type=application/json' \
154 | --body "$CLIENT_WEB_REDIRECTS"
155 |
156 | echo "Patched Web redirects"
157 |
158 | echo "Patched redirects for web and public client"
159 |
160 | _=$(az ad sp create --id $AAD_WEBSITE_APPLICATION_ID)
161 | echo "Created service principal to represent APP in directory"
162 |
163 | #Get a secret so we can do a code exchange in the app.
164 | #TODO - Conscious choice to overwrite. This should be part of a rotation
165 | WEBSITE_CLIENT_SECRET=$(az ad app credential reset --id $AAD_WEBSITE_APPLICATION_ID --query "password" -o tsv)
166 |
167 | #TODO write direct to KeyVault?
168 | echo "::add-mask::${WEBSITE_CLIENT_SECRET}"
169 | echo "applicationClientSecret=${WEBSITE_CLIENT_SECRET}" >> $GITHUB_OUTPUT
170 | echo "aadTenantId=$(az account show --query 'tenantId' --output tsv)" >> $GITHUB_OUTPUT
171 |
172 | # #Az Devops
173 | # echo "##vso[task.setvariable variable=applicationClientId;isOutput=true]${AAD_WEBSITE_APPLICATION_ID}"
174 | # echo "##vso[task.setvariable variable=applicationClientSecret;isOutput=true;issecret=true]${WEBSITE_CLIENT_SECRET}"
175 | # echo "##vso[task.setvariable variable=apiClientId;isOutput=true]${AAD_API_APPLICATION_ID}"
176 |
--------------------------------------------------------------------------------
/QuickStart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/graemefoster/QuickStart/cbe312b29ce2792d47f11f6ca38e4ee0436a0992/QuickStart.png
--------------------------------------------------------------------------------
/QuickStart.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Api", "Api", "{701C3FFA-7D6C-4C9D-BDF9-7B67A4CE1B91}"
4 | EndProject
5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleApiWithDatabase", "Application\Api\SimpleApiWithDatabase\SimpleApiWithDatabase.csproj", "{179CAA22-7D1F-4749-BA85-7E5A2027D100}"
6 | EndProject
7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiTests", "Application\Api\ApiTests\ApiTests.csproj", "{4A1B4A18-A375-4D6D-869F-FB777A8022F2}"
8 | EndProject
9 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WebApp", "WebApp", "{2CAE48D6-6178-4739-B95E-DE25DE6E1C35}"
10 | EndProject
11 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleMvcApp", "Application\WebApp\SimpleMvcApp\SimpleMvcApp.csproj", "{00FACFC2-5D00-4C34-B684-F09E1374E78B}"
12 | EndProject
13 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlAadMigrationDeployer", "SqlAadMigrationDeployer\SqlAadMigrationDeployer.csproj", "{3BDFF78B-72CB-4022-8DA1-30DC9D6540D7}"
14 | EndProject
15 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StaticApp", "StaticApp", "{177618C8-7314-4D58-A54A-8FE9572B5493}"
16 | EndProject
17 | Global
18 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
19 | Debug|Any CPU = Debug|Any CPU
20 | Release|Any CPU = Release|Any CPU
21 | EndGlobalSection
22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
23 | {179CAA22-7D1F-4749-BA85-7E5A2027D100}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
24 | {179CAA22-7D1F-4749-BA85-7E5A2027D100}.Debug|Any CPU.Build.0 = Debug|Any CPU
25 | {179CAA22-7D1F-4749-BA85-7E5A2027D100}.Release|Any CPU.ActiveCfg = Release|Any CPU
26 | {179CAA22-7D1F-4749-BA85-7E5A2027D100}.Release|Any CPU.Build.0 = Release|Any CPU
27 | {4A1B4A18-A375-4D6D-869F-FB777A8022F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
28 | {4A1B4A18-A375-4D6D-869F-FB777A8022F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
29 | {4A1B4A18-A375-4D6D-869F-FB777A8022F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
30 | {4A1B4A18-A375-4D6D-869F-FB777A8022F2}.Release|Any CPU.Build.0 = Release|Any CPU
31 | {00FACFC2-5D00-4C34-B684-F09E1374E78B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
32 | {00FACFC2-5D00-4C34-B684-F09E1374E78B}.Debug|Any CPU.Build.0 = Debug|Any CPU
33 | {00FACFC2-5D00-4C34-B684-F09E1374E78B}.Release|Any CPU.ActiveCfg = Release|Any CPU
34 | {00FACFC2-5D00-4C34-B684-F09E1374E78B}.Release|Any CPU.Build.0 = Release|Any CPU
35 | {3BDFF78B-72CB-4022-8DA1-30DC9D6540D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
36 | {3BDFF78B-72CB-4022-8DA1-30DC9D6540D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
37 | {3BDFF78B-72CB-4022-8DA1-30DC9D6540D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
38 | {3BDFF78B-72CB-4022-8DA1-30DC9D6540D7}.Release|Any CPU.Build.0 = Release|Any CPU
39 | EndGlobalSection
40 | GlobalSection(NestedProjects) = preSolution
41 | {179CAA22-7D1F-4749-BA85-7E5A2027D100} = {701C3FFA-7D6C-4C9D-BDF9-7B67A4CE1B91}
42 | {4A1B4A18-A375-4D6D-869F-FB777A8022F2} = {701C3FFA-7D6C-4C9D-BDF9-7B67A4CE1B91}
43 | {00FACFC2-5D00-4C34-B684-F09E1374E78B} = {2CAE48D6-6178-4739-B95E-DE25DE6E1C35}
44 | EndGlobalSection
45 | EndGlobal
46 |
--------------------------------------------------------------------------------
/SqlAadMigrationDeployer/Program.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.IO;
4 | using System.Linq;
5 | using System.Text;
6 | using System.Text.RegularExpressions;
7 | using System.Threading.Tasks;
8 | using Microsoft.Data.SqlClient;
9 |
10 | namespace SqlAadMigrationDeployer
11 | {
12 | class Program
13 | {
14 | static async Task Main(string[] args)
15 | {
16 | var command = args[0];
17 |
18 | var sqlConnection = args[1];
19 |
20 | var printOutput = new StringBuilder();
21 |
22 | await using var connection = new SqlConnection(sqlConnection);
23 | connection.InfoMessage += (sender, eventArgs) => { printOutput.AppendLine(eventArgs.ToString()); };
24 | await connection.OpenAsync();
25 |
26 | await using var tran = await connection.BeginTransactionAsync();
27 | try
28 | {
29 | if (command == "migrate")
30 | {
31 | var scriptFile = args[2];
32 | var parts = SplitSqlIntoBatches(await File.ReadAllTextAsync(scriptFile));
33 | foreach (var part in parts)
34 | {
35 | var cmd = connection.CreateCommand();
36 | cmd.Transaction = (SqlTransaction)tran;
37 | cmd.CommandText = part;
38 | await cmd.ExecuteNonQueryAsync();
39 | }
40 | } else if (command == "add-managed-identity")
41 | {
42 | var applicationName = args[2];
43 | var applicationId = args[3];
44 | var role = args[4];
45 |
46 | Console.WriteLine($"Adding App {applicationName} with appId {applicationId} to role {role}");
47 |
48 | var cmd = connection.CreateCommand();
49 | cmd.Transaction = (SqlTransaction)tran;
50 |
51 | //Ignoring injection as the principal executing this is intended to be CI/CD and will have a high level of access.
52 | cmd.CommandText = @$"
53 | IF NOT EXISTS (SELECT name FROM [sys].[database_principals] WHERE name = N'{applicationName}' AND TYPE='E')
54 | BEGIN
55 | CREATE USER [{applicationName}] WITH SID={FormatSqlByteLiteral(Guid.Parse(applicationId).ToByteArray())}, TYPE=E;
56 | END
57 | EXEC sp_addrolemember '{role}', '{applicationName}'
58 | ";
59 |
60 | await cmd.ExecuteNonQueryAsync();
61 | }
62 |
63 | await tran.CommitAsync();
64 | Console.WriteLine();
65 | Console.WriteLine("------------------------");
66 | Console.WriteLine(printOutput.ToString());
67 | Console.WriteLine("------------------------");
68 | Console.WriteLine();
69 | Console.WriteLine("Successfully run migration script");
70 | }
71 | catch (Exception)
72 | {
73 | try
74 | {
75 | await tran.RollbackAsync();
76 | }
77 | catch
78 | {
79 | //not much we can do here
80 | }
81 |
82 | Console.WriteLine();
83 | Console.WriteLine("------------------------");
84 | Console.WriteLine(printOutput.ToString());
85 | Console.WriteLine("------------------------");
86 | Console.WriteLine();
87 | Console.WriteLine("Failed to run migration script");
88 | throw;
89 | }
90 | }
91 |
92 | ///
93 | /// Breaks a ef-core script into parts
94 | ///
95 | ///
96 | ///
97 | ///
98 | private static IEnumerable SplitSqlIntoBatches(string batchedSql)
99 | {
100 | string[] terminators = new[] { "BEGIN TRANSACTION;", "COMMIT;" };
101 | var nextPiece = new StringBuilder();
102 | foreach (var line in batchedSql.Split(Environment.NewLine))
103 | {
104 | var trimmed = line.Trim();
105 | if (terminators.Any(x => trimmed.Equals(x, StringComparison.InvariantCultureIgnoreCase)))
106 | {
107 | //ignore - we deal with transactions separately
108 | }
109 | else if (trimmed.Equals("GO"))
110 | {
111 | //terminator line. Return the sql if we have any
112 | if (nextPiece.Length != 0)
113 | {
114 | Console.WriteLine($"Executing: {nextPiece.ToString()}");
115 | yield return ReplaceVariables(nextPiece.ToString());
116 | nextPiece = new StringBuilder();
117 | }
118 | }
119 | else
120 | {
121 | nextPiece.AppendLine(trimmed);
122 | }
123 | }
124 |
125 | if (nextPiece.Length != 0)
126 | {
127 | Console.WriteLine($"Executing: {nextPiece.ToString()}");
128 | yield return ReplaceVariables(nextPiece.ToString());
129 | }
130 | }
131 |
132 | private static string ReplaceVariables(string sql)
133 | {
134 | var regex = new Regex(@"\$\{\{\s*env\.([A-Za-z_0-9]+)\s*\}\}");
135 | var matches = regex.Matches(sql);
136 | foreach (Match match in matches)
137 | {
138 | var envVariableName = match.Groups[1].Captures[0].Value;
139 | var envVariableValue = Environment.GetEnvironmentVariable(envVariableName);
140 | sql = sql.Replace(match.Value, envVariableValue);
141 | Console.WriteLine($"Replacing environment variable {envVariableName}");
142 | }
143 |
144 | return sql;
145 | }
146 |
147 | ///
148 | /// https://github.com/MicrosoftDocs/sql-docs/issues/2323
149 | ///
150 | ///
151 | ///
152 | private static string FormatSqlByteLiteral(byte[] bytes)
153 | {
154 | var stringBuilder = new StringBuilder();
155 | stringBuilder.Append("0x");
156 | foreach (var @byte in bytes)
157 | {
158 | if (@byte < 16)
159 | {
160 | stringBuilder.Append("0");
161 | }
162 | stringBuilder.Append(Convert.ToString(@byte, 16));
163 | }
164 | return stringBuilder.ToString();
165 | }
166 | }
167 | }
--------------------------------------------------------------------------------
/SqlAadMigrationDeployer/SqlAadMigrationDeployer.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net7.0
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/docs/actions-setup.md:
--------------------------------------------------------------------------------
1 | # QuickStart Github Actions setup
2 |
3 | You'll need access to a Github account to use Github Actions.
4 |
5 | ## Step 1 - Clone the repo locally
6 |
7 | ``` git clone https://github.com/graemefoster/QuickStart/```
8 |
9 | ## Step 2 - Create a new repository in your Github account
10 |
11 | ## Step 3 - Add a remote to your local repository and push
12 |
13 | ```bash
14 | cd QuickStart
15 | git remote remove origin
16 | git remote add origin
17 | git push origin
18 | ```
19 |
20 | ## Step 4 - Create 'test' Environment secrets
21 |
22 | | Secret | Purpose | Other information |
23 | | --- | --- | --- |
24 | | RESOURCE_PREFIX | A small string that prefixes the resources. | It's just used to prevent against resource name clashes. Some services like keyvault and web-apps require globally unique names |
25 | | AZURE_CREDENTIALS | Service Principal that has Contributor permissions on your subscription. | This is the output from the ``` az ad create-for-rbac ``` command |
26 | | DEPLOYMENTPRINCIPAL_NAME | Application name of the above service principal | Used to setup the AAD Admin account for Sql Server. This must match the name of the AAD service principal |
27 |
28 | > If you are unable to grant a Service Principal the Directory.Write role then you can configure your Web Application / API to use a different Azure Active Directory to the one backing your Azure Subscription. To do this, add a secret called ``` AAD_AZURE_CREDENTIALS ``` representing a Service Principal from the other directory.
29 |
30 |
31 | ## Step 5 - Run the platform Pipeline
32 |
33 | - Goto the 'Actions' tab in your repository.
34 | - Select the 'Platform' workflow.
35 | - Click 'Run Workflow' followed by 'Run workflow'
36 |
37 | This will kick off deployment of the core resources and will take a few minutes to run.
38 |
39 | ## Step 6 - Create 'Production' Environment secrets
40 |
41 | Follow Step 4 and 5, but name the environment ``` prod ``` and use secrets for the Production platform.
42 |
43 | At this point optionally put protection over the branch. Things to consider would be:
44 |
45 | - Limit deployments to this environment to the 'main' branch
46 | - Add reviewers to deployments before they are allowed to run against this environment
47 |
48 | ## Pipelines
49 |
50 | QuickStart contains 3 github action pipelines
51 |
52 | | Pipeline | Purpose |
53 | |---|---|
54 | | platform.yaml | Build the Azure & AAD foundations to run the apps and apis |
55 | | api.yaml | Pipeline to build and deploy the Api, run a database migration, and demonstrate blue/green between old and new revision |
56 | | app.yaml | Pipeline to build and deploy the AppService, and demonstrate blue/green between old and new revision |
57 | | container-app.yaml | Pipeline to build and deploy the Micro Service, and demonstrate blue/green between old and new revision |
58 | | static-app.yaml | Pipeline to build and deploy the Static App, and demonstrate blue/green between old and new version |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # QuickStart
2 | It can be tricky for new teams to get going in a cloud environment. Here's a braindump of what you need to thing about
3 |
4 | - Setting up infrastructure
5 | - Setting up pipelines
6 | - Runtime authentication and authorisation
7 | - Incremental database schema migration
8 | - Blue / Green deployments
9 |
10 | Most starter templates focus on the infrastructure. But few stitch everything together and put a bow around it.
11 |
12 | QuickStart tries to do that for a simple scenario:
13 | - A sample Azure Web-Application
14 | - with an Api / Database
15 | - and a micro service (deployed as an Azure Container Apps)
16 | - using OIDC / OAUTH, and scopes and roles.
17 |
18 | The aim is to not just create the resources, but wire them up securely, and provide sample blue / greeen deployment pipelines against a variety of CI / CD systems.
19 |
20 | 
21 |
22 | # Supported CI / CD platforms
23 | | CI / CD | Status|
24 | |---|---|
25 | | Github Actions | Done|
26 | | Azure Devops | Not Done |
27 | | Octopus 'as code' | Not Done |
28 |
29 | # Getting Started (Common steps)
30 |
31 | To get started you'll need
32 |
33 | - An Azure Subscription to deploy the Azure resources to
34 | - An Azure Subscription to deploy AAD objects to (this can be the same as above if you have privileges to create Service Principals that can manipulate the AAD directory assigned to the subscription)
35 | - The az cli installed locally, or access to an Azure Cloud Shell.
36 |
37 | Start by creating service principals in the subscriptions to let the CI/CD pipeline deploy resources and setup AAD applications.
38 |
39 | ## Create a Service Principal in the subscription where you deploy resources
40 |
41 | ``` az ad sp create-for-rbac --name "" --role owner --sdk-auth ```
42 |
43 | This will output a JSON string with the Service Principal login information. Hold onto this as we'll use it when setting up our CI / CD Pipelines.
44 |
45 | > Owner is a high privilege role. This service principal needs at least contributor access on the subscription, as-well as the ability to assign roles to service principals. Owner provides both of these.
46 |
47 | ## Assign the above Service Principal the Directory.Writers role
48 |
49 | Quickstart will create two Azure Active Directory Applications for OIDC auth against the Web-App and API. To do this the above service principal needs to the Directory.Writers role against your AAD. Head to the [Azure Portal AAD page](https://ms.portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RolesAndAdministrators), find the 'Directory writers' role and add your Service Principal to it.
50 |
51 | > The Directory Writers role is used to create AAD Application objects to represent your API and Web Application. It's a high privilege role and requires AAD Admin consent. If you are unable to consent in the subscription you are deloying your resources into, you can still use QuickStart. Create a Service Principal in a directory that you control using ``` az ad sp create-for-rbac --name "" --skip-assignment --sdk-auth ``` and add it to the Directory.Writers role.
52 |
53 | Next, let's move on to configuring the pipelines.
54 |
55 | ## Github Actions
56 |
57 | Follow the instructions here [Github Actions Setup](./docs/actions-setup.md) to get started in Actions.
58 |
59 | # Known issues
60 |
61 | ## Github Action Pipelines
62 |
63 | ## Azure Active Directory
64 |
65 | ### Roles inside JWT tokens
66 | The sample defines two roles, Admin and User for authorisation. Both roles were declared Pascal case but when I retrieve a token I noticed the roles came back in lower-case. The Asp.Net Core libraries manage this OK.
67 |
68 | ### Audience in token
69 | Microsoft.Identity.Web can handle an audience in a token following the naming convention ``` api:// ``` . If you have a different audience remember to tell the library what to expect.
70 |
71 | ## Azure SQL AAD Authorisation
72 |
73 | ### Adding External Users required Directory.Read permission
74 | The standard approach for adding an AAD principal as a SQL User into a SQL database is
75 |
76 | ``` CREATE USER [] FROM EXTERNAL PROVIDER ```
77 |
78 | But this requires the logged in user to have 'Directory Reader' AAD permissions which is a high level permission not handed out lightly.
79 |
80 | There's a 'special' form of SQL to add the user that doesn't need this permission. It's not really supported but can get you around this limitation
81 |
82 | ``` CREATE USER [] WITH SID=, TYPE=E ```
83 |
84 | For more detail see here
85 |
86 | - https://github.com/MicrosoftDocs/sql-docs/issues/2323 for more information.
87 | - https://docs.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-service-principal-tutorial
88 |
--------------------------------------------------------------------------------