Skip to content

Commit e759f25

Browse files
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
1 parent 3e071e0 commit e759f25

File tree

8 files changed

+177
-57
lines changed

8 files changed

+177
-57
lines changed

src/EventStore.Plugins/EventStore.Plugins.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="8.0.1" />
2828
<PackageReference Include="YamlDotNet" Version="15.1.4" />
2929
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0" />
30+
<PackageReference Include="System.Reactive" Version="6.0.1" />
3031
</ItemGroup>
3132

3233
<ItemGroup>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace EventStore.Plugins.Licensing;
2+
3+
// Allows plugins to access the current license, get updates to it, and reject a license
4+
// if it is missing entitlements
5+
public interface ILicenseService {
6+
// For checking that the license service itself is authentic
7+
License SelfLicense { get; }
8+
9+
License? CurrentLicense { get; }
10+
11+
// The current license and updates to it
12+
IObservable<License> Licenses { get; }
13+
14+
void RejectLicense(Exception ex);
15+
}

src/EventStore.Plugins/Licensing/License.cs

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,39 @@
1-
using System.Security.Cryptography;
1+
using System.Diagnostics.CodeAnalysis;
2+
using System.Security.Cryptography;
23
using Microsoft.IdentityModel.JsonWebTokens;
34
using Microsoft.IdentityModel.Tokens;
45
using static System.Convert;
56

67
namespace EventStore.Plugins.Licensing;
78

89
public record License(JsonWebToken Token) {
9-
public async Task<bool> IsValidAsync(string publicKey) {
10+
public string? CurrentCultureIgnoreCase { get; private set; }
11+
12+
public async Task<bool> ValidateAsync(string publicKey) {
1013
var result = await ValidateTokenAsync(publicKey, Token.EncodedToken);
1114
return result.IsValid;
1215
}
1316

14-
public bool IsValid(string publicKey) =>
15-
IsValidAsync(publicKey).GetAwaiter().GetResult();
17+
public bool HasEntitlements(string[] entitlements, [MaybeNullWhen(true)] out string missing) {
18+
foreach (var entitlement in entitlements) {
19+
if (!HasEntitlement(entitlement)) {
20+
missing = entitlement;
21+
return false;
22+
}
23+
}
24+
25+
missing = default;
26+
return true;
27+
}
28+
29+
public bool HasEntitlement(string entitlement) {
30+
foreach (var claim in Token.Claims)
31+
if (claim.Type.Equals(entitlement, StringComparison.CurrentCultureIgnoreCase) &&
32+
claim.Value.Equals("true", StringComparison.CurrentCultureIgnoreCase))
33+
return true;
34+
35+
return false;
36+
}
1637

1738
public static async Task<License> CreateAsync(
1839
string publicKey,
@@ -66,4 +87,4 @@ static async Task<TokenValidationResult> ValidateTokenAsync(string publicKey, st
6687

6788
return result;
6889
}
69-
}
90+
}

src/EventStore.Plugins/Plugin.cs

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
using System.Diagnostics;
22
using EventStore.Plugins.Diagnostics;
3+
using EventStore.Plugins.Licensing;
34
using Microsoft.AspNetCore.Builder;
45
using Microsoft.Extensions.Configuration;
56
using Microsoft.Extensions.DependencyInjection;
67
using Microsoft.Extensions.Logging;
78
using static System.StringComparison;
89
using static EventStore.Plugins.Diagnostics.PluginDiagnosticsDataCollectionMode;
9-
using License = EventStore.Plugins.Licensing.License;
1010

1111
namespace EventStore.Plugins;
1212

1313
public record PluginOptions {
1414
public string? Name { get; init; }
1515
public string? Version { get; init; }
1616
public string? LicensePublicKey { get; init; }
17+
public string[]? RequiredEntitlements { get; init; }
1718
public string? DiagnosticsName { get; init; }
1819
public KeyValuePair<string, object?>[] DiagnosticsTags { get; init; } = [];
1920
}
@@ -24,8 +25,10 @@ protected Plugin(
2425
string? name = null,
2526
string? version = null,
2627
string? licensePublicKey = null,
28+
string[]? requiredEntitlements = null,
2729
string? diagnosticsName = null,
2830
params KeyValuePair<string, object?>[] diagnosticsTags) {
31+
2932
var pluginType = GetType();
3033

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

4043
LicensePublicKey = licensePublicKey;
44+
RequiredEntitlements = requiredEntitlements;
4145

4246
DiagnosticsName = diagnosticsName ?? Name;
4347
DiagnosticsTags = diagnosticsTags;
@@ -65,11 +69,14 @@ protected Plugin(PluginOptions options) : this(
6569
options.Name,
6670
options.Version,
6771
options.LicensePublicKey,
72+
options.RequiredEntitlements,
6873
options.DiagnosticsName,
6974
options.DiagnosticsTags) { }
7075

7176
public string? LicensePublicKey { get; }
7277

78+
public string[]? RequiredEntitlements { get; }
79+
7380
DiagnosticListener DiagnosticListener { get; }
7481

7582
(bool Enabled, string EnableInstructions) IsEnabledResult { get; set; }
@@ -127,21 +134,45 @@ void IPlugableComponent.ConfigureApplication(IApplicationBuilder app, IConfigura
127134
return;
128135
}
129136

130-
// if the plugin is enabled, but the license is invalid, throw an exception and effectivly disable the plugin
131-
var license = app.ApplicationServices.GetService<License>();
132-
if (Enabled && LicensePublicKey is not null && (license is null || !license.IsValid(LicensePublicKey))) {
133-
var ex = new PluginLicenseException(Name);
134-
135-
IsEnabledResult = (false, ex.Message);
136-
137-
PublishDiagnosticsData(new() { ["enabled"] = Enabled }, Partial);
138-
139-
logger.LogInformation(
140-
"{PluginName} {Version} plugin disabled. {EnableInstructions}",
141-
Name, Version, IsEnabledResult.EnableInstructions
142-
);
143-
144-
throw ex;
137+
if (Enabled && LicensePublicKey is not null) {
138+
// the plugin is enabled and requires a license
139+
// the EULA prevents tampering with the license mechanism. we make the license mechanism
140+
// robust enough that circumventing it requires intentional tampering.
141+
var licenseService = app.ApplicationServices.GetRequiredService<ILicenseService>();
142+
143+
// authenticate the license service itself so that we can trust it to
144+
// 1. send us any licences at all
145+
// 2. respect our decision to reject licences
146+
Task.Run(async () => {
147+
var authentic = await licenseService.SelfLicense.ValidateAsync(LicensePublicKey);
148+
if (!authentic) {
149+
// this should never happen, but could if we end up with some unknown LicenseService.
150+
logger.LogCritical("LicenseService could not be authenticated");
151+
Environment.Exit(11);
152+
}
153+
});
154+
155+
// authenticate the licenses that the license service sends us
156+
licenseService.Licenses.Subscribe(
157+
onNext: async license => {
158+
if (await license.ValidateAsync(LicensePublicKey)) {
159+
// got an authentic license. check required entitlements
160+
if (license.HasEntitlement("ALL"))
161+
return;
162+
163+
if (!license.HasEntitlements(RequiredEntitlements ?? [], out var missing)) {
164+
licenseService.RejectLicense(new PluginLicenseEntitlementException(Name, missing));
165+
}
166+
} else {
167+
// this should never happen
168+
logger.LogCritical("ESDB License was not valid");
169+
licenseService.RejectLicense(new PluginLicenseException(Name, new Exception("ESDB License was not valid")));
170+
Environment.Exit(12);
171+
}
172+
},
173+
onError: ex => {
174+
licenseService.RejectLicense(new PluginLicenseException(Name, ex));
175+
});
145176
}
146177

147178
// there is still a chance to disable the plugin when configuring the application
@@ -213,4 +244,4 @@ protected internal void PublishDiagnosticsEvent<T>(T pluginEvent) =>
213244

214245
/// <inheritdoc />
215246
public void Dispose() => DiagnosticListener.Dispose();
216-
}
247+
}
Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
namespace EventStore.Plugins;
22

3-
public class PluginLicenseException(string pluginName) : Exception(
3+
public class PluginLicenseException(string pluginName, Exception? inner = null) : Exception(
44
$"A license is required to use the {pluginName} plugin, but was not found. " +
5-
"Please obtain a license or disable the plugin."
5+
"Please obtain a license or disable the plugin.",
6+
inner
67
) {
78
public string PluginName { get; } = pluginName;
8-
}
9+
}
10+
11+
public class PluginLicenseEntitlementException(string pluginName, string entitlement) : Exception(
12+
$"{pluginName} plugin requires the {entitlement} entitlement. Please contact EventStore support.") {
13+
public string PluginName { get; } = pluginName;
14+
}

src/EventStore.Plugins/SubsystemsPlugin.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ protected SubsystemsPlugin(SubsystemsPluginOptions options) : base(options) {
2121
protected SubsystemsPlugin(
2222
string? name = null, string? version = null,
2323
string? licensePublicKey = null,
24+
string[]? requiredEntitlements = null,
2425
string? commandLineName = null,
2526
string? diagnosticsName = null,
2627
params KeyValuePair<string, object?>[] diagnosticsTags
2728
) : this(new() {
2829
Name = name,
2930
Version = version,
3031
LicensePublicKey = licensePublicKey,
32+
RequiredEntitlements = requiredEntitlements,
3133
DiagnosticsName = diagnosticsName,
3234
DiagnosticsTags = diagnosticsTags,
3335
CommandLineName = commandLineName
@@ -40,4 +42,4 @@ protected SubsystemsPlugin(
4042
public virtual Task Stop() => Task.CompletedTask;
4143

4244
public virtual IReadOnlyList<ISubsystem> GetSubsystems() => [this];
43-
}
45+
}

test/EventStore.Plugins.Tests/Licensing/LicenseTests.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,19 @@ public async Task can_create_and_validate_license() {
1616
var (publicKey, privateKey) = CreateKeyPair();
1717

1818
var license = await License.CreateAsync(publicKey, privateKey, new Dictionary<string, object> {
19-
{ "foo", "bar" }
19+
{ "foo", "bar" },
20+
{ "my_entitlement", "true" },
2021
});
2122

2223
// check repeatedly because of https://github.com/dotnet/runtime/issues/43087
23-
(await license.IsValidAsync(publicKey)).Should().BeTrue();
24-
(await license.IsValidAsync(publicKey)).Should().BeTrue();
25-
(await license.IsValidAsync(publicKey)).Should().BeTrue();
24+
(await license.ValidateAsync(publicKey)).Should().BeTrue();
25+
(await license.ValidateAsync(publicKey)).Should().BeTrue();
26+
(await license.ValidateAsync(publicKey)).Should().BeTrue();
2627

2728
license.Token.Claims.First(c => c.Type == "foo").Value.Should().Be("bar");
29+
license.HasEntitlement("my_entitlement").Should().BeTrue();
30+
license.HasEntitlements(["my_entitlement", "missing_entitlement"], out var missing).Should().BeFalse();
31+
missing.Should().Be("missing_entitlement");
2832
}
2933

3034
[Fact]
@@ -36,7 +40,7 @@ public async Task detects_incorrect_public_key() {
3640
{ "foo", "bar" }
3741
});
3842

39-
(await license.IsValidAsync(publicKey2)).Should().BeFalse();
43+
(await license.ValidateAsync(publicKey2)).Should().BeFalse();
4044
}
4145

4246
[Fact]
@@ -50,4 +54,4 @@ public async Task cannot_create_with_inconsistent_keys() {
5054

5155
await act.Should().ThrowAsync<Exception>().WithMessage("Token could not be validated");
5256
}
53-
}
57+
}

0 commit comments

Comments
 (0)