From ce2aab3f2f32323a11dcc95b2a3e4741d1d38515 Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Tue, 17 Dec 2019 14:58:58 +1100 Subject: [PATCH] Add setting to restrict license types (#50252) This adds a new "xpack.license.upload.types" setting that restricts which license types may be uploaded to a cluster. By default all types are allowed (excluding basic, which can only be generated and never uploaded). This setting does not restrict APIs that generate licenses such as the start trial API. This setting is not documented as it is intended to be set by orchestrators and not end users. Backport of: #49418 --- .../org/elasticsearch/license/License.java | 49 ++++-- .../elasticsearch/license/LicenseService.java | 49 +++++- .../license/OperationModeFileWatcher.java | 2 +- .../license/RemoteClusterLicenseChecker.java | 6 +- .../xpack/core/XPackClientPlugin.java | 1 + .../core/ml/inference/TrainedModelConfig.java | 4 +- .../license/LicenseFIPSTests.java | 10 ++ .../license/LicenseOperationModeTests.java | 6 +- .../LicenseOperationModeUpdateTests.java | 2 +- .../license/LicenseServiceTests.java | 164 ++++++++++++++++++ 10 files changed, 273 insertions(+), 20 deletions(-) diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/License.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/License.java index 9351f36f91391..b59ecf9bebd6d 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/License.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/License.java @@ -63,7 +63,23 @@ public static LicenseType parse(String type) throws IllegalArgumentException { /** * Backward compatible license type parsing for older license models */ - public static LicenseType resolve(String name) { + public static LicenseType resolve(License license) { + if (license.version == VERSION_START) { + // in 1.x: the acceptable values for 'subscription_type': none | dev | silver | gold | platinum + return resolve(license.subscriptionType); + } else { + // in 2.x: the acceptable values for 'type': trial | basic | silver | dev | gold | platinum + // in 5.x: the acceptable values for 'type': trial | basic | standard | dev | gold | platinum + // in 6.x: the acceptable values for 'type': trial | basic | standard | dev | gold | platinum + // in 7.x: the acceptable values for 'type': trial | basic | standard | dev | gold | platinum | enterprise + return resolve(license.type); + } + } + + /** + * Backward compatible license type parsing for older license models + */ + static LicenseType resolve(String name) { switch (name.toLowerCase(Locale.ROOT)) { case "missing": return null; @@ -171,8 +187,12 @@ public static int compare(OperationMode opMode1, OperationMode opMode2) { return Integer.compare(opMode1.id, opMode2.id); } - public static OperationMode resolve(String typeName) { - LicenseType type = LicenseType.resolve(typeName); + /** + * Determine the operating mode for a license type + * @see LicenseType#resolve(License) + * @see #parse(String) + */ + public static OperationMode resolve(LicenseType type) { if (type == null) { return MISSING; } @@ -193,6 +213,21 @@ public static OperationMode resolve(String typeName) { } } + /** + * Parses an {@code OperatingMode} from a String. + * The string must name an operating mode, and not a licensing level (that is, it cannot parse old style license levels + * such as "dev" or "silver"). + * @see #description() + */ + public static OperationMode parse(String mode) { + try { + return OperationMode.valueOf(mode.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("unrecognised license operating mode [ " + mode + "], supported modes are [" + + Stream.of(values()).map(OperationMode::description).collect(Collectors.joining(",")) + "]"); + } + } + public String description() { return name().toLowerCase(Locale.ROOT); } @@ -218,13 +253,7 @@ private License(int version, String uid, String issuer, String issuedTo, long is } this.maxNodes = maxNodes; this.startDate = startDate; - if (version == VERSION_START) { - // in 1.x: the acceptable values for 'subscription_type': none | dev | silver | gold | platinum - this.operationMode = OperationMode.resolve(subscriptionType); - } else { - // in 2.x: the acceptable values for 'type': trial | basic | silver | dev | gold | platinum - this.operationMode = OperationMode.resolve(type); - } + this.operationMode = OperationMode.resolve(LicenseType.resolve(this)); validate(); } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/LicenseService.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/LicenseService.java index f049d23037801..aeb7fe5fcef9d 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/LicenseService.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/LicenseService.java @@ -48,6 +48,7 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Service responsible for managing {@link LicensesMetaData}. @@ -65,6 +66,12 @@ public class LicenseService extends AbstractLifecycleComponent implements Cluste return SelfGeneratedLicense.validateSelfGeneratedType(type); }, Setting.Property.NodeScope); + static final List ALLOWABLE_UPLOAD_TYPES = getAllowableUploadTypes(); + + public static final Setting> ALLOWED_LICENSE_TYPES_SETTING = Setting.listSetting("xpack.license.upload.types", + Collections.unmodifiableList(ALLOWABLE_UPLOAD_TYPES.stream().map(License.LicenseType::getTypeName).collect(Collectors.toList())), + License.LicenseType::parse, LicenseService::validateUploadTypesSetting, Setting.Property.NodeScope); + // pkg private for tests static final TimeValue NON_BASIC_SELF_GENERATED_LICENSE_DURATION = TimeValue.timeValueHours(30 * 24); @@ -105,6 +112,12 @@ public class LicenseService extends AbstractLifecycleComponent implements Cluste */ private List expirationCallbacks = new ArrayList<>(); + /** + * Which license types are permitted to be uploaded to the cluster + * @see #ALLOWED_LICENSE_TYPES_SETTING + */ + private final List allowedLicenseTypes; + /** * Max number of nodes licensed by generated trial license */ @@ -124,6 +137,7 @@ public LicenseService(Settings settings, ClusterService clusterService, Clock cl this.clock = clock; this.scheduler = new SchedulerEngine(settings, clock); this.licenseState = licenseState; + this.allowedLicenseTypes = ALLOWED_LICENSE_TYPES_SETTING.get(settings); this.operationModeFileWatcher = new OperationModeFileWatcher(resourceWatcherService, XPackPlugin.resolveConfigFile(env, "license_mode"), logger, () -> updateLicenseState(getLicensesMetaData())); @@ -197,8 +211,20 @@ public void registerLicense(final PutLicenseRequest request, final ActionListene final long now = clock.millis(); if (!LicenseVerifier.verifyLicense(newLicense) || newLicense.issueDate() > now || newLicense.startDate() > now) { listener.onResponse(new PutLicenseResponse(true, LicensesStatus.INVALID)); - } else if (newLicense.type().equals(License.LicenseType.BASIC.getTypeName())) { + return; + } + final License.LicenseType licenseType; + try { + licenseType = License.LicenseType.resolve(newLicense); + } catch (Exception e) { + listener.onFailure(e); + return; + } + if (licenseType == License.LicenseType.BASIC) { listener.onFailure(new IllegalArgumentException("Registering basic licenses is not allowed.")); + } else if (isAllowedLicenseType(licenseType) == false) { + listener.onFailure(new IllegalArgumentException( + "Registering [" + licenseType.getTypeName() + "] licenses is not allowed on this cluster")); } else if (newLicense.expiryDate() < now) { listener.onResponse(new PutLicenseResponse(true, LicensesStatus.EXPIRED)); } else { @@ -273,6 +299,11 @@ private static boolean licenseIsCompatible(License license, Version version) { } } + private boolean isAllowedLicenseType(License.LicenseType type) { + logger.debug("Checking license [{}] against allowed license types: {}", type, allowedLicenseTypes); + return allowedLicenseTypes.contains(type); + } + public static Map getAckMessages(License newLicense, License currentLicense) { Map acknowledgeMessages = new HashMap<>(); if (!License.isAutoGeneratedLicense(currentLicense.signature()) // current license is not auto-generated @@ -575,4 +606,20 @@ private static boolean isProductionMode(Settings settings, DiscoveryNode localNo private static boolean isBoundToLoopback(DiscoveryNode localNode) { return localNode.getAddress().address().getAddress().isLoopbackAddress(); } + + private static List getAllowableUploadTypes() { + return Collections.unmodifiableList(Stream.of(License.LicenseType.values()) + .filter(t -> t != License.LicenseType.BASIC) + .collect(Collectors.toList())); + } + + private static void validateUploadTypesSetting(List value) { + if (ALLOWABLE_UPLOAD_TYPES.containsAll(value) == false) { + throw new IllegalArgumentException("Invalid value [" + + value.stream().map(License.LicenseType::getTypeName).collect(Collectors.joining(",")) + + "] for " + ALLOWED_LICENSE_TYPES_SETTING.getKey() + ", allowed values are [" + + ALLOWABLE_UPLOAD_TYPES.stream().map(License.LicenseType::getTypeName).collect(Collectors.joining(",")) + + "]"); + } + } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/OperationModeFileWatcher.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/OperationModeFileWatcher.java index b8e6446b9f49f..ee08b9f7330cf 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/OperationModeFileWatcher.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/OperationModeFileWatcher.java @@ -106,7 +106,7 @@ private synchronized void onChange(Path file) { // this UTF-8 conversion is much pickier than java String final String operationMode = new BytesRef(content).utf8ToString(); try { - newOperationMode = OperationMode.resolve(operationMode); + newOperationMode = OperationMode.parse(operationMode); } catch (IllegalArgumentException e) { logger.error( (Supplier) () -> new ParameterizedMessage( diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/RemoteClusterLicenseChecker.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/RemoteClusterLicenseChecker.java index 7d5a3b5e9a53d..5de1186767f4b 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/RemoteClusterLicenseChecker.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/RemoteClusterLicenseChecker.java @@ -138,7 +138,7 @@ public RemoteClusterLicenseChecker(final Client client, final Predicate> getSettings() { settings.addAll(XPackSettings.getAllSettings()); settings.add(LicenseService.SELF_GENERATED_LICENSE_TYPE); + settings.add(LicenseService.ALLOWED_LICENSE_TYPES_SETTING); // we add the `xpack.version` setting to all internal indices settings.add(Setting.simpleString("index.xpack.version", Setting.Property.IndexScope)); diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ml/inference/TrainedModelConfig.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ml/inference/TrainedModelConfig.java index 21e145546f8b7..343a520d9b5d3 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ml/inference/TrainedModelConfig.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ml/inference/TrainedModelConfig.java @@ -138,7 +138,7 @@ public static TrainedModelConfig.Builder fromXContent(XContentParser parser, boo throw new IllegalArgumentException("[" + ESTIMATED_OPERATIONS.getPreferredName() + "] must be greater than or equal to 0"); } this.estimatedOperations = estimatedOperations; - this.licenseLevel = License.OperationMode.resolve(ExceptionsHelper.requireNonNull(licenseLevel, LICENSE_LEVEL)); + this.licenseLevel = License.OperationMode.parse(ExceptionsHelper.requireNonNull(licenseLevel, LICENSE_LEVEL)); } public TrainedModelConfig(StreamInput in) throws IOException { @@ -153,7 +153,7 @@ public TrainedModelConfig(StreamInput in) throws IOException { input = new TrainedModelInput(in); estimatedHeapMemory = in.readVLong(); estimatedOperations = in.readVLong(); - licenseLevel = License.OperationMode.resolve(in.readString()); + licenseLevel = License.OperationMode.parse(in.readString()); } public String getModelId() { diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseFIPSTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseFIPSTests.java index c432a207fcb70..eb357661d50ca 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseFIPSTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseFIPSTests.java @@ -34,6 +34,11 @@ public void testFIPSCheckWithAllowedLicense() throws Exception { licenseService.start(); PlainActionFuture responseFuture = new PlainActionFuture<>(); licenseService.registerLicense(request, responseFuture); + if (responseFuture.isDone()) { + // If the future is done, it means request/license validation failed. + // In which case, this `actionGet` should throw a more useful exception than the verify below. + responseFuture.actionGet(); + } verify(clusterService).submitStateUpdateTask(any(String.class), any(ClusterStateUpdateTask.class)); } @@ -67,6 +72,11 @@ public void testFIPSCheckWithoutAllowedLicense() throws Exception { setInitialState(null, licenseState, settings); licenseService.start(); licenseService.registerLicense(request, responseFuture); + if (responseFuture.isDone()) { + // If the future is done, it means request/license validation failed. + // In which case, this `actionGet` should throw a more useful exception than the verify below. + responseFuture.actionGet(); + } verify(clusterService).submitStateUpdateTask(any(String.class), any(ClusterStateUpdateTask.class)); } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseOperationModeTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseOperationModeTests.java index 648f48ff2ea13..a1fbfbe6c6a41 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseOperationModeTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseOperationModeTests.java @@ -57,7 +57,8 @@ public void testResolveUnknown() { for (String type : types) { try { - OperationMode.resolve(type); + final License.LicenseType licenseType = License.LicenseType.resolve(type); + OperationMode.resolve(licenseType); fail(String.format(Locale.ROOT, "[%s] should not be recognized as an operation mode", type)); } @@ -69,7 +70,8 @@ public void testResolveUnknown() { private static void assertResolve(OperationMode expected, String... types) { for (String type : types) { - assertThat(OperationMode.resolve(type), equalTo(expected)); + License.LicenseType licenseType = License.LicenseType.resolve(type); + assertThat(OperationMode.resolve(licenseType), equalTo(expected)); } } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseOperationModeUpdateTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseOperationModeUpdateTests.java index a69331287918b..20df885261fed 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseOperationModeUpdateTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseOperationModeUpdateTests.java @@ -34,7 +34,7 @@ public void init() throws Exception { } public void testLicenseOperationModeUpdate() throws Exception { - String type = randomFrom("trial", "basic", "standard", "gold", "platinum"); + License.LicenseType type = randomFrom(License.LicenseType.values()); License license = License.builder() .uid("id") .expiryDate(0) diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseServiceTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseServiceTests.java index 750b3d67c5f62..08c334fd2e5d1 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseServiceTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/license/LicenseServiceTests.java @@ -6,12 +6,47 @@ package org.elasticsearch.license; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.cluster.AckedClusterStateUpdateTask; +import org.elasticsearch.cluster.ClusterState; +import org.elasticsearch.cluster.ClusterStateUpdateTask; +import org.elasticsearch.cluster.metadata.MetaData; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.license.licensor.LicenseSigner; +import org.elasticsearch.protocol.xpack.license.LicensesStatus; +import org.elasticsearch.protocol.xpack.license.PutLicenseResponse; import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.TestMatchers; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import java.io.IOException; +import java.nio.file.Path; +import java.time.Clock; import java.time.LocalDate; import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.startsWith; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; /** * Due to changes in JDK9 where locale data is used from CLDR, the licence message will differ in jdk 8 and jdk9+ @@ -30,4 +65,133 @@ public void testLogExpirationWarning() { assertThat(message, startsWith("License [will expire] on [Thursday, November 15, 2018].\n")); } } + + /** + * Tests loading a license when {@link LicenseService#ALLOWED_LICENSE_TYPES_SETTING} is on its default value (all license types) + */ + public void testRegisterLicenseWithoutTypeRestrictions() throws Exception { + assertRegisterValidLicense(Settings.EMPTY, + randomValueOtherThan(License.LicenseType.BASIC, () -> randomFrom(License.LicenseType.values()))); + } + + /** + * Tests loading a license when {@link LicenseService#ALLOWED_LICENSE_TYPES_SETTING} is set, + * and the uploaded license type matches + */ + public void testSuccessfullyRegisterLicenseMatchingTypeRestrictions() throws Exception { + final List allowed = randomSubsetOf( + randomIntBetween(1, LicenseService.ALLOWABLE_UPLOAD_TYPES.size() - 1), LicenseService.ALLOWABLE_UPLOAD_TYPES); + final List allowedNames = allowed.stream().map(License.LicenseType::getTypeName).collect(Collectors.toList()); + final Settings settings = Settings.builder() + .putList("xpack.license.upload.types", allowedNames) + .build(); + assertRegisterValidLicense(settings, randomFrom(allowed)); + } + + /** + * Tests loading a license when {@link LicenseService#ALLOWED_LICENSE_TYPES_SETTING} is set, + * and the uploaded license type does not match + */ + public void testFailToRegisterLicenseNotMatchingTypeRestrictions() throws Exception { + final List allowed = randomSubsetOf( + randomIntBetween(1, LicenseService.ALLOWABLE_UPLOAD_TYPES.size() - 2), LicenseService.ALLOWABLE_UPLOAD_TYPES); + final List allowedNames = allowed.stream().map(License.LicenseType::getTypeName).collect(Collectors.toList()); + final Settings settings = Settings.builder() + .putList("xpack.license.upload.types", allowedNames) + .build(); + final License.LicenseType notAllowed = randomValueOtherThanMany( + test -> allowed.contains(test), + () -> randomFrom(LicenseService.ALLOWABLE_UPLOAD_TYPES)); + assertRegisterDisallowedLicenseType(settings, notAllowed); + } + + private void assertRegisterValidLicense(Settings baseSettings, License.LicenseType licenseType) throws IOException { + tryRegisterLicense(baseSettings, licenseType, + future -> assertThat(future.actionGet().status(), equalTo(LicensesStatus.VALID))); + } + + private void assertRegisterDisallowedLicenseType(Settings baseSettings, License.LicenseType licenseType) throws IOException { + tryRegisterLicense(baseSettings, licenseType, future -> { + final IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, future::actionGet); + assertThat(exception, TestMatchers.throwableWithMessage( + "Registering [" + licenseType.getTypeName() + "] licenses is not allowed on " + "this cluster")); + }); + } + + private void tryRegisterLicense(Settings baseSettings, License.LicenseType licenseType, + Consumer> assertion) throws IOException { + final Settings settings = Settings.builder() + .put(baseSettings) + .put("path.home", createTempDir()) + .put("discovery.type", "single-node") // So we skip TLS checks + .build(); + + final ClusterState clusterState = Mockito.mock(ClusterState.class); + Mockito.when(clusterState.metaData()).thenReturn(MetaData.EMPTY_META_DATA); + + final ClusterService clusterService = Mockito.mock(ClusterService.class); + Mockito.when(clusterService.state()).thenReturn(clusterState); + + final Clock clock = randomBoolean() ? Clock.systemUTC() : Clock.systemDefaultZone(); + final Environment env = TestEnvironment.newEnvironment(settings); + final ResourceWatcherService resourceWatcherService = Mockito.mock(ResourceWatcherService.class); + final XPackLicenseState licenseState = Mockito.mock(XPackLicenseState.class); + final LicenseService service = new LicenseService(settings, clusterService, clock, env, resourceWatcherService, licenseState); + + final PutLicenseRequest request = new PutLicenseRequest(); + request.license(spec(licenseType, TimeValue.timeValueDays(randomLongBetween(1, 1000))), XContentType.JSON); + final PlainActionFuture future = new PlainActionFuture<>(); + service.registerLicense(request, future); + + if (future.isDone()) { + // If validation failed, the future might be done without calling the updater task. + assertion.accept(future); + } else { + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(ClusterStateUpdateTask.class); + verify(clusterService, times(1)).submitStateUpdateTask(any(), taskCaptor.capture()); + + final ClusterStateUpdateTask task = taskCaptor.getValue(); + assertThat(task, instanceOf(AckedClusterStateUpdateTask.class)); + ((AckedClusterStateUpdateTask) task).onAllNodesAcked(null); + + assertion.accept(future); + } + } + + private BytesReference spec(License.LicenseType type, TimeValue expires) throws IOException { + final License signed = sign(buildLicense(type, expires)); + return toSpec(signed); + } + + private BytesReference toSpec(License license) throws IOException { + XContentBuilder builder = XContentFactory.contentBuilder(XContentType.JSON); + builder.startObject(); + builder.startObject("license"); + license.toInnerXContent(builder, ToXContent.EMPTY_PARAMS); + builder.endObject(); + builder.endObject(); + builder.flush(); + return BytesReference.bytes(builder); + } + + private License sign(License license) throws IOException { + final Path publicKey = getDataPath("/public.key"); + final Path privateKey = getDataPath("/private.key"); + final LicenseSigner signer = new LicenseSigner(privateKey, publicKey); + + return signer.sign(license); + } + + private License buildLicense(License.LicenseType type, TimeValue expires) { + return License.builder() + .uid(new UUID(randomLong(), randomLong()).toString()) + .type(type) + .expiryDate(System.currentTimeMillis() + expires.millis()) + .issuer(randomAlphaOfLengthBetween(5, 60)) + .issuedTo(randomAlphaOfLengthBetween(5, 60)) + .issueDate(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(randomLongBetween(1, 5000))) + .maxNodes(randomIntBetween(1, 500)) + .signature(null) + .build(); + } }