Skip to content

Commit 1babdd5

Browse files
Merge pull request #48 from EventStore/timothycoleman/check-entitlements
[ESDB-159-3] Add support for checking Entitlements
2 parents 3e071e0 + e759f25 commit 1babdd5

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)