├── Scripts ├── .gitignore ├── conf │ ├── Global_Settings.json │ ├── ReverseProxy_Settings.json │ ├── TenantDirectory_Tenants_Development.json │ ├── TenantDirectory_Tenants_Production.json │ ├── API_Settings.json │ ├── API_Settings_Development.json │ ├── API_Settings_Production.json │ ├── ReverseProxy_Settings_Development.json │ └── ReverseProxy_Settings_Production.json ├── config-template.json ├── helpers.sh ├── postman │ ├── Template-TenantProxy-dev.postman_environment.json │ └── Template-TenantProxy.postman_collection.json ├── names.json ├── preReqs.sh ├── addCurrentTenantToRepository.sh ├── deploy.sh ├── createPostmanEnvironment.sh ├── modules │ └── addAppConfiguration.bicep ├── createDevServicePrincipal.sh ├── aadApp.sh ├── provision.sh └── main.bicep ├── Shared ├── Attributes │ ├── CustomRequirement.cs │ └── BearerAuthenticationAttribute .cs ├── Models │ ├── ServicePrincipalCredentials.cs │ ├── JWT.cs │ ├── Tenant.cs │ ├── ChangeSubscriptionSettings.cs │ └── ProxyConfig.cs ├── Shared.csproj ├── Middelware │ └── ApplicationInsights │ │ ├── MetricTagsTelemetryInitializer.cs │ │ └── DimensionTagsTelemetryInitializer.cs └── Repositories │ └── FileName.cs ├── Docs ├── Images │ ├── KQLResult.png │ ├── TenantRouting.png │ ├── ApplicationMap.png │ ├── ControlplaneAPI.png │ ├── PostmanGetToken.png │ ├── DistributedTracing.png │ ├── DataAndControlPlane.png │ ├── PostmanCallWeatherAPI.png │ └── PostmanImportEnvironment.png ├── AzureSamplesHeader.md ├── TenantRoutingMermaid.md └── DataAndControlPlaneMermaid.md ├── .editorconfig ├── WeatherApi ├── appsettings.Development.json ├── Models │ └── RequestDump.cs ├── WeatherForecast.cs ├── appsettings.json ├── Controllers │ ├── HomeController.cs │ ├── WeatherForecastController.cs │ └── DiagController.cs ├── WeatherApi.csproj ├── Configration │ ├── Logging.cs │ └── ExternalConfigurationStore.cs └── Program.cs ├── ControlPlane └── TenantManagement │ ├── appsettings.Development.json │ ├── Properties │ └── launchSettings.json │ ├── TenantManagement.csproj │ ├── appsettings.json │ ├── Configuration │ └── ExternalConfigurationStore.cs │ ├── ConfigureSwaggerOptions.cs │ ├── Controllers │ └── TenantManagerController.cs │ ├── Repositories │ └── TenantRepository.cs │ └── Program.cs ├── Proxy ├── appsettings.Production.json ├── appsettings.Development.json ├── Services │ ├── ITenantDirectoryService.cs │ ├── PermissionService.cs │ ├── TenantRoutingUpdaterService.cs │ └── TenantDirectoryService.cs ├── Controllers │ ├── AuthController.cs │ ├── ConfigController.cs │ ├── DiagController.cs │ └── ProxyController.cs ├── Transform │ └── Request │ │ └── AuthCookieToBearerTransform.cs ├── Configuration │ ├── OpenAPI.cs │ ├── Logging.cs │ ├── Auth.cs │ ├── ExternalConfigurationStore.cs │ └── Gateway.cs ├── Middleware │ └── TenantAuthorizationMiddlewareResultHandler.cs ├── appsettings.json ├── Proxy.csproj └── Program.cs ├── Shared.Services ├── Environment │ └── EnvironmentService.cs ├── Shared.Services.csproj ├── Token │ └── TokenService.cs └── EventGrid │ └── EventGridSubscriber.cs ├── LICENSE ├── .gitattributes ├── YARP-Reverse-Proxy.sln ├── .gitignore └── README.md /Scripts/.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | temp 3 | results.json -------------------------------------------------------------------------------- /Scripts/conf/Global_Settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Jwt":{ 3 | 4 | } 5 | } -------------------------------------------------------------------------------- /Shared/Attributes/CustomRequirement.cs: -------------------------------------------------------------------------------- 1 | namespace Shared.Attributes 2 | { 3 | 4 | } -------------------------------------------------------------------------------- /Scripts/conf/ReverseProxy_Settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ReverseProxy": { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /Docs/Images/KQLResult.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrikwh/Tenant-Reverse-Proxy/HEAD/Docs/Images/KQLResult.png -------------------------------------------------------------------------------- /Docs/Images/TenantRouting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrikwh/Tenant-Reverse-Proxy/HEAD/Docs/Images/TenantRouting.png -------------------------------------------------------------------------------- /Docs/Images/ApplicationMap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrikwh/Tenant-Reverse-Proxy/HEAD/Docs/Images/ApplicationMap.png -------------------------------------------------------------------------------- /Docs/Images/ControlplaneAPI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrikwh/Tenant-Reverse-Proxy/HEAD/Docs/Images/ControlplaneAPI.png -------------------------------------------------------------------------------- /Docs/Images/PostmanGetToken.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrikwh/Tenant-Reverse-Proxy/HEAD/Docs/Images/PostmanGetToken.png -------------------------------------------------------------------------------- /Docs/Images/DistributedTracing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrikwh/Tenant-Reverse-Proxy/HEAD/Docs/Images/DistributedTracing.png -------------------------------------------------------------------------------- /Docs/Images/DataAndControlPlane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrikwh/Tenant-Reverse-Proxy/HEAD/Docs/Images/DataAndControlPlane.png -------------------------------------------------------------------------------- /Docs/Images/PostmanCallWeatherAPI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrikwh/Tenant-Reverse-Proxy/HEAD/Docs/Images/PostmanCallWeatherAPI.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cs] 2 | 3 | # CS1591: Missing XML comment for publicly visible type or member 4 | dotnet_diagnostic.CS1591.severity = none 5 | -------------------------------------------------------------------------------- /Docs/Images/PostmanImportEnvironment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrikwh/Tenant-Reverse-Proxy/HEAD/Docs/Images/PostmanImportEnvironment.png -------------------------------------------------------------------------------- /Scripts/config-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "initConfig": { 3 | "resourceGroupName": "", 4 | "subscriptionId": "", 5 | "location": "westeurope", 6 | "tenantId": "" 7 | } 8 | } -------------------------------------------------------------------------------- /WeatherApi/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ControlPlane/TenantManagement/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Scripts/conf/TenantDirectory_Tenants_Development.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Tid": "TBD", 4 | "Destination": "https://localhost:9090", 5 | "Alias": "Test", 6 | "State": "Enabled" 7 | } 8 | ] -------------------------------------------------------------------------------- /WeatherApi/Models/RequestDump.cs: -------------------------------------------------------------------------------- 1 | public record RequestDump(Dictionary headers, Dictionary cookies, Dictionary claims, Dictionary> routes); 2 | -------------------------------------------------------------------------------- /Scripts/conf/TenantDirectory_Tenants_Production.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Tid": "GUID", 4 | "Destination": "https://SOMEURLK.azurewebsites.net", 5 | "Alias": "SomeAlias", 6 | "State":"Enabled" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /Proxy/appsettings.Production.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Trace", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "DefaultTenantSettings": { 9 | "DestinationUrl" : "" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Proxy/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Trace", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "DefaultTenantSettings": { 9 | "DestinationUrl" : "" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Shared/Models/ServicePrincipalCredentials.cs: -------------------------------------------------------------------------------- 1 | namespace Shared.Models 2 | { 3 | public class ServicePrincipalCredentials 4 | { 5 | public string TenantId { get; set; } = string.Empty; 6 | public string ClientId { get; set; } = string.Empty; 7 | public string Secret { get; set; } = string.Empty; 8 | } 9 | } -------------------------------------------------------------------------------- /WeatherApi/WeatherForecast.cs: -------------------------------------------------------------------------------- 1 | namespace WeatherApi 2 | { 3 | public class WeatherForecast 4 | { 5 | public DateOnly Date { get; set; } 6 | 7 | public int TemperatureC { get; set; } 8 | 9 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); 10 | 11 | public string? Summary { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /Proxy/Services/ITenantDirectoryService.cs: -------------------------------------------------------------------------------- 1 | using Shared.Models; 2 | 3 | namespace SaaS.Proxy.Services 4 | { 5 | public interface ITenantDirectoryService 6 | { 7 | //public bool IsKnownTenant(string tenantId); 8 | //public List GetTenants(); 9 | //public Tenant TenantLookup(string tenantId); 10 | public void AddTenantsToRouting(List tenant); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Shared/Models/JWT.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace Shared.Models 8 | { 9 | public class JwtSettings 10 | { 11 | public string ValidAudience { get; set; } = string.Empty; 12 | public string ValidIssuer { get; set; } = string.Empty; 13 | public string Secret { get; set; } = string.Empty; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Scripts/conf/API_Settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": true, 4 | "LogLevel": { 5 | "Default": "Trace", 6 | "Microsoft.AspNetCore": "Warning" 7 | }, 8 | "ApplicationInsights": { 9 | "IncludeScopes": true, 10 | "LogLevel": { 11 | "Microsoft": "Warning", 12 | "Azure.Data.Tables": "Information", 13 | "WebApi" : "Trace", 14 | "WeatherApi": "Trace", 15 | "Shared": "Trace" 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /Proxy/Controllers/AuthController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace Proxy.Controllers 6 | { 7 | //[Route("api/[controller]")] 8 | 9 | [ApiController] 10 | public class AuthController : ControllerBase 11 | { 12 | [HttpPost] 13 | [Route("/auth")] 14 | [Authorize] 15 | public IActionResult ReadParam() { 16 | var u = this.User; 17 | return Ok(); 18 | } 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Scripts/conf/API_Settings_Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": true, 4 | "LogLevel": { 5 | "Default": "Trace", 6 | "Microsoft.AspNetCore": "Warning" 7 | }, 8 | "ApplicationInsights": { 9 | "IncludeScopes": true, 10 | "LogLevel": { 11 | "Microsoft": "Warning", 12 | "Microsoft.Azure.AppConfiguration" :"Trace", 13 | "Azure.Data.Tables": "Information", 14 | "WebApi" : "Trace", 15 | "WeatherApi": "Trace", 16 | "Shared" : "Trace", 17 | "Yarp.ReverseProxy" : "Trace" 18 | } 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /Scripts/conf/API_Settings_Production.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": true, 4 | "LogLevel": { 5 | "Default": "Information", 6 | "Microsoft.AspNetCore": "Warning", 7 | "Proxy": "Trace", 8 | "WeatherApi": "Trace", 9 | "Shared": "Trace", 10 | "Yarp.ReverseProxy": "Trace" 11 | }, 12 | "ApplicationInsights": { 13 | "IncludeScopes": true, 14 | "LogLevel": { 15 | "Default": "Information", 16 | "Microsoft": "Warning", 17 | "Proxy": "Trace", 18 | "WeatherApi": "Trace", 19 | "Shared": "Trace", 20 | "Yarp ": "Trace" 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /Docs/AzureSamplesHeader.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | languages: 4 | - csharp 5 | - bash 6 | - azurecli 7 | - bicep 8 | 9 | products: 10 | - azure 11 | - azure-app-service 12 | - azure-app-service-web 13 | - azure-key-vault 14 | - aspnet-core 15 | - dotnet-core 16 | - azure-app-configuration 17 | - azure-service-bus 18 | - azure-event-grid 19 | - azure-log-analytics 20 | - azure-application-insights 21 | 22 | name: Reverse Proxy - managing and routing tenants 23 | description: "Sample shows how to route tenants to different endpoints, based on the tenant id claim from the bearer token. YARP reverse proxy is used, to proxy to different backends, Azure App Configuration holds the information about the tenant routing." 24 | --- -------------------------------------------------------------------------------- /Shared/Shared.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Proxy/Transform/Request/AuthCookieToBearerTransform.cs: -------------------------------------------------------------------------------- 1 | using Yarp.ReverseProxy.Transforms; 2 | 3 | namespace SaaS.Proxy.Transform.Request 4 | { 5 | //public class AuthCookieToBearerTransform : RequestTransform 6 | //{ 7 | // public override async ValueTask ApplyAsync(RequestTransformContext context) 8 | // { 9 | // var user = context.HttpContext.User; 10 | // if (user.Identity?.IsAuthenticated ?? false) 11 | // { 12 | // context.ProxyRequest.Headers.Add("isauth", "true"); 13 | // }else 14 | // context.ProxyRequest.Headers.Add("isauth", "false"); 15 | // await Task.CompletedTask; 16 | // } 17 | //} 18 | } 19 | -------------------------------------------------------------------------------- /WeatherApi/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AzureAd": { 9 | "Instance": "https://login.microsoftonline.com/", 10 | "TenantId": "common" 11 | }, 12 | "AllowedHosts": "*", 13 | "AppConfiguration": { 14 | }, 15 | "Jwt": { 16 | "ValidAudience": "https://localhost:4200", 17 | "ValidIssuer": "https://localhost:7001" 18 | 19 | }, 20 | "ChangeSubscription": { 21 | "ServiceBusTopic": "sb-appconfigurationchangetopic", 22 | "ServiceBusSubscriptionPrefix": "api", 23 | "AutoDeleteOnIdleInHours": 168, 24 | "MaxDelayBeforeCacheIsMarkedDirtyInSeconds": 30 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Shared/Models/Tenant.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Shared.Models 4 | { 5 | 6 | public enum TenantState 7 | { 8 | Enabled, 9 | Disabled, 10 | } 11 | public class Tenant 12 | { 13 | public string Tid { get; set; } = string.Empty; 14 | public string Alias { get; set; } = string.Empty; 15 | public string Destination { get; set; } = string.Empty; 16 | [JsonConverter(typeof(JsonStringEnumConverter))] 17 | public TenantState State { get; set; } = TenantState.Enabled; 18 | } 19 | 20 | 21 | 22 | public class TenantSettings 23 | { 24 | public List Tenants { get; set; } = new List(); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Shared.Services/Environment/EnvironmentService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace Shared.Services.Environment 8 | { 9 | public interface IEnvironmentService 10 | { 11 | string GetEnvironmentName(); 12 | } 13 | 14 | public class EnvironmentService : IEnvironmentService 15 | { 16 | public string GetEnvironmentName() 17 | { 18 | string env = String.IsNullOrEmpty(System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")) ? "Development" : System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Unknown"; 19 | return env; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Scripts/helpers.sh: -------------------------------------------------------------------------------- 1 | 2 | function get-value { 3 | local key="$1" ; 4 | local json ; 5 | 6 | json="$( cat "${CONFIG_FILE}" )" ; 7 | echo "${json}" | jq -r "${key}" 8 | } 9 | 10 | function put-value { 11 | local key="$1" ; 12 | local variableValue="$2" ; 13 | local json ; 14 | json="$( cat "${CONFIG_FILE}" )" ; 15 | echo "${json}" \ 16 | | jq --arg x "${variableValue}" "${key}=(\$x)" \ 17 | > "${CONFIG_FILE}" 18 | } 19 | 20 | function put-json-value { 21 | local key="$1" ; 22 | local variableValue="$2" ; 23 | local json ; 24 | json="$( cat "${CONFIG_FILE}" )" ; 25 | echo "${json}" \ 26 | | jq --arg x "${variableValue}" "${key}=(\$x | fromjson)" \ 27 | > "${CONFIG_FILE}" 28 | } 29 | -------------------------------------------------------------------------------- /Proxy/Services/PermissionService.cs: -------------------------------------------------------------------------------- 1 | namespace Proxy.Services 2 | { 3 | public interface IPermissionService 4 | { 5 | Dictionary GetPermissions(string tenantId); 6 | } 7 | 8 | public class PermissionService : IPermissionService 9 | { 10 | private readonly ILogger _logger; 11 | 12 | public PermissionService(ILogger logger) 13 | { 14 | _logger = logger; 15 | _permissions = new Dictionary { 16 | {"SomePermision","Limited" } 17 | }; 18 | } 19 | private Dictionary _permissions { get; set; } 20 | 21 | public Dictionary GetPermissions(string tenantId) 22 | { 23 | return _permissions; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Docs/TenantRoutingMermaid.md: -------------------------------------------------------------------------------- 1 | ```mermaid 2 | sequenceDiagram 3 | autonumber 4 | Client->>AAD : Login 5 | AAD-->>Client : token 6 | Client->>Proxy : Call with Token (access_as_user) 7 | Proxy->> Tenant Repository : Lookup tenant claim:tenantId 8 | alt tenant is registered in Tenant Repository 9 | Tenant Repository -->> Proxy : Tenant hosting address and metadata 10 | Proxy->> Permission Service : Lookup permissions for tenant 11 | Permission Service -->> Proxy : Permissions 12 | Proxy ->> Proxy : Create JWT for calling downstream 13 | Proxy ->> Backend : Proxy request to tenant hosting location, using created JWT 14 | Backend ->> Backend : Validate token from proxy 15 | Backend -->> Proxy : response 16 | 17 | else tenants is not known 18 | Tenant Repository -->> Proxy : 401 19 | end 20 | Proxy ->> Proxy : Response transformations 21 | Proxy -->> Client : response 22 | 23 | ``` -------------------------------------------------------------------------------- /Scripts/postman/Template-TenantProxy-dev.postman_environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TenantProxy:dev", 3 | "values": [ 4 | { 5 | "key": "localurl", 6 | "value": "https://localhost:7001", 7 | "type": "default", 8 | "enabled": true 9 | }, 10 | { 11 | "key": "url", 12 | "value": "", 13 | "type": "default", 14 | "enabled": true 15 | }, 16 | 17 | { 18 | "key": "clientId", 19 | "value": "", 20 | "type": "default", 21 | "enabled": true 22 | }, 23 | { 24 | "key": "clientSecret", 25 | "value": "", 26 | "type": "default", 27 | "enabled": true 28 | }, 29 | { 30 | "key": "scope", 31 | "value": "", 32 | "type": "default", 33 | "enabled": true 34 | } 35 | ], 36 | "_postman_variable_scope": "environment", 37 | "_postman_exported_at": "2023-06-27T14:15:14.810Z", 38 | "_postman_exported_using": "Postman/10.15.4" 39 | } -------------------------------------------------------------------------------- /WeatherApi/Controllers/HomeController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.Extensions.Options; 3 | using Microsoft.FeatureManagement.Mvc; 4 | using Yarp.ReverseProxy.Configuration; 5 | 6 | namespace WeatherApi.Controllers 7 | { 8 | [FeatureGate("ShowDebugView")] 9 | public class ConfigController : ControllerBase 10 | { 11 | 12 | private readonly ILogger _logger; 13 | private readonly IConfigurationRoot _root; 14 | 15 | public ConfigController(ILogger logger, IConfigurationRoot root) 16 | { 17 | _logger = logger; 18 | _root = root; 19 | } 20 | 21 | /// 22 | /// Shows the entire configuration. Enable featureflag to enable the endpoint. 23 | /// 24 | [HttpGet("GetConfigDump")] 25 | public string GetConfig() 26 | { 27 | return _root.GetDebugView(); 28 | } 29 | 30 | 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Scripts/names.json: -------------------------------------------------------------------------------- 1 | { 2 | "appConfiguration": { 3 | "name":"ProxyConfiguration" 4 | }, 5 | "uamis": { 6 | "appconfigDemo": "ProxyConfigurationUAMI" 7 | }, 8 | "website": { 9 | "appServiceplan": "ProxyServicePlan", 10 | "webapp": "Proxy" 11 | }, 12 | "features": { 13 | "magic": { 14 | "name": "DoMagic", 15 | "default" : "false" 16 | }, 17 | "showDebugView": { 18 | "name": "ShowDebugView", 19 | "default" : "false" 20 | }, 21 | "autoUpdateLatestVersionSecrets" : { 22 | "name": "AutoUpdateLatestVersionSecrets", 23 | "default" : "true" 24 | } 25 | }, 26 | "monitor": { 27 | "appinsights": "Appinsights", 28 | "logAnalyticsWorkspace" :"LogAnalyticsWorkspace" 29 | }, 30 | "serviceBus" : { 31 | "nameSpace" : "ProxyServicebus" 32 | }, 33 | "keyVault" : { 34 | "name" : "kvProxySample" 35 | } 36 | } -------------------------------------------------------------------------------- /Scripts/conf/ReverseProxy_Settings_Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "ReverseProxy": { 3 | "Routes": { 4 | "test": { 5 | "ClusterId": "testcluster", 6 | "AuthorizationPolicy": "customPolicy", 7 | "Match": { 8 | "Path": "/test/{**remainder}" 9 | }, 10 | "Transforms": [ 11 | { 12 | "PathRemovePrefix": "/test" 13 | }, 14 | { 15 | "RequestHeadersCopy": "true" 16 | }, 17 | { 18 | "RequestHeaderOriginalHost": "true" 19 | } 20 | ] 21 | } 22 | }, 23 | "Clusters": { 24 | "testcluster": { 25 | "Destinations": { 26 | "destination1": { 27 | "Address": "https://localhost:7297" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Scripts/conf/ReverseProxy_Settings_Production.json: -------------------------------------------------------------------------------- 1 | { 2 | "ReverseProxy": { 3 | "Routes": { 4 | "test": { 5 | "ClusterId": "testcluster", 6 | "AuthorizationPolicy": "customPolicy", 7 | "Match": { 8 | "Path": "/test/{**remainder}" 9 | }, 10 | "Transforms": [ 11 | { 12 | "PathRemovePrefix": "/test" 13 | }, 14 | { 15 | "RequestHeadersCopy": "true" 16 | }, 17 | { 18 | "RequestHeaderOriginalHost": "true" 19 | } 20 | ] 21 | } 22 | }, 23 | "Clusters": { 24 | "testcluster": { 25 | "Destinations": { 26 | "destination1": { 27 | "Address": "https://localhost:7297" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Docs/DataAndControlPlaneMermaid.md: -------------------------------------------------------------------------------- 1 | ```mermaid 2 | sequenceDiagram 3 | autonumber 4 | actor Client 5 | 6 | #RP ->> CS : Proxy startup, read lastest configuration 7 | box Data plane 8 | participant RP as Proxy 9 | participant BEA as Backend A 10 | participant BEB as Backend B 11 | end 12 | RP ->> +CS : Proxy startup, get configuration 13 | CS -->> -RP : Lastest configuration 14 | Client ->>+RP : Request, tenant1 15 | RP ->> RP : Process incomping token, create token for downstream 16 | RP->> BEA : GET 17 | BEA ->> RP : Ok 18 | RP ->> -Client : Ok 19 | 20 | box Control plane 21 | participant CS as Tenant Repository 22 | participant MS as Management Service 23 | end 24 | Actor DevOps 25 | DevOps ->> MS : Request to disable tenant 26 | MS->> CS : Update tenant information 27 | CS->> RP : Notify proxies, that tenant information is updated 28 | RP ->> CS : Get new values 29 | CS -->> RP : Tenant and routing information 30 | RP->RP : Refresh configuration, due to configuration change notification 31 | Client ->>+RP : Request, tenant1 32 | RP ->> RP : Process incomping token 33 | RP ->> -Client : 404 34 | 35 | ``` 36 | -------------------------------------------------------------------------------- /Shared.Services/Shared.Services.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Shared/Models/ChangeSubscriptionSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Shared.Models 2 | { 3 | public class ChangeSubscriptionSettings 4 | { 5 | public string? ServiceBusConnectionString { get; set; } 6 | 7 | public string? ServiceBusTopic { get; set; } 8 | string? _serviceBusSubscription; 9 | public string? ServiceBusSubscriptionPrefix 10 | { 11 | get { return _serviceBusSubscription; } 12 | set { _serviceBusSubscription = $"{value}-{Environment.MachineName.ToString()}"; } 13 | } 14 | public int AutoDeleteOnIdleInHours { get; set; } 15 | private string? _serviceBusNamespace; 16 | public string? ServiceBusNamespace 17 | { 18 | get { return _serviceBusNamespace; } 19 | set 20 | { 21 | this._serviceBusNamespace = value?.Replace(@"https://", "").Replace(@":443/", "") + ".servicebus.windows.net"; 22 | 23 | } 24 | } 25 | public int? MaxDelayBeforeCacheIsMarkedDirtyInSeconds { get; set; } 26 | public string? UserAssignedManagedIdentityClientId { get; set; } 27 | } 28 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Henrik Westergaard Hansen 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 | -------------------------------------------------------------------------------- /Shared/Middelware/ApplicationInsights/MetricTagsTelemetryInitializer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.ApplicationInsights.Channel; 2 | using Microsoft.ApplicationInsights.DataContracts; 3 | using Microsoft.ApplicationInsights.Extensibility; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Diagnostics; 7 | using System.Linq; 8 | using System.Text; 9 | using System.Threading.Tasks; 10 | 11 | namespace Shared.Middelware.ApplicationInsights.TelemetryInitializers 12 | { 13 | public class MetricTagsTelemetryInitializer : ITelemetryInitializer 14 | { 15 | /*To add to telemety customdimensions use: 16 | * Activity.Current?.AddTag("m-TagName", "TagValue"); 17 | */ 18 | public void Initialize(ITelemetry telemetry) 19 | { 20 | var activity = Activity.Current; 21 | if (telemetry is ISupportMetrics requestTelemetry) 22 | { 23 | if (activity == null) 24 | return; 25 | 26 | foreach (var tag in activity.Tags) 27 | { 28 | if (tag.Key.StartsWith("m-")) 29 | requestTelemetry.Metrics[tag.Key.Remove(0, 2)] = double.Parse(tag.Value ?? "0"); 30 | } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ControlPlane/TenantManagement/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "http": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "launchUrl": "swagger", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | }, 10 | "dotnetRunMessages": true, 11 | "applicationUrl": "http://localhost:5115" 12 | }, 13 | "https": { 14 | "commandName": "Project", 15 | "launchUrl": "swagger", 16 | "environmentVariables": { 17 | "_ASPNETCORE_ENVIRONMENT": "Production", 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | }, 20 | "dotnetRunMessages": true, 21 | "applicationUrl": "https://localhost:7098;http://localhost:5115" 22 | }, 23 | "IIS Express": { 24 | "commandName": "IISExpress", 25 | "launchBrowser": true, 26 | "launchUrl": "swagger", 27 | "environmentVariables": { 28 | "ASPNETCORE_ENVIRONMENT": "Development" 29 | } 30 | } 31 | }, 32 | "$schema": "https://json.schemastore.org/launchsettings.json", 33 | "iisSettings": { 34 | "windowsAuthentication": false, 35 | "anonymousAuthentication": true, 36 | "iisExpress": { 37 | "applicationUrl": "http://localhost:35339", 38 | "sslPort": 44342 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /Shared/Middelware/ApplicationInsights/DimensionTagsTelemetryInitializer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.ApplicationInsights.Channel; 2 | using Microsoft.ApplicationInsights.DataContracts; 3 | using Microsoft.ApplicationInsights.Extensibility; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Diagnostics; 7 | using System.Linq; 8 | using System.Reflection.Metadata.Ecma335; 9 | using System.Text; 10 | using System.Threading.Tasks; 11 | 12 | namespace Shared.Middelware.ApplicationInsights.TelemetryInitializers 13 | { 14 | public class DimensionTagsTelemetryInitializer : ITelemetryInitializer 15 | { 16 | /*To add to telemety customdimensions use: 17 | * Activity.Current?.AddTag("d-TagName", "TagValue"); 18 | */ 19 | public void Initialize(ITelemetry telemetry) 20 | { 21 | var activity = Activity.Current; 22 | 23 | if (telemetry is ISupportProperties requestTelemetry) 24 | { 25 | if (activity == null) return; 26 | 27 | foreach (var tag in activity.Tags) 28 | { 29 | if (tag.Key.StartsWith("d-")) 30 | requestTelemetry.Properties[tag.Key.Remove(0, 2)] = tag.Value; 31 | } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Scripts/preReqs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | if [$(which jq zip curl | wc -l) = 0 ]; then 5 | sudo apt install jq zip curl 6 | echo "installing jq zip curl using apt" 7 | else 8 | echo " jq zip curl already installed" 9 | fi 10 | 11 | if [ $( which az | wc -l) = 0 ]; then 12 | curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash 13 | else 14 | echo "az already installed" 15 | fi 16 | 17 | if [ $( dotnet --list-sdks | grep -G ^7.*$sdk | wc -l ) = 0 ]; then 18 | curl -sL https://dotnet.microsoft.com/download/dotnet/scripts/v1/dotnet-install.sh > dotnet-install.sh 19 | chmod +x ./dotnet-install.sh 20 | echo "Installing net7" 21 | ./dotnet-install.sh --channel 7.0 22 | else 23 | echo "dotnet already installed. SDKs:" 24 | dotnet --list-sdks 25 | fi 26 | 27 | 28 | if [ $( which dotnet | wc -l) = 0 ]; then 29 | if [ $( grep "#DOTNET path" ~/.profile | wc -l) = 0 ]; then 30 | echo "adding dotnet path to .profile" 31 | touch ~/.profile 32 | echo "#DOTNET path" >> ~/.profile 33 | echo "PATH=\$PATH:\$HOME/.dotnet:\$HOME/.dotnet/tools" >> ~/.profile 34 | source ~/.profile 35 | exit 1 36 | else 37 | echo "Already in .profile" 38 | fi 39 | else 40 | echo "dotnet already in path" 41 | fi 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /Scripts/addCurrentTenantToRepository.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source ./helpers.sh 3 | 4 | basedir="$( dirname "$( readlink -f "$0" )" )" 5 | 6 | #CONFIG_FILE="${basedir}/./config.json" 7 | CONFIG_FILE="./config.json" 8 | 9 | if [ ! -f "$CONFIG_FILE" ]; then 10 | cp ./config-template.json "${CONFIG_FILE}" 11 | fi 12 | 13 | 14 | appConfigName="$( get-value ".AppConfiguration.Name" )" 15 | echo $appConfigName 16 | 17 | 18 | 19 | payload=$(echo '[{ 20 | "Tid": "'$( get-value ".initConfig.tenantId" )'", 21 | "Destination" : "https://'$( get-value ".API.Endpoint" )'", 22 | "Alias": "Test", 23 | "State" : "Enabled" 24 | }]' | jq .) 25 | echo $payload > ./temp/tenantdirectory.json 26 | echo $payload 27 | 28 | az appconfig kv set --name $appConfigName --key "TenantDirectory:Tenants" --value "$payload" --content-type 'application/json' -y --label Production --tags dev=yes 29 | 30 | 31 | 32 | payload=$(echo '[{ 33 | "Tid": "'$( get-value ".initConfig.tenantId" )'", 34 | "Destination" : "https://localhost:9090", 35 | "Alias": "Test", 36 | "State" : "Enabled" 37 | }]' | jq .) 38 | echo $payload > ./temp/tenantdirectory.json 39 | 40 | az appconfig kv set --name $appConfigName --key "TenantDirectory:Tenants" --value "$payload" --content-type 'application/json' -y --label Development --tags dev=yes 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /Proxy/Configuration/OpenAPI.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Yarp.ReverseProxy.Configuration; 3 | 4 | namespace SaaS.Proxy.Configuration 5 | { 6 | public static class OpenApi 7 | { 8 | public static void AddOpenApi(this WebApplicationBuilder builder) 9 | { 10 | builder.Services.AddEndpointsApiExplorer(); 11 | builder.Services.AddSwaggerGen(options => 12 | { 13 | Assembly currentAssembly = Assembly.GetExecutingAssembly(); 14 | var xmlDocs = currentAssembly.GetReferencedAssemblies() 15 | .Union(new AssemblyName[] { currentAssembly.GetName() }) 16 | .Select(a => Path.Combine(Path.GetDirectoryName(currentAssembly.Location) ?? throw new NullReferenceException(), $"{a.Name}.xml")) 17 | .Where(f => File.Exists(f)).ToArray(); 18 | Array.ForEach(xmlDocs, (d) => 19 | { 20 | options.IncludeXmlComments(d); 21 | }); 22 | 23 | }); 24 | } 25 | public static void UseOpenApi(this WebApplication app) 26 | { 27 | //if (app.Environment.IsDevelopment()) 28 | { 29 | app.UseSwagger(); 30 | app.UseSwaggerUI(); 31 | } 32 | } 33 | 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /WeatherApi/Controllers/WeatherForecastController.cs: -------------------------------------------------------------------------------- 1 | using Asp.Versioning; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace WeatherApi.Controllers 6 | { 7 | [ApiController] 8 | [ApiVersion("1.0")] 9 | [Route("api/v{version:apiVersion}/[controller]")] 10 | public class WeatherForecastController : ControllerBase 11 | { 12 | private static readonly string[] Summaries = new[] 13 | { 14 | "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" 15 | }; 16 | 17 | private readonly ILogger _logger; 18 | 19 | public WeatherForecastController(ILogger logger) 20 | { 21 | _logger = logger; 22 | } 23 | 24 | [HttpGet] 25 | [Authorize] 26 | [Authorize(Policy = "Test")] 27 | public IEnumerable Get() 28 | { 29 | var n =this.User.Identity!.Name; 30 | return Enumerable.Range(1, 5).Select(index => new WeatherForecast 31 | { 32 | Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), 33 | TemperatureC = Random.Shared.Next(-20, 55), 34 | Summary = Summaries[Random.Shared.Next(Summaries.Length)] 35 | }) 36 | .ToArray(); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /Proxy/Middleware/TenantAuthorizationMiddlewareResultHandler.cs: -------------------------------------------------------------------------------- 1 | //using Microsoft.AspNetCore.Authorization.Policy; 2 | //using Microsoft.AspNetCore.Authorization; 3 | //using Microsoft.Azure.Amqp.Sasl; 4 | 5 | //namespace SaaS.Proxy.Middleware 6 | //{ 7 | 8 | 9 | // public class TenantAuthorizationMiddlewareResultHandlers : IAuthorizationMiddlewareResultHandler 10 | // { 11 | // private readonly ILogger _logger; 12 | // private readonly Microsoft.AspNetCore.Authorization.Policy.AuthorizationMiddlewareResultHandler _defaultHandler = new(); 13 | 14 | // public TenantAuthorizationMiddlewareResultHandlers(ILogger logger) 15 | // { 16 | // _logger = logger; 17 | // } 18 | 19 | // public async Task HandleAsync( 20 | // RequestDelegate next, 21 | // HttpContext context, 22 | // AuthorizationPolicy policy, 23 | // PolicyAuthorizationResult authorizeResult) 24 | // { 25 | // var authorizationFailureReason = authorizeResult.AuthorizationFailure?.FailureReasons.FirstOrDefault(); 26 | // var message = authorizationFailureReason?.Message; 27 | // _logger.LogInformation("Authorization Result says {Message}", 28 | // message 29 | // ); 30 | 31 | // await _defaultHandler.HandleAsync(next, context, policy, authorizeResult); 32 | 33 | // } 34 | // } 35 | //} 36 | -------------------------------------------------------------------------------- /WeatherApi/WeatherApi.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | c438f11b-c829-4a48-b1a1-8b85413c3174 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /Scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -u -e -o pipefail 4 | echo "Building..." 5 | source ./helpers.sh 6 | 7 | basedir="$( dirname "$( readlink -f "$0" )" )" 8 | 9 | #CONFIG_FILE="${basedir}/./config.json" 10 | CONFIG_FILE="./config.json" 11 | 12 | #get-value ".webappEndpoint" 13 | appName="$( get-value ".Proxy.Endpoint" | cut -d "." -f 1)" 14 | resourceGroupName="$( get-value ".initConfig.resourceGroupName" | cut -d "." -f 1)" 15 | 16 | echo "App: ${appName}" 17 | echo "Resource group :${resourceGroupName}" 18 | (dotnet publish "../Proxy/Proxy.csproj" -c DEBUG --output ./temp/publish ) \ 19 | || echo fail \ 20 | | exit 1 21 | 22 | cd ./temp/publish ; zip -r ../myapp.zip * ; cd ../../ 23 | 24 | 25 | echo "Deploying..." 26 | az webapp deploy --resource-group "${resourceGroupName}" --name "${appName}" --src-path ./temp/myapp.zip --type zip 27 | 28 | rm ./temp/publish -rf 29 | 30 | appName="$( get-value ".API.Endpoint" | cut -d "." -f 1)" 31 | resourceGroupName="$( get-value ".initConfig.resourceGroupName" | cut -d "." -f 1)" 32 | 33 | echo "App: ${appName}" 34 | echo "Resource group :${resourceGroupName}" 35 | (dotnet publish "../WeatherApi/WeatherApi.csproj" -c DEBUG --output ./temp/publish ) \ 36 | || echo fail \ 37 | | exit 1 38 | 39 | cd ./temp/publish ; zip -r ../myweatherapi.zip * ; cd ../../ 40 | 41 | 42 | echo "Deploying..." 43 | az webapp deploy --resource-group "${resourceGroupName}" --name "${appName}" --src-path ./temp/myweatherapi.zip --type zip 44 | 45 | 46 | rm ./temp/publish -rf 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /Proxy/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | 4 | "LogLevel": { 5 | "Default": "Trace", 6 | "Microsoft.AspNetCore": "Warning" 7 | 8 | }, 9 | "Console": { 10 | "LogLevel": { 11 | "Default": "Trace", 12 | "SaaS": "Trace", 13 | "Microsoft.AspNetCore": "Warning", 14 | "Microsoft.Extensions.Http": "Trace", 15 | "Yarp.ReverseProxy.Forwarder": "Trace", 16 | "Yarp": "Trace", 17 | "Microsoft.IdentityModel" : "Warning" 18 | 19 | } 20 | }, 21 | "ApplicationInsights": { 22 | "IncludeScopes": true, 23 | "LogLevel": { 24 | "Microsoft": "Warning", 25 | "Microsoft.Azure.AppConfiguration": "Trace", 26 | "Azure.Data.Tables": "Information", 27 | "SaaS.Proxy": "Trace", 28 | "Yarp.ReverseProxy.Forwarder": "Trace", 29 | "Yarp.ReverseProxy": "Trace", 30 | "Shared": "Trace", 31 | "Yarp": "Information" 32 | } 33 | } 34 | }, 35 | "AllowedHosts": "*", 36 | "AppConfiguration": { 37 | }, 38 | 39 | "ChangeSubscription": { 40 | "ServiceBusTopic": "sb-appconfigurationchangetopic", 41 | "ServiceBusSubscriptionPrefix": "proxy", 42 | "AutoDeleteOnIdleInHours": 168, 43 | "MaxDelayBeforeCacheIsMarkedDirtyInSeconds": 30 44 | }, 45 | "AzureAd": { 46 | "Instance": "https://login.microsoftonline.com/", 47 | "TenantId": "common", 48 | "CallbackPath": "/signin-oidc" 49 | }, 50 | 51 | "Jwt": { 52 | "ValidAudience": "https://localhost:4200", 53 | "ValidIssuer": "https://localhost:7001" 54 | 55 | } 56 | 57 | 58 | 59 | } 60 | -------------------------------------------------------------------------------- /Shared.Services/Token/TokenService.cs: -------------------------------------------------------------------------------- 1 | using Azure.Core; 2 | using Azure.Identity; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | using Shared.Models; 7 | using System; 8 | using System.Collections.Generic; 9 | using System.Linq; 10 | using System.Text; 11 | using System.Threading.Tasks; 12 | 13 | namespace Shared.Services.Token 14 | { 15 | public class TokenService : ITokenService 16 | { 17 | private readonly IConfiguration _configuration; 18 | private readonly ILogger _logger; 19 | private readonly IOptions _sp; 20 | 21 | public TokenService(IConfiguration conf, IOptions sp, ILogger logger) 22 | { 23 | _configuration = conf; 24 | _logger = logger; 25 | IConfigurationRoot cr = (IConfigurationRoot)conf; 26 | _sp = sp; 27 | } 28 | public TokenCredential GetTokenCredential() 29 | { 30 | _logger.LogInformation($"Getting token for: {_sp.Value.ClientId}"); 31 | if (string.IsNullOrEmpty(_sp.Value.TenantId)) 32 | return new ClientSecretCredential(Guid.NewGuid().ToString(), _sp.Value.ClientId, _sp.Value.Secret ?? "NOT SET"); ; //not running in production. Tenant id is not set. 33 | return new ClientSecretCredential(_sp.Value.TenantId,_sp.Value.ClientId, _sp.Value.Secret ?? "NOT SET"); 34 | } 35 | } 36 | public interface ITokenService 37 | { 38 | TokenCredential GetTokenCredential(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Shared/Repositories/FileName.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using Microsoft.Extensions.Options; 3 | using Shared.Models; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Text; 8 | using System.Text.Json.Serialization; 9 | using System.Threading.Tasks; 10 | 11 | namespace Shared.Repositories 12 | { 13 | public interface ITenantRepository 14 | { 15 | List GetAllTenants(); 16 | Tenant GetTenant(string tenantId); 17 | } 18 | [JsonConverter(typeof(JsonStringEnumConverter))] 19 | public enum ResponseCodes{ 20 | TenantNotFound, 21 | TenantDeleted, 22 | TenantCreated, 23 | TenantUpdated, 24 | TenantIsActive, 25 | } 26 | 27 | public record TenantRepositoryResponse(ResponseCodes ResponseCode, Dictionary? Properties= null); 28 | public class TenantRepository : ITenantRepository 29 | { 30 | private readonly ILogger _logger; 31 | private readonly IOptionsMonitor _repo; 32 | 33 | public TenantRepository(IOptionsMonitor tenants, ILogger logger) 34 | { 35 | _logger = logger; 36 | _repo = tenants; 37 | } 38 | public List GetAllTenants() 39 | { 40 | return _repo.CurrentValue.Tenants; 41 | } 42 | 43 | public Tenant GetTenant(string tenantId) 44 | { 45 | var r = _repo.CurrentValue.Tenants.Where(w => w.Tid == tenantId).FirstOrDefault(); 46 | return r ?? throw new Exception("Tenant does not exist"); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Shared/Attributes/BearerAuthenticationAttribute .cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Identity; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Shared.Models; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.IdentityModel.Tokens.Jwt; 9 | using System.Linq; 10 | using System.Net; 11 | using System.Security.Claims; 12 | using System.Security.Principal; 13 | using System.Text; 14 | using System.Threading.Tasks; 15 | 16 | namespace Shared.Attributes 17 | { 18 | 19 | public class AuthRequirement : IAuthorizationRequirement 20 | { 21 | } 22 | public class AuthRequirementHandler : AuthorizationHandler 23 | { 24 | private readonly IHttpContextAccessor _httpContextAccessor; 25 | 26 | public AuthRequirementHandler(IHttpContextAccessor httpContextAccessor) 27 | { 28 | _httpContextAccessor = httpContextAccessor; 29 | } 30 | 31 | 32 | 33 | protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AuthRequirement requirement) 34 | { 35 | if (_httpContextAccessor.HttpContext?.Request.Headers.Authorization.FirstOrDefault() is null) 36 | context.Fail(); 37 | else { 38 | var auth = _httpContextAccessor.HttpContext.Request.Headers.Authorization.FirstOrDefault()!.Replace("Bearer ", string.Empty); 39 | var handler = new JwtSecurityTokenHandler(); 40 | var token = handler.ReadJwtToken(auth); 41 | context.Succeed(requirement); 42 | } 43 | 44 | 45 | return Task.FromResult(0); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Scripts/createPostmanEnvironment.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source ./helpers.sh 3 | 4 | basedir="$( dirname "$( readlink -f "$0" )" )" 5 | 6 | #CONFIG_FILE="${basedir}/./config.json" 7 | CONFIG_FILE="./config.json" 8 | 9 | if [ ! -f "$CONFIG_FILE" ]; then 10 | cp ./config-template.json "${CONFIG_FILE}" 11 | fi 12 | 13 | 14 | cp ./postman/* . 15 | mv Template-TenantProxy-dev.postman_environment.json TenantProxy-dev.postman_environment.json 16 | mv Template-TenantProxy.postman_collection.json TenantProxy.postman_collection.json 17 | 18 | 19 | 20 | proxyClientId="$( get-value ".Proxy.Aad.ClientId" )" 21 | proxySecret="$( get-value ".Proxy.Aad.Secret" )" 22 | url="$( get-value ".Proxy.Endpoint" )" 23 | rg="$( get-value ".initConfig.resourceGroupName" )" 24 | 25 | echo $rg 26 | 27 | 28 | cat <<< $(jq --arg a1 "https://$url" '(.values[] | select(.key == "url") | .value) = $a1 ' ./TenantProxy-dev.postman_environment.json ) > TenantProxy-dev.postman_environment.json 29 | cat <<< $(jq --arg a1 "$proxyClientId" '(.values[] | select(.key == "clientId") | .value) = $a1 ' ./TenantProxy-dev.postman_environment.json ) > TenantProxy-dev.postman_environment.json 30 | cat <<< $(jq --arg a1 "$proxySecret" '(.values[] | select(.key == "clientSecret") | .value) = $a1 ' ./TenantProxy-dev.postman_environment.json ) > TenantProxy-dev.postman_environment.json 31 | cat <<< $(jq --arg a1 "access_as_user" '(.values[] | select(.key == "scope") | .value) = $a1 ' ./TenantProxy-dev.postman_environment.json ) > TenantProxy-dev.postman_environment.json 32 | cat <<< $(jq --arg a1 "TenantProxy:$rg" '.name = $a1 ' ./TenantProxy-dev.postman_environment.json ) > TenantProxy-dev.postman_environment.json 33 | 34 | 35 | 36 | cat <<< $(jq --arg a1 "TenantProxy:$rg" '.info.name = $a1 ' ./TenantProxy.postman_collection.json ) > TenantProxy.postman_collection.json 37 | 38 | 39 | 40 | echo $res -------------------------------------------------------------------------------- /WeatherApi/Controllers/DiagController.cs: -------------------------------------------------------------------------------- 1 | //using Microsoft.AspNetCore.Mvc; 2 | 3 | using Asp.Versioning; 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace WeatherApi.Controllers 8 | { 9 | [ApiController] 10 | [ApiVersion("1.0")] 11 | [Route("api/v{version:apiVersion}/[controller]")] 12 | [Authorize] 13 | [ApiExplorerSettings(IgnoreApi = true)] 14 | public class DiagController : ControllerBase 15 | { 16 | public record RequestDump(Dictionary headers, Dictionary cookies, Dictionary claims, Dictionary> routes); 17 | [HttpGet(Name = "GetDiag")] 18 | [Authorize] 19 | 20 | public RequestDump Diag() 21 | { 22 | var req = this.Request; 23 | var user = User; 24 | Dictionary headers = new(); 25 | Dictionary cookies = new(); 26 | Dictionary claims = new(); 27 | Dictionary> routes = new(); 28 | 29 | foreach (var header in req.Headers) 30 | { 31 | headers.Add(header.Key, header.Value!); 32 | } 33 | foreach (var cookie in req.Cookies) 34 | { 35 | cookies.Add(cookie.Key, cookie.Value); 36 | } 37 | foreach (var route in req.RouteValues) 38 | { 39 | routes.Add(route.Key, route); 40 | } 41 | if (user.Identity!.IsAuthenticated) 42 | foreach (var claim in user.Claims) 43 | { 44 | claims.Add(claim.Type, claim.Value); 45 | } 46 | 47 | return new RequestDump(headers, cookies, claims, routes); 48 | 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /WeatherApi/Configration/Logging.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | namespace WeatherApi.Configuration 4 | { 5 | public static class ApplicationInsightsLogging 6 | { 7 | public static void AddApplicationInsightsLogging(this WebApplicationBuilder builder, string configKey= "API:Settings:Logging") 8 | { 9 | 10 | 11 | builder.Services.AddApplicationInsightsTelemetry(opts => 12 | { 13 | opts.ConnectionString = builder.Configuration.GetSection("ApplicationInsights:ConnectionString").Value; 14 | opts.EnableDependencyTrackingTelemetryModule = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:EnableDependencyTrackingTelemetryModule").Value ?? "true"); 15 | opts.EnablePerformanceCounterCollectionModule = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:EnablePerformanceCounterCollectionModule").Value ?? "false"); 16 | opts.EnableAdaptiveSampling = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:EnableAdaptiveSampling").Value ?? "true"); 17 | opts.EnableHeartbeat = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:EnableHeartbeat").Value ?? "true"); 18 | opts.EnableAppServicesHeartbeatTelemetryModule = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:EnableAppServicesHeartbeatTelemetryModule").Value ?? "true"); 19 | opts.EnableRequestTrackingTelemetryModule = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:EnableRequestTrackingTelemetryModule").Value ?? "true"); 20 | opts.DeveloperMode = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:DeveloperMode").Value ?? "true"); 21 | }); 22 | 23 | } 24 | 25 | //public static void UseLogging(this WebApplication app) 26 | //{ 27 | 28 | //} 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Proxy/Proxy.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 1660c742-55e8-4ea0-8583-05ca04d33d56 8 | true 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /ControlPlane/TenantManagement/TenantManagement.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 26b50579-151f-465a-9c2f-fafea58d3cd9 8 | True 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /ControlPlane/TenantManagement/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | 4 | "LogLevel": { 5 | "Default": "Trace", 6 | "Microsoft.AspNetCore": "Warning" 7 | 8 | }, 9 | "Console": { 10 | "LogLevel": { 11 | "Default": "Trace", 12 | "SaaS": "Trace", 13 | "Microsoft.AspNetCore": "Warning", 14 | "Microsoft.Extensions.Http": "Trace", 15 | "Yarp.ReverseProxy.Forwarder": "Trace", 16 | "Yarp": "Trace", 17 | "Microsoft.IdentityModel": "Warning" 18 | 19 | } 20 | }, 21 | "ApplicationInsights": { 22 | "IncludeScopes": true, 23 | "LogLevel": { 24 | "Microsoft": "Warning", 25 | "Microsoft.Azure.AppConfiguration": "Trace", 26 | "Azure.Data.Tables": "Information", 27 | "SaaS.Proxy": "Trace", 28 | "Yarp.ReverseProxy.Forwarder": "Trace", 29 | "Yarp.ReverseProxy": "Trace", 30 | "Shared": "Trace", 31 | "Yarp": "Information" 32 | } 33 | } 34 | }, 35 | "AllowedHosts": "*", 36 | "AppConfiguration": { 37 | "UserAssignedManagedIdentityClientId": "bbcb816b-1c81-45bf-94da-b15a9844dd13", 38 | "TenantId": "e4df7223-e170-4ae0-93ad-076d478cbe95", 39 | "ClientId": "184083da-af84-41aa-86d8-2c392b9478cc", 40 | "Uri": "https://proxyconfiguration-ttm3.azconfig.io" 41 | }, 42 | 43 | "ChangeSubscription": { 44 | "ServiceBusTopic": "sb-appconfigurationchangetopic", 45 | "ServiceBusSubscriptionPrefix": "cp", 46 | "ServiceBusNamespace": "ProxyServicebus-ttm3.servicebus.windows.net", 47 | "AutoDeleteOnIdleInHours": 168, 48 | "MaxDelayBeforeCacheIsMarkedDirtyInSeconds": 30 49 | 50 | }, 51 | "AzureAd": { 52 | "Instance": "https://login.microsoftonline.com/", 53 | "Domain": "paainternettet.dk", 54 | "TenantId": "common", 55 | "ClientId": "93d91884-f044-49d0-b0e5-1a4b3190cb97", 56 | "CallbackPath": "/signin-oidc" 57 | }, 58 | 59 | "Jwt": { 60 | "ValidAudience": "https://localhost:4200", 61 | "ValidIssuer": "https://localhost:7001" 62 | 63 | } 64 | 65 | 66 | 67 | } 68 | -------------------------------------------------------------------------------- /Proxy/Controllers/ConfigController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.Extensions.Options; 4 | using Microsoft.FeatureManagement; 5 | using Microsoft.FeatureManagement.Mvc; 6 | 7 | using System.Runtime; 8 | using System.Text; 9 | using System.Text.Json; 10 | 11 | 12 | using Yarp.ReverseProxy.Configuration; 13 | 14 | namespace SaaS.Proxy.Controllers 15 | { 16 | [Route("[controller]")] 17 | //[Route("api/v{version:apiVersion}/{icao}/{sensor}/[controller]")] 18 | [ApiController] 19 | [FeatureGate("ShowDebugView")] 20 | public class ConfigController : ControllerBase 21 | { 22 | 23 | private readonly ILogger _logger; 24 | private readonly IConfigurationRoot _root; 25 | private readonly IProxyConfigProvider _proxyConfig; 26 | private readonly InMemoryConfigProvider _memConfigProvider; 27 | private readonly IOptions> _routes; 28 | 29 | public ConfigController(ILogger logger, IConfigurationRoot root, IProxyConfigProvider configProvider, InMemoryConfigProvider memConfigProvider, IOptions> r) 30 | { 31 | _logger = logger; 32 | _root = root; 33 | _proxyConfig = configProvider; 34 | _memConfigProvider = memConfigProvider; 35 | _routes = r; 36 | } 37 | 38 | /// 39 | /// Shows the entire configuration. Enable featureflag to enable the endpoint. 40 | /// 41 | [HttpGet("GetConfigDump")] 42 | public string GetConfig() 43 | { 44 | return _root.GetDebugView(); 45 | } 46 | public record ProxyConfig(IProxyConfig memory, IProxyConfig config); 47 | [HttpGet("GetproxyConfigDump")] 48 | 49 | public ProxyConfig GetProxyConfig() 50 | { 51 | var cfg = _proxyConfig.GetConfig(); 52 | var memCfg = _memConfigProvider.GetConfig(); 53 | return new ProxyConfig(memCfg, cfg); 54 | } 55 | 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /Scripts/modules/addAppConfiguration.bicep: -------------------------------------------------------------------------------- 1 | param appConfigName string 2 | param keyVaultName string 3 | 4 | @allowed([ 5 | 'text/plain' 6 | 'application/json' 7 | 'application/xml' 8 | 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' 9 | ]) 10 | param contentType string = 'application/json' 11 | param value string 12 | param keyName string 13 | param managedIdentityWithAccessToAppConfiguration string 14 | 15 | //If the value is 'NotASecret' then dont store in Key Vault 16 | param isSecrect bool= false 17 | 18 | var conf = loadJsonContent('../config.json') 19 | var roles = conf.roles 20 | 21 | resource uami 'Microsoft.ManagedIdentity/userAssignedIdentities@2022-01-31-preview' existing = { 22 | name: managedIdentityWithAccessToAppConfiguration 23 | } 24 | resource appConfigurationStore 'Microsoft.AppConfiguration/configurationStores@2022-05-01' existing = { 25 | name: appConfigName 26 | } 27 | 28 | resource configStoreEntry 'Microsoft.AppConfiguration/configurationStores/keyValues@2022-05-01' = { 29 | parent:appConfigurationStore 30 | name: keyName 31 | properties: { 32 | value: (!isSecrect) ? value : '{"uri":"${keyVaultEntry.properties.secretUri}"}' 33 | contentType: (!isSecrect) ? contentType : 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' 34 | } 35 | } 36 | 37 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = if (isSecrect){ 38 | name: keyVaultName 39 | } 40 | 41 | resource keyVaultEntry 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = if (isSecrect) { 42 | parent: keyVault 43 | name: replace(keyName, ':', '-') 44 | properties: { 45 | value: value 46 | } 47 | } 48 | 49 | resource managedIdentityCanReadNotificationSecret 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (isSecrect) { 50 | name: guid(roles['Key Vault Secrets User'], uami.id, keyVaultEntry.id) 51 | scope: keyVaultEntry 52 | properties: { 53 | roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles['Key Vault Secrets User']) 54 | principalId: uami.properties.principalId 55 | principalType: 'ServicePrincipal' 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /Proxy/Configuration/Logging.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.ApplicationInsights.Extensibility; 2 | using Shared.Middelware.ApplicationInsights.TelemetryInitializers; 3 | using System.Reflection; 4 | 5 | namespace SaaS.Proxy.Configuration 6 | { 7 | public static class ApplicationInsightsLogging 8 | { 9 | public static void AddApplicationInsightsLogging(this WebApplicationBuilder builder, string configKey= "API:Settings:Logging") 10 | { 11 | 12 | 13 | builder.Services.AddApplicationInsightsTelemetry(opts => 14 | { 15 | opts.ConnectionString = builder.Configuration.GetSection("ApplicationInsights:ConnectionString").Value; 16 | opts.EnableDependencyTrackingTelemetryModule = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:EnableDependencyTrackingTelemetryModule").Value ?? "true"); 17 | opts.EnablePerformanceCounterCollectionModule = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:EnablePerformanceCounterCollectionModule").Value ?? "false"); 18 | opts.EnableAdaptiveSampling = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:EnableAdaptiveSampling").Value ?? "true"); 19 | opts.EnableHeartbeat = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:EnableHeartbeat").Value ?? "false"); 20 | opts.EnableAppServicesHeartbeatTelemetryModule = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:EnableAppServicesHeartbeatTelemetryModule").Value ?? "false"); 21 | opts.EnableRequestTrackingTelemetryModule = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:EnableRequestTrackingTelemetryModule").Value ?? "true"); 22 | opts.DeveloperMode = bool.Parse(builder.Configuration.GetSection("ApplicationInsights:DeveloperMode").Value ?? "false"); 23 | 24 | }); 25 | builder.Services.AddSingleton(); 26 | builder.Services.AddSingleton(); 27 | 28 | } 29 | 30 | 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Proxy/Services/TenantRoutingUpdaterService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | 3 | using SaaS.Proxy.Services; 4 | using Shared.Models; 5 | 6 | namespace Proxy.Services 7 | { 8 | public class TenantRoutingUpdaterService : IHostedService 9 | { 10 | private IDisposable _optionsChangedListener; 11 | //IOptionsMonitor 12 | private TenantSettings _myCurrentOptions; 13 | private readonly IOptionsMonitor _options; 14 | private readonly ITenantDirectoryService _tds; 15 | private readonly ILogger _logger; 16 | 17 | public TenantRoutingUpdaterService(IOptionsMonitor optionsMonitor, ITenantDirectoryService tds, ILogger logger) 18 | { 19 | _optionsChangedListener = optionsMonitor.OnChange(OptionsChanged!)!; 20 | _myCurrentOptions = optionsMonitor.CurrentValue; 21 | _options = optionsMonitor; 22 | _tds = tds; 23 | _logger = logger; 24 | } 25 | 26 | private void OptionsChanged(TenantSettings newOptions, string arg2) 27 | { 28 | _myCurrentOptions = newOptions; 29 | 30 | _tds.AddTenantsToRouting(newOptions.Tenants!); 31 | } 32 | 33 | public Task StartAsync(CancellationToken cancellationToken) 34 | { 35 | _optionsChangedListener = _options.OnChange(OptionsChanged!)!; 36 | return Task.CompletedTask; 37 | } 38 | 39 | public Task StopAsync(CancellationToken cancellationToken) 40 | { 41 | return Task.CompletedTask; 42 | //throw new NotImplementedException(); 43 | } 44 | 45 | 46 | 47 | //protected override async Task ExecuteAsync(CancellationToken stoppingToken) 48 | //{ 49 | // while (!stoppingToken.IsCancellationRequested) 50 | // { 51 | // //Console.WriteLine(_myCurrentOptions.Tenants.Count); 52 | // await Task.Delay(10000, stoppingToken); 53 | // } 54 | //} 55 | 56 | //public override void Dispose() 57 | //{ 58 | // _optionsChangedListener.Dispose(); 59 | // base.Dispose(); 60 | //} 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Proxy/Controllers/DiagController.cs: -------------------------------------------------------------------------------- 1 | //using Microsoft.AspNetCore.Mvc; 2 | 3 | using Microsoft.AspNetCore.Authorization; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.Extensions.Options; 6 | using Microsoft.FeatureManagement.Mvc; 7 | using Shared.Models; 8 | 9 | namespace SaaS.Proxy.Controllers 10 | { 11 | [ApiController] 12 | [Route("[controller]")] 13 | [FeatureGate("ShowDebugView")] 14 | public class DiagController : ControllerBase 15 | { 16 | private readonly ILogger _logger; 17 | private readonly IOptionsMonitor _tenants; 18 | 19 | public DiagController(ILogger logger, IOptionsMonitor tenants) 20 | { 21 | _logger = logger; 22 | _tenants = tenants; 23 | } 24 | 25 | public record RequestDump(Dictionary headers, Dictionary cookies, Dictionary claims, 26 | Dictionary> routes, 27 | List tenants); 28 | 29 | [HttpGet(Name = "GetDiag")] 30 | 31 | public RequestDump Diag() 32 | { 33 | var req = this.Request; 34 | var user = User; 35 | Dictionary headers = new(); 36 | Dictionary cookies = new(); 37 | Dictionary claims = new(); 38 | Dictionary> routes = new(); 39 | 40 | foreach (var header in req.Headers) 41 | { 42 | headers.Add(header.Key, header.Value!); 43 | } 44 | foreach (var cookie in req.Cookies) 45 | { 46 | cookies.Add(cookie.Key, cookie.Value); 47 | } 48 | foreach (var route in req.RouteValues) 49 | { 50 | routes.Add(route.Key, route); 51 | } 52 | if (user.Identity!.IsAuthenticated) 53 | foreach (var claim in user.Claims) 54 | { 55 | claims.Add(claim.Type, claim.Value); 56 | } 57 | 58 | return new RequestDump(headers, cookies, claims, routes,_tenants.CurrentValue.Tenants); 59 | 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Scripts/createDevServicePrincipal.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source ./helpers.sh 4 | 5 | basedir="$( dirname "$( readlink -f "$0" )" )" 6 | 7 | #CONFIG_FILE="${basedir}/./config.json" 8 | CONFIG_FILE="./config.json" 9 | 10 | if [ ! -f "$CONFIG_FILE" ]; then 11 | echo "Creating new config file" 12 | cp ./config-template.json "${CONFIG_FILE}" 13 | fi 14 | 15 | 16 | 17 | jsonpath=".initConfig.resourceGroupName" 18 | resourceGroupName="$( get-value "${jsonpath}" )" 19 | servicePrincipalName="ProxyDeveloment-$resourceGroupName" 20 | echo $servicePrincipalName 21 | 22 | jsonpath=".initConfig.subscriptionId" 23 | subscriptionId="$( get-value "${jsonpath}" )" 24 | 25 | 26 | 27 | #az group list --query "[].{name:name}" --output tsv 28 | 29 | #az account list --query "[].{name:name, id:id}" --output tsv 30 | 31 | 32 | #sp=$(az ad sp create-for-rbac -n DevelopmentCredentials |jq .) 33 | 34 | 35 | scope="/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName" 36 | echo $scope 37 | 38 | sp=$(az ad sp create-for-rbac --name "${servicePrincipalName}" \ 39 | --role 'Owner' \ 40 | --scopes $scope | jq .) 41 | 42 | echo $sp 43 | put-value '.Development.ServicePrincipal.ClientId' "$(echo "${sp}" | jq -r '.appId' )" 44 | put-value '.Development.ServicePrincipal.Secret' "$(echo "${sp}" | jq -r '.password' )" 45 | put-value '.Development.ServicePrincipal.TenantId' "$(echo "${sp}" | jq -r '.tenant' )" 46 | put-value '.Development.ServicePrincipal.DisplayName' "$(echo "${sp}" | jq -r '.displayName' )" 47 | 48 | assignee="$(echo "${sp}" | jq -r '.appId' )" 49 | echo $assignee 50 | 51 | 52 | 53 | 54 | #todo: Create loop over permissions already in config.json, roles 55 | az role assignment create --assignee $assignee \ 56 | --role 'b24988ac-6180-42a0-ab88-20f7382dd24c' \ 57 | --scope $scope 58 | 59 | 60 | az role assignment create --assignee $assignee \ 61 | --role '5ae67dd6-50cb-40e7-96ff-dc2bfa4b606b' \ 62 | --scope $scope 63 | 64 | 65 | az role assignment create --assignee $assignee \ 66 | --role '516239f1-63e1-4d78-a4de-a74fb236a071' \ 67 | --scope $scope 68 | 69 | az role assignment create --assignee $assignee \ 70 | --role '4633458b-17de-408a-b874-0445c86b69e6' \ 71 | --scope $scope 72 | 73 | 74 | az role assignment create --assignee $assignee \ 75 | --role '21090545-7ca7-4776-b22c-e363652d74d2' \ 76 | --scope $scope 77 | 78 | az role assignment create --assignee $assignee \ 79 | --role '4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0' \ 80 | --scope $scope 81 | 82 | 83 | -------------------------------------------------------------------------------- /Proxy/Configuration/Auth.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication.JwtBearer; 2 | using Microsoft.AspNetCore.Authentication.OpenIdConnect; 3 | using Microsoft.AspNetCore.Authorization; 4 | using Microsoft.Identity.Web; 5 | 6 | 7 | 8 | namespace SaaS.Proxy.Configuration 9 | { 10 | public static class Auth 11 | { 12 | 13 | public static void AddAuth(this WebApplicationBuilder builder) 14 | { 15 | 16 | //var initialScopes = builder.Configuration["DownstreamApi:Scopes"]?.Split(' ') ?? builder.Configuration["MicrosoftGraph:Scopes"]?.Split(' '); 17 | 18 | 19 | 20 | // Add services to the container. 21 | builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) 22 | .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd")) 23 | //.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")) 24 | .EnableTokenAcquisitionToCallDownstreamApi() 25 | // .AddMicrosoftGraph(builder.Configuration.GetSection("MicrosoftGraph")) 26 | .AddInMemoryTokenCaches() 27 | ; 28 | 29 | builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 30 | //.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd")) 31 | .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")) 32 | .EnableTokenAcquisitionToCallDownstreamApi() 33 | // .AddMicrosoftGraph(builder.Configuration.GetSection("MicrosoftGraph")) 34 | .AddInMemoryTokenCaches() 35 | ; 36 | 37 | builder.Services.AddAuthorization(options => 38 | { 39 | 40 | //options.FallbackPolicy = options.DefaultPolicy; 41 | options.AddPolicy("customPolicy", policy => 42 | { 43 | policy.RequireAuthenticatedUser(); 44 | }); 45 | options.AddPolicy("tokenPolicy", policy => 46 | { 47 | policy.AuthenticationSchemes.Add(JwtBearerDefaults.AuthenticationScheme); 48 | policy.RequireAuthenticatedUser(); 49 | }); 50 | 51 | }); 52 | 53 | } 54 | public static void UseAuth(this WebApplication app) 55 | { 56 | //app.UseSession(); 57 | app.UseCookiePolicy(); 58 | //app.UseXsrfCookie(); 59 | 60 | app.UseRouting(); 61 | 62 | app.UseAuthentication(); 63 | app.UseAuthorization(); 64 | 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /ControlPlane/TenantManagement/Configuration/ExternalConfigurationStore.cs: -------------------------------------------------------------------------------- 1 | using Azure.Identity; 2 | using Microsoft.Extensions.Configuration.AzureAppConfiguration; 3 | using Shared.Models; 4 | 5 | namespace ControlPlane.Configuration 6 | { 7 | public static class ExternalConfigurationStore 8 | { 9 | public static void AddExternalConfigurationStore(this WebApplicationBuilder builder) 10 | { 11 | string env = String.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")) ? "Development" : Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Unknown"; 12 | builder.Configuration.AddAzureAppConfiguration(opts => 13 | { 14 | var uami = builder.Configuration.GetSection("AppConfiguration:UserAssignedManagedIdentityClientId").Value ?? "Unknown"; 15 | var tenantId = builder.Configuration.GetSection("Development:ServicePrincipal:TenantId").Value ?? "Unknown"; 16 | var clientId = builder.Configuration.GetSection("Development:ServicePrincipal:ClientId").Value ?? "Unknown"; 17 | var secret = builder.Configuration.GetSection("Development:ServicePrincipal:Secret").Value ?? "Unknown"; 18 | 19 | var managedCredential = new ManagedIdentityCredential(uami); 20 | var credential = new ChainedTokenCredential(managedCredential, new ClientSecretCredential(tenantId, clientId, secret)); 21 | string c = builder.Configuration.GetSection("AppConfiguration:Uri").Value ?? throw new Exception("AppConfiguration not set"); 22 | opts.Connect(new Uri(c), credential).ConfigureKeyVault(opts => opts.SetCredential(credential)); 23 | opts 24 | .Select("API:*") 25 | .Select($"TenantDirectory", env) 26 | .ConfigureRefresh(refresh => 27 | { 28 | refresh 29 | .Register("TenantDirectory", env, refreshAll: true) 30 | .SetCacheExpiration(TimeSpan.FromDays(1)); 31 | }) 32 | .UseFeatureFlags(featureFlagOptions => 33 | { 34 | featureFlagOptions.CacheExpirationInterval = TimeSpan.FromDays(1); 35 | featureFlagOptions.Select(KeyFilter.Any, LabelFilter.Null).Select(KeyFilter.Any, env); 36 | }); 37 | }, optional: false); 38 | 39 | 40 | builder.Services 41 | .AddAzureAppConfiguration() 42 | .AddSingleton(builder.Configuration); 43 | } 44 | 45 | public static void UseExternalConfigurationStore(this WebApplication app) 46 | { 47 | app.UseAzureAppConfiguration(); 48 | } 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | 65 | 66 | 67 | # Declare files that will always have CRLF line endings on checkout. 68 | *.sln text eol=crlf 69 | # Declare files that will always have LF line endings on checkout. 70 | *.sh text eol=lf 71 | -------------------------------------------------------------------------------- /ControlPlane/TenantManagement/ConfigureSwaggerOptions.cs: -------------------------------------------------------------------------------- 1 | namespace TenantManagement; 2 | 3 | using Asp.Versioning; 4 | using Asp.Versioning.ApiExplorer; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Options; 7 | using Microsoft.OpenApi.Models; 8 | using Swashbuckle.AspNetCore.SwaggerGen; 9 | using System.Text; 10 | 11 | /// 12 | /// Configures the Swagger generation options. 13 | /// 14 | /// This allows API versioning to define a Swagger document per API version after the 15 | /// service has been resolved from the service container. 16 | public class ConfigureSwaggerOptions : IConfigureOptions 17 | { 18 | private readonly IApiVersionDescriptionProvider provider; 19 | 20 | /// 21 | /// Initializes a new instance of the class. 22 | /// 23 | /// The provider used to generate Swagger documents. 24 | public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) => this.provider = provider; 25 | 26 | /// 27 | public void Configure(SwaggerGenOptions options) 28 | { 29 | // add a swagger document for each discovered API version 30 | // note: you might choose to skip or document deprecated API versions differently 31 | foreach (var description in provider.ApiVersionDescriptions) 32 | { 33 | options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description)); 34 | } 35 | } 36 | 37 | private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description) 38 | { 39 | var text = new StringBuilder("An example application with OpenAPI, Swashbuckle, and API versioning."); 40 | var info = new OpenApiInfo() 41 | { 42 | Title = "Example API", 43 | Version = description.ApiVersion.ToString(), 44 | //Contact = new OpenApiContact() { Name = "Bill Mei", Email = "bill.mei@somewhere.com" }, 45 | //License = new OpenApiLicense() { Name = "MIT", Url = new Uri("https://opensource.org/licenses/MIT") } 46 | }; 47 | 48 | if (description.IsDeprecated) 49 | { 50 | text.Append(" This API version has been deprecated."); 51 | } 52 | 53 | if (description.SunsetPolicy is SunsetPolicy policy) 54 | { 55 | if (policy.Date is DateTimeOffset when) 56 | { 57 | text.Append(" The API will be sunset on ") 58 | .Append(when.Date.ToShortDateString()) 59 | .Append('.'); 60 | } 61 | 62 | if (policy.HasLinks) 63 | { 64 | text.AppendLine(); 65 | 66 | for (var i = 0; i < policy.Links.Count; i++) 67 | { 68 | var link = policy.Links[i]; 69 | 70 | if (link.Type == "text/html") 71 | { 72 | text.AppendLine(); 73 | 74 | if (link.Title.HasValue) 75 | { 76 | text.Append(link.Title.Value).Append(": "); 77 | } 78 | 79 | text.Append(link.LinkTarget.OriginalString); 80 | } 81 | } 82 | } 83 | } 84 | 85 | info.Description = text.ToString(); 86 | 87 | return info; 88 | } 89 | } -------------------------------------------------------------------------------- /Proxy/Controllers/ProxyController.cs: -------------------------------------------------------------------------------- 1 | //using Microsoft.AspNetCore.Mvc; 2 | //using Microsoft.FeatureManagement.Mvc; 3 | //using Yarp.ReverseProxy.Configuration; 4 | 5 | //namespace SaaS.Proxy.Controllers 6 | //{ 7 | // [ApiController] 8 | // [Route("[controller]")] 9 | // //[FeatureGate("ShowDebugView")] 10 | // public class ProxyController : ControllerBase 11 | // { 12 | 13 | // private readonly ILogger _logger; 14 | // private readonly InMemoryConfigProvider _memConfigProvider; 15 | // private readonly IProxyConfigProvider _configProvider; 16 | 17 | // public ProxyController(ILogger logger, InMemoryConfigProvider memConfigProvider, IProxyConfigProvider configProvider) 18 | // { 19 | // _logger = logger; 20 | // _memConfigProvider = memConfigProvider; 21 | // _configProvider = configProvider; 22 | // } 23 | 24 | // /// 25 | // /// 26 | // /// 27 | 28 | // /// 29 | // [HttpGet(Name = "GetProxyConfig")] 30 | // public IProxyConfig Get() 31 | // { 32 | // var cfg = _configProvider.GetConfig(); 33 | 34 | 35 | // return cfg; 36 | // } 37 | 38 | // /// 39 | // /// 40 | // /// 41 | // /// 42 | // /// 43 | // /// 44 | // /// 45 | 46 | // [HttpPost(Name = "PostProxyConfig")] 47 | // public IActionResult Post(string tenantId, string path, Uri address) 48 | // { 49 | // var cfg = _memConfigProvider.GetConfig(); 50 | // var clusters = cfg.Clusters.ToList(); 51 | 52 | 53 | // var routes = cfg.Routes.ToList(); 54 | 55 | // if (routes.Count(c => c.RouteId == tenantId) != 0) 56 | // { 57 | // return new BadRequestObjectResult("Tenant route already exists"); 58 | // } 59 | 60 | // clusters.Add(new ClusterConfig() 61 | // { 62 | // ClusterId = tenantId, 63 | // Destinations = new Dictionary() { 64 | // { "1", new DestinationConfig() { Address = address.ToString() } } } 65 | // }); ; 66 | 67 | // Dictionary d = new Dictionary(); 68 | // routes.Add(new RouteConfig() 69 | // { 70 | // RouteId = tenantId, 71 | // ClusterId = tenantId, 72 | // Match = new RouteMatch() { Path = path }, 73 | // Transforms = new List>() { 74 | // new Dictionary(){ 75 | // { "PathRemovePrefix", path.Split('/')[1] } 76 | // } 77 | 78 | // } 79 | // }); 80 | // _memConfigProvider.Update(routes, clusters); 81 | // return new OkResult(); 82 | // } 83 | // [HttpDelete(Name = "DeleteProxyConfig")] 84 | // public IActionResult Delete(string tenantId) 85 | // { 86 | // var cfg = _memConfigProvider.GetConfig(); 87 | // var clusters = cfg.Clusters.Where(s => s.ClusterId != tenantId).ToList(); 88 | // var routes = cfg.Routes.Where(s => s.RouteId != tenantId).ToList(); 89 | // _memConfigProvider.Update(routes, clusters); 90 | // return new OkResult(); 91 | // } 92 | // } 93 | //} -------------------------------------------------------------------------------- /YARP-Reverse-Proxy.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.5.33530.505 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shared", "Shared\Shared.csproj", "{9526A822-30DE-45D5-AAAF-27F605818AFC}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shared.Services", "Shared.Services\Shared.Services.csproj", "{F8DFAE44-B4A4-41B0-B821-81E35406AEEF}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WeatherApi", "WeatherApi\WeatherApi.csproj", "{744D4291-5DBF-4E01-9DDD-E159729DFBEB}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Proxy", "Proxy\Proxy.csproj", "{FBD6E79C-09D6-450D-98D1-FD9127915728}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ControlPlane", "ControlPlane", "{A67B2B8A-98FD-4A9D-A990-A92E95610038}" 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TenantManagement", "ControlPlane\TenantManagement\TenantManagement.csproj", "{DFEFFE12-C303-4E4A-8310-B59C9F7F601A}" 17 | EndProject 18 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{987669F7-646E-4D4D-9EA6-DBC9E9A81C8B}" 19 | ProjectSection(SolutionItems) = preProject 20 | .editorconfig = .editorconfig 21 | EndProjectSection 22 | EndProject 23 | Global 24 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 25 | Debug|Any CPU = Debug|Any CPU 26 | Release|Any CPU = Release|Any CPU 27 | EndGlobalSection 28 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 29 | {9526A822-30DE-45D5-AAAF-27F605818AFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {9526A822-30DE-45D5-AAAF-27F605818AFC}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {9526A822-30DE-45D5-AAAF-27F605818AFC}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {9526A822-30DE-45D5-AAAF-27F605818AFC}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {F8DFAE44-B4A4-41B0-B821-81E35406AEEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 34 | {F8DFAE44-B4A4-41B0-B821-81E35406AEEF}.Debug|Any CPU.Build.0 = Debug|Any CPU 35 | {F8DFAE44-B4A4-41B0-B821-81E35406AEEF}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {F8DFAE44-B4A4-41B0-B821-81E35406AEEF}.Release|Any CPU.Build.0 = Release|Any CPU 37 | {744D4291-5DBF-4E01-9DDD-E159729DFBEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {744D4291-5DBF-4E01-9DDD-E159729DFBEB}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {744D4291-5DBF-4E01-9DDD-E159729DFBEB}.Release|Any CPU.ActiveCfg = Release|Any CPU 40 | {744D4291-5DBF-4E01-9DDD-E159729DFBEB}.Release|Any CPU.Build.0 = Release|Any CPU 41 | {FBD6E79C-09D6-450D-98D1-FD9127915728}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 42 | {FBD6E79C-09D6-450D-98D1-FD9127915728}.Debug|Any CPU.Build.0 = Debug|Any CPU 43 | {FBD6E79C-09D6-450D-98D1-FD9127915728}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {FBD6E79C-09D6-450D-98D1-FD9127915728}.Release|Any CPU.Build.0 = Release|Any CPU 45 | {DFEFFE12-C303-4E4A-8310-B59C9F7F601A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 46 | {DFEFFE12-C303-4E4A-8310-B59C9F7F601A}.Debug|Any CPU.Build.0 = Debug|Any CPU 47 | {DFEFFE12-C303-4E4A-8310-B59C9F7F601A}.Release|Any CPU.ActiveCfg = Release|Any CPU 48 | {DFEFFE12-C303-4E4A-8310-B59C9F7F601A}.Release|Any CPU.Build.0 = Release|Any CPU 49 | EndGlobalSection 50 | GlobalSection(SolutionProperties) = preSolution 51 | HideSolutionNode = FALSE 52 | EndGlobalSection 53 | GlobalSection(NestedProjects) = preSolution 54 | {DFEFFE12-C303-4E4A-8310-B59C9F7F601A} = {A67B2B8A-98FD-4A9D-A990-A92E95610038} 55 | EndGlobalSection 56 | GlobalSection(ExtensibilityGlobals) = postSolution 57 | SolutionGuid = {C66B48A3-A59A-40FA-B899-138FD824819A} 58 | EndGlobalSection 59 | EndGlobal 60 | -------------------------------------------------------------------------------- /Shared/Models/ProxyConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.CompilerServices; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | using Yarp.ReverseProxy.Configuration; 8 | 9 | namespace Shared.Models 10 | { 11 | public class ProxyConfig 12 | { 13 | public List Routes { get; set; } = new List(); 14 | public List Clusters { get; set; } = new List(); 15 | } 16 | //public class MyRouteConfig 17 | //{ 18 | // public class RouteConfig 19 | // { 20 | 21 | 22 | 23 | // // 24 | // // Summary: 25 | // // Globally unique identifier of the route. This field is required. 26 | // public string RouteId { get; set; } 27 | 28 | // // 29 | // // Summary: 30 | // // Parameters used to match requests. This field is required. 31 | // public RouteMatch Match { get; set; } 32 | 33 | // // 34 | // // Summary: 35 | // // Optionally, an order value for this route. Routes with lower numbers take precedence 36 | // // over higher numbers. 37 | // public int? Order { get; set; } 38 | 39 | // // 40 | // // Summary: 41 | // // Gets or sets the cluster that requests matching this route should be proxied 42 | // // to. 43 | // public string? ClusterId { get; set; } 44 | 45 | // // 46 | // // Summary: 47 | // // The name of the AuthorizationPolicy to apply to this route. If not set then only 48 | // // the FallbackPolicy will apply. Set to "Default" to enable authorization with 49 | // // the applications default policy. Set to "Anonymous" to disable all authorization 50 | // // checks for this route. 51 | // public string? AuthorizationPolicy { get; set; } 52 | 53 | // // 54 | // // Summary: 55 | // // The name of the RateLimiterPolicy to apply to this route. If not set then only 56 | // // the GlobalLimiter will apply. Set to "Disable" to disable rate limiting for this 57 | // // route. Set to "Default" or leave empty to use the global rate limits, if any. 58 | // public string? RateLimiterPolicy { get; set; } 59 | 60 | // // 61 | // // Summary: 62 | // // The name of the CorsPolicy to apply to this route. If not set then the route 63 | // // won't be automatically matched for cors preflight requests. Set to "Default" 64 | // // to enable cors with the default policy. Set to "Disable" to refuses cors requests 65 | // // for this route. 66 | // public string? CorsPolicy { get; set; } 67 | 68 | // // 69 | // // Summary: 70 | // // An optional override for how large request bodies can be in bytes. If set, this 71 | // // overrides the server's default (30MB) per request. Set to '-1' to disable the 72 | // // limit for this route. 73 | // public long? MaxRequestBodySize { get; set; } 74 | 75 | // // 76 | // // Summary: 77 | // // Arbitrary key-value pairs that further describe this route. 78 | // public IReadOnlyDictionary? Metadata { get; set; } 79 | 80 | // // 81 | // // Summary: 82 | // // Parameters used to transform the request and response. See Yarp.ReverseProxy.Transforms.Builder.ITransformBuilder. 83 | // public IReadOnlyList>? Transforms { get; set; } 84 | 85 | // } 86 | //} 87 | } 88 | -------------------------------------------------------------------------------- /WeatherApi/Configration/ExternalConfigurationStore.cs: -------------------------------------------------------------------------------- 1 | using Azure.Identity; 2 | 3 | using Microsoft.Extensions.Configuration.AzureAppConfiguration; 4 | using Shared.Models; 5 | using Shared.Services.EventGrid; 6 | using Shared.Services.Token; 7 | using System.Runtime; 8 | 9 | 10 | namespace WeatherApi.Configuration 11 | { 12 | public static class ExternalConfigurationStore 13 | { 14 | public static void AddExternalConfigurationStore(this WebApplicationBuilder builder) 15 | { 16 | string env = String.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")) ? "Development" : Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Unknown"; 17 | builder.Configuration.AddAzureAppConfiguration(opts => 18 | { 19 | var uami = builder.Configuration.GetSection("AppConfiguration:UserAssignedManagedIdentityClientId").Value ?? "Unknown"; 20 | var tenantId = builder.Configuration.GetSection("Development:ServicePrincipal:TenantId").Value ?? "Unknown"; 21 | var clientId = builder.Configuration.GetSection("Development:ServicePrincipal:ClientId").Value ?? "Unknown"; 22 | var secret = builder.Configuration.GetSection("Development:ServicePrincipal:Secret").Value ?? "Unknown"; 23 | 24 | var managedCredential = new ManagedIdentityCredential(uami); 25 | var credential = new ChainedTokenCredential(managedCredential, new ClientSecretCredential(tenantId, clientId, secret)); 26 | string c = builder.Configuration.GetSection("AppConfiguration:Uri").Value ?? throw new Exception("AppConfiguration not set"); 27 | opts.Connect(new Uri(c), credential).ConfigureKeyVault(opts => opts.SetCredential(credential)); 28 | opts 29 | .Select("WeatherApi:*") 30 | .Select("ChangeSubscription:*") 31 | .Select("Jwt:*") 32 | .ConfigureRefresh(refresh => 33 | { 34 | refresh 35 | .Register("Jwt:*", refreshAll: true) 36 | .SetCacheExpiration(TimeSpan.FromDays(1)); 37 | }) 38 | .UseFeatureFlags(featureFlagOptions => 39 | { 40 | featureFlagOptions.CacheExpirationInterval = TimeSpan.FromDays(1); 41 | featureFlagOptions.Select(KeyFilter.Any, LabelFilter.Null).Select(KeyFilter.Any, env); 42 | }); 43 | }, optional: false); 44 | 45 | 46 | builder.Services 47 | .AddAzureAppConfiguration() 48 | .AddHostedService() 49 | .Configure(builder.Configuration.GetSection("ChangeSubscription")) 50 | .Configure(builder.Configuration.GetSection("Development:ServicePrincipal")) 51 | .AddSingleton() 52 | .AddSingleton(builder.Configuration); 53 | 54 | 55 | 56 | //builder.Services.Configure(options => builder.Configuration.GetSection("TenantDirectory").Bind(options)); 57 | 58 | //builder.Services.Configure(options => builder.Configuration.GetSection("TenantDirectory").Bind(options)); 59 | 60 | //var d = builder.Configuration.GetSection("TenantDirectory:Tenants").Get(); 61 | //var t = builder.Services.Configure(builder.Configuration.GetSection("TenantDirectory:Tenants")); 62 | } 63 | 64 | public static void UseExternalConfigurationStore(this WebApplication app) 65 | { 66 | app.UseAzureAppConfiguration(); 67 | } 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /Scripts/aadApp.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source ./helpers.sh 3 | 4 | basedir="$( dirname "$( readlink -f "$0" )" )" 5 | 6 | #CONFIG_FILE="${basedir}/./config.json" 7 | CONFIG_FILE="./config.json" 8 | 9 | if [ ! -f "$CONFIG_FILE" ]; then 10 | echo "Creating new config file" 11 | cp ./config-template.json "${CONFIG_FILE}" 12 | fi 13 | 14 | jsonpath=".Proxy.Aad.ClientId" 15 | clientId="$( get-value "${jsonpath}" )" 16 | 17 | #deleteRequest=$(az ad app delete --id $clientId | jq -r . ) 18 | #echo $deleteRequest 19 | 20 | display_name="TenantProxyDemo7" 21 | redirect_uri=https://localhost:6001 22 | web_redirect_uri="https://localhost:7001 https://localhost:7001/signin-oidc https://oauth.pstmn.io/v1/callback" 23 | 24 | query=$(az ad app list --display-name $display_name | jq -r . ) 25 | 26 | #echo $query | jq . 27 | 28 | installedClientId="$(echo "${query}" | jq -r '.[0].appId' )" 29 | 30 | 31 | if [ $installedClientId != 'null' ] 32 | then 33 | put-value '.Proxy.Aad.ClientId' "$(echo "${query}" | jq -r '.[0].appId' )" 34 | echo "Application already created" 35 | # exit 1; 36 | else 37 | echo "Creating Application" 38 | json=$( az ad app create \ 39 | --display-name $display_name \ 40 | --public-client-redirect-uris $redirect_uri \ 41 | --web-redirect-uris $web_redirect_uri \ 42 | --only-show-errors \ 43 | --enable-access-token-issuance true \ 44 | --enable-id-token-issuance true \ 45 | --sign-in-audience AzureADMultipleOrgs \ 46 | --query "{Id:id, AppId:appId}" | jq -r . ) 47 | 48 | echo $json | jq . 49 | put-value '.Proxy.Aad.ClientId' "$(echo "${json}" | jq -r '.AppId' )" 50 | installedClientId="$(echo "${json}" | jq -r '.AppId' )" 51 | echo $installedClientId 52 | 53 | 54 | #echo $installedClientId 55 | apiId="api://${installedClientId}" 56 | #echo $apiId 57 | 58 | api=$(echo '{ 59 | "acceptMappedClaims": null, 60 | "knownClientApplications": [], 61 | "oauth2PermissionScopes": [{ 62 | "adminConsentDescription": "test", 63 | "adminConsentDisplayName": "test", 64 | "id": "'$installedClientId'", 65 | "isEnabled": true, 66 | "type": "User", 67 | "userConsentDescription": "test", 68 | "userConsentDisplayName": "test", 69 | "value": "access_as_user" 70 | }], 71 | "preAuthorizedApplications": [], 72 | "requestedAccessTokenVersion": 2 73 | }' | jq .) 74 | echo $api | jq . 75 | 76 | echo "Updating app" 77 | updateResponse=$(az ad app update \ 78 | --id $installedClientId \ 79 | --set signInAudience="AzureADMultipleOrgs" \ 80 | --enable-access-token-issuance true \ 81 | --enable-id-token-issuance true \ 82 | --identifier-uris $apiId \ 83 | --web-redirect-uris $web_redirect_uri \ 84 | --set api="$api" | jq -r .) 85 | 86 | 87 | apiPermission="${installedClientId}=Scope" 88 | echo $apiPermission 89 | echo $installedClientId 90 | 91 | echo "Adding permissions" 92 | az ad app permission add --id $installedClientId --api $installedClientId --api-permissions $apiPermission 93 | 94 | scope="api//${installedClientId}/access_as_user" 95 | echo $scope 96 | echo "Adding assiging permissions" 97 | az ad app permission grant --id $installedClientId --api $installedClientId --scope 'access_as_user' 98 | 99 | echo "Creating service principal" 100 | az ad sp create --id $installedClientId 101 | fi 102 | 103 | echo "Granting concent" 104 | response=$(az ad app permission admin-consent --id $installedClientId | jq .) 105 | echo $response 106 | 107 | -------------------------------------------------------------------------------- /Proxy/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.HttpOverrides; 2 | using Microsoft.FeatureManagement.FeatureFilters; 3 | using Microsoft.FeatureManagement; 4 | using Shared.Models; 5 | using SaaS.Proxy.Configuration; 6 | using Microsoft.AspNetCore.ResponseCompression; 7 | using System.Security.Claims; 8 | using SaaS.Proxy.Services; 9 | using Microsoft.Extensions.DependencyInjection; 10 | 11 | 12 | using System.Reflection; 13 | using Proxy.Services; 14 | 15 | using Shared.Repositories; 16 | using Microsoft.Extensions.Caching.Memory; 17 | 18 | namespace SaaS.Proxy 19 | { 20 | public class Program 21 | { 22 | 23 | private static readonly bool UseExternalConfigStore = true; 24 | public static void Main(string[] args) 25 | { 26 | var builder = WebApplication.CreateBuilder(args); 27 | string env = String.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")) ? "Development" : Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Unknown"; 28 | if(env=="Development") 29 | builder.Configuration.AddJsonFile($"{Environment.CurrentDirectory}/../Scripts/config.json", optional: false, reloadOnChange: true); 30 | 31 | builder.Configuration.AddUserSecrets(Assembly.GetExecutingAssembly(), true); 32 | 33 | 34 | builder.Services.AddResponseCompression(options => 35 | { 36 | options.EnableForHttps = true; 37 | options.Providers.Add(); 38 | options.Providers.Add(); 39 | }); 40 | 41 | builder.Services.AddProblemDetails(); 42 | builder.Services.AddControllers(); 43 | 44 | builder.Services.AddHttpContextAccessor(); 45 | if(Program.UseExternalConfigStore) 46 | builder.AddExternalConfigurationStore(); 47 | builder.Services.AddSingleton(builder.Configuration); 48 | builder.Services.AddSingleton((IConfigurationRoot)builder.Configuration); 49 | 50 | builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging")); 51 | 52 | builder.AddApplicationInsightsLogging(); 53 | //builder.Services.AddApplicationInsightsTelemetry(); 54 | 55 | builder.Services.AddFeatureManagement().AddFeatureFilter().AddFeatureFilter(); 56 | 57 | builder.AddAuth(); 58 | builder.AddOpenApi(); 59 | builder.Services.AddSingleton(); 60 | builder.Services.AddSingleton(); 61 | builder.Services.AddSingleton(); 62 | 63 | builder.Services.AddHostedService(); 64 | 65 | builder.Services.AddMemoryCache(opts => 66 | { 67 | 68 | 69 | }); 70 | 71 | builder.Services.Configure(options => 72 | { 73 | options.ForwardedHeaders = 74 | ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; 75 | }); 76 | 77 | builder.AddGateway(builder.Services.BuildServiceProvider(), "ReverseProxy"); 78 | 79 | 80 | var app = builder.Build(); 81 | 82 | app.UseResponseCompression(); 83 | 84 | app.UseDeveloperExceptionPage(); 85 | app.UseHttpLogging(); 86 | 87 | app.UseOpenApi(); 88 | 89 | if (Program.UseExternalConfigStore) 90 | app.UseExternalConfigurationStore(); 91 | 92 | app.UseHttpsRedirection(); 93 | 94 | app.UseAuth(); 95 | 96 | app.UseGateway(); 97 | 98 | app.MapGet("/", () => "Tenant Proxy"); 99 | //app.MapGet("/proxy/has-user", (ClaimsPrincipal user) => user.Identity.Name) 100 | // .RequireAuthorization(); 101 | 102 | app.UseForwardedHeaders(); 103 | 104 | app.MapControllers(); 105 | 106 | app.Run(); 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /Scripts/postman/Template-TenantProxy.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "name": "TenantProxy", 4 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 5 | "_exporter_id": "3348652" 6 | }, 7 | "item": [ 8 | { 9 | "name": "Dataplane", 10 | "item": [ 11 | { 12 | "name": "Debug", 13 | "item": [ 14 | { 15 | "name": "ProxyConfig", 16 | "request": { 17 | "method": "GET", 18 | "header": [], 19 | "url": { 20 | "raw": "{{url}}/Config/GetproxyConfigDump", 21 | "host": [ 22 | "{{url}}" 23 | ], 24 | "path": [ 25 | "Config", 26 | "GetproxyConfigDump" 27 | ] 28 | } 29 | }, 30 | "response": [] 31 | }, 32 | { 33 | "name": "Diag", 34 | "request": { 35 | "method": "GET", 36 | "header": [], 37 | "url": { 38 | "raw": "{{url}}/diag", 39 | "host": [ 40 | "{{url}}" 41 | ], 42 | "path": [ 43 | "diag" 44 | ] 45 | } 46 | }, 47 | "response": [] 48 | } 49 | ] 50 | }, 51 | { 52 | "name": "Azure", 53 | "item": [ 54 | { 55 | "name": "GetWeatherForTenant", 56 | "request": { 57 | "method": "GET", 58 | "header": [], 59 | "url": { 60 | "raw": "{{url}}/api/v1.0/WeatherForecast", 61 | "host": [ 62 | "{{url}}" 63 | ], 64 | "path": [ 65 | "api", 66 | "v1.0", 67 | "WeatherForecast" 68 | ] 69 | } 70 | }, 71 | "response": [] 72 | } 73 | ] 74 | }, 75 | { 76 | "name": "Localhost", 77 | "item": [ 78 | { 79 | "name": "GetWeatherForTenant", 80 | "request": { 81 | "method": "GET", 82 | "header": [], 83 | "url": { 84 | "raw": "{{localurl}}/api/v1.0/WeatherForecast", 85 | "host": [ 86 | "{{localurl}}" 87 | ], 88 | "path": [ 89 | "api", 90 | "v1.0", 91 | "WeatherForecast" 92 | ] 93 | } 94 | }, 95 | "response": [] 96 | } 97 | ] 98 | } 99 | ] 100 | } 101 | ], 102 | "auth": { 103 | "type": "oauth2", 104 | "oauth2": [ 105 | { 106 | "key": "useBrowser", 107 | "value": true, 108 | "type": "boolean" 109 | }, 110 | { 111 | "key": "grant_type", 112 | "value": "implicit", 113 | "type": "string" 114 | }, 115 | { 116 | "key": "scope", 117 | "value": "api://{{clientId}}/{{scope}}", 118 | "type": "string" 119 | }, 120 | { 121 | "key": "clientSecret", 122 | "value": "{{clientSecret}}", 123 | "type": "string" 124 | }, 125 | { 126 | "key": "clientId", 127 | "value": "{{clientId}}", 128 | "type": "string" 129 | }, 130 | { 131 | "key": "refreshRequestParams", 132 | "value": [], 133 | "type": "any" 134 | }, 135 | { 136 | "key": "tokenRequestParams", 137 | "value": [], 138 | "type": "any" 139 | }, 140 | { 141 | "key": "authRequestParams", 142 | "value": [], 143 | "type": "any" 144 | }, 145 | { 146 | "key": "tokenName", 147 | "value": "test", 148 | "type": "string" 149 | }, 150 | { 151 | "key": "challengeAlgorithm", 152 | "value": "S256", 153 | "type": "string" 154 | }, 155 | { 156 | "key": "redirect_uri", 157 | "value": "https://localhost:7001/signin-oidc", 158 | "type": "string" 159 | }, 160 | { 161 | "key": "authUrl", 162 | "value": "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize", 163 | "type": "string" 164 | }, 165 | { 166 | "key": "addTokenTo", 167 | "value": "header", 168 | "type": "string" 169 | }, 170 | { 171 | "key": "client_authentication", 172 | "value": "header", 173 | "type": "string" 174 | }, 175 | { 176 | "key": "accessTokenUrl", 177 | "value": "https://login.microsoftonline.com/organizations/oauth2/v2.0/token", 178 | "type": "string" 179 | } 180 | ] 181 | }, 182 | "event": [ 183 | { 184 | "listen": "prerequest", 185 | "script": { 186 | "type": "text/javascript", 187 | "exec": [ 188 | "" 189 | ] 190 | } 191 | }, 192 | { 193 | "listen": "test", 194 | "script": { 195 | "type": "text/javascript", 196 | "exec": [ 197 | "" 198 | ] 199 | } 200 | } 201 | ] 202 | } -------------------------------------------------------------------------------- /Proxy/Configuration/ExternalConfigurationStore.cs: -------------------------------------------------------------------------------- 1 | using Azure.Identity; 2 | using Microsoft.Extensions.Configuration.AzureAppConfiguration; 3 | using Shared.Models; 4 | using Shared.Services.EventGrid; 5 | using Shared.Services.Token; 6 | 7 | 8 | namespace SaaS.Proxy.Configuration 9 | { 10 | public static class ExternalConfigurationStore 11 | { 12 | public static void AddExternalConfigurationStore(this WebApplicationBuilder builder) 13 | { 14 | string env = String.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")) ? "Development" : Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Unknown"; 15 | builder.Configuration.AddAzureAppConfiguration(opts => 16 | { 17 | var uami = builder.Configuration.GetSection("AppConfiguration:UserAssignedManagedIdentityClientId").Value ?? "Unknown"; 18 | var tenantId = builder.Configuration.GetSection("Development:ServicePrincipal:TenantId").Value ?? "Unknown"; 19 | var clientId = builder.Configuration.GetSection("Development:ServicePrincipal:ClientId").Value ?? "Unknown"; 20 | var secret = builder.Configuration.GetSection("Development:ServicePrincipal:Secret").Value ?? "Unknown"; 21 | 22 | var managedCredential = new ManagedIdentityCredential(uami); 23 | var credential = new ChainedTokenCredential(managedCredential, new ClientSecretCredential(tenantId, clientId, secret)); 24 | string c = builder.Configuration.GetSection("AppConfiguration:Uri").Value ?? throw new Exception("AppConfiguration not set"); 25 | opts.Connect(new Uri(c), credential).ConfigureKeyVault(opts => opts.SetCredential(credential)); 26 | opts 27 | .Select("API:*") //load all settings from the API section 28 | .Select("API:*",env) // load all settings from the API section, for the specific environment 29 | .Select("AzureAd:*") 30 | .Select("ChangeSubscription:*") 31 | .Select("ReverseProxy:*",env) 32 | .Select("Jwt:*") 33 | .Select("TenantDirectory:Tenants", env) // configure the tenants for the environment 34 | .ConfigureRefresh(refresh => 35 | { 36 | refresh //set refresh for select keys 37 | .Register("API:Settings", env, refreshAll: true) 38 | .Register("TenantDirectory:Tenants", env, refreshAll: false) 39 | .Register("Jwt:*", refreshAll: false) 40 | .SetCacheExpiration(TimeSpan.FromDays(1)); 41 | }) 42 | .UseFeatureFlags(featureFlagOptions => 43 | { 44 | featureFlagOptions.CacheExpirationInterval = TimeSpan.FromDays(1); 45 | featureFlagOptions.Select(KeyFilter.Any, LabelFilter.Null).Select(KeyFilter.Any, env); 46 | }); 47 | }, optional: false); 48 | 49 | 50 | builder.Services 51 | .AddAzureAppConfiguration() 52 | .AddHostedService() 53 | .Configure(builder.Configuration.GetSection("ChangeSubscription")) 54 | .Configure(builder.Configuration.GetSection("Development:ServicePrincipal")) 55 | .AddSingleton() 56 | .AddSingleton(builder.Configuration) 57 | ; 58 | 59 | builder.Services.AddOptions(); 60 | 61 | // builder.Services.Configure(options => builder.Configuration.GetSection("TenantDirectory").Bind(options)); 62 | builder.Services.Configure(builder.Configuration.GetSection("TenantDirectory")); 63 | 64 | // builder.Services.Configure(options => builder.Configuration.GetSection("TenantDirectory").Bind(options)); 65 | builder.Services.Configure(options => builder.Configuration.GetSection("Jwt").Bind(options)); 66 | //var d = builder.Configuration.GetSection("TenantDirectory:Tenants").Get(); 67 | //var t = builder.Services.Configure(builder.Configuration.GetSection("TenantDirectory:Tenants")); 68 | 69 | } 70 | 71 | public static void UseExternalConfigurationStore(this WebApplication app) 72 | { 73 | app.UseAzureAppConfiguration(); 74 | } 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /Scripts/provision.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Running az cli $(az version | jq '."azure-cli"' )" 4 | echo "Running in subscription $( az account show | jq -r '.id') / $( az account show | jq -r '.name'), AAD Tenant $( az account show | jq -r '.tenantId')" 5 | 6 | source ./helpers.sh 7 | 8 | basedir="$( dirname "$( readlink -f "$0" )" )" 9 | 10 | #CONFIG_FILE="${basedir}/./config.json" 11 | CONFIG_FILE="./config.json" 12 | 13 | if [ ! -f "$CONFIG_FILE" ]; then 14 | cp ./config-template.json "${CONFIG_FILE}" 15 | fi 16 | 17 | 18 | jsonpath=".initConfig.location" 19 | location="$( get-value "${jsonpath}" )" 20 | [ "${location}" == "" ] && { echo "Please configure ${jsonpath} in file ${CONFIG_FILE}" ; exit 1 ; } 21 | 22 | jsonpath=".initConfig.resourceGroupName" 23 | resourceGroupName="$( get-value "${jsonpath}" )" 24 | 25 | if [ "${resourceGroupName}" == "" ]; then 26 | read -p "Resource group name: " resourceGroupName 27 | put-value '.initConfig.resourceGroupName' $resourceGroupName 28 | fi 29 | 30 | put-value '.initConfig.subscriptionId' "$( az account show | jq -r '.id')" 31 | put-value '.initConfig.tenantId' "$( az account show | jq -r '.tenantId')" 32 | 33 | # 34 | # Get the role definitions, so they can be statically refereced in bicep 35 | # 36 | 37 | roles="$( az role definition list | jq '[.[] | {name: .name, roleName: .roleName}] | map({(.roleName|tostring): .name}) | add' ) " 38 | mkdir -p temp 39 | echo "${roles}" > temp/roles.json 40 | 41 | put-value '.roles.Contributor' "$(echo "${roles}" | jq -r '.Contributor' )" 42 | put-value '.roles."App Configuration Data Owner"' "$(echo "${roles}" | jq -r '."App Configuration Data Owner"' )" 43 | put-value '.roles."App Configuration Data Reader"' "$(echo "${roles}" | jq -r '."App Configuration Data Reader"' )" 44 | put-value '.roles."Key Vault Secrets User"' "$(echo "${roles}" | jq -r '."Key Vault Secrets User"' )" 45 | put-value '.roles."Key Vault Reader"' "$(echo "${roles}" | jq -r '."Key Vault Reader"' )" 46 | put-value '.roles."Azure Service Bus Data Receiver"' "$(echo "${roles}" | jq -r '."Azure Service Bus Data Receiver"' )" 47 | 48 | # 49 | # Create the resource group 50 | # 51 | ( az group create --location "${location}" --name "${resourceGroupName}" \ 52 | && echo "Creation of resource group ${resourceGroupName} complete." ) \ 53 | || echo "Failed to create resource group ${resourceGroupName}." \ 54 | | exit 1 55 | 56 | # 57 | # Deploy 58 | # 59 | deploymentResultJSON="$( az deployment group create \ 60 | --resource-group "${resourceGroupName}" \ 61 | --template-file "./main.bicep" \ 62 | --parameters \ 63 | location="${location}" \ 64 | secret="$( openssl rand 128 | base32 --wrap=0 )" \ 65 | --output json )" 66 | 67 | echo "ARM Deployment: $( echo "${deploymentResultJSON}" | jq -r .properties.provisioningState )" 68 | echo "${deploymentResultJSON}" > results.json 69 | 70 | if ! [ $( echo "${deploymentResultJSON}" | jq -r .properties.provisioningState ) = "Succeeded" ]; then 71 | echo "Deployment failed. Do not proceed" 72 | exit 1 73 | fi 74 | 75 | put-value '.ConnectionStrings.ApplicationInsights' "$(echo "${deploymentResultJSON}" | jq -r '.properties.outputs.applicationInsights_ConnectionString.value' )" 76 | put-value '.ConnectionStrings.ServiceBus' "$(echo "${deploymentResultJSON}" | jq -r '.properties.outputs.changeSubscription_ServiceBusConnectionString.value' )" 77 | put-value '.ConnectionStrings.AppConfiguration' "$(echo "${deploymentResultJSON}" | jq -r '.properties.outputs.connectionStrings_AppConfig.value' )" 78 | 79 | put-value '.Proxy.Endpoint' "$(echo "${deploymentResultJSON}" | jq -r '.properties.outputs.proxyEndpoint.value' )" 80 | put-value '.API.Endpoint' "$(echo "${deploymentResultJSON}" | jq -r '.properties.outputs.webappWeatherEndpoint.value' )" 81 | put-value '.ControlPlane.ManagementServiceEndpoint.Endpoint' "$(echo "${deploymentResultJSON}" | jq -r '.properties.outputs.managementServiceEndpoint.value' )" 82 | 83 | put-value '.AppConfiguration.ReadConnectionString' "$(echo "${deploymentResultJSON}" | jq -r '.properties.outputs.connectionStrings_AppConfig.value' )" 84 | #put-value '.AppConfiguration.Uri' "$(echo "${deploymentResultJSON}" | jq -r '.properties.outputs.connectionStrings_AppConfig.value' )" 85 | put-value '.AppConfiguration.ReadWriteConnectionString' "$(echo "${deploymentResultJSON}" | jq -r '.properties.outputs.appConfigReadWriteConnectionString.value' )" 86 | put-value '.AppConfiguration.Name' "$(echo "${deploymentResultJSON}" | jq -r '.properties.outputs.appConfigurationName.value' )" 87 | put-value '.AppConfiguration.Uri' "$(echo "${deploymentResultJSON}" | jq -r '.properties.outputs.appConfigurationEndpoint.value' )" 88 | 89 | 90 | 91 | # ./deploy.sh 92 | -------------------------------------------------------------------------------- /ControlPlane/TenantManagement/Controllers/TenantManagerController.cs: -------------------------------------------------------------------------------- 1 | using Asp.Versioning; 2 | using Azure.Data.AppConfiguration; 3 | using Azure.Identity; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.OpenApi.Validations.Rules; 6 | using Shared.Models; 7 | using Shared.Repositories; 8 | using System.ComponentModel; 9 | using TenantManagement.Repositories; 10 | 11 | namespace TenantManagement.Controllers 12 | { 13 | [ApiController] 14 | [ApiVersion("1.0")] 15 | [ApiVersion("1.0-debug")] 16 | 17 | [Route("api/v{version:apiVersion}/[controller]")] 18 | 19 | public class TenantManagerController : ControllerBase 20 | { 21 | private readonly IConfigurationRoot _root; 22 | private readonly ITenantRepositoryReadWrite _repo; 23 | 24 | private readonly ILogger _logger; 25 | 26 | public TenantManagerController(ILogger logger, ITenantRepositoryReadWrite repo, IConfigurationRoot root) 27 | { 28 | _root = root; 29 | _repo = repo; 30 | _logger = logger; 31 | } 32 | [MapToApiVersion("1.0-debug")] 33 | [HttpGet("GetConfigDump")] 34 | public string GetConfig() 35 | { 36 | return _root.GetDebugView(); 37 | } 38 | 39 | [HttpGet("Tenant")] 40 | [Produces("application/json")] 41 | [ProducesResponseType(typeof(Tenant), 200)] 42 | [ProducesResponseType(404)] 43 | [MapToApiVersion("1.0")] 44 | public IActionResult GetTenant(string id) 45 | { 46 | var r = _repo.GetTenant(id); 47 | return Ok(r); 48 | } 49 | [HttpGet("Tenants")] 50 | [Produces("application/json")] 51 | [ProducesResponseType(typeof(List), 200)] 52 | [ProducesResponseType(404)] 53 | [MapToApiVersion("1.0")] 54 | public IActionResult GetTenants() 55 | { 56 | var r = _repo.GetAllTenants(); 57 | return Ok(r); 58 | } 59 | 60 | [HttpPatch("Tenant")] 61 | [Produces("application/json")] 62 | [ProducesResponseType(typeof(TenantRepositoryResponse), 200)] 63 | 64 | [ProducesResponseType(404)] 65 | [MapToApiVersion("1.0")] 66 | public IActionResult PatchTenant(Tenant t) 67 | { 68 | var resp = _repo.UpdateTenant(t); 69 | return Ok(resp); 70 | } 71 | 72 | 73 | /// 74 | /// Create a new tenant 75 | /// 76 | 77 | [HttpPost("CreateTenant")] 78 | [Produces("application/json")] 79 | [ProducesResponseType(typeof(TenantRepositoryResponse),200)] 80 | 81 | [ProducesResponseType(404)] 82 | [MapToApiVersion("1.0")] 83 | 84 | public IActionResult CreateTenant(Tenant t) 85 | { 86 | var resp = _repo.CreateTenant(t); 87 | return Ok(resp); 88 | } 89 | /// 90 | /// Deprovisions a tenant 91 | /// 92 | /// /Id of the tenant to be deprovisioned 93 | /// Ok 94 | [HttpDelete("Tenant")] 95 | [Produces("application/json")] 96 | [ProducesResponseType(typeof(TenantRepositoryResponse), 200)] 97 | [ProducesResponseType(404)] 98 | [MapToApiVersion("1.0")] 99 | public IActionResult DeleteTenant(string tenantId) 100 | { 101 | var resp = _repo.DeleteTenant(tenantId); 102 | return Ok(resp); 103 | } 104 | 105 | /// 106 | /// Deprovisions a tenant 107 | /// 108 | /// /Id of the tenant to be deprovisioned 109 | /// Ok 110 | [HttpPatch("DisableTenant")] 111 | [Produces("application/json")] 112 | [ProducesResponseType(typeof(TenantRepositoryResponse), 200)] 113 | [ProducesResponseType(404)] 114 | [MapToApiVersion("1.0")] 115 | public IActionResult DisableTenant(string tenantId) 116 | { 117 | var resp = _repo.GetTenant(tenantId); 118 | resp.State = TenantState.Disabled; 119 | var result= _repo.UpdateTenant(resp); 120 | return Ok(result); 121 | } 122 | 123 | [HttpPatch("EnableTenant")] 124 | [Produces("application/json")] 125 | [ProducesResponseType(typeof(TenantRepositoryResponse), 200)] 126 | [ProducesResponseType(404)] 127 | [MapToApiVersion("1.0")] 128 | public IActionResult EnableTenant(string tenantId) 129 | { 130 | var resp = _repo.GetTenant(tenantId); 131 | resp.State = TenantState.Enabled ; 132 | var result = _repo.UpdateTenant(resp); 133 | return Ok(result); 134 | } 135 | 136 | } 137 | } -------------------------------------------------------------------------------- /Proxy/Services/TenantDirectoryService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using Microsoft.Extensions.Options; 3 | using System.Runtime.CompilerServices; 4 | 5 | using Yarp.ReverseProxy.Configuration; 6 | 7 | using Shared.Models; 8 | using Shared.Repositories; 9 | 10 | namespace SaaS.Proxy.Services 11 | { 12 | public class TenantDirectoryService : ITenantDirectoryService 13 | { 14 | private readonly ILogger _logger; 15 | private readonly List? _tenants; 16 | private readonly InMemoryConfigProvider _proxyConfig; 17 | private readonly ITenantRepository _tenantRepo; 18 | //public static Dictionary _tds = new(); 19 | private static object? _singleton; 20 | public TenantDirectoryService(ILogger logger, ITenantRepository tenantRepo, InMemoryConfigProvider memConfig) 21 | { 22 | _logger = logger; 23 | _proxyConfig = memConfig; 24 | _tenantRepo = tenantRepo; 25 | //singleton ensures that the tenant directory service is initialised. 26 | if (_singleton is null) 27 | { 28 | _singleton = new object(); 29 | _tenants = tenantRepo.GetAllTenants(); 30 | //on startup the tenants are loaded and populated. 31 | this.AddTenantsToRouting(_tenants); 32 | } 33 | } 34 | public Tenant TenantLookup(string tenantId) 35 | { 36 | return _tenantRepo.GetTenant(tenantId); 37 | } 38 | public void AddTenantsToRouting(List tenants) 39 | { 40 | var cfg = _proxyConfig.GetConfig(); 41 | 42 | var clusters = new List(); 43 | var routes = new List(); 44 | if (tenants == null) 45 | { 46 | _logger.LogWarning("No tenants in collection"); 47 | } 48 | else 49 | foreach (Tenant tenant in tenants!.Where(w=>w.State==TenantState.Enabled)) 50 | { 51 | clusters.Add(new ClusterConfig() 52 | { 53 | ClusterId = tenant.Tid!, 54 | Destinations = new Dictionary() { 55 | { tenant.Tid!, new DestinationConfig() { Address = tenant.Destination!} } 56 | } 57 | }); 58 | 59 | Dictionary d = new Dictionary(); 60 | routes.Add(new RouteConfig() 61 | { 62 | //add proxyrule, where tenantid is in the route 63 | RouteId = tenant.Tid + "-route", 64 | ClusterId = tenant.Tid, 65 | Order = 1, 66 | Match = new RouteMatch() { Path = "/" + tenant.Tid + "/{**remainder}" }, 67 | AuthorizationPolicy = "customPolicy", 68 | Transforms = new List>() { 69 | new Dictionary(){ 70 | { "PathRemovePrefix", "/" +tenant.Tid } 71 | } 72 | } 73 | }); 74 | 75 | routes.Add(new RouteConfig() 76 | { 77 | //add proxyrule, where tenantid is in the header 78 | RouteId = tenant.Tid + "-header", 79 | ClusterId = tenant.Tid, 80 | Order = 100, 81 | Match = new RouteMatch() 82 | { 83 | Path = "{**catch-all}", 84 | Headers = new[] { 85 | new RouteHeader() 86 | { 87 | Name = "TenantId", 88 | Values = new[] { tenant.Tid! }, 89 | Mode = HeaderMatchMode.ExactHeader 90 | } 91 | } 92 | }, 93 | AuthorizationPolicy = "customPolicy" 94 | }); 95 | } 96 | 97 | //generel rules that catches all api requests. 98 | 99 | var tokenRoute = cfg.Routes.Where(w => w.RouteId == "token").First(); 100 | var clusterDefault = cfg.Clusters.Where(w => w.ClusterId == "root").First(); 101 | if (tokenRoute == null || clusterDefault == null) 102 | { 103 | _logger.LogCritical("Token routing rules not set"); 104 | throw new Exception("Token routing rules not set"); 105 | 106 | } 107 | routes.Add(tokenRoute); 108 | clusters.Add(clusterDefault); 109 | _proxyConfig.Update(routes, clusters); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /ControlPlane/TenantManagement/Repositories/TenantRepository.cs: -------------------------------------------------------------------------------- 1 | using Azure.Data.AppConfiguration; 2 | using Azure.Identity; 3 | using Microsoft.AspNetCore.Authentication; 4 | using Shared.Models; 5 | using Shared.Repositories; 6 | using Shared.Services.Environment; 7 | using System.Security.Cryptography; 8 | using System.Text.Json; 9 | using System.Text.Json.Nodes; 10 | using System.Text.RegularExpressions; 11 | 12 | namespace TenantManagement.Repositories 13 | { 14 | 15 | public interface ITenantRepositoryReadWrite : Shared.Repositories.ITenantRepository 16 | { 17 | TenantRepositoryResponse UpdateTenant(Tenant t); 18 | TenantRepositoryResponse CreateTenant(Tenant t); 19 | TenantRepositoryResponse DeleteTenant(string tenantId); 20 | } 21 | 22 | public class TenantRepositoryReadWrite : ITenantRepository, ITenantRepositoryReadWrite 23 | { 24 | 25 | 26 | 27 | private readonly ConfigurationClient configurationClient; 28 | private readonly string _env; 29 | private IConfiguration _config; 30 | private static readonly string tenantDirectoryKey = "TenantDirectory:Tenants"; 31 | public TenantRepositoryReadWrite(IConfiguration config, IEnvironmentService environment) 32 | { 33 | _env = environment.GetEnvironmentName(); 34 | _config = config; 35 | string endpoint = config.GetValue("AppConfiguration:Uri") ?? ""; 36 | 37 | var uami = config.GetSection("AppConfiguration:UserAssignedManagedIdentityClientId").Value ?? "Unknown"; 38 | var tenantId = config.GetSection("AppConfiguration:TenantId").Value ?? "Unknown"; 39 | var clientId = config.GetSection("AppConfiguration:ClientId").Value ?? "Unknown"; 40 | var secret = config.GetSection("AppConfiguration:Secret").Value ?? "Unknown"; 41 | 42 | var managedCredential = new ManagedIdentityCredential(uami); 43 | var credential = new ChainedTokenCredential(managedCredential, new ClientSecretCredential(tenantId, clientId, secret)); 44 | 45 | configurationClient = new ConfigurationClient(new Uri(endpoint), credential); 46 | 47 | } 48 | 49 | public List GetAllTenants() 50 | { 51 | var r = configurationClient.GetConfigurationSetting(tenantDirectoryKey, _env); 52 | var json = JsonNode.Parse(r.Value.Value)!; 53 | 54 | List? res = JsonSerializer.Deserialize>(json); 55 | return res!; 56 | 57 | } 58 | 59 | public Tenant GetTenant(string tenantId) 60 | { 61 | var r = configurationClient.GetConfigurationSetting(tenantDirectoryKey, _env); 62 | var json = JsonNode.Parse(r.Value.Value)!; 63 | 64 | List? res = 65 | JsonSerializer.Deserialize>(json); 66 | return res!.Where(w => w.Tid == tenantId).FirstOrDefault()!; ; 67 | } 68 | 69 | public TenantRepositoryResponse UpdateTenant(Tenant t) 70 | { 71 | var allTenants = GetAllTenants(); 72 | var old = allTenants.Where(w => w.Tid == t.Tid).First(); 73 | allTenants.Remove(old); 74 | allTenants.Add(t); 75 | string jsonString = JsonSerializer.Serialize(allTenants); 76 | 77 | 78 | ConfigurationSetting setting = new ConfigurationSetting(tenantDirectoryKey, jsonString, _env) { ContentType = "application/json"}; 79 | 80 | var resp = configurationClient.SetConfigurationSetting(setting); 81 | Dictionary d = new(); 82 | d.Add("status", resp.GetRawResponse().Status.ToString()); 83 | d.Add("isError", resp.GetRawResponse().IsError.ToString()); 84 | return new TenantRepositoryResponse(ResponseCodes.TenantUpdated,d); 85 | } 86 | public TenantRepositoryResponse CreateTenant(Tenant t) 87 | { 88 | var allTenants = GetAllTenants(); 89 | allTenants.Add(t); 90 | string jsonString = JsonSerializer.Serialize(allTenants); 91 | 92 | ConfigurationSetting setting = new ConfigurationSetting(tenantDirectoryKey, jsonString, _env) { ContentType = "application/json" }; 93 | configurationClient.SetConfigurationSetting(setting); 94 | return new TenantRepositoryResponse(ResponseCodes.TenantCreated); 95 | } 96 | 97 | public TenantRepositoryResponse DeleteTenant(string tenantId) 98 | { 99 | 100 | var allTenants = GetAllTenants(); 101 | if(!allTenants.Any(w=>w.Tid == tenantId)) 102 | return new TenantRepositoryResponse(ResponseCodes.TenantNotFound); 103 | if (allTenants.Where(w => w.Tid == tenantId).First().State == TenantState.Enabled) 104 | return new TenantRepositoryResponse(ResponseCodes.TenantIsActive); 105 | string jsonString = JsonSerializer.Serialize(allTenants.Where(w=>w.Tid!=tenantId)); 106 | configurationClient.SetConfigurationSetting(tenantDirectoryKey, jsonString, _env); 107 | 108 | return new TenantRepositoryResponse(ResponseCodes.TenantDeleted); 109 | } 110 | 111 | 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /ControlPlane/TenantManagement/Program.cs: -------------------------------------------------------------------------------- 1 | 2 | 3 | using Asp.Versioning; 4 | using ControlPlane.Configuration; 5 | using Microsoft.Extensions.Options; 6 | using Shared.Services.Environment; 7 | using Swashbuckle.AspNetCore.SwaggerGen; 8 | using System.Reflection; 9 | using System.Text.Json; 10 | using TenantManagement.Repositories; 11 | 12 | 13 | namespace TenantManagement 14 | { 15 | public class Program 16 | { 17 | public static void Main(string[] args) 18 | { 19 | var builder = WebApplication.CreateBuilder(args); 20 | //for local development, load the config file created duing enlistment 21 | string env = String.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")) ? "Development" : Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Unknown"; 22 | if (env == "Development") 23 | builder.Configuration.AddJsonFile($"{Environment.CurrentDirectory}/../../Scripts/config.json", optional: false, reloadOnChange: true); 24 | 25 | builder.AddExternalConfigurationStore(); 26 | builder.Services.AddControllers().AddJsonOptions(options => 27 | { 28 | options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; 29 | 30 | }); 31 | builder.Configuration.AddUserSecrets(Assembly.GetExecutingAssembly(), true); 32 | 33 | builder.Services.AddSingleton(); 34 | builder.Services.AddSingleton(builder.Configuration); 35 | builder.Services.AddSingleton(); 36 | builder.Services.AddProblemDetails(); 37 | 38 | builder.Services.AddApiVersioning( 39 | options => 40 | { 41 | // reporting api versions will return the headers 42 | // "api-supported-versions" and "api-deprecated-versions" 43 | options.DefaultApiVersion = new ApiVersion(1, 0); 44 | options.AssumeDefaultVersionWhenUnspecified = true; 45 | options.ReportApiVersions = true; 46 | options.ApiVersionReader = new UrlSegmentApiVersionReader(); 47 | options.Policies.Sunset(0.9) 48 | .Effective(DateTimeOffset.Now.AddDays(60)) 49 | .Link("policy.html") 50 | .Title("Versioning Policy") 51 | .Type("text/html"); 52 | }) 53 | .AddMvc() 54 | .AddApiExplorer( 55 | options => 56 | { 57 | // add the versioned api explorer, which also adds IApiVersionDescriptionProvider service 58 | // note: the specified format code will format the version as "'v'major[.minor][-status]" 59 | options.GroupNameFormat = "'v'VVV"; 60 | // note: this option is only necessary when versioning by url segment. the SubstitutionFormat 61 | // can also be used to control the format of the API version in route templates 62 | options.SubstituteApiVersionInUrl = true; 63 | options.AssumeDefaultVersionWhenUnspecified = true; 64 | options.SubstitutionFormat = "VVVV"; 65 | options.DefaultApiVersion = new ApiVersion(1, 0); 66 | 67 | }); 68 | 69 | builder.Services.AddTransient, ConfigureSwaggerOptions>(); 70 | builder.Services.AddSwaggerGen( 71 | options => 72 | { 73 | 74 | // add a custom operation filter which sets default values 75 | //options.OperationFilter(); 76 | var fileName = typeof(Program).Assembly.GetName().Name + ".xml"; 77 | var filePath = Path.Combine(AppContext.BaseDirectory, fileName); 78 | 79 | // integrate xml comments 80 | options.IncludeXmlComments(filePath); 81 | }); 82 | var app = builder.Build(); 83 | app.UseExternalConfigurationStore(); 84 | // Configure the HTTP request pipeline. 85 | if (app.Environment.IsDevelopment()) 86 | { 87 | app.UseSwagger(); 88 | app.UseSwaggerUI( 89 | options => 90 | { 91 | var descriptions = app.DescribeApiVersions(); 92 | // build a swagger endpoint for each discovered API version 93 | foreach (var description in descriptions.OrderByDescending(o => o.ApiVersion)) 94 | { 95 | var url = $"/swagger/{description.GroupName}/swagger.json"; 96 | var name = description.GroupName.ToUpperInvariant(); 97 | options.SwaggerEndpoint(url, name); 98 | 99 | } 100 | }); 101 | } 102 | 103 | app.UseHttpsRedirection(); 104 | app.UseAuthorization(); 105 | app.MapControllers(); 106 | app.Run(); 107 | Console.ReadKey(); 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /WeatherApi/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication.JwtBearer; 2 | using Microsoft.AspNetCore.Authorization; 3 | 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.Extensions.Options; 6 | using Microsoft.IdentityModel.Tokens; 7 | using Shared.Attributes; 8 | using System.Net; 9 | using System.Security.Claims; 10 | using System.Text; 11 | using Shared.Models; 12 | using WeatherApi.Configuration; 13 | using Microsoft.Identity.Web; 14 | using Asp.Versioning; 15 | using Microsoft.FeatureManagement.FeatureFilters; 16 | using Microsoft.FeatureManagement; 17 | using Microsoft.Azure.Amqp.Framing; 18 | 19 | var builder = WebApplication.CreateBuilder(args); 20 | string env = String.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")) ? "Development" : Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Unknown"; 21 | if (env == "Development") 22 | builder.Configuration.AddJsonFile($"{Environment.CurrentDirectory}/../Scripts/config.json", optional: false, reloadOnChange: true); 23 | 24 | var logger = LoggerFactory.Create(config => 25 | { 26 | config.AddConsole(); 27 | config.AddConfiguration(builder.Configuration.GetSection("Logging")); 28 | }).CreateLogger("Program"); 29 | 30 | builder.AddExternalConfigurationStore(); 31 | builder.Services.AddFeatureManagement().AddFeatureFilter().AddFeatureFilter(); 32 | JwtSettings jwt = new JwtSettings(); 33 | builder.Configuration.GetSection("Jwt").Bind(jwt); 34 | builder.Services.AddHttpContextAccessor(); 35 | 36 | builder.Services.AddApplicationInsightsTelemetry(); 37 | 38 | 39 | builder.Services.AddEndpointsApiExplorer(); 40 | 41 | builder.Services.AddAuthentication(options => 42 | { 43 | options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; 44 | options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; 45 | 46 | }) 47 | .AddJwtBearer(options => 48 | { 49 | options.TokenValidationParameters = new TokenValidationParameters 50 | { 51 | ValidateIssuer = false, 52 | ValidateAudience = false, 53 | ValidateLifetime = false, 54 | ValidateIssuerSigningKey = true, 55 | ValidIssuer = jwt.ValidIssuer, 56 | ValidAudience = jwt.ValidAudience, 57 | IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Secret)) 58 | }; 59 | 60 | options.Events = new JwtBearerEvents 61 | { 62 | OnAuthenticationFailed = context => 63 | { 64 | Console.WriteLine("OnAuthenticationFailed: " + 65 | context.Exception.Message); 66 | logger.LogError(context.Exception, context.Exception.Message); 67 | return Task.CompletedTask; 68 | }, 69 | OnTokenValidated = context => 70 | { 71 | Console.WriteLine("OnTokenValidated: " + 72 | context.SecurityToken); 73 | logger.LogInformation("Login successful"); 74 | return Task.CompletedTask; 75 | } 76 | }; 77 | }); 78 | 79 | builder.Services.AddAuthorization(options => 80 | { 81 | 82 | options.AddPolicy("WebApi", policy => 83 | { 84 | policy.AuthenticationSchemes.Add("Bearer"); 85 | policy.RequireAuthenticatedUser(); 86 | }); 87 | options.AddPolicy("Test", policy => 88 | { 89 | policy.Requirements.Add(new AuthRequirement()); 90 | }); 91 | 92 | }); 93 | 94 | builder.Services.AddSwaggerGen(); 95 | 96 | builder.Services.AddSingleton(); 97 | //// Add services to the container. 98 | 99 | 100 | //#endregion 101 | 102 | builder.Services.AddApiVersioning( 103 | o => 104 | { 105 | //o.Conventions.Controller().HasApiVersion(1, 0); 106 | o.ReportApiVersions = true; 107 | o.AssumeDefaultVersionWhenUnspecified = true; 108 | o.ApiVersionReader = new UrlSegmentApiVersionReader(); 109 | o.DefaultApiVersion = new ApiVersion(1, 0); 110 | } 111 | ); 112 | 113 | // note: the specified format code will format the version as "'v'major[.minor][-status]" 114 | //builder.Services.AddVersionedApiExplorer( 115 | //options => 116 | //{ 117 | // options.GroupNameFormat = "'v'VVVV"; 118 | 119 | // // note: this option is only necessary when versioning by url segment. the SubstitutionFormat 120 | // // can also be used to control the format of the API version in route templates 121 | // options.SubstituteApiVersionInUrl = true; 122 | 123 | //}); 124 | 125 | 126 | builder.Services.AddControllers(); 127 | builder.Services.AddProblemDetails(); 128 | var app = builder.Build(); 129 | 130 | // Configure the HTTP request pipeline. 131 | 132 | app.UseHttpsRedirection(); 133 | app.UseExternalConfigurationStore(); 134 | app.UseAuthentication(); 135 | 136 | app.UseAuthorization(); 137 | //if (app.Environment.IsDevelopment()) 138 | { 139 | app.UseSwagger(); 140 | app.UseSwaggerUI(); 141 | } 142 | 143 | app.MapControllers(); 144 | app.MapGet("/diag", (HttpRequest req, ClaimsPrincipal user) => 145 | { 146 | 147 | Dictionary headers = new(); 148 | Dictionary cookies = new(); 149 | Dictionary claims = new(); 150 | Dictionary> routes = new(); 151 | 152 | 153 | foreach (var header in req.Headers) 154 | { 155 | headers.Add(header.Key, header.Value!); 156 | } 157 | foreach (var cookie in req.Cookies) 158 | { 159 | cookies.Add(cookie.Key, cookie.Value); 160 | } 161 | foreach (var route in req.RouteValues) 162 | { 163 | routes.Add(route.Key, route); 164 | } 165 | if (user.Identity!.IsAuthenticated) 166 | foreach (var claim in user.Claims) 167 | { 168 | claims.Add(claim.Type, claim.Value); 169 | } 170 | 171 | return new RequestDump(headers, cookies, claims, routes); 172 | 173 | 174 | }).Produces(200, typeof(RequestDump), contentType: "application/json");//.RequireAuthorization(); 175 | 176 | 177 | app.Run(); 178 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | /TeamsReverseProxy/TeamsBackendFacade/Properties 352 | /WeatherApi/Properties/ServiceDependencies/democonfwebapp-asep-weatherapi - Web Deploy 353 | /WeatherApi/.config 354 | /Proxy/.config 355 | /Proxy/Properties 356 | /WeatherApi/Properties 357 | /Scripts/TenantProxy-dev.postman_environment.json 358 | /Scripts/TenantProxy.postman_collection.json 359 | -------------------------------------------------------------------------------- /Shared.Services/EventGrid/EventGridSubscriber.cs: -------------------------------------------------------------------------------- 1 | using Azure.Identity; 2 | using Azure.Messaging.ServiceBus; 3 | using Azure.Messaging.ServiceBus.Administration; 4 | using Microsoft.AspNetCore.Mvc.Diagnostics; 5 | 6 | using Microsoft.Extensions.Hosting; 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Extensions.Options; 9 | using Shared.Models; 10 | using Shared.Services.Token; 11 | using System.Text.Json; 12 | using Azure.Messaging.EventGrid; 13 | using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; 14 | using Microsoft.Extensions.Configuration.AzureAppConfiguration; 15 | using Microsoft.Extensions.Configuration; 16 | using Yarp.ReverseProxy.Configuration; 17 | using System.Collections.Generic; 18 | using System; 19 | 20 | using Azure.Data.AppConfiguration; 21 | 22 | namespace Shared.Services.EventGrid 23 | { 24 | public class EventGridSubscriber : IHostedService 25 | { 26 | private readonly IOptions _changeSubscriptionSettings; 27 | private readonly ILogger _logger; 28 | private readonly ITokenService _tokenService; 29 | private readonly IConfigurationRefresher? _refresher; 30 | private readonly IConfigurationRoot _configurationRoot; 31 | //private readonly IEnumerable _processors; 32 | 33 | 34 | public EventGridSubscriber(IOptions ChangeSubscriptionSettings, ITokenService tokenService, IConfigurationRefresherProvider refreshProvider, IConfigurationRoot configuration, ILogger logger) 35 | { 36 | _changeSubscriptionSettings = ChangeSubscriptionSettings; 37 | _logger = logger; 38 | _tokenService = tokenService; 39 | _refresher = refreshProvider.Refreshers.FirstOrDefault(); 40 | _configurationRoot = configuration; 41 | // _processors = p; 42 | } 43 | 44 | public Task StartAsync(CancellationToken cancellationToken) 45 | { 46 | 47 | _logger.LogInformation("Starting eventsubscription"); 48 | ConfigurationChangeSubscriber().GetAwaiter(); 49 | return Task.CompletedTask; 50 | } 51 | 52 | public Task StopAsync(CancellationToken cancellationToken) 53 | { 54 | throw new NotImplementedException(); 55 | } 56 | private ServiceBusClient GetServiceBusClient() 57 | { 58 | var clientOptions = new ServiceBusClientOptions() { TransportType = ServiceBusTransportType.AmqpWebSockets }; 59 | ServiceBusClient client; 60 | 61 | var managedCredential = new ManagedIdentityCredential(_changeSubscriptionSettings.Value.UserAssignedManagedIdentityClientId); 62 | var t = _tokenService.GetTokenCredential(); 63 | // _logger.LogInformation($"Using MI for EventGridsubscriber, GetServiceBusClient: {_changeSubscriptionSettings.Value.UserAssignedManagedIdentityClientId ?? "NONE"}"); 64 | var credential = new ChainedTokenCredential(managedCredential, t); 65 | client = new ServiceBusClient(_changeSubscriptionSettings.Value.ServiceBusNamespace, credential); 66 | return client; 67 | } 68 | private ServiceBusAdministrationClient GetServiceBusAdminClient() 69 | { 70 | ServiceBusAdministrationClient client; 71 | var managedCredential = new ManagedIdentityCredential(_changeSubscriptionSettings.Value.UserAssignedManagedIdentityClientId); 72 | // _logger.LogInformation($"Using MI for EventGridsubscriber, GetServiceBusAdminClient: {_changeSubscriptionSettings.Value.UserAssignedManagedIdentityClientId ?? "NONE"}"); 73 | var credential = new ChainedTokenCredential(managedCredential, _tokenService.GetTokenCredential()); 74 | 75 | client = new ServiceBusAdministrationClient(_changeSubscriptionSettings.Value.ServiceBusNamespace, credential); 76 | return client; 77 | } 78 | private async Task ConfigurationChangeSubscriber() 79 | { 80 | try 81 | { 82 | var client = GetServiceBusAdminClient(); 83 | if (!client.SubscriptionExistsAsync(_changeSubscriptionSettings.Value.ServiceBusTopic, _changeSubscriptionSettings.Value.ServiceBusSubscriptionPrefix).Result) 84 | { 85 | var so = new CreateSubscriptionOptions(_changeSubscriptionSettings.Value.ServiceBusTopic, _changeSubscriptionSettings.Value.ServiceBusSubscriptionPrefix); 86 | so.AutoDeleteOnIdle = TimeSpan.FromHours(_changeSubscriptionSettings.Value.AutoDeleteOnIdleInHours); 87 | await client.CreateSubscriptionAsync(so); 88 | _logger.LogInformation("Change subscription created"); 89 | } 90 | 91 | var servicebusClient = GetServiceBusClient(); 92 | var processor = servicebusClient.CreateProcessor(_changeSubscriptionSettings.Value.ServiceBusTopic, _changeSubscriptionSettings.Value.ServiceBusSubscriptionPrefix, new ServiceBusProcessorOptions() { }); 93 | 94 | processor.ProcessMessageAsync += MessageHandler; 95 | 96 | processor.ProcessErrorAsync += Processor_ProcessErrorAsync; 97 | //+= ErrorHandler; 98 | 99 | await processor.StartProcessingAsync(); 100 | } 101 | catch (Exception ex) when (ex.Message.Contains("already exists")) 102 | { 103 | _logger.LogTrace(ex, ex.Message); 104 | } 105 | catch (Exception e) 106 | { 107 | _logger.LogError("Error registering subscription: " + _changeSubscriptionSettings.Value.ServiceBusTopic); 108 | _logger.LogError(e, e.Message); 109 | throw; 110 | } 111 | } 112 | 113 | private Task Processor_ProcessErrorAsync(ProcessErrorEventArgs arg) 114 | { 115 | _logger.LogError(arg.Exception, "Error subscribing to topic"); 116 | throw new Exception("what!"); 117 | } 118 | 119 | private record EventData(string ObjectType, string VaultName, string ObjectName); 120 | private async Task MessageHandler(ProcessMessageEventArgs args) 121 | { 122 | try 123 | { 124 | EventGridEvent eventGridEvent = EventGridEvent.Parse(BinaryData.FromBytes(args.Message.Body)); 125 | _logger.LogTrace($"Received: {eventGridEvent.Data}"); 126 | 127 | eventGridEvent.TryCreatePushNotification(out PushNotification pushNotification); 128 | 129 | var d = System.Text.Json.JsonSerializer.Deserialize(eventGridEvent.Data, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); 130 | 131 | if (!string.IsNullOrEmpty(d?.ObjectName) && (d.ObjectType.ToLower() == "secret" || d.ObjectType.ToLower() == "certificate")) 132 | { 133 | if (eventGridEvent.EventType == "Microsoft.KeyVault.SecretNewVersionCreated") 134 | { 135 | _logger.LogTrace($"Refreshing all, triggered by secret: " + eventGridEvent.Subject); 136 | _configurationRoot.Reload(); 137 | } 138 | await args.CompleteMessageAsync(args.Message); 139 | } 140 | else if (pushNotification != null) 141 | { 142 | string env = String.IsNullOrEmpty(System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")) ? "Development" : System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Unknown"; 143 | var data = System.Text.Json.JsonSerializer.Deserialize>(eventGridEvent.Data); 144 | 145 | data!.TryGetValue("label", out string? label); 146 | if (label != env && !string.IsNullOrEmpty(label)) 147 | { 148 | await args.CompleteMessageAsync(args.Message); 149 | return; 150 | } 151 | 152 | //_refresher.ProcessPushNotification(pushNotification, TimeSpan.FromSeconds(5)); 153 | _refresher!.ProcessPushNotification(pushNotification); 154 | 155 | 156 | await args.CompleteMessageAsync(args.Message); 157 | } 158 | else 159 | throw new Exception("Unknown message"); 160 | } 161 | catch (Exception e) 162 | { 163 | _logger.LogError(e, e.Message); 164 | throw; 165 | } 166 | } 167 | 168 | 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Proxy/Configuration/Gateway.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.IdentityModel.Tokens; 2 | using Shared.Models; 3 | using System.Diagnostics; 4 | using System.IdentityModel.Tokens.Jwt; 5 | using System.Net.Http.Headers; 6 | using System.Security.Claims; 7 | using System.Text; 8 | using Yarp.ReverseProxy.Configuration; 9 | using Yarp.ReverseProxy.Forwarder; 10 | using Yarp.ReverseProxy.Transforms; 11 | using Shared.Repositories; 12 | using Microsoft.Extensions.Caching.Memory; 13 | using Microsoft.Extensions.DependencyInjection; 14 | using System.Security.Cryptography; 15 | using Proxy.Services; 16 | using Microsoft.AspNetCore.Mvc; 17 | 18 | 19 | namespace SaaS.Proxy.Configuration 20 | { 21 | public static class Gateway 22 | { 23 | public static string CreateHash(string input) 24 | { 25 | byte[] inputBytes = Encoding.ASCII.GetBytes(input); 26 | byte[] hashBytes = System.Security.Cryptography.SHA256.HashData(inputBytes); 27 | StringBuilder sb = new(); 28 | for (int i = 0; i < hashBytes.Length; i++) 29 | { 30 | sb.Append(hashBytes[i].ToString("X2")); 31 | } 32 | return sb.ToString(); 33 | } 34 | 35 | public static async Task GetTokenAsync(List authClaims, JwtSettings jwt, IPermissionService permissions) 36 | { 37 | try 38 | { 39 | var mySecret = jwt.Secret; 40 | var mySecurityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(mySecret)); 41 | 42 | var myIssuer = jwt.ValidIssuer; 43 | var myAudience = jwt.ValidAudience; 44 | 45 | var tokenHandler = new JwtSecurityTokenHandler(); 46 | 47 | var exp = int.Parse(authClaims.Where(w => w.Type == "exp").Select(s => s.Value).First()); 48 | var expiration = DateTime.Now - new DateTime(1970, 1, 1).AddSeconds(exp); 49 | 50 | 51 | var tokenDescriptor = new SecurityTokenDescriptor 52 | { 53 | Subject = new ClaimsIdentity(authClaims.Where(w => w.Type != "aud")) 54 | , 55 | Expires = DateTime.UtcNow.AddDays(7), 56 | Issuer = myIssuer, 57 | Audience = myAudience, 58 | SigningCredentials = new SigningCredentials(mySecurityKey, SecurityAlgorithms.HmacSha256Signature) 59 | , 60 | Claims = permissions.GetPermissions(authClaims.Where(w => w.Type == "http://schemas.microsoft.com/identity/claims/tenantid").Select(s => s.Value).First()) ?? null 61 | }; 62 | 63 | var token = tokenHandler.CreateToken(tokenDescriptor); 64 | 65 | return await Task.FromResult(tokenHandler.WriteToken(token)); 66 | } 67 | catch (Exception) 68 | { 69 | throw; 70 | } 71 | 72 | } 73 | 74 | 75 | public static void AddGateway(this WebApplicationBuilder builder, IServiceProvider serviceProvider, string configKey = "ReverseProxy:Settings:ReverseProxy") 76 | { 77 | ITenantRepository tenantRepository = (ITenantRepository?)serviceProvider.GetService(typeof(ITenantRepository)) ?? throw new Exception("TenantRepository not found"); 78 | IMemoryCache cache = (IMemoryCache?)serviceProvider.GetService(typeof(IMemoryCache)) ?? throw new Exception("IMemoryCache not found"); 79 | IPermissionService permissionService = (IPermissionService?)serviceProvider.GetService(typeof(IPermissionService)) ?? throw new Exception("IPermissionService not found"); 80 | 81 | JwtSettings jwt = new(); 82 | builder.Configuration.GetSection("Jwt").Bind(jwt); ; 83 | 84 | builder.Services.AddReverseProxy() 85 | .LoadFromMemory(new List() { 86 | new RouteConfig(){ RouteId = "root", ClusterId = "root", Match = new RouteMatch(){ Path="/root" } }.WithTransformPathRemovePrefix("/root"), 87 | new RouteConfig(){ 88 | RouteId = "token", 89 | //Note: clusterid does not matter! At runtime, its changed dynamically 90 | ClusterId = "root", 91 | Order = 100, 92 | Match = new RouteMatch() 93 | { 94 | Path = "/api/{**catch-all}", 95 | Headers = new[] { 96 | new RouteHeader() 97 | { 98 | Name = "TenantId", 99 | Mode = HeaderMatchMode.NotExists 100 | } 101 | } 102 | }, 103 | AuthorizationPolicy = "tokenPolicy" 104 | } 105 | }, 106 | new List() { 107 | new ClusterConfig(){ ClusterId = "root", Destinations = new Dictionary(){ 108 | { "d1", new DestinationConfig(){ Address = "https://cphwh-signup.azurewebsites.net" } } 109 | } 110 | } 111 | 112 | }) 113 | .LoadFromConfig(builder.Configuration.GetSection(configKey)) 114 | .AddTransforms(async ctx => 115 | { 116 | ctx.RequestTransforms.Add(new RequestHeaderRemoveTransform("Cookie")); 117 | var authPolicy = ctx.Route.AuthorizationPolicy; 118 | if (!string.IsNullOrEmpty(authPolicy)) 119 | { 120 | ctx.RequestTransforms.Add(new RequestHeaderValueTransform("yarp-AuthZPolicy", authPolicy ?? string.Empty, true)); 121 | } 122 | await Task.CompletedTask; 123 | }) 124 | .AddTransforms(async transformBuilderContext => // Add transforms inline 125 | { 126 | List claims = new(); 127 | string tenantId = "NA"; 128 | 129 | if (!string.IsNullOrEmpty(transformBuilderContext.Route.AuthorizationPolicy)) 130 | transformBuilderContext.AddRequestTransform(async transformContext => 131 | { 132 | claims = transformContext.HttpContext.User.Claims.ToList(); 133 | tenantId = claims.Where(w => w.Type == "http://schemas.microsoft.com/identity/claims/tenantid").Select(s => s.Value).First(); 134 | var uid = claims.Where(w => w.Type == "http://schemas.microsoft.com/identity/claims/objectidentifier").Select(s => s.Value).First(); 135 | 136 | Activity.Current?.AddTag("d-TenantId", tenantId); 137 | Activity.Current?.AddTag("d-UserId", uid); 138 | 139 | var authheader = CreateHash(transformContext.HttpContext.Request.Headers.Authorization.ToString()); 140 | //cache the token mapping, to ensure that token is not created on every request. 141 | if (!cache.TryGetValue(authheader, out string? header)) 142 | { 143 | jwt.ValidAudience = transformContext.DestinationPrefix ?? "NA"; 144 | var token = await GetTokenAsync(claims, jwt, permissionService); 145 | transformContext.ProxyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); 146 | 147 | //cache the token, based on the token expiration issued from IDP 148 | var exp = int.Parse(claims.Where(w => w.Type == "exp").Select(s => s.Value).First()); 149 | TimeSpan expiration = DateTime.Now - new DateTime(1970, 1, 1).AddSeconds(exp); 150 | cache.Set(authheader, token, new MemoryCacheEntryOptions() { AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(expiration.TotalMinutes)}); 151 | } 152 | else 153 | { 154 | transformContext.ProxyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", header); 155 | } 156 | }); 157 | 158 | if (string.Equals("tokenPolicy", transformBuilderContext.Route.AuthorizationPolicy)) 159 | { 160 | transformBuilderContext.AddRequestTransform(async transformContext => 161 | { 162 | var t = tenantRepository!.GetTenant(tenantId); 163 | 164 | transformContext.ProxyRequest.RequestUri = RequestUtilities.MakeDestinationAddress(t!.Destination!, transformContext.HttpContext.Request.Path, transformContext.HttpContext.Request.QueryString); 165 | await Task.CompletedTask; 166 | }); 167 | } 168 | await Task.CompletedTask; 169 | }) 170 | .Services.AddOptions() 171 | 172 | ; 173 | 174 | 175 | 176 | //builder.Services.AddAuthorization(options => 177 | //{ 178 | // options.AddPolicy("customPolicy", policy => 179 | // { 180 | 181 | // policy.RequireAuthenticatedUser(); 182 | // }); 183 | //}); 184 | 185 | 186 | } 187 | private static void UseYarp(this WebApplication app) 188 | { 189 | #pragma warning disable ASP0014 190 | app.UseEndpoints(endpoints => 191 | { 192 | endpoints.MapReverseProxy(proxyPipeline => 193 | { 194 | proxyPipeline.Use((context, next) => 195 | { 196 | return next(); 197 | }); 198 | proxyPipeline.UseSessionAffinity(); 199 | //proxyPipeline.UseLoadBalancing(); 200 | //proxyPipeline.UsePassiveHealthChecks(); 201 | proxyPipeline.Use(async (context, next) => 202 | { 203 | LogRequest(context); 204 | await next(); 205 | LogResponse(context); 206 | }); 207 | }); 208 | }); 209 | #pragma warning restore ASP0014 210 | } 211 | 212 | private static void LogResponse(HttpContext context) 213 | { 214 | Console.WriteLine(context.Request.Path); 215 | } 216 | 217 | private static void LogRequest(HttpContext context) 218 | { 219 | Console.WriteLine(context.Response.ContentLength); 220 | } 221 | 222 | public static void UseGateway(this WebApplication app) 223 | { 224 | app.UseRouting(); 225 | 226 | //app.UseSession(); 227 | app.UseCookiePolicy(); 228 | 229 | //app.UseGatewayEndpoints(); 230 | app.UseYarp(); 231 | } 232 | } 233 | 234 | } 235 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reverse Proxy - managing and routing tenants 2 | 3 | Tenant routing rules, claims processing, and authentication protocol translations are often needed for exposing a backend to any number of frontends and clients. A reverse proxy serves to hide some of this complexity for the frontends connecting as well as centralizing the management. Scenarios are amongst: 4 | 5 | * Frontend leverages modern OAuth, backend services do not. Transformation is needed for downstream calls. Like OICD/OAuth transformation to other legacy auth used downstream from proxy 6 | * Token claim enrichments or creating new tokens to be used downstream - i.e. add additional claims, like membership, etc. 7 | * Existing backend has specific tenant routing requirements like subdomains (**customer1**.contoso.com), query parameters, path segments, headers, or claims from bearer tokens. 8 | * Specific handling of tenants in a multitenant backend (like special treatment for paid vs unpaid tenants) or handling limits pr tenant, pr client, or other dimensions 9 | 10 | A reverse proxy or gateway can solve these and other challenges, as it sits between client devices and one or more backend servers, forwarding client requests to servers and then returning the server's response back to the client. The [Gateway Routing pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/gateway-routing) speaks to and addresses these challenges. 11 | 12 | The current sample uses [YARP proxy](https://microsoft.github.io/reverse-proxy/articles/getting-started.html), as the reverse proxy implementation. Services like Application Gateway and API Management, provide much of the same, and more, functionality. These existing PaaS services do not offer token-based routing or claims transformation, like adding claims or mapping to other auth protocols. YARP provides that flexibility, as the pipeline is fully extendable. 13 | 14 | ## Main components 15 | 16 | The sample consists of the following: 17 | 18 | 1. YARP proxy implementation which routes tenants to different backends based on the AAD tenant id. Deployed on an Azure web application. 19 | 20 | 1. A Tenant Repository (```ITenantRepository```), is used to serve information regarding tenants. For data persistence, it's using Azure App Configuration as the store for known tenants. Any data store could be used. Azure App Configuration provides some benefits like versioning and restore of configurations and change events. [Here dynamic configuration is used](https://github.com/henrikwh/azure-app-configuration-sample), causing changes in App Configuration to be pushed to subscribers- 21 | 22 | 1. Backend API, which tenants are routed to. This is the weather API, but *configured to authorize using the token issued from the proxy*. 23 | 24 | 1. A tenant management service (API) with OpenAPI exposed. This acts as the interface to update the tenant repository. Not deployed with scripts above. 25 | 26 | ## Tenant routing 27 | 28 | The end-to-end flow for performing a request from a client (tenant) to a designated backend involved these steps: 29 | 30 | 1. Proxy receives an authorized request 31 | 32 | 2. Proxy inspects the bearer token and extracts claims needed. In this case, only `http://schemas.microsoft.com/identity/claims/tenantid` claim is used 33 | 34 | 3. Proxy uses tenant id to retrieve tenant information in Tenant Repository. Tenant Repository contains hosting Uri and state of the tenant (enabled or disabled) 35 | 36 | 4. Additional permissions are read from the 'Permission Service', depending on what the downstream services need 37 | 38 | 5. Proxy creates a new self-signed JWT and uses that downstream when proxying to the hosting location for the tenant. This could be any transformation required for calling downstream services. In this sample, the permission service is called to add additional claims to the JWT 39 | 40 | 6. Request is proxied based on information from the "tenant repository". 41 | 42 | Sequence diagram below illustrates the flow. 43 | 44 | ![Tenant Routing](Docs/Images/TenantRouting.png) 45 | 46 | ## Tenant management 47 | 48 | The sample includes a simple management abstraction that allows for configuring tenant settings/routing. Configuration storage is handled in Azure App Configuration as the [external configuration store](https://learn.microsoft.com/en-us/azure/architecture/patterns/external-configuration-store), but any other backing store could be implemented. This would typically be deployed as part of the SaaS [control plane](https://learn.microsoft.com/en-us/azure/architecture/guide/multitenant/considerations/control-planes). A tenant management component/service handles scenarios like: 49 | 50 | - Adding, removing/blocking tenants 51 | - Assigning capacity, limits pr tenant, limits pr hosting service 52 | - Reassigning tenants to new hosts, by changing destination for tenants 53 | 54 | Here changes to the routing configuration are pushed to subscribing proxies. 55 | 56 | The high level sequence diagram for calling the backend looks like: 57 | 58 | ![Data and control plane](Docs/Images/DataAndControlPlane.png) 59 | 60 | 61 | Some key considerations are that the proxy requires hosting, maintenance, and operations and that the proxy is on the critical path. Application Gateway or API Management are hosted services and takes these responsibilities. Besides that, adding a proxy, or gateway, have a cost and adds an additional layer of processing which will have some performance impact. Networking configuration has not been included in this sample, where for most cases, deployments would be performed into separate subnets and a web application firewall would front the proxy. 62 | 63 | 64 | ### Observability 65 | 66 | The sample is configured to send to application insights. This gives the operational telemetry needed to inspect how proxy and downstream services are performing and operating. 67 | 68 | > **Note:** Trace levels are configured centrally in App Configuration, in the configuration `API:Settings${environment}`. Other application insights functionality, like dependency tracing, can also be centrally enabled or disabled. 69 | 70 | Having visibility into the end-to-end request is essential. The KQL below shows how a specific request can be traced using `operation_id`. 71 | 72 | ```kql 73 | let opid ="INSERT OPERATIOb_ID"; 74 | union requests,traces,dependencies, exceptions 75 | | where operation_Id == opid or operation_ParentId == opid 76 | | extend CategoryName_ = tostring(customDimensions.CategoryName) 77 | | extend executingProcess = strcat(cloud_RoleName, '-',cloud_RoleInstance) 78 | | project-reorder timestamp,itemType, operation_Id, executingProcess, name, message,CategoryName_, url, duration 79 | | order by timestamp asc 80 | ``` 81 | 82 | ![Distributed tracing](Docs/Images/KQLResult.png) 83 | 84 | Requests, traces, and dependencies are logged. Above shows the request to the proxy, trace for the proxy (Proxying to..), the dependency trace, the request to the weather API, and finally the trace response. 85 | 86 | #### Application map 87 | 88 | The application map shows the communication between services. The obvious are calls from proxy and downstream to weather API. There are also a few other dependencies shown, that illustrate how the service operates: 89 | 90 | Application map 91 | 92 | * `127.0.0.1:*` - looking into that reveals `GET 127.0.0.1:41793/msi/token/`. This is the service getting a token for the managed identity used to connect other services 93 | * `proxyservicebus-*` is the subscription to the Service Bus topic which nodes subscribe to, to get notified of configuration changes 94 | * `configurationclient` - calls to configuration client are triggered once a node receives a message on the Service Bus. 95 | 96 | ### Building and deploying the sample 97 | 98 | Install scripts are tested on Ubuntu on WSL. 99 | 100 | *Main principle for the scripts* is to store variables in `config.json` once scripts run. That will result in specific deployment parameters, which are used in Bicep to provision and configure. `config.json` is also used directly by ASP.NET core configuration provider if the sample is running locally. 101 | 102 | 1. Clone the repository 103 | 104 | 2. Go to the scripts folder 105 | 106 | 3. Optional: run `./preReqs.sh` to install needed prerequisites. 107 | For manual installation of the prerequisites: `apt install jq zip azure-cli dotnet-sdk-7.0` 108 | 109 | 4. run `./aadApp.sh` to create application registration in AAD. This will create a multitenant AAD app, which the proxy will use. 110 | 111 | 5. run `./provision.sh` to provision Azure resources, using Bicep. 112 | 113 | 7. run `./addCurrentTenantToRepository.sh`, this will create a registration that will route your tenant the weather API installed for testing 114 | routing information is stored in App Configuration under `TenantDirectory:Tenants$Production` with this format: 115 | 116 | ```json 117 | [ 118 | { 119 | "Tid": "{Id of the AAD tenant, for which the proxy will route requests}", 120 | "Destination": "https://{Replace with weather api prefix}.azurewebsites.net", 121 | "Alias": "{Tenant-Something}", 122 | "State":"Enabled" 123 | } 124 | ] 125 | ``` 126 | 8. run `./deploy.sh` to build the solution and deploy the solution 127 | 128 | #### Setting up Postman to call the APIs 129 | 130 | A Postman environment is generated to help setup the authentication and to make the request against the proxy. 131 | 132 | 1) In the scripts folder, run `createPostmanEnvironment.sh`, this will generate an environment file and copy the sample collection. 133 | 2) Open Postman and import the generated files `TenantProxy-dev.postman_environment.json` and `TenantProxy.postman_collection.json` 134 | ![image-20230629114232652](Docs/Images/PostmanImportEnvironment.png) 135 | 3) In Postman select the imported environment, to make sure the right variables are used. The environment name contains the resourcegroup name for the deployment 136 | 4) Get a new access token, and press "Use Token". This will use the environment which contains the client id, scope etc. 137 | ![image-20230629115606224](Docs/Images/PostmanGetToken.png) 138 | 5) Call the API, using the token 139 | ![image-20230629115216440](Docs/Images/PostmanCallWeatherAPI.png) 140 | 6) Enjoy the weather, served through the proxy, by the backend API the tenant is routed to 141 | 142 | #### Running locally 143 | 144 | From the scripts folder do the following: 145 | 146 | 1) Make sure that the above setup steps have been performed, to provision resources on Azure 147 | 2) From the scripts folder run `createDevServicePrincipal.sh`. This will create a service principal with permissions identical to the managed identity. Values are stored in `config.json` and read as part of the startup. 148 | 3) For startup projects, select multiple and select Proxy and WeatherApi. Proxy needs to run https on port 7001, and WeatherApi on port 9090. 149 | 4) If testing with Postman, use the request stored in the localhost folder 150 | 5) Ensure that the right configuration is loaded for the Development environment by going to Azure App Configuration and edit the configuration `TenantDirectory:Tenants$Development`. If *not* changed, routing will be towards the cloud deployment - which is also possible. 151 | 152 | ##### Control plane APIs 153 | 154 | For interacting with the control plane, there's a simple sample, that updates the Tenant Repository. It can be started locally (localhost), for test purposes, using a service principal to talk to App Configuration service. 155 | 156 | ![image-20230705101135254](Docs/Images/ControlplaneAPI.png) 157 | 158 | Above are a few operations relevant for managing tenants. 159 | 160 | ### Summary 161 | 162 | A reverse proxy works as an intermediary, in this case between clients and backends. Having a proxy or gateway in between enables inspection and rewrite of requests. For this sample, the scenario is to route tenants identified by the tenant id claim in the bearer token, to the right hosting endpoint in the backend. Tenant management is implemented to hold the individual configurations of the tenants. 163 | 164 | #### Resources 165 | 166 | [YARP Documentation (microsoft.github.io)](https://microsoft.github.io/reverse-proxy/) 167 | 168 | [Backends for Frontends pattern - Azure Architecture Center | Microsoft Learn](https://learn.microsoft.com/en-us/azure/architecture/patterns/backends-for-frontends) 169 | 170 | Gateway routing: https://learn.microsoft.com/en-us/azure/architecture/patterns/gateway-routing 171 | 172 | [External Configuration Store pattern - Azure Architecture Center | Microsoft Learn](https://learn.microsoft.com/en-us/azure/architecture/patterns/external-configuration-store) 173 | 174 | [Federated Identity pattern - Azure Architecture Center | Microsoft Learn](https://learn.microsoft.com/en-us/azure/architecture/patterns/federated-identity) 175 | 176 | -------------------------------------------------------------------------------- /Scripts/main.bicep: -------------------------------------------------------------------------------- 1 | @description('SKU size') 2 | @allowed([ 3 | 'S1' 4 | 'F1' 5 | ]) 6 | param skuSize string = 'S1' 7 | 8 | param location string 9 | 10 | @secure() 11 | param secret string 12 | 13 | var names = loadJsonContent('names.json') 14 | var conf = loadJsonContent('config.json') 15 | var roles = conf.roles 16 | 17 | var baseName = substring(uniqueString(resourceGroup().id), 0, 4) 18 | 19 | 20 | var environments = [ 21 | 'Development' 22 | 'Test' 23 | 'Production' 24 | ] 25 | 26 | @description('Specifies the names of the key-value resources. The name is a combination of key and label with $ as delimiter. The label is optional.') 27 | param keyValueNames array = [ 28 | 'API:Settings' 29 | 'API:Settings$Development' 30 | 'API:Settings$Production' 31 | 'ReverseProxy:Settings' 32 | 'ReverseProxy:Settings$Development' 33 | 'ReverseProxy:Settings$Production' 34 | 'TenantDirectory:Tenants$Development' 35 | 'TenantDirectory:Tenants$Production' 36 | 'Generel' 37 | 38 | ] 39 | 40 | @description('Array holding settings to be loaded into app confiuration') 41 | param keyValueValues array = [ 42 | loadTextContent('conf/API_Settings.json') 43 | loadTextContent('conf/API_Settings_Development.json') 44 | loadTextContent('conf/API_Settings_Production.json') 45 | loadTextContent('conf/ReverseProxy_Settings.json') 46 | loadTextContent('conf/ReverseProxy_Settings_Development.json') 47 | loadTextContent('conf/ReverseProxy_Settings_Production.json') 48 | loadTextContent('conf/TenantDirectory_Tenants_Development.json') 49 | loadTextContent('conf/TenantDirectory_Tenants_Production.json') 50 | loadTextContent('conf/Global_Settings.json') 51 | ] 52 | //============================= User assigned managed identity ============================= 53 | resource webappUamis 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { 54 | name: '${names.uamis.appconfigDemo}-${baseName}' 55 | location: location 56 | } 57 | 58 | resource controlPlaneUamis 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { 59 | name: 'controlPlane-${baseName}' 60 | location: location 61 | } 62 | 63 | 64 | //============================= Web app ============================= 65 | resource serverFarm 'Microsoft.Web/serverfarms@2022-09-01' = { 66 | name: names.website.appServiceplan 67 | location: location 68 | sku: { 69 | size: skuSize 70 | name: skuSize 71 | capacity: 1 72 | } 73 | kind: 'windows' 74 | 75 | } 76 | 77 | resource proxyApp 'Microsoft.Web/sites@2022-09-01' = { 78 | name: '${names.website.webapp}-${baseName}' 79 | location: location 80 | kind: 'app' 81 | properties: { 82 | 83 | serverFarmId: serverFarm.id 84 | clientAffinityEnabled: false 85 | } 86 | identity: { 87 | type: 'UserAssigned' 88 | userAssignedIdentities: { '${webappUamis.id}': {} } 89 | } 90 | resource appsettings 'config@2022-09-01' = { 91 | name: 'appsettings' 92 | properties: { 93 | 'ApplicationInsights:ConnectionString': applicationInsights.properties.ConnectionString 94 | KeyVaultName: keyVault.name 95 | ASPNETCORE_ENVIRONMENT: 'Production' 96 | 'AppConfiguration:UserAssignedManagedIdentityClientId': webappUamis.properties.clientId 97 | 'ChangeSubscription:UserAssignedManagedIdentityClientId': webappUamis.properties.clientId 98 | 'AppConfiguration:Uri': configurationStore.properties.endpoint 99 | 'ChangeSubscription:ServiceBusTopic': serviceBusTopicForChangeNotification.name 100 | 'ChangeSubscription:ServiceBusNamespace': serviceBusNamespace.properties.serviceBusEndpoint 101 | } 102 | } 103 | 104 | resource web 'config' = { 105 | name: 'web' 106 | properties: { 107 | 108 | netFrameworkVersion: 'v6.0' 109 | use32BitWorkerProcess: false 110 | loadBalancing: 'PerSiteRoundRobin' 111 | alwaysOn: true 112 | minTlsVersion: '1.2' 113 | ftpsState: 'Disabled' 114 | } 115 | 116 | } 117 | } 118 | 119 | 120 | resource managementService 'Microsoft.Web/sites@2022-09-01' = { 121 | name: 'managementService-${baseName}' 122 | location: location 123 | kind: 'app' 124 | properties: { 125 | 126 | serverFarmId: serverFarm.id 127 | clientAffinityEnabled: false 128 | } 129 | identity: { 130 | type: 'UserAssigned' 131 | userAssignedIdentities: { '${controlPlaneUamis.id}': {} } 132 | } 133 | resource appsettings 'config@2022-09-01' = { 134 | name: 'appsettings' 135 | properties: { 136 | 'ApplicationInsights:ConnectionString': applicationInsights.properties.ConnectionString 137 | KeyVaultName: keyVault.name 138 | ASPNETCORE_ENVIRONMENT: 'Production' 139 | 'AppConfiguration:UserAssignedManagedIdentityClientId': controlPlaneUamis.properties.clientId 140 | 'ChangeSubscription:UserAssignedManagedIdentityClientId': controlPlaneUamis.properties.clientId 141 | 'AppConfiguration:Uri': configurationStore.properties.endpoint 142 | 'ChangeSubscription:ServiceBusTopic': serviceBusTopicForChangeNotification.name 143 | 'ChangeSubscription:ServiceBusNamespace': serviceBusNamespace.properties.serviceBusEndpoint 144 | } 145 | } 146 | 147 | resource web 'config' = { 148 | name: 'web' 149 | properties: { 150 | 151 | netFrameworkVersion: 'v6.0' 152 | use32BitWorkerProcess: false 153 | loadBalancing: 'PerSiteRoundRobin' 154 | alwaysOn: true 155 | minTlsVersion: '1.2' 156 | ftpsState: 'Disabled' 157 | } 158 | 159 | } 160 | } 161 | 162 | 163 | 164 | 165 | resource webAppWeatherReport 'Microsoft.Web/sites@2022-03-01' = { 166 | name: '${names.website.webapp}-weather-${baseName}' 167 | location: location 168 | kind: 'app' 169 | properties: { 170 | 171 | serverFarmId: serverFarm.id 172 | clientAffinityEnabled: false 173 | /*siteConfig: { 174 | netFrameworkVersion: 'v6.0' 175 | }*/ 176 | 177 | } 178 | identity: { 179 | type: 'UserAssigned' 180 | userAssignedIdentities: { '${webappUamis.id}': {} } 181 | } 182 | resource appsettings 'config@2022-03-01' = { 183 | name: 'appsettings' 184 | properties: { 185 | 'ApplicationInsights:ConnectionString': applicationInsights.properties.ConnectionString 186 | KeyVaultName: keyVault.name 187 | ASPNETCORE_ENVIRONMENT: 'Production' 188 | 'AppConfiguration:UserAssignedManagedIdentityClientId': webappUamis.properties.clientId 189 | 'AppConfiguration:Uri': configurationStore.properties.endpoint 190 | 'ChangeSubscription:ServiceBusTopic': serviceBusTopicForChangeNotification.name 191 | 'ChangeSubscription:ServiceBusNamespace': serviceBusNamespace.properties.serviceBusEndpoint 192 | 'ChangeSubscription:UserAssignedManagedIdentityClientId': webappUamis.properties.clientId 193 | } 194 | } 195 | 196 | resource web 'config' = { 197 | name: 'web' 198 | properties: { 199 | 200 | netFrameworkVersion: 'v6.0' 201 | use32BitWorkerProcess: false 202 | loadBalancing: 'PerSiteRoundRobin' 203 | alwaysOn: true 204 | minTlsVersion: '1.2' 205 | ftpsState: 'Disabled' 206 | } 207 | 208 | } 209 | } 210 | 211 | 212 | 213 | //============================= App Configuration ============================= 214 | 215 | resource configurationStore 'Microsoft.AppConfiguration/configurationStores@2023-03-01' = { 216 | location: location 217 | name: '${names.appConfiguration.name}-${baseName}' 218 | sku: { 219 | name: 'standard' 220 | } 221 | properties: { 222 | 223 | } 224 | } 225 | 226 | 227 | 228 | 229 | module AddEnvironmentSettings 'modules/addAppConfiguration.bicep' = [for (item, i) in keyValueNames: { 230 | name: replace(replace('Adding-${item}',':',''),'$','') 231 | params: { 232 | keyName: item 233 | managedIdentityWithAccessToAppConfiguration: webappUamis.name 234 | value: keyValueValues[i] 235 | appConfigName : configurationStore.name 236 | isSecrect: false 237 | keyVaultName: keyVault.name 238 | } 239 | }] 240 | 241 | 242 | module JwtKeyAdd 'modules/addAppConfiguration.bicep' = { 243 | name: 'JwtKey' 244 | params: { 245 | keyName: 'Jwt:Secret' 246 | managedIdentityWithAccessToAppConfiguration: webappUamis.name 247 | value: secret 248 | appConfigName : configurationStore.name 249 | keyVaultName: keyVault.name 250 | isSecrect: true 251 | 252 | } 253 | } 254 | module ChangeSubscriptionUserAssignedManagedIdentityClientId 'modules/addAppConfiguration.bicep' = { 255 | name: 'ChangeSubscriptionUserAssignedManagedIdentityClientId' 256 | params: { 257 | keyName: 'ChangeSubscription:UserAssignedManagedIdentityClientId' 258 | managedIdentityWithAccessToAppConfiguration: webappUamis.name 259 | value: webappUamis.properties.clientId 260 | appConfigName : configurationStore.name 261 | contentType: 'text/plain' 262 | isSecrect:false 263 | keyVaultName: keyVault.name 264 | } 265 | } 266 | 267 | module AppConfigurationUserAssignedManagedIdentityClientId 'modules/addAppConfiguration.bicep' = { 268 | name: 'AppConfigurationUserAssignedManagedIdentityClientId' 269 | params: { 270 | keyName: 'AppConfiguration:UserAssignedManagedIdentityClientId' 271 | managedIdentityWithAccessToAppConfiguration: webappUamis.name 272 | value: webappUamis.properties.clientId 273 | appConfigName : configurationStore.name 274 | contentType: 'text/plain' 275 | isSecrect:false 276 | keyVaultName: keyVault.name 277 | } 278 | } 279 | 280 | 281 | module AzureAdClientId 'modules/addAppConfiguration.bicep' = { 282 | name: 'AzureAdClientId' 283 | params: { 284 | keyName: 'AzureAd:ClientId' 285 | managedIdentityWithAccessToAppConfiguration: webappUamis.name 286 | value: conf.Proxy.Aad.ClientId 287 | appConfigName : configurationStore.name 288 | contentType: 'text/plain' 289 | isSecrect:false 290 | keyVaultName: keyVault.name 291 | } 292 | } 293 | 294 | 295 | 296 | module ServiceBusNamespace 'modules/addAppConfiguration.bicep' = { 297 | name: 'ServiceBusNamespace' 298 | params: { 299 | keyName: 'ChangeSubscription:ServiceBusNamespace' 300 | managedIdentityWithAccessToAppConfiguration: webappUamis.name 301 | value: serviceBusNamespace.name 302 | appConfigName : configurationStore.name 303 | contentType: 'text/plain' 304 | isSecrect:false 305 | keyVaultName: keyVault.name 306 | } 307 | } 308 | 309 | 310 | module ServiceTopic 'modules/addAppConfiguration.bicep' = { 311 | name: 'ServiceTopc' 312 | params: { 313 | keyName: 'ChangeSubscription:ServiceBusTopic' 314 | managedIdentityWithAccessToAppConfiguration: webappUamis.name 315 | value: serviceBusTopicForChangeNotification.name 316 | appConfigName : configurationStore.name 317 | contentType: 'text/plain' 318 | isSecrect:false 319 | keyVaultName: keyVault.name 320 | } 321 | } 322 | 323 | 324 | resource featureFlagDoMagic 'Microsoft.AppConfiguration/configurationStores/keyValues@2023-03-01' = [for env in environments: { 325 | name: '.appconfig.featureflag~2F${names.features.magic.name}$${env}' 326 | parent: configurationStore 327 | properties: { 328 | contentType: 'application/vnd.microsoft.appconfig.ff+json;charset=utf-8' 329 | tags: {} 330 | value: '{"id": "${names.features.magic.name}", "description": "", "enabled": ${names.features.magic.default}, "conditions": {"client_filters":[]}}' 331 | } 332 | }] 333 | 334 | resource featureFlagShowDebugView 'Microsoft.AppConfiguration/configurationStores/keyValues@2022-05-01' = [for env in environments: { 335 | name: '.appconfig.featureflag~2F${names.features.showDebugView.name}$${env}' 336 | parent: configurationStore 337 | properties: { 338 | contentType: 'application/vnd.microsoft.appconfig.ff+json;charset=utf-8' 339 | tags: {} 340 | value: '{"id": "${names.features.showDebugView.name}", "description": "", "enabled": ${names.features.showDebugView.default}, "conditions": {"client_filters":[]}}' 341 | } 342 | }] 343 | 344 | resource featureFlagProcessKeyVaultChangeEvents 'Microsoft.AppConfiguration/configurationStores/keyValues@2022-05-01' = [for env in environments: { 345 | name: '.appconfig.featureflag~2F${names.features.autoUpdateLatestVersionSecrets.name}$${env}' 346 | parent: configurationStore 347 | properties: { 348 | contentType: 'application/vnd.microsoft.appconfig.ff+json;charset=utf-8' 349 | tags: {} 350 | value: '{"id": "${names.features.autoUpdateLatestVersionSecrets.name}", "description": "", "enabled": ${names.features.autoUpdateLatestVersionSecrets.default}, "conditions": {"client_filters":[]}}' 351 | } 352 | }] 353 | 354 | //============================= EventGrid and Service bus ============================= 355 | 356 | resource serviceBusNamespace 'Microsoft.ServiceBus/namespaces@2021-11-01' = { 357 | name: '${names.serviceBus.nameSpace}-${baseName}' 358 | location: location 359 | sku: { 360 | name: 'Standard' 361 | } 362 | } 363 | 364 | resource serviceBusTopicForChangeNotification 'Microsoft.ServiceBus/namespaces/topics@2021-11-01' = { 365 | name: 'sb-appconfigurationChangeTopic' 366 | parent: serviceBusNamespace 367 | properties: { 368 | } 369 | } 370 | 371 | // resource serviceBusTopicForProxyChangeNotification 'Microsoft.ServiceBus/namespaces/topics@2021-11-01' = { 372 | // name: 'sb-proxyChangeTopic' 373 | // parent: serviceBusNamespace 374 | // properties: { 375 | // } 376 | // } 377 | 378 | resource eventGridSystemTopicForConfigurationStore 'Microsoft.EventGrid/systemTopics@2022-06-15' = { 379 | name: 'eg-systemChangeTopic' 380 | location: location 381 | properties: { 382 | source: configurationStore.id 383 | topicType: 'Microsoft.AppConfiguration.ConfigurationStores' 384 | } 385 | } 386 | 387 | resource eventGridSystemTopicForKeyVault 'Microsoft.EventGrid/systemTopics@2022-06-15' = { 388 | name: 'eg-keyVaultSystemChangeTopic' 389 | location: location 390 | properties: { 391 | source: keyVault.id 392 | topicType: 'Microsoft.KeyVault.Vaults' 393 | } 394 | } 395 | 396 | resource changeEventSubscriptionac 'Microsoft.EventGrid/systemTopics/eventSubscriptions@2022-06-15' = { 397 | name: 'changeSubscription-kv' 398 | parent: eventGridSystemTopicForKeyVault 399 | properties: { 400 | destination: { 401 | endpointType: 'ServiceBusTopic' 402 | properties: { 403 | resourceId: serviceBusTopicForChangeNotification.id 404 | } 405 | } 406 | filter: { 407 | includedEventTypes: [ 408 | 'Microsoft.KeyVault.KeyNewVersionCreated' 409 | 'Microsoft.KeyVault.KeyNearExpiry' 410 | 'Microsoft.KeyVault.KeyExpired' 411 | 'Microsoft.KeyVault.SecretNewVersionCreated' 412 | 'Microsoft.KeyVault.SecretNearExpiry' 413 | 'Microsoft.KeyVault.SecretExpired' 414 | // 'Microsoft.KeyVault.CertificateNewVersionCreated' 415 | // 'Microsoft.KeyVault.CertificateNearExpiry' 416 | // 'Microsoft.KeyVault.CertificateExpired' 417 | ] 418 | } 419 | eventDeliverySchema: 'EventGridSchema' 420 | } 421 | } 422 | 423 | resource changeEventSubscription 'Microsoft.EventGrid/systemTopics/eventSubscriptions@2022-06-15' = { 424 | name: 'changeSubscription' 425 | parent: eventGridSystemTopicForConfigurationStore 426 | properties: { 427 | destination: { 428 | endpointType: 'ServiceBusTopic' 429 | properties: { 430 | resourceId: serviceBusTopicForChangeNotification.id 431 | } 432 | } 433 | filter: { 434 | includedEventTypes: [ 435 | 'Microsoft.AppConfiguration.KeyValueModified' 436 | ] 437 | } 438 | eventDeliverySchema: 'EventGridSchema' 439 | } 440 | } 441 | 442 | //============================= Key Vault and secrect ============================= 443 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { 444 | name: '${names.keyVault.name}-${baseName}' 445 | location: location 446 | tags: { 447 | 448 | } 449 | 450 | properties: { 451 | createMode: 'default' 452 | enabledForDeployment: false 453 | enabledForDiskEncryption: false 454 | enabledForTemplateDeployment: false 455 | enableSoftDelete: true 456 | enableRbacAuthorization: true 457 | sku: { 458 | family: 'A' 459 | name: 'standard' 460 | } 461 | softDeleteRetentionInDays: 7 462 | tenantId: subscription().tenantId 463 | } 464 | } 465 | 466 | 467 | 468 | module AppInstighsConnectionStringSecret 'modules/addAppConfiguration.bicep' = { 469 | name: 'StoreAppInsightsConnections' 470 | params: { 471 | keyName: 'API:Settings:Secrets:ConnectionStrings:AppInsights' 472 | managedIdentityWithAccessToAppConfiguration: webappUamis.name 473 | value: applicationInsights.properties.ConnectionString 474 | appConfigName: configurationStore.name 475 | keyVaultName: keyVault.name 476 | isSecrect: false 477 | contentType: 'text/plain' 478 | } 479 | } 480 | 481 | module ServiceBusConnectionStringSecret 'modules/addAppConfiguration.bicep' = { 482 | name: 'ServiceBusConnectionStringSecret' 483 | params: { 484 | keyName: 'API:Settings:Secrets:ConnectionStrings:ServiceBus' 485 | managedIdentityWithAccessToAppConfiguration: webappUamis.name 486 | value: serviceBusConnectionString 487 | appConfigName: configurationStore.name 488 | keyVaultName: keyVault.name 489 | isSecrect: false 490 | contentType: 'text/plain' 491 | 492 | } 493 | } 494 | 495 | module AppConfigurationConnectionStringSecret 'modules/addAppConfiguration.bicep' = { 496 | name: 'AppConfigurationConnectionStringSecret' 497 | params: { 498 | keyName: 'API:Settings:Secrets:ConnectionStrings:AppConfig' 499 | managedIdentityWithAccessToAppConfiguration: webappUamis.name 500 | value: appConfigReadonlyConnectionString.connectionString 501 | appConfigName: configurationStore.name 502 | keyVaultName: keyVault.name 503 | isSecrect: false 504 | contentType: 'text/plain' 505 | } 506 | } 507 | 508 | module AppConfigurationWriteConnectionStringSecret 'modules/addAppConfiguration.bicep' = { 509 | name: 'AppConfigurationWroteConnectionStringSecret' 510 | params: { 511 | keyName: 'TenantManagement:Settings:Secrets:ConnectionStrings:AppConfig' 512 | managedIdentityWithAccessToAppConfiguration: controlPlaneUamis.name 513 | value: appConfigReadWriteConnectionString.connectionString 514 | appConfigName: configurationStore.name 515 | keyVaultName: keyVault.name 516 | } 517 | } 518 | 519 | //============================= Role assignments ============================= 520 | 521 | resource managedIdentityCanReadConfigurationStore 'Microsoft.Authorization/roleAssignments@2022-04-01' ={ 522 | name: guid(roles['App Configuration Data Reader'], webappUamis.id) 523 | scope: configurationStore 524 | properties: { 525 | roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles['App Configuration Data Reader']) 526 | principalId: webappUamis.properties.principalId 527 | principalType: 'ServicePrincipal' 528 | } 529 | } 530 | 531 | resource managedIdentityCanWriteReadConfigurationStore 'Microsoft.Authorization/roleAssignments@2022-04-01' ={ 532 | name: guid(roles['App Configuration Data Owner'], controlPlaneUamis.id) 533 | scope: configurationStore 534 | properties: { 535 | roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles['App Configuration Data Owner']) 536 | principalId: controlPlaneUamis.properties.principalId 537 | principalType: 'ServicePrincipal' 538 | } 539 | } 540 | 541 | 542 | 543 | resource managedIdentityReadNotification 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 544 | name: guid(serviceBusNamespace.id, roles['Azure Service Bus Data Receiver'], webappUamis.id) 545 | scope: serviceBusTopicForChangeNotification 546 | properties: { 547 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roles['Azure Service Bus Data Receiver']) 548 | principalId: webappUamis.properties.principalId 549 | principalType: 'ServicePrincipal' 550 | } 551 | } 552 | 553 | resource managedIdentityCreateSubscription 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 554 | name: guid(serviceBusNamespace.id, roles.Contributor, webappUamis.id) 555 | scope: serviceBusTopicForChangeNotification 556 | properties: { 557 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roles.Contributor) 558 | principalId: webappUamis.properties.principalId 559 | principalType: 'ServicePrincipal' 560 | } 561 | } 562 | 563 | //============================= Log analytics ============================= 564 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { 565 | name: '${names.monitor.appinsights}-${baseName}' 566 | location: location 567 | kind: 'web' 568 | properties: { 569 | Application_Type: 'web' 570 | //DisableIpMasking: false 571 | //DisableLocalAuth: false 572 | //Flow_Type: 'Bluefield' 573 | //ForceCustomerStorageForProfiler: false 574 | //publicNetworkAccessForIngestion: 'Enabled' 575 | //publicNetworkAccessForQuery: 'Enabled' 576 | Request_Source: 'rest' 577 | WorkspaceResourceId: logAnalyticsWorkspace.id 578 | } 579 | } 580 | 581 | param sku string = 'PerGB2018' 582 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { 583 | name: '${names.monitor.logAnalyticsWorkspace}-${baseName}' 584 | location: location 585 | properties: { 586 | sku: { 587 | name: sku 588 | } 589 | retentionInDays: 30 590 | features: { 591 | searchVersion: 1 592 | legacy: 0 593 | enableLogAccessUsingOnlyResourcePermissions: true 594 | } 595 | } 596 | } 597 | 598 | 599 | 600 | 601 | 602 | 603 | var serviceBusEndpoint = '${serviceBusNamespace.id}/AuthorizationRules/RootManageSharedAccessKey' 604 | var serviceBusConnectionString = listKeys(serviceBusEndpoint, serviceBusNamespace.apiVersion).primaryConnectionString 605 | 606 | 607 | var appConfigReadonlyConnectionString = filter(configurationStore.listKeys().value, k => k.name == 'Primary Read Only')[0] 608 | var appConfigReadWriteConnectionString = filter(configurationStore.listKeys().value, k => k.name == 'Primary')[0] 609 | output applicationInsights_ConnectionString string = applicationInsights.properties.ConnectionString 610 | output changeSubscription_ServiceBusConnectionString string = serviceBusConnectionString 611 | output connectionStrings_AppConfig string = appConfigReadonlyConnectionString.connectionString 612 | output proxyEndpoint string = proxyApp.properties.defaultHostName 613 | output webappWeatherEndpoint string = webAppWeatherReport.properties.defaultHostName 614 | output managementServiceEndpoint string = managementService.properties.defaultHostName 615 | 616 | 617 | 618 | //Devevlopment settings below. Used for running locally 619 | output appConfigReadWriteConnectionString string = appConfigReadWriteConnectionString.connectionString 620 | output appConfigReadConnectionString string = appConfigReadonlyConnectionString.connectionString 621 | output appConfigurationName string = configurationStore.name 622 | output appConfigurationEndpoint string = configurationStore.properties.endpoint 623 | 624 | --------------------------------------------------------------------------------