From 7a914cf3ece2748aef7368dc6f5b9eaa1fb9904b Mon Sep 17 00:00:00 2001 From: Ash Davies <3853061+DrizzlyOwl@users.noreply.github.com> Date: Mon, 16 Jan 2023 14:07:00 +0000 Subject: [PATCH 1/2] Split workflow tasks apart into steps to improve readibility --- .../build-and-push-image-development.yml | 90 ++++++++++++++----- 1 file changed, 69 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build-and-push-image-development.yml b/.github/workflows/build-and-push-image-development.yml index a137fac2f..d58d39d84 100644 --- a/.github/workflows/build-and-push-image-development.yml +++ b/.github/workflows/build-and-push-image-development.yml @@ -5,46 +5,94 @@ on: branches: - main +env: + DOCKER_IMAGE: acatran-app + jobs: - build-and-push-image-development: - name: Build and push image development + set-env: + name: Prepare + runs-on: ubuntu-latest + outputs: + branch: ${{ steps.var.outputs.branch }} + release: ${{ steps.var.outputs.release }} + steps: + - id: var + run: | + GIT_REF=${{ github.ref }} + GIT_BRANCH=${GIT_REF##*/} + RELEASE=dev-`date +%Y-%m-%d`.${{ github.run_number }} + echo "branch=$GIT_BRANCH" >> $GITHUB_OUTPUT + echo "release=${RELEASE}" >> $GITHUB_OUTPUT + + build-and-push-image: + name: Build and push image + needs: [ set-env ] environment: Dev runs-on: ubuntu-latest steps: - - name: Check out code - uses: actions/checkout@v3 + - uses: actions/checkout@v3 - - name: Azure Container Registry login + - name: Login to ACR uses: docker/login-action@v2 with: username: ${{ secrets.DEVELOPMENT_AZURE_ACR_CLIENTID }} password: ${{ secrets.DEVELOPMENT_AZURE_ACR_SECRET }} registry: ${{ secrets.DEVELOPMENT_AZURE_ACR_URL }} - - name: Prepare tags - id: prepare-tags - run: | - DOCKER_IMAGE=${{ secrets.DEVELOPMENT_AZURE_ACR_URL }}/acatran-app - VERSION=dev-`date +%Y-%m-%d`.${{ github.run_number }} - SHA=sha-${GITHUB_SHA} - TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${SHA}" - echo "tags=${TAGS}" >> $GITHUB_OUTPUT - echo "deploy-version=${VERSION}" >> $GITHUB_OUTPUT - - - name: Push image + - name: Build and push image uses: docker/build-push-action@v3 with: context: . - file: ./Dockerfile.gpaas-azure-migration + file: Dockerfile.gpaas-azure-migration + tags: | + ${{ secrets.DEVELOPMENT_AZURE_ACR_URL }}/${{ env.DOCKER_IMAGE }}:${{ needs.set-env.outputs.branch }} + ${{ secrets.DEVELOPMENT_AZURE_ACR_URL }}/${{ env.DOCKER_IMAGE }}:${{ needs.set-env.outputs.release }} + ${{ secrets.DEVELOPMENT_AZURE_ACR_URL }}/${{ env.DOCKER_IMAGE }}:latest push: true - tags: ${{ steps.prepare-tags.outputs.tags }} - - name: Azure login with ACA credentials + create-tag: + name: Tag and release + needs: [ set-env ] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Create tag + run: | + git tag ${{ needs.set-env.outputs.release }} + git push origin ${{ needs.set-env.outputs.release }} + + - name: Create release + uses: "actions/github-script@v6" + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + script: | + try { + await github.rest.repos.createRelease({ + draft: false, + generate_release_notes: true, + name: "${{ needs.set-env.outputs.release }}", + owner: context.repo.owner, + prerelease: false, + repo: context.repo.repo, + tag_name: "${{ needs.set-env.outputs.release }}", + }); + } catch (error) { + core.setFailed(error.message); + } + + deploy-image: + name: Deploy to Development + needs: [ build-and-push-image, set-env ] + runs-on: ubuntu-latest + environment: Dev + steps: + - name: Login to Azure uses: azure/login@v1 with: creds: ${{ secrets.DEVELOPMENT_AZURE_ACA_CREDENTIALS }} - - name: Update Azure Container Apps Revision + - name: Update Container Revision uses: azure/CLI@v1 with: azcliversion: 2.40.0 @@ -53,5 +101,5 @@ jobs: az containerapp update \ --name ${{ secrets.DEVELOPMENT_AZURE_ACA_CONTAINERAPP_NAME }} \ --resource-group ${{ secrets.DEVELOPMENT_AZURE_ACA_RESOURCE_GROUP }} \ - --image ${{ secrets.DEVELOPMENT_AZURE_ACR_URL }}/acatran-app:${{ steps.prepare-tags.outputs.deploy-version }} \ + --image ${{ secrets.DEVELOPMENT_AZURE_ACR_URL }}/${{ env.DOCKER_IMAGE }}:${{ needs.set-env.outputs.release }} \ --output none From d55f4154b313603e57f89b67fcdba2b27fcb8cfc Mon Sep 17 00:00:00 2001 From: WILDE Date: Thu, 26 Jan 2023 15:48:01 +0000 Subject: [PATCH 2/2] Remove Redis and clean up. --- Frontend/Options/ServiceLinkOptions.cs | 10 +- Frontend/Services/AzureAd/AzureAdOptions.cs | 25 +- Frontend/Startup.cs | 445 +++++++++----------- Frontend/appsettings.json | 5 +- 4 files changed, 218 insertions(+), 267 deletions(-) diff --git a/Frontend/Options/ServiceLinkOptions.cs b/Frontend/Options/ServiceLinkOptions.cs index 3b2eae8ec..993b87ef2 100644 --- a/Frontend/Options/ServiceLinkOptions.cs +++ b/Frontend/Options/ServiceLinkOptions.cs @@ -1,8 +1,6 @@ -namespace Frontend.Options +namespace Frontend.Options; + +public class ServiceLinkOptions { - public class ServiceLinkOptions - { - public const string Name = "ServiceLink"; - public string ConversionsUrl { get; set; } - } + public string ConversionsUrl { get; set; } } diff --git a/Frontend/Services/AzureAd/AzureAdOptions.cs b/Frontend/Services/AzureAd/AzureAdOptions.cs index fe51a0d60..135ca3f1b 100644 --- a/Frontend/Services/AzureAd/AzureAdOptions.cs +++ b/Frontend/Services/AzureAd/AzureAdOptions.cs @@ -2,17 +2,18 @@ using System.Collections.Generic; using System.Globalization; -namespace Frontend.Services.AzureAd +namespace Frontend.Services.AzureAd; + +public class AzureAdOptions { - public class AzureAdOptions - { - public const string Name = "AzureAd"; - public Guid ClientId { get; set; } - public string ClientSecret { get; set; } - public Guid TenantId { get; set; } - public Guid GroupId { get; set; } - public string ApiUrl { get; set; } = "https://graph.microsoft.com/"; - public string Authority => string.Format(CultureInfo.InvariantCulture, "https://login.microsoftonline.com/{0}", TenantId); - public IEnumerable Scopes => new[] { $"{ApiUrl}.default" }; - } + public Guid ClientId { get; set; } + public string ClientSecret { get; set; } + public Guid TenantId { get; set; } + public Guid GroupId { get; set; } + public string ApiUrl { get; set; } = "https://graph.microsoft.com/"; + + public string Authority => + string.Format(CultureInfo.InvariantCulture, "https://login.microsoftonline.com/{0}", TenantId); + + public IEnumerable Scopes => new[] { $"{ApiUrl}.default" }; } diff --git a/Frontend/Startup.cs b/Frontend/Startup.cs index 95375e676..0b92dabe3 100644 --- a/Frontend/Startup.cs +++ b/Frontend/Startup.cs @@ -1,3 +1,6 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; using Data; using Data.Models; using Data.Models.KeyStagePerformance; @@ -6,271 +9,217 @@ using Data.TRAMS.Mappers.Response; using Data.TRAMS.Models; using Data.TRAMS.Models.EducationPerformance; +using FluentValidation; using FluentValidation.AspNetCore; +using Frontend.Authorization; +using Frontend.BackgroundServices; +using Frontend.Options; using Frontend.Security; using Frontend.Services; +using Frontend.Services.AzureAd; using Frontend.Services.Interfaces; using Frontend.Validators.Features; using Helpers; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Newtonsoft.Json.Linq; -using StackExchange.Redis; -using System; -using System.Security.Claims; -using System.Threading.Tasks; -using FluentValidation; -using Frontend.Authorization; -using Frontend.BackgroundServices; -using Frontend.Options; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Identity.Web; using Microsoft.Identity.Web.UI; -using Frontend.Services.AzureAd; -namespace Frontend +namespace Frontend; + +public class Startup { - public class Startup - { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services - .AddRazorPages(options => - { - options.Conventions.AuthorizeFolder("/"); - options.Conventions.AllowAnonymousToPage("/AccessibilityStatement"); - options.Conventions.AllowAnonymousToPage("/SessionTimedOut"); - }) - .AddViewOptions(options => { options.HtmlHelperOptions.ClientValidationEnabled = false; }); - - services.AddControllersWithViews(options => options.Filters.Add( - new AutoValidateAntiforgeryTokenAttribute())) - .AddSessionStateTempDataProvider() - .AddMicrosoftIdentityUI(); - - - services.Configure(Configuration.GetSection(ServiceLinkOptions.Name)); - - services - .AddFluentValidationAutoValidation() - .AddFluentValidationClientsideAdapters() - .AddValidatorsFromAssemblyContaining(); - - ConfigureRedisConnection(services); - - services.Configure(options => { options.LowercaseUrls = true; }); - services.Configure(Configuration.GetSection(AzureAdOptions.Name)); - - AddServices(services, Configuration); - - var policyBuilder = SetupAuthorizationPolicyBuilder(); - services.AddAuthorization(options => { options.DefaultPolicy = policyBuilder.Build(); }); - services.AddSession(options => - { - options.IdleTimeout = - TimeSpan.FromMinutes(Int32.Parse(Configuration["AuthenticationExpirationInMinutes"])); - options.Cookie.Name = ".ManageAnAcademyTransfer.Session"; - options.Cookie.HttpOnly = true; - options.Cookie.IsEssential = true; - if (string.IsNullOrEmpty(Configuration["CI"])) - { - options.Cookie.SecurePolicy = CookieSecurePolicy.Always; - } - }); - services.AddMicrosoftIdentityWebAppAuthentication(Configuration); - services.Configure(CookieAuthenticationDefaults.AuthenticationScheme, - options => - { - options.AccessDeniedPath = "/access-denied"; - options.Cookie.Name = ".ManageAnAcademyTransfer.Login"; - options.Cookie.HttpOnly = true; - options.Cookie.IsEssential = true; - options.ExpireTimeSpan = - TimeSpan.FromMinutes(int.Parse(Configuration["AuthenticationExpirationInMinutes"])); - options.SlidingExpiration = true; - if (string.IsNullOrEmpty(Configuration["CI"])) - { - options.Cookie.SecurePolicy = CookieSecurePolicy.Always; - } - }); - services.AddHealthChecks(); - } - - /// - /// Builds Authorization policy - /// Ensure authenticated user and restrict roles if they are provided in configuration - /// - /// AuthorizationPolicyBuilder - private AuthorizationPolicyBuilder SetupAuthorizationPolicyBuilder() - { - var policyBuilder = new AuthorizationPolicyBuilder(); - var allowedRoles = Configuration.GetSection("AzureAd")["AllowedRoles"]; - policyBuilder.RequireAuthenticatedUser(); - if (!string.IsNullOrWhiteSpace(allowedRoles)) - { - policyBuilder.RequireClaim(ClaimTypes.Role, allowedRoles.Split(',')); - } - return policyBuilder; - } - - private void ConfigureRedisConnection(IServiceCollection services) - { - var vcapServicesDefined = !string.IsNullOrEmpty(Configuration["VCAP_SERVICES"]); - var redisUrlDefined = !string.IsNullOrEmpty(Configuration["REDIS_URL"]); - - if (!vcapServicesDefined && !redisUrlDefined) - { - return; - } - - var redisPass = ""; - var redisHost = ""; - var redisPort = ""; - var redisTls = false; - - if (!string.IsNullOrEmpty(Configuration["VCAP_SERVICES"])) - { - var vcapConfiguration = JObject.Parse(Configuration["VCAP_SERVICES"]); - var redisCredentials = vcapConfiguration["redis"]?[0]?["credentials"]; - redisPass = (string) redisCredentials?["password"]; - redisHost = (string) redisCredentials?["host"]; - redisPort = (string) redisCredentials?["port"]; - redisTls = (bool) redisCredentials?["tls_enabled"]; - } - else if (!string.IsNullOrEmpty(Configuration["REDIS_URL"])) - { - var redisUri = new Uri(Configuration["REDIS_URL"]); - redisPass = redisUri.UserInfo.Split(":")[1]; - redisHost = redisUri.Host; - redisPort = redisUri.Port.ToString(); - } - - var redisConfigurationOptions = new ConfigurationOptions() - { - Password = redisPass, - EndPoints = {$"{redisHost}:{redisPort}"}, - Ssl = redisTls - }; - - var redisConnection = ConnectionMultiplexer.Connect(redisConfigurationOptions); - - services.AddStackExchangeRedisCache( - options => { options.ConfigurationOptions = redisConfigurationOptions; }); - services.AddDataProtection().PersistKeysToStackExchangeRedis(redisConnection, "DataProtectionKeys"); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - else - { - app.UseExceptionHandler("/Errors"); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. - app.UseHsts(); - } - - app.UseSecurityHeaders( - SecureHeadersDefinitions.SecurityHeadersDefinitions.GetHeaderPolicyCollection(env.IsDevelopment()) - .AddXssProtectionDisabled() - ); - - app.UseStatusCodePagesWithReExecute("/Errors", "?statusCode={0}"); - - if (!string.IsNullOrEmpty(Configuration["CI"])) - { - app.UseHttpsRedirection(); - } - - //For Azure AD redirect uri to remain https - var forwardOptions = new ForwardedHeadersOptions - { - ForwardedHeaders = ForwardedHeaders.All, - RequireHeaderSymmetry = false - }; - forwardOptions.KnownNetworks.Clear(); - forwardOptions.KnownProxies.Clear(); - app.UseForwardedHeaders(forwardOptions); - - app.UseStaticFiles(); - - app.UseRouting(); - - app.UseSentryTracing(); - - app.UseAuthentication(); - app.UseAuthorization(); - - app.UseSession(); - - app.UseEndpoints(endpoints => - { - endpoints.MapGet("/", context => - { - context.Response.Redirect("project-type", false); - return Task.CompletedTask; - }); - endpoints.MapRazorPages(); - endpoints.MapControllerRoute("default", "{controller}/{action}/"); - endpoints.MapHealthChecks("/health").WithMetadata(new AllowAnonymousAttribute()); - }); - } - - private static void AddServices(IServiceCollection services, IConfiguration configuration) - { - var tramsApiBase = configuration["TRAMS_API_BASE"]; - var tramsApiKey = configuration["TRAMS_API_KEY"]; - - services.AddScoped(); - - services.AddTransient, TramsSearchResultMapper>(); - services.AddTransient, TramsTrustMapper>(); - services.AddTransient, TramsEstablishmentMapper>(); - services.AddTransient, TramsProjectSummariesMapper>(); - services.AddTransient, TramsProjectMapper>(); - services.AddTransient, TramsEducationPerformanceMapper>(); - services.AddTransient, InternalProjectToUpdateMapper>(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - services.AddSingleton(new TramsHttpClient(tramsApiBase, tramsApiKey)); - services.AddSingleton(r => new TramsHttpClient(tramsApiBase, tramsApiKey)); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - services.AddHostedService(); - } - } + private readonly TimeSpan _authenticationExpiration; + + public Startup(IConfiguration configuration) + { + Configuration = configuration; + + _authenticationExpiration = + TimeSpan.FromMinutes(int.Parse(Configuration["AuthenticationExpirationInMinutes"] ?? "60")); + } + + private IConfiguration Configuration { get; } + + private IConfigurationSection GetConfigurationSection() + { + string sectionName = typeof(T).Name.Replace("Options", string.Empty); + return Configuration.GetRequiredSection(sectionName); + } + + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services + .AddRazorPages(options => + { + options.Conventions.AuthorizeFolder("/"); + options.Conventions.AllowAnonymousToPage("/AccessibilityStatement"); + options.Conventions.AllowAnonymousToPage("/SessionTimedOut"); + }) + .AddViewOptions(options => { options.HtmlHelperOptions.ClientValidationEnabled = false; }); + + services.AddControllersWithViews(options => options.Filters.Add( + new AutoValidateAntiforgeryTokenAttribute())) + .AddSessionStateTempDataProvider() + .AddMicrosoftIdentityUI(); + + + services.Configure(GetConfigurationSection()); + + services + .AddFluentValidationAutoValidation() + .AddFluentValidationClientsideAdapters() + .AddValidatorsFromAssemblyContaining(); + + services.Configure(options => { options.LowercaseUrls = true; }); + services.Configure(GetConfigurationSection()); + + AddServices(services, Configuration); + + AuthorizationPolicyBuilder policyBuilder = SetupAuthorizationPolicyBuilder(); + services.AddAuthorization(options => { options.DefaultPolicy = policyBuilder.Build(); }); + + services.AddSession(options => + { + options.IdleTimeout = _authenticationExpiration; + options.Cookie.Name = ".ManageAnAcademyTransfer.Session"; + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + if (string.IsNullOrEmpty(Configuration["CI"])) options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + }); + services.AddMicrosoftIdentityWebAppAuthentication(Configuration); + services.Configure(CookieAuthenticationDefaults.AuthenticationScheme, + options => + { + options.AccessDeniedPath = "/access-denied"; + options.Cookie.Name = ".ManageAnAcademyTransfer.Login"; + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.ExpireTimeSpan = _authenticationExpiration; + options.SlidingExpiration = true; + if (string.IsNullOrEmpty(Configuration["CI"])) options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + }); + services.AddHealthChecks(); + } + + /// + /// Builds Authorization policy + /// Ensure authenticated user and restrict roles if they are provided in configuration + /// + /// AuthorizationPolicyBuilder + private AuthorizationPolicyBuilder SetupAuthorizationPolicyBuilder() + { + var policyBuilder = new AuthorizationPolicyBuilder(); + var allowedRoles = Configuration.GetSection("AzureAd")["AllowedRoles"]; + policyBuilder.RequireAuthenticatedUser(); + if (!string.IsNullOrWhiteSpace(allowedRoles)) + policyBuilder.RequireClaim(ClaimTypes.Role, allowedRoles.Split(',')); + return policyBuilder; + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + app.UseExceptionHandler("/Errors"); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); + } + + app.UseSecurityHeaders( + SecureHeadersDefinitions.SecurityHeadersDefinitions.GetHeaderPolicyCollection(env.IsDevelopment()) + .AddXssProtectionDisabled() + ); + + app.UseStatusCodePagesWithReExecute("/Errors", "?statusCode={0}"); + + if (!string.IsNullOrEmpty(Configuration["CI"])) app.UseHttpsRedirection(); + + //For Azure AD redirect uri to remain https + var forwardOptions = new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.All, + RequireHeaderSymmetry = false + }; + forwardOptions.KnownNetworks.Clear(); + forwardOptions.KnownProxies.Clear(); + app.UseForwardedHeaders(forwardOptions); + + app.UseStaticFiles(); + + app.UseRouting(); + + app.UseSentryTracing(); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseSession(); + + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/", context => + { + context.Response.Redirect("project-type", false); + return Task.CompletedTask; + }); + endpoints.MapRazorPages(); + endpoints.MapControllerRoute("default", "{controller}/{action}/"); + endpoints.MapHealthChecks("/health").WithMetadata(new AllowAnonymousAttribute()); + }); + } + + private static void AddServices(IServiceCollection services, IConfiguration configuration) + { + var tramsApiBase = configuration["TRAMS_API_BASE"]; + var tramsApiKey = configuration["TRAMS_API_KEY"]; + + services.AddScoped(); + + services.AddTransient, TramsSearchResultMapper>(); + services.AddTransient, TramsTrustMapper>(); + services.AddTransient, TramsEstablishmentMapper>(); + services.AddTransient, TramsProjectSummariesMapper>(); + services.AddTransient, TramsProjectMapper>(); + services + .AddTransient, TramsEducationPerformanceMapper>(); + services.AddTransient, InternalProjectToUpdateMapper>(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + services.AddSingleton(new TramsHttpClient(tramsApiBase, tramsApiKey)); + services.AddSingleton(r => new TramsHttpClient(tramsApiBase, tramsApiKey)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddHostedService(); + } } diff --git a/Frontend/appsettings.json b/Frontend/appsettings.json index 96107f0b5..13e398e6e 100644 --- a/Frontend/appsettings.json +++ b/Frontend/appsettings.json @@ -28,5 +28,8 @@ }, "CypressTestSecret": "secret", "AuthenticationExpirationInMinutes": 60, - "BetaFeedbackLink": "https://forms.office.com.mcas.ms/Pages/ResponsePage.aspx?id=yXfS-grGoU2187O4s0qC-d2CvT4DIP1JvSu1_jhCZ1xURE4zWktYMjNBM1RNWFcxRElCSVJYMTREUS4u" + "BetaFeedbackLink": "https://forms.office.com.mcas.ms/Pages/ResponsePage.aspx?id=yXfS-grGoU2187O4s0qC-d2CvT4DIP1JvSu1_jhCZ1xURE4zWktYMjNBM1RNWFcxRElCSVJYMTREUS4u", + "ServiceLink": { + "ConversionsUrl": "" + } }