Skip to content

Commit

Permalink
Merge pull request #48 from EventStore/timothycoleman/check-entitlements
Browse files Browse the repository at this point in the history
[ESDB-159-3] Add support for checking Entitlements
  • Loading branch information
timothycoleman authored Jul 25, 2024
2 parents 3e071e0 + e759f25 commit 1babdd5
Show file tree
Hide file tree
Showing 8 changed files with 177 additions and 57 deletions.
1 change: 1 addition & 0 deletions src/EventStore.Plugins/EventStore.Plugins.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="8.0.1" />
<PackageReference Include="YamlDotNet" Version="15.1.4" />
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageReference Include="System.Reactive" Version="6.0.1" />
</ItemGroup>

<ItemGroup>
Expand Down
15 changes: 15 additions & 0 deletions src/EventStore.Plugins/Licensing/ILicenseService.cs
Original file line number Diff line number Diff line change
@@ -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<License> Licenses { get; }

void RejectLicense(Exception ex);
}
31 changes: 26 additions & 5 deletions src/EventStore.Plugins/Licensing/License.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,39 @@
using System.Security.Cryptography;
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using static System.Convert;

namespace EventStore.Plugins.Licensing;

public record License(JsonWebToken Token) {
public async Task<bool> IsValidAsync(string publicKey) {
public string? CurrentCultureIgnoreCase { get; private set; }

public async Task<bool> 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<License> CreateAsync(
string publicKey,
Expand Down Expand Up @@ -66,4 +87,4 @@ static async Task<TokenValidationResult> ValidateTokenAsync(string publicKey, st

return result;
}
}
}
65 changes: 48 additions & 17 deletions src/EventStore.Plugins/Plugin.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
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;

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<string, object?>[] DiagnosticsTags { get; init; } = [];
}
Expand All @@ -24,8 +25,10 @@ protected Plugin(
string? name = null,
string? version = null,
string? licensePublicKey = null,
string[]? requiredEntitlements = null,
string? diagnosticsName = null,
params KeyValuePair<string, object?>[] diagnosticsTags) {

var pluginType = GetType();

Name = name ?? pluginType.Name
Expand All @@ -38,6 +41,7 @@ protected Plugin(
Version = GetPluginVersion(version, pluginType);

LicensePublicKey = licensePublicKey;
RequiredEntitlements = requiredEntitlements;

DiagnosticsName = diagnosticsName ?? Name;
DiagnosticsTags = diagnosticsTags;
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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<License>();
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<ILicenseService>();

// 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
Expand Down Expand Up @@ -213,4 +244,4 @@ protected internal void PublishDiagnosticsEvent<T>(T pluginEvent) =>

/// <inheritdoc />
public void Dispose() => DiagnosticListener.Dispose();
}
}
12 changes: 9 additions & 3 deletions src/EventStore.Plugins/PluginLicenseException.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}

public class PluginLicenseEntitlementException(string pluginName, string entitlement) : Exception(
$"{pluginName} plugin requires the {entitlement} entitlement. Please contact EventStore support.") {
public string PluginName { get; } = pluginName;
}
4 changes: 3 additions & 1 deletion src/EventStore.Plugins/SubsystemsPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ 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<string, object?>[] diagnosticsTags
) : this(new() {
Name = name,
Version = version,
LicensePublicKey = licensePublicKey,
RequiredEntitlements = requiredEntitlements,
DiagnosticsName = diagnosticsName,
DiagnosticsTags = diagnosticsTags,
CommandLineName = commandLineName
Expand All @@ -40,4 +42,4 @@ protected SubsystemsPlugin(
public virtual Task Stop() => Task.CompletedTask;

public virtual IReadOnlyList<ISubsystem> GetSubsystems() => [this];
}
}
16 changes: 10 additions & 6 deletions test/EventStore.Plugins.Tests/Licensing/LicenseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object> {
{ "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]
Expand All @@ -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]
Expand All @@ -50,4 +54,4 @@ public async Task cannot_create_with_inconsistent_keys() {

await act.Should().ThrowAsync<Exception>().WithMessage("Token could not be validated");
}
}
}
Loading

0 comments on commit 1babdd5

Please sign in to comment.