Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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 memoryCache)
Comment thread
pata9 marked this conversation as resolved.
Outdated
: 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 memoryCache.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
56 changes: 24 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,12 @@ public class PermissionChecker(IUserService userService, IRolesService rolesServ
private const string SiteScopePrefix = $"{SiteScope}:";
private const string IcbScope = "icb";
private const string IcbScopePrefix = $"{IcbScope}:";

private readonly string _rolesCacheKey = "roles";
private readonly TimeSpan _rolesCacheDuration = TimeSpan.FromMinutes(5);

private string UserRolesCacheKey(string userId) => $"user_roles_{userId}";
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 +175,26 @@ 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;
var tryPatternOptions = new TryPatternOptions<IEnumerable<RoleAssignment>>(true, []);

return await cacheService.GetCacheValue(
UserRolesCacheKey(userId),
new CacheOptions<IEnumerable<RoleAssignment>>(
() => userService.GetUserRoleAssignments(userId),
_userRolesCacheDuration,
tryPatternOptions));
}

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;
var tryPatternOptions = new TryPatternOptions<IEnumerable<Role>>(true, []);

return await cacheService.GetCacheValue(
_rolesCacheKey,
new CacheOptions<IEnumerable<Role>>(
rolesService.GetRoles,
_rolesCacheDuration,
tryPatternOptions));
}

private async Task<IEnumerable<RoleAssignmentScope>> GetScopesWithPermissionsAsync(string userId, string permission)
Expand Down
39 changes: 28 additions & 11 deletions src/api/Nhs.Appointments.Core/Caching/CacheService.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
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 record CacheOptions<T>(Func<Task<T>> UpdateOperation, TimeSpan AbsoluteExpiration, TryPatternOptions<T> TryPatternOptions = null);

public record TryPatternOptions<T>(bool UseTryPattern = true, T DefaultResponse = default);

public interface ICacheService
{
Expand All @@ -13,7 +14,7 @@ public interface ICacheService
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();

Expand Down Expand Up @@ -41,7 +42,7 @@ public async Task<T> GetLazySlidingCacheValue<T>(string cacheKey, LazySlideCache

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 +63,7 @@ public async Task<T> GetLazySlidingCacheValue<T>(string cacheKey, LazySlideCache
}

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,17 +79,33 @@ public async Task<T> GetLazySlidingCacheValue<T>(string cacheKey, LazySlideCache

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)
{
return cacheObj.Value;
}
}

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

T newValue;
if (options.TryPatternOptions?.UseTryPattern ?? false)
{
var tryResult = await TryPattern.TryAsync(options.UpdateOperation);

if (!tryResult.Completed)
{
return options.TryPatternOptions.DefaultResponse;
}

newValue = tryResult.Result;
}
else
{
newValue = await options.UpdateOperation();
}

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

Expand All @@ -97,7 +114,7 @@ private async Task SlideCache<T>(string lazySlideCacheKey, LazySlideCacheOptions
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,7 +125,7 @@ private async Task SlideCache<T>(string lazySlideCacheKey, LazySlideCacheOptions

//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));
}

Expand Down
9 changes: 9 additions & 0 deletions src/api/Nhs.Appointments.Core/Caching/ICacheStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Nhs.Appointments.Core.Caching;

public interface ICacheStore
{
Task<T> GetAsync<T>(string key);
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 absoluteExpirationRelativeToNow);
}
11 changes: 11 additions & 0 deletions src/api/Nhs.Appointments.Core/Caching/InMemoryCacheStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Microsoft.Extensions.Caching.Memory;

namespace Nhs.Appointments.Core.Caching;

public class InMemoryCacheStore(IMemoryCache memoryCache) : ICacheStore
{
public Task<T> GetAsync<T>(string key) => Task.FromResult(memoryCache.Get<T>(key));
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 absoluteExpirationRelativeToNow) => Task.FromResult(memoryCache.Set(key, value, absoluteExpirationRelativeToNow));
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
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 const string ClinicalServiceCacheKey = "clinical-service";
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(60);

public async Task<IEnumerable<ClinicalServiceType>> Get()
{
var clinicalServices = await store.Get();
Expand All @@ -19,14 +22,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(
ClinicalServiceCacheKey,
new CacheOptions<IEnumerable<ClinicalServiceType>>(
Get,
_cacheDuration));
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
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 const string CacheKey = "notification_configuration";
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(60);

public async Task<NotificationConfiguration> GetNotificationConfigurationsAsync(string eventType)
{
var config = await LoadNotificationConfiguration();
Expand Down Expand Up @@ -35,14 +38,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,
new CacheOptions<IEnumerable<NotificationConfiguration>>(
notificationConfigurationStore.GetNotificationConfiguration,
_cacheDuration));
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public static IServiceCollection AddServices(this IServiceCollection services)
.AddTransient<INotificationConfigurationService, NotificationConfigurationService>()
.AddTransient<ISiteReportService, SiteReportService>()
.AddScoped<ISiteService, SiteService>()
.AddTransient<ICacheStore, InMemoryCacheStore>()
.AddSingleton<ICacheService, CacheService>();

return services;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.IdentityModel.Tokens;
using Moq;
using Nhs.Appointments.Api.Auth;
using Nhs.Appointments.Core.Caching;

namespace Nhs.Appointments.Api.Tests.Auth;

Expand All @@ -14,7 +15,7 @@ public class JwksRetrieverTests

public JwksRetrieverTests()
{
_sut = new JwksRetriever(_httpClientFactory.Object, _memoryCache.Object);
_sut = new JwksRetriever(_httpClientFactory.Object, new CacheService(new InMemoryCacheStore(_memoryCache.Object), TimeProvider.System));
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Extensions.Caching.Memory;
using Moq;
using Nhs.Appointments.Api.Auth;
using Nhs.Appointments.Core.Caching;
using Nhs.Appointments.Core.Sites;
using Nhs.Appointments.Core.Users;

Expand All @@ -23,7 +24,7 @@ public PermissionCheckerTests()
_sut = new PermissionChecker(
_userAssignmentService.Object,
_roleService.Object,
_cache.Object,
new CacheService(new InMemoryCacheStore(_cache.Object), TimeProvider.System),
_siteService.Object);

_cache.Setup(x => x.CreateEntry(It.IsAny<string>())).Returns(_cacheEntry.Object);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public static IServiceCollection AddDependenciesNotUnderTest(this IServiceCollec
.AddSingleton<IAvailabilityStore, AvailabilityDocumentStore>()
.AddSingleton<INotificationConfigurationStore, NotificationConfigurationStore>()
.AddSingleton<ISiteService, SiteService>()
.AddTransient<ICacheStore, InMemoryCacheStore>()
.AddSingleton<ICacheService, CacheService>()
.AddSingleton<INotificationConfigurationService, NotificationConfigurationService>();

Expand Down
Loading
Loading