From e759f25d2d827e07d389a31859b0c95ff91c40e3 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Wed, 24 Jul 2024 22:04:26 +0100 Subject: [PATCH] Add support for checking License Entitlements Also do not expect to find a License in the DI. Putting the license in the DI involved doing some sync over async and also meant that the licence couldn't change later (say, if it expired). Instead we now find a ILicenseService in the DI which provides us with the current license and an observable of license updates. Also provides a method for the plugins to reject a license if it doesn't have sufficient entitlements --- .../EventStore.Plugins.csproj | 1 + .../Licensing/ILicenseService.cs | 15 ++++ src/EventStore.Plugins/Licensing/License.cs | 31 +++++-- src/EventStore.Plugins/Plugin.cs | 65 ++++++++++---- .../PluginLicenseException.cs | 12 ++- src/EventStore.Plugins/SubsystemsPlugin.cs | 4 +- .../Licensing/LicenseTests.cs | 16 ++-- .../PluginBaseTests.cs | 90 +++++++++++++------ 8 files changed, 177 insertions(+), 57 deletions(-) create mode 100644 src/EventStore.Plugins/Licensing/ILicenseService.cs diff --git a/src/EventStore.Plugins/EventStore.Plugins.csproj b/src/EventStore.Plugins/EventStore.Plugins.csproj index 8ee083d..18e705f 100644 --- a/src/EventStore.Plugins/EventStore.Plugins.csproj +++ b/src/EventStore.Plugins/EventStore.Plugins.csproj @@ -27,6 +27,7 @@ + diff --git a/src/EventStore.Plugins/Licensing/ILicenseService.cs b/src/EventStore.Plugins/Licensing/ILicenseService.cs new file mode 100644 index 0000000..d549f48 --- /dev/null +++ b/src/EventStore.Plugins/Licensing/ILicenseService.cs @@ -0,0 +1,15 @@ +namespace EventStore.Plugins.Licensing; + +// Allows plugins to access the current license, get updates to it, and reject a license +// if it is missing entitlements +public interface ILicenseService { + // For checking that the license service itself is authentic + License SelfLicense { get; } + + License? CurrentLicense { get; } + + // The current license and updates to it + IObservable Licenses { get; } + + void RejectLicense(Exception ex); +} diff --git a/src/EventStore.Plugins/Licensing/License.cs b/src/EventStore.Plugins/Licensing/License.cs index fbef7cf..7960020 100644 --- a/src/EventStore.Plugins/Licensing/License.cs +++ b/src/EventStore.Plugins/Licensing/License.cs @@ -1,4 +1,5 @@ -using System.Security.Cryptography; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using static System.Convert; @@ -6,13 +7,33 @@ namespace EventStore.Plugins.Licensing; public record License(JsonWebToken Token) { - public async Task IsValidAsync(string publicKey) { + public string? CurrentCultureIgnoreCase { get; private set; } + + public async Task ValidateAsync(string publicKey) { var result = await ValidateTokenAsync(publicKey, Token.EncodedToken); return result.IsValid; } - public bool IsValid(string publicKey) => - IsValidAsync(publicKey).GetAwaiter().GetResult(); + public bool HasEntitlements(string[] entitlements, [MaybeNullWhen(true)] out string missing) { + foreach (var entitlement in entitlements) { + if (!HasEntitlement(entitlement)) { + missing = entitlement; + return false; + } + } + + missing = default; + return true; + } + + public bool HasEntitlement(string entitlement) { + foreach (var claim in Token.Claims) + if (claim.Type.Equals(entitlement, StringComparison.CurrentCultureIgnoreCase) && + claim.Value.Equals("true", StringComparison.CurrentCultureIgnoreCase)) + return true; + + return false; + } public static async Task CreateAsync( string publicKey, @@ -66,4 +87,4 @@ static async Task ValidateTokenAsync(string publicKey, st return result; } -} \ No newline at end of file +} diff --git a/src/EventStore.Plugins/Plugin.cs b/src/EventStore.Plugins/Plugin.cs index 38a5ba6..68ea30d 100644 --- a/src/EventStore.Plugins/Plugin.cs +++ b/src/EventStore.Plugins/Plugin.cs @@ -1,12 +1,12 @@ using System.Diagnostics; using EventStore.Plugins.Diagnostics; +using EventStore.Plugins.Licensing; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using static System.StringComparison; using static EventStore.Plugins.Diagnostics.PluginDiagnosticsDataCollectionMode; -using License = EventStore.Plugins.Licensing.License; namespace EventStore.Plugins; @@ -14,6 +14,7 @@ public record PluginOptions { public string? Name { get; init; } public string? Version { get; init; } public string? LicensePublicKey { get; init; } + public string[]? RequiredEntitlements { get; init; } public string? DiagnosticsName { get; init; } public KeyValuePair[] DiagnosticsTags { get; init; } = []; } @@ -24,8 +25,10 @@ protected Plugin( string? name = null, string? version = null, string? licensePublicKey = null, + string[]? requiredEntitlements = null, string? diagnosticsName = null, params KeyValuePair[] diagnosticsTags) { + var pluginType = GetType(); Name = name ?? pluginType.Name @@ -38,6 +41,7 @@ protected Plugin( Version = GetPluginVersion(version, pluginType); LicensePublicKey = licensePublicKey; + RequiredEntitlements = requiredEntitlements; DiagnosticsName = diagnosticsName ?? Name; DiagnosticsTags = diagnosticsTags; @@ -65,11 +69,14 @@ protected Plugin(PluginOptions options) : this( options.Name, options.Version, options.LicensePublicKey, + options.RequiredEntitlements, options.DiagnosticsName, options.DiagnosticsTags) { } public string? LicensePublicKey { get; } + public string[]? RequiredEntitlements { get; } + DiagnosticListener DiagnosticListener { get; } (bool Enabled, string EnableInstructions) IsEnabledResult { get; set; } @@ -127,21 +134,45 @@ void IPlugableComponent.ConfigureApplication(IApplicationBuilder app, IConfigura return; } - // if the plugin is enabled, but the license is invalid, throw an exception and effectivly disable the plugin - var license = app.ApplicationServices.GetService(); - if (Enabled && LicensePublicKey is not null && (license is null || !license.IsValid(LicensePublicKey))) { - var ex = new PluginLicenseException(Name); - - IsEnabledResult = (false, ex.Message); - - PublishDiagnosticsData(new() { ["enabled"] = Enabled }, Partial); - - logger.LogInformation( - "{PluginName} {Version} plugin disabled. {EnableInstructions}", - Name, Version, IsEnabledResult.EnableInstructions - ); - - throw ex; + if (Enabled && LicensePublicKey is not null) { + // the plugin is enabled and requires a license + // the EULA prevents tampering with the license mechanism. we make the license mechanism + // robust enough that circumventing it requires intentional tampering. + var licenseService = app.ApplicationServices.GetRequiredService(); + + // authenticate the license service itself so that we can trust it to + // 1. send us any licences at all + // 2. respect our decision to reject licences + Task.Run(async () => { + var authentic = await licenseService.SelfLicense.ValidateAsync(LicensePublicKey); + if (!authentic) { + // this should never happen, but could if we end up with some unknown LicenseService. + logger.LogCritical("LicenseService could not be authenticated"); + Environment.Exit(11); + } + }); + + // authenticate the licenses that the license service sends us + licenseService.Licenses.Subscribe( + onNext: async license => { + if (await license.ValidateAsync(LicensePublicKey)) { + // got an authentic license. check required entitlements + if (license.HasEntitlement("ALL")) + return; + + if (!license.HasEntitlements(RequiredEntitlements ?? [], out var missing)) { + licenseService.RejectLicense(new PluginLicenseEntitlementException(Name, missing)); + } + } else { + // this should never happen + logger.LogCritical("ESDB License was not valid"); + licenseService.RejectLicense(new PluginLicenseException(Name, new Exception("ESDB License was not valid"))); + Environment.Exit(12); + } + }, + onError: ex => { + licenseService.RejectLicense(new PluginLicenseException(Name, ex)); + }); } // there is still a chance to disable the plugin when configuring the application @@ -213,4 +244,4 @@ protected internal void PublishDiagnosticsEvent(T pluginEvent) => /// public void Dispose() => DiagnosticListener.Dispose(); -} \ No newline at end of file +} diff --git a/src/EventStore.Plugins/PluginLicenseException.cs b/src/EventStore.Plugins/PluginLicenseException.cs index b59b40e..ab59da2 100644 --- a/src/EventStore.Plugins/PluginLicenseException.cs +++ b/src/EventStore.Plugins/PluginLicenseException.cs @@ -1,8 +1,14 @@ namespace EventStore.Plugins; -public class PluginLicenseException(string pluginName) : Exception( +public class PluginLicenseException(string pluginName, Exception? inner = null) : Exception( $"A license is required to use the {pluginName} plugin, but was not found. " + - "Please obtain a license or disable the plugin." + "Please obtain a license or disable the plugin.", + inner ) { public string PluginName { get; } = pluginName; -} \ No newline at end of file +} + +public class PluginLicenseEntitlementException(string pluginName, string entitlement) : Exception( + $"{pluginName} plugin requires the {entitlement} entitlement. Please contact EventStore support.") { + public string PluginName { get; } = pluginName; +} diff --git a/src/EventStore.Plugins/SubsystemsPlugin.cs b/src/EventStore.Plugins/SubsystemsPlugin.cs index 1fdf2c5..9653e0c 100644 --- a/src/EventStore.Plugins/SubsystemsPlugin.cs +++ b/src/EventStore.Plugins/SubsystemsPlugin.cs @@ -21,6 +21,7 @@ protected SubsystemsPlugin(SubsystemsPluginOptions options) : base(options) { protected SubsystemsPlugin( string? name = null, string? version = null, string? licensePublicKey = null, + string[]? requiredEntitlements = null, string? commandLineName = null, string? diagnosticsName = null, params KeyValuePair[] diagnosticsTags @@ -28,6 +29,7 @@ protected SubsystemsPlugin( Name = name, Version = version, LicensePublicKey = licensePublicKey, + RequiredEntitlements = requiredEntitlements, DiagnosticsName = diagnosticsName, DiagnosticsTags = diagnosticsTags, CommandLineName = commandLineName @@ -40,4 +42,4 @@ protected SubsystemsPlugin( public virtual Task Stop() => Task.CompletedTask; public virtual IReadOnlyList GetSubsystems() => [this]; -} \ No newline at end of file +} diff --git a/test/EventStore.Plugins.Tests/Licensing/LicenseTests.cs b/test/EventStore.Plugins.Tests/Licensing/LicenseTests.cs index 34321f8..d3ca72a 100644 --- a/test/EventStore.Plugins.Tests/Licensing/LicenseTests.cs +++ b/test/EventStore.Plugins.Tests/Licensing/LicenseTests.cs @@ -16,15 +16,19 @@ public async Task can_create_and_validate_license() { var (publicKey, privateKey) = CreateKeyPair(); var license = await License.CreateAsync(publicKey, privateKey, new Dictionary { - { "foo", "bar" } + { "foo", "bar" }, + { "my_entitlement", "true" }, }); // check repeatedly because of https://github.com/dotnet/runtime/issues/43087 - (await license.IsValidAsync(publicKey)).Should().BeTrue(); - (await license.IsValidAsync(publicKey)).Should().BeTrue(); - (await license.IsValidAsync(publicKey)).Should().BeTrue(); + (await license.ValidateAsync(publicKey)).Should().BeTrue(); + (await license.ValidateAsync(publicKey)).Should().BeTrue(); + (await license.ValidateAsync(publicKey)).Should().BeTrue(); license.Token.Claims.First(c => c.Type == "foo").Value.Should().Be("bar"); + license.HasEntitlement("my_entitlement").Should().BeTrue(); + license.HasEntitlements(["my_entitlement", "missing_entitlement"], out var missing).Should().BeFalse(); + missing.Should().Be("missing_entitlement"); } [Fact] @@ -36,7 +40,7 @@ public async Task detects_incorrect_public_key() { { "foo", "bar" } }); - (await license.IsValidAsync(publicKey2)).Should().BeFalse(); + (await license.ValidateAsync(publicKey2)).Should().BeFalse(); } [Fact] @@ -50,4 +54,4 @@ public async Task cannot_create_with_inconsistent_keys() { await act.Should().ThrowAsync().WithMessage("Token could not be validated"); } -} \ No newline at end of file +} diff --git a/test/EventStore.Plugins.Tests/PluginBaseTests.cs b/test/EventStore.Plugins.Tests/PluginBaseTests.cs index 0ef5af1..2f07004 100644 --- a/test/EventStore.Plugins.Tests/PluginBaseTests.cs +++ b/test/EventStore.Plugins.Tests/PluginBaseTests.cs @@ -1,5 +1,6 @@ // ReSharper disable AccessToDisposedClosure +using System.Reactive.Subjects; using System.Security.Cryptography; using EventStore.Plugins.Diagnostics; using EventStore.Plugins.Licensing; @@ -16,7 +17,7 @@ public void plugin_base_sets_defaults_automatically() { var expectedOptions = new PluginOptions { Name = "NightCity", Version = "1.0.0.0", - DiagnosticsName = "NightCity" + DiagnosticsName = "NightCity", }; using var plugin = new NightCityPlugin(); @@ -71,62 +72,70 @@ public void plugin_diagnostics_snapshot_is_not_overriden_internally() { } [Fact] - public void comercial_plugin_is_disabled_when_licence_is_missing() { + public void commercial_plugin_is_disabled_when_licence_is_missing() { // Arrange + var licenseService = new FakeLicenseService(createLicense: false); + IPlugableComponent plugin = new NightCityPlugin(new() { - LicensePublicKey = "valid-public-key" + LicensePublicKey = licenseService.PublicKey, + RequiredEntitlements = ["starlight"], }); var builder = WebApplication.CreateBuilder(); + builder.Services.AddSingleton(licenseService); + plugin.ConfigureServices(builder.Services, builder.Configuration); using var app = builder.Build(); - var configure = () => plugin.ConfigureApplication(app, app.Configuration); + // Act + plugin.ConfigureApplication(app, app.Configuration); - // Act & Assert - configure.Should().Throw().Which + // Assert + licenseService.RejectionException.Should().BeOfType().Which .PluginName.Should().Be(plugin.Name); } [Fact] - public void comercial_plugin_is_disabled_when_licence_is_invalid() { + public void commercial_plugin_is_disabled_when_licence_is_missing_entitlement() { // Arrange - var (license, _) = CreateLicense(); - var (_, invalidPublicKey) = CreateLicense(); + var licenseService = new FakeLicenseService(createLicense: true); IPlugableComponent plugin = new NightCityPlugin(new() { - LicensePublicKey = invalidPublicKey + LicensePublicKey = licenseService.PublicKey, + RequiredEntitlements = ["starlight"], }); var builder = WebApplication.CreateBuilder(); - builder.Services.AddSingleton(license); + builder.Services.AddSingleton(licenseService); plugin.ConfigureServices(builder.Services, builder.Configuration); using var app = builder.Build(); - var configure = () => plugin.ConfigureApplication(app, app.Configuration); + // Act + plugin.ConfigureApplication(app, app.Configuration); - // Act & Assert - configure.Should().Throw().Which + // Assert + licenseService.RejectionException.Should().BeOfType().Which .PluginName.Should().Be(plugin.Name); } [Fact] - public void comercial_plugin_is_enabled_when_licence_is_present() { + public void commercial_plugin_is_enabled_when_licence_is_present() { // Arrange - var (license, publicKey) = CreateLicense(); + var licenseService = new FakeLicenseService(createLicense: true, "starlight"); IPlugableComponent plugin = new NightCityPlugin(new() { - LicensePublicKey = publicKey + LicensePublicKey = licenseService.PublicKey, + RequiredEntitlements = ["starlight"], }); var builder = WebApplication.CreateBuilder(); - builder.Services.AddSingleton(license); + builder.Services.AddSingleton(licenseService); plugin.ConfigureServices(builder.Services, builder.Configuration); @@ -188,13 +197,43 @@ public void plugin_can_be_disabled_on_ConfigureApplication() { .WhoseValue.Should().BeEquivalentTo(false); } - static (License License, string PublicKey) CreateLicense(Dictionary? claims = null) { - using var rsa = RSA.Create(1024); + class FakeLicenseService : ILicenseService { + public FakeLicenseService( + bool createLicense, + params string[] entitlements) { + + using var rsa = RSA.Create(1024); + + PublicKey = ToBase64String(rsa.ExportRSAPublicKey()); + var privateKey = ToBase64String(rsa.ExportRSAPrivateKey()); + + SelfLicense = License.Create(PublicKey, privateKey); + + if (createLicense) { + CurrentLicense = License.Create(PublicKey, privateKey, entitlements.ToDictionary( + x => x, + x => (object)"true")); + Licenses = new BehaviorSubject(CurrentLicense); + } else { + CurrentLicense = null; + var licenses = new Subject(); + licenses.OnError(new Exception("license expired, say")); + Licenses = licenses; + } + } + + public string PublicKey { get; } + public License SelfLicense { get; } + + public License? CurrentLicense { get; } - var publicKey = ToBase64String(rsa.ExportRSAPublicKey()); - var privateKey = ToBase64String(rsa.ExportRSAPrivateKey()); + public IObservable Licenses { get; } - return (License.Create(publicKey, privateKey, claims), publicKey); + public void RejectLicense(Exception ex) { + RejectionException = ex; + } + + public Exception? RejectionException { get; private set; } } class NightCityPlugin : Plugin { @@ -202,7 +241,8 @@ public NightCityPlugin(PluginOptions options) : base(options) { Options = options with { Name = Name, Version = Version, - DiagnosticsName = DiagnosticsName + RequiredEntitlements = RequiredEntitlements, + DiagnosticsName = DiagnosticsName, }; } @@ -234,4 +274,4 @@ public PhantomLibertySubsystemsPlugin() : this(new()) { } public SubsystemsPluginOptions Options { get; } } -} \ No newline at end of file +}