Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add authorization caching #591

Merged
merged 3 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -103,6 +104,16 @@ public static bool TryGetAuthenticationLevel(this ClaimsPrincipal claimsPrincipa
return false;
}

public static IEnumerable<Claim> GetIdentifyingClaims(this List<Claim> 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AltinnAuthorizationClient> 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<DialogDetailsAuthorizationResult> 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<DialogSearchAuthorizationResult> GetAuthorizedResourcesForSearch(
List<string> constraintParties,
Expand All @@ -51,12 +60,15 @@ public async Task<DialogSearchAuthorizationResult> 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<DialogSearchAuthorizationResult> PerformNonScalableDialogSearchAuthorization(DialogSearchAuthorizationRequest request, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,3 +17,22 @@ public sealed class DialogDetailsAuthorizationRequest
// eg. "urn:altinn:subresource:some-sub-resource" or "urn:altinn:task:task_1"
public required HashSet<AltinnAction> 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}";
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -8,3 +11,22 @@ public sealed class DialogSearchAuthorizationRequest
public List<string> ConstraintParties { get; set; } = [];
public List<string> 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}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<DialogDbContext>((services, options) =>
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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<TClient, TImplementation, TClientDefinition>(
Expand Down Expand Up @@ -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()
Expand Down