diff --git a/src/Particular.LicensingComponent.Contracts/LicensedEndpointDetails.cs b/src/Particular.LicensingComponent.Contracts/LicensedEndpointDetails.cs index c54c9611e7..7ecd1b3490 100644 --- a/src/Particular.LicensingComponent.Contracts/LicensedEndpointDetails.cs +++ b/src/Particular.LicensingComponent.Contracts/LicensedEndpointDetails.cs @@ -6,28 +6,51 @@ public class LicensedEndpointDetails public LicensedEndpoint[] Endpoints { get; set; } = []; public QueueIdentity[] InfrastructureQueues { get; set; } = []; public QueueIdentity[] ExcludedQueues { get; set; } = []; + public QueueIdentity[] UnassignedQueues { get; set; } = []; public string? ServiceEndDate { get; set; } public Product[] Products { get; set; } = []; public bool ValidId { get; set; } } + public class LicensedEndpointDetailsAmendment + { + public LicensedEndpointAmendment[] Endpoints { get; set; } = []; + public string[] RemovedEndpointIds { get; set; } = []; + public QueueIdentity[] InfrastructureQueues { get; set; } = []; + public QueueIdentity[] ExcludedQueues { get; set; } = []; + public QueueIdentity[] UnassignedQueues { get; set; } = []; + public QueueIdentity[] UnmappedQueues { get; set; } = []; + + } + public class Product { public string? ProductCode { get; set; } public int? MonthlyThroughput { get; set; } } - public class QueueIdentity + public record QueueIdentity { public string? NameHash { get; set; } public string? ScopeHash { get; set; } + public string? OriginalLocation { get; set; } } - public class LicensedEndpoint + public record LicensedEndpoint { + public string? ClientId { get; set; } public string? Name { get; set; } public int? Classification { get; set; } public string? EndpointSize { get; set; } + public QueueIdentity[] Queues { get; set; } = []; } + + public class LicensedEndpointAmendment + { + public string? ClientId { get; set; } + public string? Name { get; set; } + public int? Classification { get; set; } + public QueueIdentity[] AmendedQueues { get; set; } = []; + } } diff --git a/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs b/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs index 257eef677a..8ed4679bbe 100644 --- a/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs +++ b/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs @@ -16,6 +16,7 @@ public class InMemoryLicensingDataStore : ILicensingDataStore AuditServiceMetadata auditServiceMetadata = new([], []); List reportMasks = []; LicensedEndpointDetails? endpointDetails = null; + LicensedEndpointDetailsAmendment? endpointDetailsAmendment = null; public Task> GetAllEndpoints(bool includePlatformEndpoints, CancellationToken cancellationToken = default) { @@ -175,11 +176,19 @@ public Task SaveReportMasks(List reportMasks, CancellationToken cancella return Task.CompletedTask; } - public Task GetLicensedEndpointDetails(CancellationToken cancellationToken = default) => Task.FromResult(endpointDetails); + public Task GetLicensedEndpointDetails(CancellationToken cancellationToken = default) => Task.FromResult(endpointDetails?.Amend(endpointDetailsAmendment)); public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken = default) { endpointDetails = result; + endpointDetailsAmendment = new LicensedEndpointDetailsAmendment(); + return Task.CompletedTask; + } + + public Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAmendment result, CancellationToken cancellationToken = default) + { + result.UnmappedQueues = [.. endpointDetailsAmendment?.UnmappedQueues ?? [], .. result.UnmappedQueues]; + endpointDetailsAmendment = result; return Task.CompletedTask; } diff --git a/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs b/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs index 9dc65e1caf..dab80b5fc4 100644 --- a/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs +++ b/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs @@ -41,4 +41,5 @@ Task RecordEndpointThroughput(string endpointName, ThroughputSource throughputSo Task GetLicensedEndpointDetails(CancellationToken cancellationToken = default); Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken = default); + Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAmendment result, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Particular.LicensingComponent.Persistence/LicenseMetadata.cs b/src/Particular.LicensingComponent.Persistence/LicenseMetadata.cs new file mode 100644 index 0000000000..60079668bb --- /dev/null +++ b/src/Particular.LicensingComponent.Persistence/LicenseMetadata.cs @@ -0,0 +1,68 @@ +namespace Particular.LicensingComponent.Persistence +{ + using System; + using Particular.LicensingComponent.Contracts; + + public static class LicenseMetadata + { + extension(LicensedEndpointDetails baseDetails) + { + public LicensedEndpointDetails Amend(LicensedEndpointDetailsAmendment? amendments) + { + if (amendments is null) + { + return baseDetails; + } + + //Note: baseDetails.UnassignedQueues will always be empty before amendments + baseDetails.UnassignedQueues = amendments.UnassignedQueues; + baseDetails.ExcludedQueues = [.. baseDetails.ExcludedQueues.Except( + [ + .. amendments.UnassignedQueues, + .. amendments.UnmappedQueues, + .. amendments.InfrastructureQueues, + .. amendments.Endpoints.SelectMany(queue => queue.AmendedQueues)]), + .. amendments.ExcludedQueues + ]; + baseDetails.InfrastructureQueues = [.. baseDetails.InfrastructureQueues.Except( + [ + .. amendments.UnassignedQueues, + .. amendments.UnmappedQueues, + .. amendments.ExcludedQueues, + .. amendments.Endpoints.SelectMany(queue => queue.AmendedQueues)]), + .. amendments.InfrastructureQueues + ]; + baseDetails.Endpoints = [ + .. baseDetails.Endpoints.Where(endpoint => !amendments.RemovedEndpointIds.Contains(endpoint.ClientId)) + .Select(endpoint => + { + var amendment = amendments.Endpoints.FirstOrDefault(amendment => amendment.ClientId == endpoint.ClientId); + return endpoint with + { + Name = amendment?.Name ?? endpoint.Name, + Classification = amendment?.Classification ?? endpoint.Classification, + Queues = [.. endpoint.Queues.Except( + [ + .. amendments.UnassignedQueues, + .. amendments.UnmappedQueues, + .. amendments.ExcludedQueues, + .. amendments.InfrastructureQueues, + .. amendments.Endpoints.SelectMany(queue => queue.AmendedQueues)]), + .. amendment?.AmendedQueues ?? [] + ] + }; + }), + .. amendments.Endpoints.Where(endpoint => !baseDetails.Endpoints.Any(baseEndpoint => baseEndpoint.ClientId == endpoint.ClientId)) + .Select(endpoint => new LicensedEndpoint + { + ClientId = endpoint.ClientId, + Name = endpoint.Name, + Classification = endpoint.Classification, + Queues = endpoint.AmendedQueues + })]; + + return baseDetails; + } + } + } +} diff --git a/src/Particular.LicensingComponent.UnitTests/MonitoringService_Tests.cs b/src/Particular.LicensingComponent.UnitTests/MonitoringService_Tests.cs index a4d39e1b1c..1b4104f5be 100644 --- a/src/Particular.LicensingComponent.UnitTests/MonitoringService_Tests.cs +++ b/src/Particular.LicensingComponent.UnitTests/MonitoringService_Tests.cs @@ -264,6 +264,7 @@ public Task GetLicensedEndpointDetails(CancellationToke public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task RemoveEndpoints(EndpointIdentifier[] endpointIds, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAmendment result, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } class BrokerThroughputQuery_WithSanitization : IBrokerThroughputQuery diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs index ba4d6ec45a..f6fd359362 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs @@ -4,7 +4,6 @@ namespace ServiceControl.Persistence.EFCore.Implementation; using Microsoft.Extensions.DependencyInjection; using Particular.LicensingComponent.Contracts; using Particular.LicensingComponent.Persistence; -using ServiceControl.Persistence.EFCore.DbContexts; using ServiceControl.Persistence.EFCore.Entities; using ServiceControl.Persistence.EFCore.Infrastructure; @@ -310,10 +309,35 @@ public Task SaveReportMasks(List reportMasks, CancellationToken cancella ExecuteWithDbContext((context, token) => context.StoreSetting(SettingKeys.ReportMasks, reportMasks, token), cancellationToken); public Task GetLicensedEndpointDetails(CancellationToken cancellationToken = default) => - ExecuteWithDbContext((context, token) => context.GetSetting(SettingKeys.LicensedEndpointDetails, token), cancellationToken); + ExecuteWithDbContext(async (context, token) => + { + var baseDetails = await context.GetSetting(SettingKeys.LicensedEndpointDetails, token); + if (baseDetails is null) + { + return null; + } + var amendments = await context.GetSetting(SettingKeys.LicensedEndpointDetailsAmendment, token); + return baseDetails.Amend(amendments); + }, cancellationToken); public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken = default) => - ExecuteWithDbContext((context, token) => context.StoreSetting(SettingKeys.LicensedEndpointDetails, result, token), cancellationToken); + ExecuteWithDbContext(async (context, token) => + { + await context.StoreSetting(SettingKeys.LicensedEndpointDetails, result, token); + await context.StoreSetting(SettingKeys.LicensedEndpointDetailsAmendment, new LicensedEndpointDetailsAmendment(), token); + }, cancellationToken); + + public Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAmendment result, CancellationToken cancellationToken = default) => + ExecuteWithDbContext(async (context, token) => + { + var existing = await context.GetSetting(SettingKeys.LicensedEndpointDetailsAmendment, token); + if (existing is not null) + { + // these do not round-trip + result.UnmappedQueues = [.. existing.UnmappedQueues, .. result.UnmappedQueues]; + } + await context.StoreSetting(SettingKeys.LicensedEndpointDetailsAmendment, result, token); + }, cancellationToken); static Endpoint ToEndpoint(LicensingEndpointEntity row) => ToEndpoint(row, lastCollectedDate: null); diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/SettingKeys.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/SettingKeys.cs index 7a8e403170..a97f3e100b 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/SettingKeys.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/SettingKeys.cs @@ -8,5 +8,6 @@ static class SettingKeys public const string AuditServiceMetadata = "AuditServiceMetadata"; public const string ReportMasks = "ReportMasks"; public const string LicensedEndpointDetails = "LicensedEndpointDetails"; + public const string LicensedEndpointDetailsAmendment = "LicensedEndpointDetailsAmendment"; public const string NotificationEmails = "NotificationEmails"; } diff --git a/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs index ad0a9e2b64..dfecbbe661 100644 --- a/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs @@ -25,6 +25,7 @@ class LicensingDataStore( const string BrokerMetadataDocumentId = "BrokerMetadata"; const string ReportMasksDocumentId = "ReportMasks"; const string LicencedEndpointDetailsDocumentId = "LicensedEndpointDetails"; + const string LicencedEndpointDetailsAmendmentDocumentId = "LicensedEndpointDetailsAmendment"; static readonly AuditServiceMetadata DefaultAuditServiceMetadata = new([], []); static readonly ReportConfigurationDocument DefaultReportConfiguration = new(); @@ -161,7 +162,8 @@ public async Task>> GetEndpointT { var endpointDailyThroughputs = timeSeries .Where(entry => !throughputMaxDate.HasValue || entry.Timestamp < maxDateTime) - .Select(entry => new EndpointDailyThroughput(DateOnly.FromDateTime(entry.Timestamp), (long)entry.Value)); + .Select(entry => new EndpointDailyThroughput(DateOnly.FromDateTime(entry.Timestamp), (long)entry.Value)) + .ToList(); var throughputData = new ThroughputData(endpointDailyThroughputs) { ThroughputSource = document.EndpointId.ThroughputSource @@ -341,7 +343,13 @@ public async Task SaveReportMasks(List reportMasks, CancellationToken ca var store = await storeProvider.GetDocumentStore(cancellationToken); using IAsyncDocumentSession session = store.OpenAsyncSession(databaseConfiguration.Name); - return await session.LoadAsync(LicencedEndpointDetailsDocumentId, cancellationToken); + var baseDetails = await session.LoadAsync(LicencedEndpointDetailsDocumentId, cancellationToken); + if (baseDetails is null) + { + return null; + } + var amendments = await session.LoadAsync(LicencedEndpointDetailsAmendmentDocumentId, cancellationToken); + return baseDetails.Amend(amendments); } public async Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken = default) @@ -350,6 +358,24 @@ public async Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, Ca using IAsyncDocumentSession session = store.OpenAsyncSession(databaseConfiguration.Name); await session.StoreAsync(result, LicencedEndpointDetailsDocumentId, cancellationToken); + await session.StoreAsync(new LicensedEndpointDetailsAmendment(), LicencedEndpointDetailsAmendmentDocumentId, cancellationToken); + await session.SaveChangesAsync(cancellationToken); + } + + public async Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAmendment result, CancellationToken cancellationToken = default) + { + var store = await storeProvider.GetDocumentStore(cancellationToken); + using IAsyncDocumentSession session = store.OpenAsyncSession(databaseConfiguration.Name); + + var existing = await session.LoadAsync(LicencedEndpointDetailsAmendmentDocumentId, cancellationToken); + if (existing is not null) + { + // these do not round-trip + result.UnmappedQueues = [.. existing.UnmappedQueues, .. result.UnmappedQueues]; + session.Advanced.Evict(existing); + } + + await session.StoreAsync(result, LicencedEndpointDetailsAmendmentDocumentId, cancellationToken); await session.SaveChangesAsync(cancellationToken); } } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Tests/Throughput/LicenseMetadataTests.cs b/src/ServiceControl.Persistence.Tests/Throughput/LicenseMetadataTests.cs new file mode 100644 index 0000000000..756e66c250 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/Throughput/LicenseMetadataTests.cs @@ -0,0 +1,203 @@ +namespace ServiceControl.Persistence.Tests.Throughput; + +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Particular.LicensingComponent.Contracts; + +[TestFixture] +class LicensedEndpointDetailsTests : PersistenceTestBase +{ + [Test] + public async Task Should_return_null_when_endpoint_details_have_not_been_saved() + { + var retrievedDetails = await LicensingDataStore.GetLicensedEndpointDetails(); + + Assert.That(retrievedDetails, Is.Null); + } + + [Test] + public async Task Should_return_null_when_only_an_amendment_has_been_saved() + { + await LicensingDataStore.SaveLicensedEndpointDetailsAmendment( + new LicensedEndpointDetailsAmendment + { + RemovedEndpointIds = ["endpoint-id"] + }); + + var retrievedDetails = await LicensingDataStore.GetLicensedEndpointDetails(); + + Assert.That(retrievedDetails, Is.Null); + } + + [Test] + public async Task Should_apply_endpoint_amendments() + { + await LicensingDataStore.SaveLicensedEndpointDetails(new LicensedEndpointDetails + { + Endpoints = + [ + new LicensedEndpoint + { + ClientId = "endpoint-id", + Name = "Original name", + Classification = 1, + Queues = + [ + new QueueIdentity { NameHash = "original-queue" } + ] + } + ] + }); + + await LicensingDataStore.SaveLicensedEndpointDetailsAmendment( + new LicensedEndpointDetailsAmendment + { + Endpoints = + [ + new LicensedEndpointAmendment + { + ClientId = "endpoint-id", + Name = "Amended name", + Classification = 2, + AmendedQueues = + [ + new QueueIdentity { NameHash = "additional-queue" } + ] + } + ] + }); + + var retrievedDetails = await LicensingDataStore.GetLicensedEndpointDetails(); + + Assert.That(retrievedDetails, Is.Not.Null); + var endpoint = retrievedDetails.Endpoints.Single(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(endpoint.ClientId, Is.EqualTo("endpoint-id")); + Assert.That(endpoint.Name, Is.EqualTo("Amended name")); + Assert.That(endpoint.Classification, Is.EqualTo(2)); + Assert.That(endpoint.Queues.Select(q => q.NameHash), Is.EqualTo(["original-queue", "additional-queue"])); + } + } + + [Test] + public async Task Should_remove_endpoints_listed_in_the_amendment() + { + await LicensingDataStore.SaveLicensedEndpointDetails(new LicensedEndpointDetails + { + Endpoints = + [ + new LicensedEndpoint + { + ClientId = "endpoint-to-keep", + Name = "Endpoint to keep" + }, + new LicensedEndpoint + { + ClientId = "endpoint-to-remove", + Name = "Endpoint to remove" + } + ] + }); + + await LicensingDataStore.SaveLicensedEndpointDetailsAmendment( + new LicensedEndpointDetailsAmendment + { + RemovedEndpointIds = ["endpoint-to-remove"] + }); + + var retrievedDetails = await LicensingDataStore.GetLicensedEndpointDetails(); + + Assert.That(retrievedDetails, Is.Not.Null); + var endpoint = retrievedDetails.Endpoints.Single(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(endpoint.ClientId, Is.EqualTo("endpoint-to-keep")); + Assert.That(endpoint.Name, Is.EqualTo("Endpoint to keep")); + } + } + + [Test] + public async Task Should_replace_an_existing_amendment() + { + await LicensingDataStore.SaveLicensedEndpointDetails(new LicensedEndpointDetails + { + Endpoints = + [ + new LicensedEndpoint + { + ClientId = "first-endpoint", + Name = "First endpoint" + }, + new LicensedEndpoint + { + ClientId = "second-endpoint", + Name = "Second endpoint" + } + ] + }); + + await LicensingDataStore.SaveLicensedEndpointDetailsAmendment( + new LicensedEndpointDetailsAmendment + { + RemovedEndpointIds = ["first-endpoint"] + }); + + await LicensingDataStore.SaveLicensedEndpointDetailsAmendment( + new LicensedEndpointDetailsAmendment + { + RemovedEndpointIds = ["second-endpoint"] + }); + + var retrievedDetails = await LicensingDataStore.GetLicensedEndpointDetails(); + + Assert.That(retrievedDetails, Is.Not.Null); + Assert.That( + retrievedDetails.Endpoints.Select(endpoint => endpoint.ClientId), + Is.EqualTo(["first-endpoint"])); + } + + [Test] + public async Task Saving_endpoint_details_should_clear_the_existing_amendment() + { + await LicensingDataStore.SaveLicensedEndpointDetails(new LicensedEndpointDetails + { + Endpoints = + [ + new LicensedEndpoint + { + ClientId = "endpoint-id", + Name = "Original name" + } + ] + }); + + await LicensingDataStore.SaveLicensedEndpointDetailsAmendment( + new LicensedEndpointDetailsAmendment + { + RemovedEndpointIds = ["endpoint-id"] + }); + + await LicensingDataStore.SaveLicensedEndpointDetails(new LicensedEndpointDetails + { + Endpoints = + [ + new LicensedEndpoint + { + ClientId = "endpoint-id", + Name = "Replacement name" + } + ] + }); + + var retrievedDetails = await LicensingDataStore.GetLicensedEndpointDetails(); + + Assert.That(retrievedDetails, Is.Not.Null); + Assert.That( + retrievedDetails.Endpoints.Single().Name, + Is.EqualTo("Replacement name")); + } +} \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt index 69498d4d04..d9f20d7486 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt @@ -41,6 +41,7 @@ GET /eventlogitems => ServiceControl.EventLog.EventLogApiController:Items(Paging GET /heartbeats/stats => ServiceControl.Monitoring.EndpointsMonitoringController:HeartbeatStats() GET /instance-info => ServiceControl.Infrastructure.WebApi.RootController:Config(CancellationToken cancellationToken) GET /license => ServiceControl.Licensing.LicenseController:License(Boolean refresh, String clientName, CancellationToken cancellationToken) +POST /license/amendDetails => ServiceControl.Licensing.LicenseController:AmendLicenseDetails(LicensedEndpointDetailsAmendment amendment, CancellationToken cancellationToken) GET /license/details => ServiceControl.Licensing.LicenseController:LicenseDetails(CancellationToken cancellationToken) POST /license/detailsUpload => ServiceControl.Licensing.LicenseController:UploadLicenseDetails(IFormFile file, CancellationToken cancellationToken) GET /messages => ServiceControl.CompositeViews.Messages.GetMessagesController:Messages(PagingInfo pagingInfo, SortInfo sortInfo, Boolean includeSystemMessages, CancellationToken cancellationToken) diff --git a/src/ServiceControl/Licensing/LicenseController.cs b/src/ServiceControl/Licensing/LicenseController.cs index a2c734b261..fd2ff4b142 100644 --- a/src/ServiceControl/Licensing/LicenseController.cs +++ b/src/ServiceControl/Licensing/LicenseController.cs @@ -1,6 +1,7 @@ #nullable enable namespace ServiceControl.Licensing { + using System; using System.IO; using System.IO.Compression; using System.Text.Json; @@ -81,8 +82,31 @@ public async Task UploadLicenseDetails([FromForm] IFormFile file, CancellationTo var result = JsonSerializer.Deserialize(brotliStream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidDataException("File contents cannot be deserialized"); + //assign unique identifiers to entities for amendment tracking + foreach (var endpoint in result.Endpoints) + { + endpoint.ClientId = Guid.NewGuid().ToString(); + foreach (var queue in endpoint.Queues) + { + queue.OriginalLocation = endpoint.ClientId; + } + } + foreach (var queue in result.InfrastructureQueues) + { + queue.OriginalLocation = "infrastructure"; + } + foreach (var queue in result.ExcludedQueues) + { + queue.OriginalLocation = "excluded"; + } //persist await dataStore.SaveLicensedEndpointDetails(result, cancellationToken); } + + [Authorize(Policy = Permissions.ErrorThroughputManage)] + [HttpPost] + [Route("license/amendDetails")] + public async Task AmendLicenseDetails([FromBody] LicensedEndpointDetailsAmendment amendment, CancellationToken cancellationToken = default) => + await dataStore.SaveLicensedEndpointDetailsAmendment(amendment, cancellationToken); } } \ No newline at end of file