Skip to content
Open
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
42 changes: 18 additions & 24 deletions src/api/Nhs.Appointments.Api/Auth/JwksRetriever.cs
Original file line number Diff line number Diff line change
@@ -1,46 +1,40 @@
using IdentityModel;
using IdentityModel.Client;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Nhs.Appointments.Core.Caching;

namespace Nhs.Appointments.Api.Auth;

public class JwksRetriever : IJwksRetriever
public class JwksRetriever(IHttpClientFactory httpClientFactory, ICacheService cacheService)
: IJwksRetriever
{
private readonly IMemoryCache _memoryCache;
private readonly IHttpClientFactory _httpClientFactory;

public JwksRetriever(IHttpClientFactory httpClientFactory, IMemoryCache memoryCache)
public async Task<IEnumerable<SecurityKey>> GetKeys(string jwksEndpoint)
{
_httpClientFactory = httpClientFactory;
_memoryCache = memoryCache;
return await cacheService.GetCacheValue(
jwksEndpoint,
new CacheOptions<IEnumerable<SecurityKey>>(
async () => await GetSecurityKeys(jwksEndpoint),
TimeSpan.FromHours(1)));
}

public async Task<IEnumerable<SecurityKey>> GetKeys(string jwksEndpoint)
private async Task<IEnumerable<SecurityKey>> GetSecurityKeys(string jwksEndpoint)
{
var client = _httpClientFactory.CreateClient();

var result = _memoryCache.Get<IEnumerable<SecurityKey>>(jwksEndpoint);
if (result == null)
try
{
var client = httpClientFactory.CreateClient();
var keys = await client.GetJsonWebKeySetAsync(jwksEndpoint);
return keys.KeySet.Keys.ToList().ConvertAll(ToSecurityKey);
}
catch (Exception ex)
{
try
{
var jwksResponse = await client.GetJsonWebKeySetAsync(jwksEndpoint);
result = jwksResponse.KeySet.Keys.ToList().ConvertAll(ToSecurityKey);
_memoryCache.Set(jwksEndpoint, result, DateTimeOffset.UtcNow.AddHours(1));
}
catch (Exception ex)
{
throw new SecurityTokenException("Unable to retrieve jwks from configured endpoint", ex);
}
throw new SecurityTokenException("Unable to retrieve jwks from configured endpoint", ex);
}
return result;
}

private static SecurityKey ToSecurityKey(IdentityModel.Jwk.JsonWebKey webKey)
Expand Down
49 changes: 17 additions & 32 deletions src/api/Nhs.Appointments.Api/Auth/PermissionChecker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Nhs.Appointments.Core;
using Nhs.Appointments.Core.Caching;
using Nhs.Appointments.Core.Sites;
using Nhs.Appointments.Core.Users;

namespace Nhs.Appointments.Api.Auth;

public class PermissionChecker(IUserService userService, IRolesService rolesService, IMemoryCache cache, ISiteService siteService)
public class PermissionChecker(IUserService userService, IRolesService rolesService, ICacheService cacheService, ISiteService siteService)
: IPermissionChecker
{
private const string GlobalScope = "global";
Expand All @@ -19,6 +18,9 @@ public class PermissionChecker(IUserService userService, IRolesService rolesServ
private const string SiteScopePrefix = $"{SiteScope}:";
private const string IcbScope = "icb";
private const string IcbScopePrefix = $"{IcbScope}:";

private readonly TimeSpan _rolesCacheDuration = TimeSpan.FromMinutes(5);
private readonly TimeSpan _userRolesCacheDuration = TimeSpan.FromSeconds(20);

public async Task<bool> HasPermissionAsync(string userId, IEnumerable<string> siteIds, string requiredPermission)
{
Expand Down Expand Up @@ -170,39 +172,22 @@ private static bool ScopeAssignedToSite(RoleAssignmentScope scope, Site site)

private async Task<IEnumerable<RoleAssignment>> GetUserRoleAssignmentsAsync(string userId)
{
var cacheKey = $"user_roles_{userId}";
if (cache.TryGetValue(cacheKey, out List<RoleAssignment> cachedRoleAssignments))
{
return cachedRoleAssignments;
}

var userRoleAssignmentsOp = await TryPattern.TryAsync(() => userService.GetUserRoleAssignments(userId));
if (userRoleAssignmentsOp.Completed == false)
{
return [];
}

var userRoleAssignments = userRoleAssignmentsOp.Result.ToList();
cache.Set(cacheKey, userRoleAssignments, TimeSpan.FromSeconds(20));
return userRoleAssignments;
return await cacheService.GetCacheValueWithDefault(
CacheKey.UserRolesCacheKey(userId),
new CacheOptions<IEnumerable<RoleAssignment>>(
() => userService.GetUserRoleAssignments(userId),
_userRolesCacheDuration),
[]);
}

private async Task<IEnumerable<Role>> GetRolesAsync()
{
if (cache.TryGetValue("roles", out List<Role> cachedRoles))
{
return cachedRoles;
}

var rolesOp = await TryPattern.TryAsync(rolesService.GetRoles);
if (rolesOp.Completed == false)
{
return [];
}

var roles = rolesOp.Result.ToList();
cache.Set("roles", roles, TimeSpan.FromMinutes(5));
return roles;
return await cacheService.GetCacheValueWithDefault(
CacheKey.RolesCacheKey,
new CacheOptions<IEnumerable<Role>>(
rolesService.GetRoles,
_rolesCacheDuration),
[]);
}

private async Task<IEnumerable<RoleAssignmentScope>> GetScopesWithPermissionsAsync(string userId, string permission)
Expand Down
12 changes: 12 additions & 0 deletions src/api/Nhs.Appointments.Core/Caching/CacheKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Nhs.Appointments.Core.Caching;

public static class CacheKey
{
public const string ClinicalService = "clinical-service";
public const string RolesCacheKey = "roles";
public const string NotificationConfiguration = "notification_configuration";
public static string LazySlideCacheKey(string cacheKey) => $"LazySlide:{cacheKey}";
public static string UserRolesCacheKey(string userId) => $"user_roles_{userId}";
public static string GetCacheSiteServiceSupportDateRangeKey(string siteId, List<string> services, DateOnly from,
DateOnly until) => $"site_{siteId}_supports_{string.Join('_', services)}_in_{from:yyyyMMdd}_{until:yyyyMMdd}";
}
4 changes: 4 additions & 0 deletions src/api/Nhs.Appointments.Core/Caching/CacheObject.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace Nhs.Appointments.Core.Caching;

internal record LazySlideCacheObject(object Value, DateTimeOffset DateTimeUpdated);
internal record CacheObject<T>(T Value);
4 changes: 4 additions & 0 deletions src/api/Nhs.Appointments.Core/Caching/CacheOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace Nhs.Appointments.Core.Caching;

public record LazySlideCacheOptions<T>(Func<Task<T>> UpdateOperation, TimeSpan AbsoluteExpiration, TimeSpan SlideThreshold);
public record CacheOptions<T>(Func<Task<T>> UpdateOperation, TimeSpan AbsoluteExpiration);
55 changes: 31 additions & 24 deletions src/api/Nhs.Appointments.Core/Caching/CacheService.cs
Original file line number Diff line number Diff line change
@@ -1,28 +1,15 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Memory;

namespace Nhs.Appointments.Core.Caching;

public record LazySlideCacheOptions<T>(Func<Task<T>> UpdateOperation, TimeSpan SlideThreshold, TimeSpan AbsoluteExpiration);
public record CacheOptions<T>(Func<Task<T>> UpdateOperation, TimeSpan AbsoluteExpiration);

public interface ICacheService
{
Task<T> GetLazySlidingCacheValue<T>(string cacheKey, LazySlideCacheOptions<T> options);

Task<T> GetCacheValue<T>(string cacheKey, CacheOptions<T> options);
}

public class CacheService(IMemoryCache memoryCache, TimeProvider timeProvider) : ICacheService
public class CacheService(ICacheStore cacheStore, TimeProvider timeProvider) : ICacheService
{
private static readonly ConcurrentDictionary<string, SemaphoreSlim> LazySlidingCacheLocks = new();

internal static string LazySlideCacheKey(string cacheKey) => $"LazySlide:{cacheKey}";

public async Task<T> GetLazySlidingCacheValue<T>(string cacheKey, LazySlideCacheOptions<T> options)
{
//intentionally prefix cache key indicating that it is a lazy sliding value
var lazySlideCacheKey = LazySlideCacheKey(cacheKey);
var lazySlideCacheKey = CacheKey.LazySlideCacheKey(cacheKey);

if (options.AbsoluteExpiration <= options.SlideThreshold)
{
Expand All @@ -41,7 +28,7 @@

try
{
if (memoryCache.TryGetValue<LazySlideCacheObject>(lazySlideCacheKey, out var lazySlideCacheObject))
if (await cacheStore.TryGetAsync<LazySlideCacheObject>(lazySlideCacheKey, out var lazySlideCacheObject))
{
ArgumentNullException.ThrowIfNull(lazySlideCacheObject);

Expand All @@ -62,7 +49,7 @@
}

var value = await options.UpdateOperation();
memoryCache.Set(lazySlideCacheKey, new LazySlideCacheObject(value, utcNow),
await cacheStore.SetAsync(lazySlideCacheKey, new LazySlideCacheObject(value, utcNow),
utcNow.Add(options.AbsoluteExpiration));
return value;
}
Expand All @@ -78,7 +65,7 @@

public async Task<T> GetCacheValue<T>(string cacheKey, CacheOptions<T> options)
{
if (memoryCache.TryGetValue<CacheObject<T>>(cacheKey, out var cacheObj))
if (await cacheStore.TryGetAsync<CacheObject<T>>(cacheKey, out var cacheObj))
{
ArgumentNullException.ThrowIfNull(cacheObj);
if (cacheObj.Value != null)
Expand All @@ -88,16 +75,39 @@
}

var newValue = await options.UpdateOperation();
memoryCache.Set(cacheKey, new CacheObject<T>(newValue), options.AbsoluteExpiration);

await cacheStore.SetAsync(cacheKey, new CacheObject<T>(newValue), options.AbsoluteExpiration);
return newValue;
}

public async Task<T> GetCacheValueWithDefault<T>(string cacheKey, CacheOptions<T> options, T defaultValue)
{
if (await cacheStore.TryGetAsync<CacheObject<T>>(cacheKey, out var cacheObj))
{
ArgumentNullException.ThrowIfNull(cacheObj);
if (cacheObj.Value != null)

Check warning on line 88 in src/api/Nhs.Appointments.Core/Caching/CacheService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a comparison to 'default(T)' instead or add a constraint to 'T' so that it can't be a value type.

See more on https://sonarcloud.io/project/issues?id=NHSDigital_nbs-appointments-management-service&issues=AZ2VzFMvmHgo7qzUHmza&open=AZ2VzFMvmHgo7qzUHmza&pullRequest=1563
{
return cacheObj.Value;
}
}

var tryResult = await TryPattern.TryAsync(options.UpdateOperation);

if (!tryResult.Completed)
{
return defaultValue;
}

await cacheStore.SetAsync(cacheKey, new CacheObject<T>(tryResult.Result), options.AbsoluteExpiration);
return tryResult.Result;
}

private async Task SlideCache<T>(string lazySlideCacheKey, LazySlideCacheOptions<T> options, SemaphoreSlim lazySlideCacheLock, T lazyValue, DateTimeOffset dateTime)
{
try
{
//update the cache datetime prematurely so that concurrent waiting threads do not trigger their own slide operation
memoryCache.Set(lazySlideCacheKey, new LazySlideCacheObject(lazyValue, dateTime),
await cacheStore.SetAsync(lazySlideCacheKey, new LazySlideCacheObject(lazyValue, dateTime),
dateTime.Add(options.AbsoluteExpiration));
}
finally
Expand All @@ -108,10 +118,7 @@

//then update the actual value now that no locks are being held
var value = await options.UpdateOperation();
memoryCache.Set(lazySlideCacheKey, new LazySlideCacheObject(value, dateTime),
await cacheStore.SetAsync(lazySlideCacheKey, new LazySlideCacheObject(value, dateTime),
dateTime.Add(options.AbsoluteExpiration));
}

internal record LazySlideCacheObject(object Value, DateTimeOffset DateTimeUpdated);
internal record CacheObject<T>(T Value);
}
10 changes: 10 additions & 0 deletions src/api/Nhs.Appointments.Core/Caching/ICacheService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Nhs.Appointments.Core.Caching;

public interface ICacheService
{
Task<T> GetLazySlidingCacheValue<T>(string cacheKey, LazySlideCacheOptions<T> options);

Task<T> GetCacheValue<T>(string cacheKey, CacheOptions<T> options);

Task<T> GetCacheValueWithDefault<T>(string cacheKey, CacheOptions<T> options, T defaultValue);
}
8 changes: 8 additions & 0 deletions src/api/Nhs.Appointments.Core/Caching/ICacheStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Nhs.Appointments.Core.Caching;

public interface ICacheStore
{
Task<bool> TryGetAsync<T>(string key, out T value);
Task SetAsync<T>(string key, T value, DateTimeOffset absoluteExpiration);
Task SetAsync<T>(string key, T value, TimeSpan expirationRelativeToNow);
}
10 changes: 10 additions & 0 deletions src/api/Nhs.Appointments.Core/Caching/InMemoryCacheStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Microsoft.Extensions.Caching.Memory;

namespace Nhs.Appointments.Core.Caching;

public class InMemoryCacheStore(IMemoryCache memoryCache) : ICacheStore
{
public Task<bool> TryGetAsync<T>(string key, out T value) => Task.FromResult(memoryCache.TryGetValue(key, out value));
public Task SetAsync<T>(string key, T value, DateTimeOffset absoluteExpiration) => Task.FromResult(memoryCache.Set(key, value, absoluteExpiration));
public Task SetAsync<T>(string key, T value, TimeSpan expirationRelativeToNow) => Task.FromResult(memoryCache.Set(key, value, expirationRelativeToNow));
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using Microsoft.Extensions.Caching.Memory;
using Nhs.Appointments.Core.Caching;

namespace Nhs.Appointments.Core.ClinicalServices;
public class ClinicalServiceProvider(IClinicalServiceStore store, IMemoryCache memoryCache) : IClinicalServiceProvider
public class ClinicalServiceProvider(IClinicalServiceStore store, ICacheService cacheService) : IClinicalServiceProvider
{
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(60);

public async Task<IEnumerable<ClinicalServiceType>> Get()
{
var clinicalServices = await store.Get();
Expand All @@ -19,14 +21,10 @@ public async Task<ClinicalServiceType> Get(string service)

public async Task<IEnumerable<ClinicalServiceType>> GetFromCache()
{
var cacheKey = "clinical-service";
var clinicalServices = memoryCache.Get<IEnumerable<ClinicalServiceType>>(cacheKey);
if (clinicalServices == null)
{
clinicalServices = await Get();
memoryCache.Set(cacheKey, clinicalServices, DateTimeOffset.UtcNow.AddMinutes(60));
}

return clinicalServices;
return await cacheService.GetCacheValue(
CacheKey.ClinicalService,
new CacheOptions<IEnumerable<ClinicalServiceType>>(
Get,
_cacheDuration));
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using Microsoft.Extensions.Caching.Memory;
using Nhs.Appointments.Core.Bookings;
using Nhs.Appointments.Core.Caching;

namespace Nhs.Appointments.Core.Messaging;

public class NotificationConfigurationService(IMemoryCache memoryCache, INotificationConfigurationStore notificationConfigurationStore) : INotificationConfigurationService
public class NotificationConfigurationService(ICacheService cacheService, INotificationConfigurationStore notificationConfigurationStore) : INotificationConfigurationService
{
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(60);

public async Task<NotificationConfiguration> GetNotificationConfigurationsAsync(string eventType)
{
var config = await LoadNotificationConfiguration();
Expand Down Expand Up @@ -35,14 +37,11 @@ public async Task<NotificationConfiguration> GetNotificationConfigurationsAsync(

private async Task<IEnumerable<NotificationConfiguration>> LoadNotificationConfiguration()
{
const string cacheKey = "notification_configuration";
var notificationConfiguration = memoryCache.Get<IEnumerable<NotificationConfiguration>>(cacheKey);
if(notificationConfiguration == null)
{
notificationConfiguration = await notificationConfigurationStore.GetNotificationConfiguration();
memoryCache.Set(cacheKey, notificationConfiguration, DateTimeOffset.UtcNow.AddMinutes(60));
}
return notificationConfiguration;
return await cacheService.GetCacheValue(
CacheKey.NotificationConfiguration,
new CacheOptions<IEnumerable<NotificationConfiguration>>(
notificationConfigurationStore.GetNotificationConfiguration,
_cacheDuration));
}
}

3 changes: 3 additions & 0 deletions src/api/Nhs.Appointments.Core/Nhs.Appointments.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>Nhs.Appointments.Core.UnitTests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>Nhs.Appointments.Api.UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

</Project>
Loading