Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ESDB-159-3] Add support for checking Entitlements #48

Merged
merged 1 commit into from
Jul 25, 2024
Merged
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
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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't necessary for the preview release since there is only one entitlement, but it might be more user-friendly to return all of the missing entitlements rather than just the first one

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah fair comment, lets consider that in the future especially if it turns out that some plugins have multiple required entitlements

}
}

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
Loading