diff --git a/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ClaimsPrincipalExtensions.cs b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ClaimsPrincipalExtensions.cs index 0a7746098..2ecf6c5b6 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ClaimsPrincipalExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ClaimsPrincipalExtensions.cs @@ -15,7 +15,10 @@ public static class ClaimsPrincipalExtensions private const string IdClaim = "ID"; private const char IdDelimiter = ':'; private const string IdPrefix = "0192"; + private const string AltinnClaimPrefix = "urn:altinn:"; private const string OrgClaim = "urn:altinn:org"; + private const string IdportenAuthLevelClaim = "acr"; + private const string AltinnAuthLevelClaim = "urn:altinn:authlevel"; private const string PidClaim = "pid"; public static bool TryGetClaimValue(this ClaimsPrincipal claimsPrincipal, string claimType, [NotNullWhen(true)] out string? value) @@ -86,13 +89,11 @@ public static bool TryGetOrgNumber(this Claim? consumerClaim, [NotNullWhen(true) public static bool TryGetAuthenticationLevel(this ClaimsPrincipal claimsPrincipal, [NotNullWhen(true)] out int? authenticationLevel) { - string[] claimTypes = ["acr", "urn:altinn:authlevel"]; - - foreach (var claimType in claimTypes) + foreach (var claimType in new[] { IdportenAuthLevelClaim, AltinnAuthLevelClaim }) { if (!claimsPrincipal.TryGetClaimValue(claimType, out var claimValue)) continue; // The acr claim value is "LevelX" where X is the authentication level - var valueToParse = claimType == "acr" ? claimValue[5..] : claimValue; + var valueToParse = claimType == IdportenAuthLevelClaim ? claimValue[5..] : claimValue; if (!int.TryParse(valueToParse, out var level)) continue; authenticationLevel = level; @@ -103,6 +104,16 @@ public static bool TryGetAuthenticationLevel(this ClaimsPrincipal claimsPrincipa return false; } + public static IEnumerable GetIdentifyingClaims(this List claims) => + claims.Where(c => + c.Type == PidClaim || + c.Type == ConsumerClaim || + c.Type == SupplierClaim || + c.Type == OrgClaim || + c.Type == IdportenAuthLevelClaim || + c.Type.StartsWith(AltinnClaimPrefix, StringComparison.Ordinal) + ).OrderBy(c => c.Type); + private static bool TryGetOrgShortName(this ClaimsPrincipal claimsPrincipal, [NotNullWhen(true)] out string? orgShortName) => claimsPrincipal.FindFirst(OrgClaim).TryGetOrgShortName(out orgShortName); diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs index eeb272953..9f4b78648 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs @@ -11,38 +11,47 @@ using Digdir.Domain.Dialogporten.Domain.Parties.Abstractions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; internal sealed class AltinnAuthorizationClient : IAltinnAuthorization { private readonly HttpClient _httpClient; + private readonly IFusionCache _cache; private readonly IUser _user; private readonly IDialogDbContext _db; private readonly ILogger _logger; public AltinnAuthorizationClient( HttpClient client, + IFusionCacheProvider cacheProvider, IUser user, IDialogDbContext db, ILogger logger) { _httpClient = client ?? throw new ArgumentNullException(nameof(client)); + _cache = cacheProvider.GetCache(nameof(Authorization)) ?? throw new ArgumentNullException(nameof(cacheProvider)); _user = user ?? throw new ArgumentNullException(nameof(user)); _db = db ?? throw new ArgumentNullException(nameof(db)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetDialogDetailsAuthorization(DialogEntity dialogEntity, - CancellationToken cancellationToken = default) => - await PerformDialogDetailsAuthorization(new DialogDetailsAuthorizationRequest + CancellationToken cancellationToken = default) + { + var request = new DialogDetailsAuthorizationRequest { Claims = _user.GetPrincipal().Claims.ToList(), ServiceResource = dialogEntity.ServiceResource, DialogId = dialogEntity.Id, Party = dialogEntity.Party, AltinnActions = dialogEntity.GetAltinnActions() - }, cancellationToken); + }; + + return await _cache.GetOrSetAsync(request.GenerateCacheKey(), async token + => await PerformDialogDetailsAuthorization(request, token), token: cancellationToken); + } public async Task GetAuthorizedResourcesForSearch( List constraintParties, @@ -51,12 +60,15 @@ public async Task GetAuthorizedResourcesForSear CancellationToken cancellationToken = default) { var claims = GetOrCreateClaimsBasedOnEndUserId(endUserId); - return await PerformNonScalableDialogSearchAuthorization(new DialogSearchAuthorizationRequest + var request = new DialogSearchAuthorizationRequest { Claims = claims, ConstraintParties = constraintParties, ConstraintServiceResources = serviceResources - }, cancellationToken); + }; + + return await _cache.GetOrSetAsync(request.GenerateCacheKey(), async token + => await PerformNonScalableDialogSearchAuthorization(request, token), token: cancellationToken); } private async Task PerformNonScalableDialogSearchAuthorization(DialogSearchAuthorizationRequest request, CancellationToken cancellationToken) diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogDetailsAuthorizationRequest.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogDetailsAuthorizationRequest.cs index 845eda79a..ea554f88d 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogDetailsAuthorizationRequest.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogDetailsAuthorizationRequest.cs @@ -1,4 +1,7 @@ using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Digdir.Domain.Dialogporten.Application.Common.Extensions; using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; @@ -14,3 +17,22 @@ public sealed class DialogDetailsAuthorizationRequest // eg. "urn:altinn:subresource:some-sub-resource" or "urn:altinn:task:task_1" public required HashSet AltinnActions { get; init; } } + +public static class DialogDetailsAuthorizationRequestExtensions +{ + public static string GenerateCacheKey(this DialogDetailsAuthorizationRequest request) + { + var claimsKey = string.Join(";", request.Claims.GetIdentifyingClaims() + .Select(c => $"{c.Type}:{c.Value}")); + + var actionsKey = string.Join(";", request.AltinnActions.OrderBy(a => a.Name) + .Select(a => $"{a.Name}:{a.AuthorizationAttribute}")); + + var rawKey = $"{request.DialogId}||{claimsKey}|{actionsKey}"; + + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawKey)); + var hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + + return $"auth:details:{hashString}"; + } +} diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogSearchAuthorizationRequest.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogSearchAuthorizationRequest.cs index 0102fba24..6e9f555ee 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogSearchAuthorizationRequest.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogSearchAuthorizationRequest.cs @@ -1,4 +1,7 @@ using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Digdir.Domain.Dialogporten.Application.Common.Extensions; namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; @@ -8,3 +11,22 @@ public sealed class DialogSearchAuthorizationRequest public List ConstraintParties { get; set; } = []; public List ConstraintServiceResources { get; set; } = []; } + +public static class DialogSearchAuthorizationRequestExtensions +{ + public static string GenerateCacheKey(this DialogSearchAuthorizationRequest request) + { + var claimsKey = string.Join(";", request.Claims.GetIdentifyingClaims() + .Select(c => $"{c.Type}:{c.Value}")); + + var partiesKey = string.Join(";", request.ConstraintParties.OrderBy(p => p)); + var resourcesKey = string.Join(";", request.ConstraintServiceResources.OrderBy(r => r)); + + var rawKey = $"{claimsKey}|{partiesKey}|{resourcesKey}"; + + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawKey)); + var hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + + return $"auth:search:{hashString}"; + } +} diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs index 0fa59ac00..73fc4717d 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs @@ -83,6 +83,29 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi .ConfigureFusionCache(nameof(Altinn.OrganizationRegistry), new() { Duration = TimeSpan.FromDays(1) + }) + .ConfigureFusionCache(nameof(Altinn.Authorization), new() + { + // This cache has high cardinality, and will create several keys per user: + // - One entry per user per dialog search and combination of party and service resource parameters + // - One entry per dialog details + // EU systems iterating over several parties in search view and fetching details for each dialog will + // potentially create hundreds over even thousands of cache entries within the cache TTL. To avoid + // memory exhaustion, we therefore disable the memory cache for authorization results, and rely solely on + // the distributed cache. + SkipMemoryCache = true, + // In normal operations, 15 minutes delay is deemed acceptable for authorization data + Duration = TimeSpan.FromMinutes(15), + // In case Altinn Authorization is down/overloaded, we allow the re-usage of stale authorization data + // for an additional 15 minutes. Using default FailSafeThrottleDuration. + FailSafeMaxDuration = TimeSpan.FromMinutes(30), + // If the request to Altinn Authorization takes too long, we allow the cache to return stale data + // temporarily whilst updating the cache in the background. Note that we are also using eager refresh + // and a backplane. + FactorySoftTimeout = TimeSpan.FromSeconds(2), + // Timeout for the cache to wait for the factory to complete, which when reached without fail safe data + // will cause an exception to be thrown + FactoryHardTimeout = TimeSpan.FromSeconds(10), }); services.AddDbContext((services, options) => @@ -145,7 +168,6 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi client.BaseAddress = altinnSettings.BaseUri; client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", altinnSettings.SubscriptionKey); }) - // TODO! Add cache policy based on request body .AddPolicyHandlerFromRegistry(PollyPolicy.DefaultHttpRetryPolicy); if (environment.IsDevelopment()) @@ -173,6 +195,7 @@ public class FusionCacheSettings public bool IsFailSafeEnabled { get; set; } = true; public TimeSpan JitterMaxDuration { get; set; } = TimeSpan.FromSeconds(2); public float EagerRefreshThreshold { get; set; } = 0.8f; + public bool SkipMemoryCache { get; set; } } private static IHttpClientBuilder AddMaskinportenHttpClient( @@ -216,7 +239,9 @@ private static IServiceCollection ConfigureFusionCache(this IServiceCollection s AllowBackgroundDistributedCacheOperations = settings.AllowBackgroundDistributedCacheOperations, JitterMaxDuration = settings.JitterMaxDuration, - EagerRefreshThreshold = settings.EagerRefreshThreshold + EagerRefreshThreshold = settings.EagerRefreshThreshold, + + SkipMemoryCache = settings.SkipMemoryCache, }) .WithRegisteredSerializer() .WithRegisteredDistributedCache()