Skip to content

Commit

Permalink
Add authorization caching
Browse files Browse the repository at this point in the history
  • Loading branch information
elsand committed Apr 4, 2024
1 parent bbf5c16 commit b3de5eb
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 14 deletions.
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 Expand Up @@ -121,7 +133,7 @@ private List<Claim> GetOrCreateClaimsBasedOnEndUserId(string? endUserId)
return claims;
}

private static readonly JsonSerializerOptions _serializerOptions = new()
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault
Expand All @@ -130,7 +142,7 @@ private List<Claim> GetOrCreateClaimsBasedOnEndUserId(string? endUserId)
private async Task<XacmlJsonResponse?> SendRequest(XacmlJsonRequestRoot xacmlJsonRequest, CancellationToken cancellationToken)
{
const string apiUrl = "authorization/api/v1/authorize";
var requestJson = JsonSerializer.Serialize(xacmlJsonRequest, _serializerOptions);
var requestJson = JsonSerializer.Serialize(xacmlJsonRequest, SerializerOptions);
_logger.LogDebug("Generated XACML request: {RequestJson}", requestJson);
var httpContent = new StringContent(requestJson, Encoding.UTF8, "application/json");

Expand All @@ -147,6 +159,6 @@ private List<Claim> GetOrCreateClaimsBasedOnEndUserId(string? endUserId)
}

var responseData = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<XacmlJsonResponse>(responseData, _serializerOptions);
return JsonSerializer.Deserialize<XacmlJsonResponse>(responseData, SerializerOptions);
}
}
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 @@ -89,6 +89,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 @@ -151,7 +174,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 @@ -179,6 +201,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 @@ -222,7 +245,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

0 comments on commit b3de5eb

Please sign in to comment.