diff --git a/transact-spring-boot-starter/src/main/java/dev/dbos/transact/spring/DBOSAutoConfiguration.java b/transact-spring-boot-starter/src/main/java/dev/dbos/transact/spring/DBOSAutoConfiguration.java index 99e1d20f1..03ab74987 100644 --- a/transact-spring-boot-starter/src/main/java/dev/dbos/transact/spring/DBOSAutoConfiguration.java +++ b/transact-spring-boot-starter/src/main/java/dev/dbos/transact/spring/DBOSAutoConfiguration.java @@ -131,6 +131,9 @@ private DBOSConfig buildConfig(DBOSProperties props, String springAppName) { config = config.withConductorDomain(props.getConductor().getDomain()); } config = config.withConductorMetadataOnlyMode(props.getConductor().isMetadataOnlyMode()); + if (props.getTransactEnterpriseKey() != null) { + config = config.withTransactEnterpriseKey(props.getTransactEnterpriseKey()); + } if (props.getApplication().getVersion() != null) { config = config.withAppVersion(props.getApplication().getVersion()); } diff --git a/transact-spring-boot-starter/src/main/java/dev/dbos/transact/spring/DBOSProperties.java b/transact-spring-boot-starter/src/main/java/dev/dbos/transact/spring/DBOSProperties.java index a45898a42..463d66897 100644 --- a/transact-spring-boot-starter/src/main/java/dev/dbos/transact/spring/DBOSProperties.java +++ b/transact-spring-boot-starter/src/main/java/dev/dbos/transact/spring/DBOSProperties.java @@ -28,6 +28,12 @@ public class DBOSProperties { /** Executor ID for this instance. */ private String executorId; + /** + * The DBOS key that entitles this application to Transact Enterprise features. When not set, the + * Conductor key stands in for it. + */ + private String transactEnterpriseKey; + /** Whether to enable workflow patching. Defaults to {@code false}. */ private boolean enablePatching = false; @@ -242,6 +248,14 @@ public void setExecutorId(String executorId) { this.executorId = executorId; } + public String getTransactEnterpriseKey() { + return transactEnterpriseKey; + } + + public void setTransactEnterpriseKey(String transactEnterpriseKey) { + this.transactEnterpriseKey = transactEnterpriseKey; + } + public boolean isEnablePatching() { return enablePatching; } diff --git a/transact-spring-boot-starter/src/test/java/dev/dbos/transact/spring/DBOSAutoConfigurationTest.java b/transact-spring-boot-starter/src/test/java/dev/dbos/transact/spring/DBOSAutoConfigurationTest.java index 7560282a6..d9c49d8f9 100644 --- a/transact-spring-boot-starter/src/test/java/dev/dbos/transact/spring/DBOSAutoConfigurationTest.java +++ b/transact-spring-boot-starter/src/test/java/dev/dbos/transact/spring/DBOSAutoConfigurationTest.java @@ -49,6 +49,23 @@ void dbosConfigReflectsAppNameProperty() { }); } + @Test + void emptyKeysAreUnset() { + // As from placeholders whose environment variables are unset: no Conductor connection, and no + // enterprise key. + runner + .withPropertyValues( + "dbos.conductor.key=", "dbos.conductor.domain=", "dbos.transact-enterprise-key=") + .run( + context -> { + assertThat(context).hasNotFailed(); + var config = context.getBean(DBOSConfig.class); + assertThat(config.conductorKey()).isNull(); + assertThat(config.conductorDomain()).isNull(); + assertThat(config.transactEnterpriseKey()).isNull(); + }); + } + @Test void dbosConfigReflectsDatasourceProperties() { runner @@ -73,6 +90,7 @@ void dbosConfigReflectsOptionalProperties() { "dbos.conductor.key=my-key", "dbos.conductor.domain=my-domain", "dbos.conductor.metadata-only-mode=true", + "dbos.transact-enterprise-key=my-enterprise-key", "dbos.application.version=1.2.3", "dbos.executor-id=exec-1", "dbos.datasource.schema=my_schema", @@ -88,6 +106,7 @@ void dbosConfigReflectsOptionalProperties() { assertThat(config.conductorKey()).isEqualTo("my-key"); assertThat(config.conductorDomain()).isEqualTo("my-domain"); assertThat(config.conductorMetadataOnlyMode()).isTrue(); + assertThat(config.transactEnterpriseKey()).isEqualTo("my-enterprise-key"); assertThat(config.appVersion()).isEqualTo("1.2.3"); assertThat(config.executorId()).isEqualTo("exec-1"); assertThat(config.databaseSchema()).isEqualTo("my_schema"); diff --git a/transact-spring-boot-starter/src/test/java/dev/dbos/transact/spring/DBOSPropertiesTest.java b/transact-spring-boot-starter/src/test/java/dev/dbos/transact/spring/DBOSPropertiesTest.java index fd93963b7..8b895d886 100644 --- a/transact-spring-boot-starter/src/test/java/dev/dbos/transact/spring/DBOSPropertiesTest.java +++ b/transact-spring-boot-starter/src/test/java/dev/dbos/transact/spring/DBOSPropertiesTest.java @@ -38,6 +38,7 @@ void defaults() { assertNull(props.getConductor().getDomain()); assertFalse(props.getConductor().isMetadataOnlyMode()); assertNull(props.getExecutorId()); + assertNull(props.getTransactEnterpriseKey()); assertNull(props.getDatasource().getSchema()); assertNull(props.getSchedulerPollingInterval()); assertNotNull(props.getDatasource()); @@ -103,6 +104,12 @@ void bindsConductorProperties() { assertTrue(props.getConductor().isMetadataOnlyMode()); } + @Test + void bindsTheTransactEnterpriseKey() { + var props = bind(Map.of("dbos.transact-enterprise-key", "my-enterprise-key")); + assertEquals("my-enterprise-key", props.getTransactEnterpriseKey()); + } + @Test void bindsIdentityProperties() { var props = bind(Map.of("dbos.executor-id", "exec-1")); diff --git a/transact/src/main/java/dev/dbos/transact/conductor/ConductorProviders.java b/transact/src/main/java/dev/dbos/transact/conductor/ConductorProviders.java index c81413764..1eafe299b 100644 --- a/transact/src/main/java/dev/dbos/transact/conductor/ConductorProviders.java +++ b/transact/src/main/java/dev/dbos/transact/conductor/ConductorProviders.java @@ -1,20 +1,13 @@ package dev.dbos.transact.conductor; -import dev.dbos.transact.DBOS; +import dev.dbos.transact.internal.EnterpriseProviders; -import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.ServiceConfigurationError; -import java.util.ServiceLoader; -import java.util.regex.Pattern; /** Finds the {@link ConductorProvider} on the class path. */ public final class ConductorProviders { - static final String ENTERPRISE_ARTIFACT = "dev.dbos:transact-enterprise"; - - private static final Pattern RELEASE = Pattern.compile("\\d+\\.\\d+\\.\\d+"); - private static final Pattern MINOR = Pattern.compile("(\\d+\\.\\d+)\\..*"); + // Connecting to Conductor needs the enterprise artifact, but no entitlement. + private static final String FEATURE = "Connecting to DBOS Conductor"; private ConductorProviders() {} @@ -27,66 +20,10 @@ private ConductorProviders() {} * initialize */ public static ConductorProvider load() { - var loaders = new ArrayList(); - var context = Thread.currentThread().getContextClassLoader(); - if (context != null) { - loaders.add(context); - } - var own = ConductorProvider.class.getClassLoader(); - if (own != context) { - loaders.add(own); - } - - // One instance per provider class, constructed once: a loader that delegates to its parent sees - // the parent's registrations too, so duplicates are dropped before they are instantiated. - var providers = new LinkedHashMap, ConductorProvider>(); - try { - for (var loader : loaders) { - ServiceLoader.load(ConductorProvider.class, loader).stream() - .filter(provider -> !providers.containsKey(provider.type())) - .forEach(provider -> providers.put(provider.type(), provider.get())); - } - } catch (ServiceConfigurationError e) { - // A provider's constructor rejected this DBOS, e.g. for a version mismatch. Its own message - // says why; ServiceLoader's names the provider. - var cause = e.getCause(); - var message = - cause != null && cause.getMessage() != null - ? e.getMessage() + ": " + cause.getMessage() - : e.getMessage(); - throw new IllegalStateException(message, e); - } - return select(List.copyOf(providers.values()), DBOS.version()); + return EnterpriseProviders.load(ConductorProvider.class, FEATURE); } static ConductorProvider select(List providers, String version) { - if (providers.isEmpty()) { - throw new IllegalStateException( - "Connecting to DBOS Conductor requires " - + ENTERPRISE_ARTIFACT - + " on the class path. " - + versionAdvice(version)); - } - if (providers.size() > 1) { - var names = providers.stream().map(p -> p.getClass().getName()).toList(); - throw new IllegalStateException( - "Found more than one DBOS Conductor provider on the class path: " + names); - } - return providers.get(0); - } - - // Previews are numbered by commits in each repo, so only a release names the exact version. - private static String versionAdvice(String version) { - if (version != null && RELEASE.matcher(version).matches()) { - return String.format( - "Add %s:%s, the same version as dev.dbos:transact", ENTERPRISE_ARTIFACT, version); - } - var minor = version == null ? null : MINOR.matcher(version); - if (minor != null && minor.matches()) { - return String.format( - "Add a %s %s build, as dev.dbos:transact is %s", - ENTERPRISE_ARTIFACT, minor.group(1), version); - } - return "Add the " + ENTERPRISE_ARTIFACT + " version that matches dev.dbos:transact"; + return EnterpriseProviders.select(ConductorProvider.class, providers, version, FEATURE); } } diff --git a/transact/src/main/java/dev/dbos/transact/config/DBOSConfig.java b/transact/src/main/java/dev/dbos/transact/config/DBOSConfig.java index c43e9c502..c9454a994 100644 --- a/transact/src/main/java/dev/dbos/transact/config/DBOSConfig.java +++ b/transact/src/main/java/dev/dbos/transact/config/DBOSConfig.java @@ -42,9 +42,12 @@ * @param adminServerPort port for the built-in admin HTTP server; deprecated and will be removed * before 1.0 — use DBOS Conductor instead * @param migrate whether to run database migrations on startup - * @param conductorKey API key for DBOS Conductor; must not be empty if non-null. Connecting to - * Conductor requires {@code dev.dbos:transact-enterprise} on the class path - * @param conductorDomain custom hostname for DBOS Conductor; must not be empty if non-null + * @param conductorKey API key for DBOS Conductor; null or blank to not connect. Surrounding + * whitespace is removed. Connecting to Conductor requires {@code dev.dbos:transact-enterprise} + * on the class path. Masked in {@link #toString()} + * @param conductorDomain URL of a self-hosted DBOS Conductor, such as {@code ws://localhost:8090}; + * null or blank for DBOS's hosted Conductor. Surrounding whitespace is removed, and credentials + * in it are masked in {@link #toString()} * @param conductorExecutorMetadata arbitrary key-value metadata attached to this executor's * registration in Conductor * @param appVersion application version string used for versioned workflow routing; must not be @@ -76,6 +79,9 @@ * workflow data: no inputs, outputs, errors, step outputs, events, notifications, streams, * schedule context, exports or imports. Requires {@code dev.dbos:transact-enterprise}, which * enforces it + * @param transactEnterpriseKey the DBOS key that entitles this application to Transact Enterprise + * features. A blank value is the same as null, and when it is null, {@code conductorKey} stands + * in for it. Surrounding whitespace is removed. Masked in {@link #toString()} */ public record DBOSConfig( @NonNull String appName, @@ -100,7 +106,8 @@ public record DBOSConfig( @Nullable Duration notificationCoalesceInterval, @Nullable Integer databasePollingConcurrency, @Nullable Duration systemDatabaseIdleTransactionTimeout, - boolean conductorMetadataOnlyMode) { + boolean conductorMetadataOnlyMode, + @Nullable String transactEnterpriseKey) { /** Default flush interval for batched stream and workflow-event notifications. */ public static final Duration DEFAULT_NOTIFICATION_COALESCE_INTERVAL = Duration.ofMillis(10); @@ -128,13 +135,13 @@ public record DBOSConfig( if (appName == null || appName.isEmpty()) { throw new IllegalArgumentException("DBOSConfig.appName must not be null or empty"); } - if (conductorKey != null && conductorKey.isEmpty()) { - throw new IllegalArgumentException("DBOSConfig.conductorKey must not be empty if specified"); - } - if (conductorDomain != null && conductorDomain.isEmpty()) { - throw new IllegalArgumentException( - "DBOSConfig.conductorDomain must not be empty if specified"); - } + // A blank Conductor setting is unset, as in the other SDKs, so an empty placeholder (an unset + // environment variable) means the default instead of failing. Surrounding whitespace, such as + // the trailing newline of a secret file, is not part of the value, so the entitlement check + // and the Conductor client always see the same one. + conductorKey = setting(conductorKey); + conductorDomain = setting(conductorDomain); + transactEnterpriseKey = setting(transactEnterpriseKey); if (appVersion != null && appVersion.isEmpty()) { throw new IllegalArgumentException("DBOSConfig.appVersion must not be empty if specified"); } @@ -199,6 +206,25 @@ public int adminServerPort() { return adminServerPort; } + private static @Nullable String setting(@Nullable String value) { + if (value == null) { + return null; + } + var stripped = value.strip(); + return stripped.isEmpty() ? null : stripped; + } + + /** + * The domain with everything up to its last {@code @} replaced, for printing. That masks any + * {@code user:password@}, even one with a raw '/' or '@' in the password, at the cost of also + * masking a path that contains '@'. + */ + private static @Nullable String withoutCredentials(@Nullable String domain) { + return domain == null + ? null + : domain.replaceFirst("(?s)^((?:[A-Za-z][A-Za-z0-9+.-]*://)?).*@", "$1***@"); + } + // Copy constructor public DBOSConfig(DBOSConfig other) { this( @@ -226,7 +252,8 @@ public DBOSConfig(DBOSConfig other) { other.notificationCoalesceInterval, other.databasePollingConcurrency, other.systemDatabaseIdleTransactionTimeout, - other.conductorMetadataOnlyMode); + other.conductorMetadataOnlyMode, + other.transactEnterpriseKey); } /** @@ -248,7 +275,8 @@ public DBOSConfig(DBOSConfig other) { null, // notificationCoalesceInterval null, // databasePollingConcurrency null, // systemDatabaseIdleTransactionTimeout - false); // conductorMetadataOnlyMode + false, // conductorMetadataOnlyMode + null); // transactEnterpriseKey } /** @@ -301,7 +329,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** Returns a copy of this config with {@code databaseUrl} set to {@code v}. */ @@ -329,7 +358,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** Returns a copy of this config with {@code dbUser} set to {@code v}. */ @@ -357,7 +387,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** Returns a copy of this config with {@code dbPassword} set to {@code v}. */ @@ -385,7 +416,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** @@ -418,7 +450,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** @@ -451,7 +484,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** @@ -484,7 +518,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** Returns a copy of this config with {@code migrate} set to {@code v}. */ @@ -512,12 +547,14 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** - * Returns a copy of this config with {@code conductorKey} set to {@code v}. Connecting to - * Conductor requires {@code dev.dbos:transact-enterprise} on the class path. + * Returns a copy of this config with {@code conductorKey} set to {@code v}; null or blank to not + * connect. Connecting to Conductor requires {@code dev.dbos:transact-enterprise} on the class + * path. */ public @NonNull DBOSConfig withConductorKey(@Nullable String v) { return new DBOSConfig( @@ -543,10 +580,15 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } - /** Returns a copy of this config with {@code conductorDomain} set to {@code v}. */ + /** + * Returns a copy of this config with {@code conductorDomain} set to {@code v}: the URL of a + * self-hosted DBOS Conductor, such as {@code ws://localhost:8090}; null or blank for DBOS's + * hosted Conductor. + */ public @NonNull DBOSConfig withConductorDomain(@Nullable String v) { return new DBOSConfig( appName, @@ -571,7 +613,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** Returns a copy of this config with {@code conductorExecutorMetadata} set to {@code v}. */ @@ -599,7 +642,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** Returns a copy of this config with {@code appVersion} set to {@code v}. */ @@ -627,7 +671,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** Returns a copy of this config with {@code executorId} set to {@code v}. */ @@ -655,7 +700,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** Returns a copy of this config with {@code databaseSchema} set to {@code v}. */ @@ -683,7 +729,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** Returns a copy of this config with {@code enablePatching} set to {@code true}. */ @@ -721,7 +768,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** @@ -845,7 +893,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** Returns a copy of this config with {@code serializer} set to {@code v}. */ @@ -873,7 +922,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** Returns a copy of this config with {@code schedulerPollingInterval} set to {@code v}. */ @@ -901,7 +951,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** Returns a copy of this config with {@code useListenNotify} set to {@code v}. */ @@ -929,7 +980,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** @@ -969,7 +1021,8 @@ public DBOSConfig(DBOSConfig other) { v, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** @@ -1008,7 +1061,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, v, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** @@ -1053,7 +1107,8 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, v, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey); } /** @@ -1087,10 +1142,45 @@ public DBOSConfig(DBOSConfig other) { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, + v, + transactEnterpriseKey); + } + + /** + * Returns a copy of this config with {@code transactEnterpriseKey} set to {@code v}: the DBOS key + * that entitles this application to Transact Enterprise features. When it is null or blank, the + * {@code conductorKey} stands in for it. + */ + public @NonNull DBOSConfig withTransactEnterpriseKey(@Nullable String v) { + return new DBOSConfig( + appName, + databaseUrl, + dbUser, + dbPassword, + dataSource, + adminServer, + adminServerPort, + migrate, + conductorKey, + conductorDomain, + conductorExecutorMetadata, + appVersion, + executorId, + databaseSchema, + enablePatching, + listenQueues, + serializer, + schedulerPollingInterval, + useListenNotify, + notificationCoalesceInterval, + databasePollingConcurrency, + systemDatabaseIdleTransactionTimeout, + conductorMetadataOnlyMode, v); } - // Override toString to mask the DB password + // Override toString to mask the DB password, the Conductor and Transact Enterprise keys, and any + // credentials in the Conductor domain @Override public String toString() { return """ @@ -1100,7 +1190,8 @@ public String toString() { conductorExecutorMetadata=%s, appVersion=%s, executorId=%s, enablePatching=%s, \ listenQueues=%s, serializer=%s, schedulerPollingInterval=%s, useListenNotify=%s, \ notificationCoalesceInterval=%s, databasePollingConcurrency=%s, \ - systemDatabaseIdleTransactionTimeout=%s, conductorMetadataOnlyMode=%s] + systemDatabaseIdleTransactionTimeout=%s, conductorMetadataOnlyMode=%s, \ + transactEnterpriseKey=%s] """ .formatted( appName, @@ -1111,8 +1202,8 @@ public String toString() { adminServer, adminServerPort, migrate, - conductorKey, - conductorDomain, + conductorKey == null ? null : "***", + withoutCredentials(conductorDomain), conductorExecutorMetadata, appVersion, executorId, @@ -1124,6 +1215,7 @@ public String toString() { notificationCoalesceInterval, databasePollingConcurrency, systemDatabaseIdleTransactionTimeout, - conductorMetadataOnlyMode); + conductorMetadataOnlyMode, + transactEnterpriseKey == null ? null : "***"); } } diff --git a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java index 73f350021..9809bb9b3 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -36,6 +36,7 @@ import dev.dbos.transact.exceptions.DBOSWorkflowFunctionNotFoundException; import dev.dbos.transact.exceptions.DBOSWorkflowIdInUseException; import dev.dbos.transact.internal.AppVersionComputer; +import dev.dbos.transact.internal.Entitlements; import dev.dbos.transact.internal.StepCheckpointStore; import dev.dbos.transact.internal.Validation; import dev.dbos.transact.internal.WorkflowRegistry; @@ -246,6 +247,15 @@ public DBOSExecutor(DBOSConfig config) { logger.warn(msg); } + // Every Transact Enterprise feature needs Conductor to report the organization entitled to it. + // Checked before anything is started, so a refusal fails launch cleanly. + Entitlements.require( + Entitlements.enabledFeatures(config), + config.transactEnterpriseKey(), + config.conductorKey(), + config.conductorDomain(), + dbosCloud); + // Found before anything is started, so a missing provider fails launch cleanly. An // application that will not connect, including one on DBOS Cloud without Conductor settings, // needs no provider. @@ -439,11 +449,8 @@ public void start( if (conductorKey == null) { return null; } - String domain = config.conductorDomain(); - if (domain != null && domain.trim().isEmpty()) { - domain = null; - } - return new ConductorOptions(conductorKey, null, domain, config.conductorMetadataOnlyMode()); + return new ConductorOptions( + conductorKey, null, config.conductorDomain(), config.conductorMetadataOnlyMode()); } @Nullable ConductorOptions conductorOptions() { diff --git a/transact/src/main/java/dev/dbos/transact/internal/EnterpriseProviders.java b/transact/src/main/java/dev/dbos/transact/internal/EnterpriseProviders.java new file mode 100644 index 000000000..abe569f0c --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/internal/EnterpriseProviders.java @@ -0,0 +1,101 @@ +package dev.dbos.transact.internal; + +import dev.dbos.transact.DBOS; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.ServiceConfigurationError; +import java.util.ServiceLoader; +import java.util.regex.Pattern; + +/** + * Finds the implementation of a Transact Enterprise provider interface on the class path. Core + * declares each interface; the {@code dev.dbos:transact-enterprise} artifact registers its + * implementations with {@link ServiceLoader}. A feature that needs a license is loaded only once + * {@link Entitlements#require} has passed for it. + */ +public final class EnterpriseProviders { + public static final String ENTERPRISE_ARTIFACT = "dev.dbos:transact-enterprise"; + + private static final Pattern RELEASE = Pattern.compile("\\d+\\.\\d+\\.\\d+"); + private static final Pattern MINOR = Pattern.compile("(\\d+\\.\\d+)\\..*"); + + private EnterpriseProviders() {} + + /** + * Returns the one registered implementation of {@code type}. Looks in the thread context class + * loader and in the one that loaded DBOS, so a provider deployed beside the application is found + * when DBOS itself is a shared library, as in a servlet container. + * + * @param type the provider interface + * @param feature what needs the provider, as errors name it (e.g. "Connecting to DBOS Conductor") + * @throws IllegalStateException if none is registered, more than one is, or one fails to + * initialize + */ + public static T load(Class type, String feature) { + var loaders = new ArrayList(); + var context = Thread.currentThread().getContextClassLoader(); + if (context != null) { + loaders.add(context); + } + var own = type.getClassLoader(); + if (own != context) { + loaders.add(own); + } + + // One instance per provider class, constructed once: a loader that delegates to its parent sees + // the parent's registrations too, so duplicates are dropped before they are instantiated. + var providers = new LinkedHashMap, T>(); + try { + for (var loader : loaders) { + ServiceLoader.load(type, loader).stream() + .filter(provider -> !providers.containsKey(provider.type())) + .forEach(provider -> providers.put(provider.type(), provider.get())); + } + } catch (ServiceConfigurationError e) { + // A provider's constructor rejected this DBOS, e.g. for a version mismatch. Its own message + // says why; ServiceLoader's names the provider. + var cause = e.getCause(); + var message = + cause != null && cause.getMessage() != null + ? e.getMessage() + ": " + cause.getMessage() + : e.getMessage(); + throw new IllegalStateException(message, e); + } + return select(type, List.copyOf(providers.values()), DBOS.version(), feature); + } + + /** The one provider in {@code providers}, or an error naming the artifact to add. */ + public static T select(Class type, List providers, String version, String feature) { + if (providers.isEmpty()) { + throw new IllegalStateException( + feature + + " requires " + + ENTERPRISE_ARTIFACT + + " on the class path. " + + versionAdvice(version)); + } + if (providers.size() > 1) { + var names = providers.stream().map(p -> p.getClass().getName()).toList(); + throw new IllegalStateException( + "Found more than one " + type.getSimpleName() + " on the class path: " + names); + } + return providers.get(0); + } + + // Previews are numbered by commits in each repo, so only a release names the exact version. + private static String versionAdvice(String version) { + if (version != null && RELEASE.matcher(version).matches()) { + return String.format( + "Add %s:%s, the same version as dev.dbos:transact", ENTERPRISE_ARTIFACT, version); + } + var minor = version == null ? null : MINOR.matcher(version); + if (minor != null && minor.matches()) { + return String.format( + "Add a %s %s build, as dev.dbos:transact is %s", + ENTERPRISE_ARTIFACT, minor.group(1), version); + } + return "Add the " + ENTERPRISE_ARTIFACT + " version that matches dev.dbos:transact"; + } +} diff --git a/transact/src/main/java/dev/dbos/transact/internal/Entitlements.java b/transact/src/main/java/dev/dbos/transact/internal/Entitlements.java new file mode 100644 index 000000000..241ecf353 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/internal/Entitlements.java @@ -0,0 +1,480 @@ +package dev.dbos.transact.internal; + +import dev.dbos.transact.DBOS; +import dev.dbos.transact.config.DBOSConfig; + +import java.io.IOException; +import java.net.ProxySelector; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * The entitlement check every Transact Enterprise feature passes before DBOS loads it: DBOS + * Conductor must report the key's organization entitled to the feature. + * + *

Transact Enterprise ({@code dev.dbos:transact-enterprise}) is proprietary, not MIT-licensed + * like this project. Its license (https://www.dbos.dev/conductor-license) forbids modifying it, and + * makes disabling or circumventing the check here a material breach. + */ +public final class Entitlements { + private static final Logger logger = LoggerFactory.getLogger(Entitlements.class); + + /** + * A Transact Enterprise feature. + * + * @param entitlement the name Conductor reports the entitlement under, in camelCase + * @param description what the feature does, as messages name it + */ + public record Feature(String entitlement, String description) {} + + static final String ENTITLEMENTS_PATH = "/v2/entitlements"; + static final Duration TIMEOUT = Duration.ofSeconds(10); + static final String HOSTED_CONDUCTOR_URL = "wss://cloud.dbos.dev/conductor/v1alpha1"; + // The hosted Conductor's entitlement route, which identifies it however its URL is written. + static final String HOSTED_ROUTE = "https://cloud.dbos.dev/conductor" + ENTITLEMENTS_PATH; + // Conductor's machine-readable error code, in this header, for an org past its plan limit. + private static final String ERROR_CODE_HEADER = "X-DBOS-Error"; + private static final String PAST_LIMIT = "past_limit"; + // From the hosted Conductor, these mean it could not answer, not that it refused the key. + private static final Set HOSTED_UNAVAILABLE = Set.of(403, 407, 408, 425, 429); + private static final String LICENSE_URL = "https://www.dbos.dev/conductor-license"; + private static final String LICENSE_NOTICE = + "Transact Enterprise is licensed under the DBOS Conductor Software License (" + + LICENSE_URL + + "), which makes disabling or circumventing this check a material breach."; + private static final String KEY_HINT = + "Set transactEnterpriseKey or conductorKey in your DBOSConfig."; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private Entitlements() {} + + /** + * The enterprise features a config turns on. Each entitled feature adds its switch here when it + * lands, so the check runs for it at launch. + */ + public static List enabledFeatures(DBOSConfig config) { + return List.of(); + } + + /** + * Refuses to go on unless Conductor reports the organization entitled to every feature in {@code + * features}. A self-hosted Conductor may answer without a key. DBOS's hosted Conductor fails + * open, with a warning, when it cannot answer; any other fails closed. DBOS Cloud passes without + * a check. + * + *

A license control: the Transact Enterprise license makes disabling or circumventing it a + * material breach. + * + * @param features the features to check; nothing is checked when empty + * @param enterpriseKey the configured Transact Enterprise key, or null + * @param conductorKey the configured Conductor key, which stands in for a missing enterprise key + * @param conductorUrl the configured Conductor URL, or null for DBOS's hosted Conductor + * @param dbosCloud whether this application runs in DBOS Cloud + * @throws IllegalStateException if the check refuses, naming why + */ + public static void require( + List features, + @Nullable String enterpriseKey, + @Nullable String conductorKey, + @Nullable String conductorUrl, + boolean dbosCloud) { + if (features.isEmpty() || dbosCloud) { + return; + } + check( + features, + enterpriseKey, + conductorKey, + conductorUrl, + System.getenv("DBOS_DOMAIN"), + http(TIMEOUT)); + } + + /** The answer to the entitlement request. */ + record Answer(int status, @Nullable String errorCode, String body) {} + + /** Sends the entitlement request. */ + @FunctionalInterface + interface Http { + Answer get(URI uri, Map headers) throws IOException, InterruptedException; + } + + static void check( + List features, + @Nullable String enterpriseKey, + @Nullable String conductorKey, + @Nullable String conductorUrl, + @Nullable String dbosDomain, + Http http) { + if (features.isEmpty()) { + return; + } + var names = join(features, Feature::description); + var entitlements = join(features, Feature::entitlement); + // As DBOSConfig reads them: blank is unset, and surrounding whitespace (a trailing newline from + // a secret file) is not part of the value. + var key = setting(enterpriseKey); + if (key == null) { + key = setting(conductorKey); + } + conductorUrl = setting(conductorUrl); + var target = conductorUrl != null ? conductorUrl : defaultConductorUrl(dbosDomain); + String url; + try { + url = entitlementUrl(target); + } catch (InvalidUrl e) { + // The URL is not quoted: it may carry credentials. + throw new IllegalStateException( + names + + " requires an entitled DBOS key, but the Conductor URL is invalid: " + + e.getMessage()); + } + // DBOS's hosted Conductor however its URL is written (a trailing slash, upper case), by the + // route the check asks. + var hosted = url.equalsIgnoreCase(HOSTED_ROUTE); + // Only a Conductor named explicitly is asked without a key, and never DBOS's hosted one. + if (key == null && (conductorUrl == null || hosted)) { + throw new IllegalStateException(names + " requires a DBOS key. " + KEY_HINT); + } + // A DBOS key is printable ASCII. Anything else is refused here, unquoted, rather than by the + // HTTP client, whose message would quote the key. + if (key != null && !key.chars().allMatch(c -> c > 0x20 && c < 0x7f)) { + throw new IllegalStateException( + names + + " requires a DBOS key, but the configured key is not one: it contains a character" + + " other than printable ASCII."); + } + var headers = new HashMap(); + headers.put("User-Agent", "dbos-transact-java/" + DBOS.version()); + headers.put("Accept", "application/json"); + if (key != null) { + headers.put("Authorization", "Bearer " + key); + } + + String failure; + Exception cause = null; + try { + var answer = http.get(URI.create(url), headers); + var status = answer.status(); + if (status >= 200 && status < 300) { + var granted = parseEntitlements(answer.body()); + var missing = + features.stream().filter(f -> !granted.getOrDefault(f.entitlement(), false)).toList(); + if (!missing.isEmpty()) { + throw refusal( + "Your DBOS organization is not entitled to " + + join(missing, Feature::entitlement) + + " (" + + join(missing, Feature::description) + + "): its plan does not include it. Contact DBOS to enable it."); + } + return; + } + // Conductor's own words, but never the key, should an answer echo it. + var detail = redact(problemDetail(answer.body()), key); + var reason = detail != null ? ": " + detail : ""; + if (status == 403 && PAST_LIMIT.equals(answer.errorCode())) { + throw refusal( + "Your DBOS organization is past its plan limit, so it is not entitled to " + + entitlements + + " (" + + names + + "). Upgrade its plan to continue."); + } + if (hosted && HOSTED_UNAVAILABLE.contains(status)) { + // An outage, handled below. + } else if (status == 404 && detail == null) { + // A bare 404 is a missing route: a Conductor predating per-feature entitlements. + throw refusal( + names + + " requires an entitled DBOS key, but Conductor at " + + url + + " has no entitlements route (HTTP 404). Upgrade Conductor, or check its URL."); + } else if (status == 401 && key == null) { + throw new IllegalStateException( + names + + " requires a DBOS key: Conductor at " + + url + + " refused to check without one (HTTP 401)" + + reason + + ". " + + KEY_HINT); + } else if (status == 401) { + // An unknown key, or a user credential instead of an API key. + throw refusal( + names + + " requires a valid DBOS key. Conductor at " + + url + + " rejected the key (HTTP 401)" + + reason); + } else if (status < 500) { + // Any other refusal, such as the key's organization being gone, or a redirect. + throw refusal( + names + + " requires an entitled DBOS key, but Conductor at " + + url + + " refused the check (HTTP " + + status + + ")" + + reason); + } + failure = "HTTP " + status + reason; + } catch (MalformedAnswer e) { + failure = e.getMessage(); + cause = e; + } catch (IOException e) { + rethrowError(e); + failure = e.getMessage() != null ? redact(e.getMessage(), key) : e.getClass().getSimpleName(); + cause = e; + } catch (IllegalArgumentException e) { + // The HTTP client refused to send the request: not an outage, so it never fails open. Its + // message may quote a header, so it is not passed on. + throw refusal( + names + + " requires an entitled DBOS key, but the entitlement request to Conductor at " + + url + + " could not be sent."); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while checking the DBOS key's entitlements with Conductor at " + url, e); + } + if (hosted) { + logger.warn( + "Could not verify the DBOS key's entitlements with Conductor at {} ({}); starting anyway.", + url, + failure); + return; + } + // Any other Conductor fails closed, so an unreachable URL cannot stand in for a key. + var refused = + refusal( + names + + " requires an entitled DBOS key, but checking it with Conductor at " + + url + + " failed (" + + failure + + "). Retry once Conductor is reachable."); + if (cause != null) { + refused.initCause(cause); + } + throw refused; + } + + /** DBOS's hosted Conductor, on the domain {@code DBOS_DOMAIN} names. */ + static String defaultConductorUrl(@Nullable String dbosDomain) { + var domain = dbosDomain == null || dbosDomain.isBlank() ? "cloud.dbos.dev" : dbosDomain.trim(); + return "wss://" + domain + "/conductor/v1alpha1"; + } + + /** {@code text} with every occurrence of {@code key} masked, for a diagnostic. */ + private static @Nullable String redact(@Nullable String text, @Nullable String key) { + return text == null || key == null ? text : text.replace(key, "***"); + } + + /** A setting without surrounding whitespace, or null if it is unset or blank. */ + private static @Nullable String setting(@Nullable String value) { + if (value == null) { + return null; + } + var stripped = value.strip(); + return stripped.isEmpty() ? null : stripped; + } + + /** A Conductor URL that cannot be used, with a message that never quotes it. */ + static final class InvalidUrl extends Exception { + InvalidUrl(String message) { + super(message); + } + } + + /** The entitlement route of the Conductor at {@code conductorUrl}, over HTTP(S). */ + static String entitlementUrl(String conductorUrl) throws InvalidUrl { + URI uri; + try { + uri = new URI(conductorUrl); + } catch (URISyntaxException e) { + // The reason, but not the message, which quotes the whole URL. + throw new InvalidUrl(e.getReason()); + } + var scheme = uri.getScheme(); + var authority = uri.getRawAuthority(); + if (scheme == null || authority == null) { + throw new InvalidUrl("it is not an absolute URL"); + } + scheme = + switch (scheme.toLowerCase()) { + // The Conductor client is a WebSocket client, which takes only these. + case "wss" -> "https"; + case "ws" -> "http"; + default -> throw new InvalidUrl("its scheme is not ws or wss"); + }; + // A host the parser cannot identify (an underscore, a raw '/' or '@' in a password) leaves the + // credentials unseparated. The Conductor client cannot connect to such a URL either. + if (uri.getHost() == null) { + throw new InvalidUrl("its host is not valid"); + } + // A raw '/' in a password can also parse as a port and a path ("user:2024/pass@host"), so the + // host is wrong and the rest of the password sits in the path. A Conductor URL never has '@' + // after its authority. + for (var part : new String[] {uri.getRawPath(), uri.getRawQuery(), uri.getRawFragment()}) { + if (part != null && part.contains("@")) { + throw new InvalidUrl("it has an '@' after its host"); + } + } + if (uri.getPort() > 65535) { + throw new InvalidUrl("its port is out of range"); + } + // Without any user:password@, which would otherwise be sent and quoted in errors. + var host = uri.getHost() + (uri.getPort() == -1 ? "" : ":" + uri.getPort()); + // The hosted websocket URL carries an API version; routes sit beside it. + var path = uri.getRawPath() == null ? "" : uri.getRawPath().replaceAll("/+$", ""); + if (path.endsWith("/v1alpha1")) { + path = path.substring(0, path.length() - "/v1alpha1".length()); + } + return scheme + "://" + host + path + ENTITLEMENTS_PATH; + } + + /** Sends the request over HTTP, giving the whole exchange, body included, {@code timeout}. */ + static Http http(Duration timeout) { + return (uri, headers) -> { + var builder = HttpClient.newBuilder().connectTimeout(timeout); + // Never follow a redirect: it would carry the key to whatever host it names. + builder.followRedirects(HttpClient.Redirect.NEVER); + // Built per call, so proxy settings are read when the check runs. + var proxies = ProxySelector.getDefault(); + if (proxies != null) { + builder.proxy(proxies); + } + // No request timeout: it would end at the headers, and race the deadline below. + var request = HttpRequest.newBuilder(uri).GET(); + headers.forEach(request::header); + var pending = + builder.build().sendAsync(request.build(), HttpResponse.BodyHandlers.ofString()); + HttpResponse response; + try { + // One deadline for the whole exchange, body included, so an answer that stalls mid-body + // cannot hold up launch. + response = pending.get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + pending.cancel(true); + throw new HttpTimeoutException("no answer within " + timeout.toMillis() + " ms"); + } catch (InterruptedException e) { + pending.cancel(true); + throw e; + } catch (ExecutionException e) { + // The exchange failed in flight: an outage. The JDK reports some malformed answers (a + // non-numeric Content-Length) as an IllegalArgumentException, which must not read as a + // request this client refused to send. A port out of range, which the client would also + // refuse in flight, is refused by entitlementUrl before any request. + var cause = e.getCause(); + rethrowError(cause); + if (cause instanceof IOException io) { + throw io; + } + throw new IOException( + "the exchange failed (" + cause.getClass().getSimpleName() + ")", cause); + } + return new Answer( + response.statusCode(), + response.headers().firstValue(ERROR_CODE_HEADER).orElse(null), + response.body()); + }; + } + + /** + * Throws the first {@link Error} in {@code failure}'s cause chain. Not an outage: a damaged JVM + * must never fail open, and the HTTP client reports one (an OutOfMemoryError in its selector + * thread) as the cause of an IOException. + */ + private static void rethrowError(Throwable failure) { + var seen = Collections.newSetFromMap(new IdentityHashMap()); + for (var t = failure; t != null && seen.add(t); t = t.getCause()) { + if (t instanceof Error error) { + throw error; + } + } + } + + /** An answer that is not an entitlement list, which counts as Conductor failing to answer. */ + private static final class MalformedAnswer extends Exception { + MalformedAnswer(String message) { + super(message); + } + } + + private static Map parseEntitlements(String body) throws MalformedAnswer { + JsonNode parsed; + try { + parsed = MAPPER.readTree(body); + } catch (JacksonException e) { + throw new MalformedAnswer("the response is not JSON"); + } + var entitlements = parsed == null ? null : parsed.get("entitlements"); + if (entitlements == null || !entitlements.isObject()) { + throw new MalformedAnswer("the response has no entitlements"); + } + var granted = new HashMap(); + // Anything but true grants nothing. + for (var entry : entitlements.properties()) { + granted.put(entry.getKey(), entry.getValue().isBoolean() && entry.getValue().booleanValue()); + } + return granted; + } + + /** The reason Conductor gave in its JSON error body, or null if it sent none. */ + private static @Nullable String problemDetail(String body) { + JsonNode parsed; + try { + parsed = MAPPER.readTree(body); + } catch (JacksonException e) { + // Not JSON: a bare router answer, such as "404 page not found". + return null; + } + if (parsed == null || !parsed.isObject()) { + return null; + } + for (var field : List.of("detail", "message")) { + var value = parsed.get(field); + if (value == null || value.isNull()) { + continue; + } + var text = value.isString() ? value.asString() : value.toString(); + if (!text.isEmpty()) { + return text; + } + } + return null; + } + + private static IllegalStateException refusal(String message) { + return new IllegalStateException(message + " " + LICENSE_NOTICE); + } + + private static String join(List features, Function name) { + return features.stream().map(name).collect(Collectors.joining(" and ")); + } +} diff --git a/transact/src/test/java/dev/dbos/transact/config/ConfigTest.java b/transact/src/test/java/dev/dbos/transact/config/ConfigTest.java index ac7f9cd3a..ddc9fe711 100644 --- a/transact/src/test/java/dev/dbos/transact/config/ConfigTest.java +++ b/transact/src/test/java/dev/dbos/transact/config/ConfigTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -235,9 +236,10 @@ public void cantSetEmptyConfigFields() throws Exception { assertThrows(IllegalArgumentException.class, () -> config.withAppName("")); assertThrows(IllegalArgumentException.class, () -> config.withAppName(null)); - assertThrows(IllegalArgumentException.class, () -> config.withConductorKey("")); + // Blank Conductor settings are unset rather than refused (see DBOSConfigKeysTest). + assertNull(config.withConductorKey("").conductorKey()); assertDoesNotThrow(() -> config.withConductorKey(null)); - assertThrows(IllegalArgumentException.class, () -> config.withConductorDomain("")); + assertNull(config.withConductorDomain("").conductorDomain()); assertDoesNotThrow(() -> config.withConductorDomain(null)); assertThrows(IllegalArgumentException.class, () -> config.withExecutorId("")); assertDoesNotThrow(() -> config.withExecutorId(null)); diff --git a/transact/src/test/java/dev/dbos/transact/config/DBOSConfigKeysTest.java b/transact/src/test/java/dev/dbos/transact/config/DBOSConfigKeysTest.java new file mode 100644 index 000000000..50cc450ce --- /dev/null +++ b/transact/src/test/java/dev/dbos/transact/config/DBOSConfigKeysTest.java @@ -0,0 +1,106 @@ +package dev.dbos.transact.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** The Conductor key and domain and the Transact Enterprise key, which DBOSConfig handles alike. */ +public class DBOSConfigKeysTest { + + @Test + public void theKeysAreUnsetByDefault() { + for (var config : + new DBOSConfig[] {DBOSConfig.defaults("app"), DBOSConfig.defaultsFromEnv("app")}) { + assertNull(config.conductorKey()); + assertNull(config.transactEnterpriseKey()); + } + } + + @Test + public void theKeysAreKeptThroughCopiesAndOtherSettings() { + var config = + DBOSConfig.defaults("app") + .withConductorKey("conductor-key") + .withTransactEnterpriseKey("enterprise-key"); + var copy = new DBOSConfig(config); + assertEquals("conductor-key", copy.conductorKey()); + assertEquals("enterprise-key", copy.transactEnterpriseKey()); + var changed = config.withConductorMetadataOnlyMode(true).withAppName("x"); + assertEquals("conductor-key", changed.conductorKey()); + assertEquals("enterprise-key", changed.transactEnterpriseKey()); + assertNull(changed.withTransactEnterpriseKey(null).transactEnterpriseKey()); + assertNull(changed.withConductorKey(null).conductorKey()); + } + + @Test + public void aBlankKeyIsUnset() { + // So an empty placeholder, such as an unset environment variable, means no key instead of + // failing: no Conductor connection, and the Conductor key standing in for the enterprise key. + for (var blank : new String[] {"", " ", "\n"}) { + var config = + DBOSConfig.defaults("app") + .withConductorKey(blank) + .withConductorDomain(blank) + .withTransactEnterpriseKey(blank); + assertNull(config.conductorKey(), "conductorKey " + blank); + assertNull(config.conductorDomain(), "conductorDomain " + blank); + assertNull(config.transactEnterpriseKey(), "transactEnterpriseKey " + blank); + } + } + + @Test + public void surroundingWhitespaceIsRemoved() { + // As read from a secret file that ends in a newline: the entitlement check and the Conductor + // client must both see the value without it. + var config = + DBOSConfig.defaults("app") + .withConductorKey(" conductor-key\n") + .withConductorDomain(" ws://localhost:8090\r\n") + .withTransactEnterpriseKey("enterprise-key\n"); + assertEquals("conductor-key", config.conductorKey()); + assertEquals("ws://localhost:8090", config.conductorDomain()); + assertEquals("enterprise-key", config.transactEnterpriseKey()); + } + + @Test + public void credentialsInTheConductorDomainAreMaskedInToString() { + var text = + DBOSConfig.defaults("app") + .withConductorDomain("wss://user:secret@conductor.example.com/conductor/v1alpha1") + .toString(); + assertFalse(text.contains("secret"), text); + assertFalse(text.contains("user"), text); + assertTrue( + text.contains("conductorDomain=wss://***@conductor.example.com/conductor/v1alpha1"), text); + // Even with a raw '@' or '/' in the password. + for (var domain : + new String[] { + "wss://user:p@secret@host/conductor/v1alpha1", "ws://user:pa/secret@host/x" + }) { + var masked = DBOSConfig.defaults("app").withConductorDomain(domain).toString(); + assertFalse(masked.contains("secret"), masked); + assertFalse(masked.contains("user"), masked); + } + var plain = DBOSConfig.defaults("app").withConductorDomain("ws://localhost:8090").toString(); + assertTrue(plain.contains("conductorDomain=ws://localhost:8090,"), plain); + } + + @Test + public void theKeysAreMaskedInToString() { + var text = + DBOSConfig.defaults("app") + .withConductorKey("conductor-key") + .withTransactEnterpriseKey("enterprise-key") + .toString(); + assertFalse(text.contains("conductor-key"), text); + assertFalse(text.contains("enterprise-key"), text); + assertTrue(text.contains("conductorKey=***"), text); + assertTrue(text.contains("transactEnterpriseKey=***"), text); + var unset = DBOSConfig.defaults("app").toString(); + assertTrue(unset.contains("conductorKey=null"), unset); + assertTrue(unset.contains("transactEnterpriseKey=null"), unset); + } +} diff --git a/transact/src/test/java/dev/dbos/transact/internal/EntitlementsTest.java b/transact/src/test/java/dev/dbos/transact/internal/EntitlementsTest.java new file mode 100644 index 000000000..00b264b4e --- /dev/null +++ b/transact/src/test/java/dev/dbos/transact/internal/EntitlementsTest.java @@ -0,0 +1,829 @@ +package dev.dbos.transact.internal; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.dbos.transact.config.DBOSConfig; +import dev.dbos.transact.internal.Entitlements.Answer; +import dev.dbos.transact.internal.Entitlements.Feature; +import dev.dbos.transact.utils.LogCapture; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; + +import ch.qos.logback.classic.Level; +import com.sun.net.httpserver.HttpServer; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +public class EntitlementsTest { + + private static final Feature SOME_FEATURE = new Feature("someFeature", "Some feature"); + private static final Feature OTHER_FEATURE = new Feature("otherFeature", "Doing another thing"); + // Tests that read Entitlements' log, which all tests share: they never run alongside each other. + private static final String ENTITLEMENTS_LOG = "Entitlements log"; + private static final String HOSTED_ROUTE = "https://cloud.dbos.dev/conductor/v2/entitlements"; + + /** A local stand-in for Conductor's entitlements route. */ + static final class FakeConductor implements AutoCloseable { + final HttpServer server; + final String url; + final List> requests = + Collections.synchronizedList(new ArrayList<>()); + volatile int status = 200; + volatile String body = "{\"entitlements\": {\"someFeature\": true}}"; + volatile Map headers = Map.of(); + // When set, the answer's headers go out, then its body stalls until the server closes. + volatile boolean stall = false; + final CountDownLatch closed = new CountDownLatch(1); + + FakeConductor() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + exchange -> { + requests.add( + new java.util.AbstractMap.SimpleEntry<>( + exchange.getRequestURI().getPath(), + exchange.getRequestHeaders().getFirst("Authorization"))); + var payload = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + headers.forEach((name, value) -> exchange.getResponseHeaders().add(name, value)); + exchange.sendResponseHeaders(status, payload.length); + try (var out = exchange.getResponseBody()) { + if (stall) { + out.write(payload, 0, 1); + out.flush(); + try { + closed.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return; + } + out.write(payload); + } + }); + server.start(); + url = "ws://127.0.0.1:" + server.getAddress().getPort(); + } + + void reject(int status, String detail) { + reject(status, detail, Map.of()); + } + + void reject(int status, String detail, Map headers) { + this.status = status; + this.body = + "{\"title\": \"Rejected\", \"status\": " + status + ", \"detail\": \"" + detail + "\"}"; + this.headers = headers; + } + + void entitle(String json) { + this.status = 200; + this.body = "{\"entitlements\": " + json + "}"; + } + + @Override + public void close() { + closed.countDown(); + server.stop(0); + } + } + + private FakeConductor conductor; + + @BeforeEach + void startConductor() throws IOException { + conductor = new FakeConductor(); + } + + @AfterEach + void stopConductor() { + conductor.close(); + } + + /** Checks against the fake Conductor, over HTTP. */ + private void check(@Nullable String enterpriseKey, @Nullable String conductorKey) { + check(List.of(SOME_FEATURE), enterpriseKey, conductorKey); + } + + private void check( + List features, @Nullable String enterpriseKey, @Nullable String conductorKey) { + Entitlements.require(features, enterpriseKey, conductorKey, conductor.url, false); + } + + private IllegalStateException refused(@Nullable String enterpriseKey) { + return assertThrows(IllegalStateException.class, () -> check(enterpriseKey, null)); + } + + /** Checks against DBOS's hosted Conductor, answered by {@code http}. */ + private static void checkHosted(@Nullable String dbosDomain, Entitlements.Http http) { + Entitlements.check(List.of(SOME_FEATURE), "enterprise-key", null, null, dbosDomain, http); + } + + private static Entitlements.Http answering(int status, @Nullable String errorCode) { + return (uri, headers) -> new Answer(status, errorCode, ""); + } + + @Test + public void entitlementUrlSitsBesideTheConductorUrl() throws Exception { + assertEquals(HOSTED_ROUTE, Entitlements.entitlementUrl(Entitlements.HOSTED_CONDUCTOR_URL)); + assertEquals( + HOSTED_ROUTE, Entitlements.entitlementUrl("wss://cloud.dbos.dev/conductor/v1alpha1/")); + // Credentials in the URL are dropped, so they are never sent or quoted in errors. + assertEquals( + "https://conductor.example.com:8443/conductor/v2/entitlements", + Entitlements.entitlementUrl( + "wss://user:secret@conductor.example.com:8443/conductor/v1alpha1")); + assertEquals( + "http://[::1]:8090/v2/entitlements", + Entitlements.entitlementUrl("ws://user:secret@[::1]:8090")); + // A self-hosted Conductor serves its routes from the root. + assertEquals( + "http://localhost:8090/v2/entitlements", + Entitlements.entitlementUrl("ws://localhost:8090")); + assertEquals( + "https://conductor.example.com/v2/entitlements", + Entitlements.entitlementUrl("wss://conductor.example.com/")); + } + + @Test + public void anInvalidConductorUrlIsRefused() { + var e = + assertThrows( + IllegalStateException.class, + () -> + Entitlements.require( + List.of(SOME_FEATURE), "enterprise-key", null, "localhost:8090", false)); + assertTrue(e.getMessage().contains("the Conductor URL is invalid"), e.getMessage()); + } + + @ParameterizedTest + @ValueSource( + strings = { + // An unencoded % in the password, which java.net.URI rejects quoting the whole URL. + "wss://user:p%zzsecret@host/conductor/v1alpha1", + // A space in the password. + "wss://user:pa secret@host/conductor/v1alpha1", + // No scheme, so the user name reads as one. + "user:secret@host", + "htp://user:secret@host", + // A raw '/' in the password: the parser reads "user:pa" as the whole authority. + "ws://user:pa/secret@127.0.0.1:1/x", + // A raw '@' in the password. + "wss://user:p@secret@host/conductor/v1alpha1", + // A raw '/' after digits: "user:2024" parses as a host and port, and the rest as a path. + "wss://user:2024/secret@conductor.example.com/conductor/v1alpha1", + "ws://user:/secret@127.0.0.1:1", + // Not a WebSocket URL, which the Conductor client cannot use. + "https://user:secret@conductor.example.com" + }) + public void anInvalidConductorUrlIsNeverQuoted(String url) { + var e = + assertThrows( + IllegalStateException.class, + () -> Entitlements.require(List.of(SOME_FEATURE), "enterprise-key", null, url, false)); + assertTrue(e.getMessage().contains("the Conductor URL is invalid"), e.getMessage()); + assertFalse(e.getMessage().contains("secret"), e.getMessage()); + assertFalse(e.getMessage().contains("user"), e.getMessage()); + // Nor in the stack trace. + assertEquals(null, e.getCause()); + } + + @Test + public void theDefaultConductorFollowsTheDbosDomain() { + assertEquals(Entitlements.HOSTED_CONDUCTOR_URL, Entitlements.defaultConductorUrl(null)); + assertEquals(Entitlements.HOSTED_CONDUCTOR_URL, Entitlements.defaultConductorUrl(" ")); + assertEquals( + "wss://staging.dbos.dev/conductor/v1alpha1", + Entitlements.defaultConductorUrl("staging.dbos.dev")); + } + + @Test + public void anEntitledKeyUnlocksEnterpriseFeatures() { + check("enterprise-key", null); + check(null, "conductor-key"); + // The enterprise key is checked when both are set. + check("enterprise-key", "conductor-key"); + assertEquals( + List.of( + Map.entry("/v2/entitlements", "Bearer enterprise-key"), + Map.entry("/v2/entitlements", "Bearer conductor-key"), + Map.entry("/v2/entitlements", "Bearer enterprise-key")), + conductor.requests.stream().map(e -> Map.entry(e.getKey(), e.getValue())).toList()); + } + + @Test + public void aBlankEnterpriseKeyFallsBackToTheConductorKey() { + check(" ", "conductor-key"); + check("", "conductor-key"); + assertEquals(2, conductor.requests.size()); + conductor.requests.forEach(r -> assertEquals("Bearer conductor-key", r.getValue())); + } + + @Test + public void surroundingWhitespaceIsNotPartOfTheKey() { + // As read from a secret file that ends in a newline. + check("enterprise-key\n", null); + check(null, " conductor-key\r\n"); + assertEquals("Bearer enterprise-key", conductor.requests.get(0).getValue()); + assertEquals("Bearer conductor-key", conductor.requests.get(1).getValue()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "enterprise\nkey", + "enterprise\tkey", + "enterprise\u007fkey", + "enterprise\u0080key", + "enterprise\u00ffkey", + "enterprise\u20ackey", + "enterprise\u0100key", + "enterprise key" + }) + public void aKeyThatIsNotPrintableAsciiIsRefusedWithoutQuotingIt(String key) { + var e = refused(key); + assertTrue(e.getMessage().contains("other than printable ASCII"), e.getMessage()); + assertFalse(e.getMessage().contains("enterprise"), e.getMessage()); + assertEquals(List.of(), conductor.requests); + // Against the hosted Conductor too, where an outage would start anyway. + var asked = new ArrayList(); + var hosted = + assertThrows( + IllegalStateException.class, + () -> + Entitlements.check( + List.of(SOME_FEATURE), + key, + null, + null, + null, + (uri, headers) -> { + asked.add(uri); + return new Answer(200, null, "{\"entitlements\": {\"someFeature\": true}}"); + })); + assertFalse(hosted.getMessage().contains("enterprise"), hosted.getMessage()); + assertEquals(List.of(), asked); + } + + @Test + @ResourceLock(ENTITLEMENTS_LOG) + public void aRequestTheHttpClientRefusesFailsClosedEvenWhenHosted() { + // Not an outage, so the hosted Conductor's fail-open does not apply. The client's message, + // which can quote the Authorization header, is not passed on. + Entitlements.Http refusing = + (uri, headers) -> { + throw new IllegalArgumentException("invalid header value: \"Bearer secret\""); + }; + try (var logs = new LogCapture(Entitlements.class)) { + var e = assertThrows(IllegalStateException.class, () -> checkHosted(null, refusing)); + assertTrue(e.getMessage().contains("could not be sent"), e.getMessage()); + assertFalse(e.getMessage().contains("secret"), e.getMessage()); + assertEquals(null, e.getCause()); + assertEquals(List.of(), logs.messages(Level.TRACE, "")); + } + } + + @ParameterizedTest + @CsvSource({"ws://127.0.0.1:70000, its port is out of range"}) + public void aUrlTheHttpClientWouldRefuseInFlightIsInvalid(String url, String reason) { + var e = + assertThrows( + IllegalStateException.class, + () -> Entitlements.require(List.of(SOME_FEATURE), "enterprise-key", null, url, false)); + assertTrue(e.getMessage().contains("the Conductor URL is invalid: " + reason), e.getMessage()); + } + + @Test + public void aHostTheParserCannotIdentifyIsInvalid() { + // Such as an underscore in the host. The Conductor client cannot connect to it either. + var e = + assertThrows( + IllegalStateException.class, + () -> + Entitlements.require( + List.of(SOME_FEATURE), + "enterprise-key", + null, + "ws://conductor_svc:8090", + false)); + assertTrue(e.getMessage().contains("its host is not valid"), e.getMessage()); + } + + /** + * A local server that answers its first request with a non-numeric Content-Length, which the JDK + * reports as a NumberFormatException: an IllegalArgumentException that is the server's fault. + */ + private static ServerSocket malformedServer() throws IOException { + var server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()); + var answering = + new Thread( + () -> { + try (var socket = server.accept()) { + var in = socket.getInputStream(); + // Read the request up to its blank line. + var seen = 0; + while (seen < 4) { + var c = in.read(); + if (c < 0) { + return; + } + seen = (c == '\r' || c == '\n') ? seen + 1 : 0; + } + socket + .getOutputStream() + .write( + "HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\n" + .getBytes(StandardCharsets.US_ASCII)); + socket.getOutputStream().flush(); + Thread.sleep(2000); + } catch (IOException | InterruptedException e) { + // The test is over. + } + }); + answering.setDaemon(true); + answering.start(); + return server; + } + + @Test + public void aMalformedAnswerIsAnOutageNotARefusalToSend() throws Exception { + // An outage, which only the hosted Conductor survives, never "could not be sent". + try (var server = malformedServer()) { + var e = + assertThrows( + IllegalStateException.class, + () -> + Entitlements.require( + List.of(SOME_FEATURE), + "enterprise-key", + null, + "ws://127.0.0.1:" + server.getLocalPort(), + false)); + assertTrue(e.getMessage().contains("Retry once Conductor is reachable"), e.getMessage()); + assertFalse(e.getMessage().contains("could not be sent"), e.getMessage()); + } + } + + @Test + @ResourceLock(ENTITLEMENTS_LOG) + public void aMalformedAnswerFromTheHostedConductorStartsAnyway() throws Exception { + // The real HTTP client, with the hosted Conductor's request sent to the local server instead. + try (var server = malformedServer(); + var logs = new LogCapture(Entitlements.class)) { + var local = URI.create("http://127.0.0.1:" + server.getLocalPort() + "/v2/entitlements"); + var http = Entitlements.http(Duration.ofSeconds(5)); + var asked = new ArrayList(); + checkHosted( + null, + (uri, headers) -> { + asked.add(uri); + return http.get(local, headers); + }); + assertEquals(List.of(URI.create(HOSTED_ROUTE)), asked); + // Whatever the JDK calls the failure, it is an outage, which the hosted Conductor survives. + var warnings = logs.messages(Level.WARN, "starting anyway"); + assertEquals(1, warnings.size(), warnings.toString()); + } + } + + @Test + public void anAnswerThatEchoesTheKeyNeverQuotesIt() { + conductor.reject(401, "invalid API key: enterprise-key"); + var message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("rejected the key (HTTP 401): invalid API key: ***"), message); + assertFalse(message.contains("enterprise-key"), message); + conductor.reject(503, "no org for enterprise-key"); + message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("failed (HTTP 503: no org for ***)"), message); + assertFalse(message.contains("enterprise-key"), message); + } + + @Test + @ResourceLock(ENTITLEMENTS_LOG) + public void aTransportFailureThatEchoesTheKeyNeverQuotesIt() { + Entitlements.Http echoing = + (uri, headers) -> { + throw new IOException("bad answer to enterprise-key"); + }; + // Another Conductor fails closed, without the key in its message. + var e = + assertThrows( + IllegalStateException.class, + () -> + Entitlements.check( + List.of(SOME_FEATURE), + "enterprise-key", + null, + "ws://localhost:8090", + null, + echoing)); + assertTrue(e.getMessage().contains("failed (bad answer to ***)"), e.getMessage()); + assertFalse(e.getMessage().contains("enterprise-key"), e.getMessage()); + // The hosted one warns, without the key in its warning. + try (var logs = new LogCapture(Entitlements.class)) { + checkHosted(null, echoing); + var warnings = logs.messages(Level.WARN, "starting anyway"); + assertEquals(1, warnings.size(), warnings.toString()); + assertTrue(warnings.get(0).contains("bad answer to ***"), warnings.get(0)); + assertFalse(warnings.get(0).contains("enterprise-key"), warnings.get(0)); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "wss://cloud.dbos.dev/conductor/v1alpha1/", + "WSS://Cloud.DBOS.dev/conductor/v1alpha1", + "wss://cloud.dbos.dev/conductor" + }) + @ResourceLock(ENTITLEMENTS_LOG) + public void theHostedConductorIsRecognizedHoweverItsUrlIsWritten(String url) { + // Never asked without a key. + var asked = new ArrayList(); + var e = + assertThrows( + IllegalStateException.class, + () -> + Entitlements.check( + List.of(SOME_FEATURE), + null, + null, + url, + null, + (uri, headers) -> { + asked.add(uri); + return new Answer(200, null, "{\"entitlements\": {\"someFeature\": true}}"); + })); + assertTrue(e.getMessage().startsWith("Some feature requires a DBOS key."), e.getMessage()); + assertEquals(List.of(), asked); + // And its outage starts anyway, as for the default URL. + try (var logs = new LogCapture(Entitlements.class)) { + Entitlements.check( + List.of(SOME_FEATURE), + "enterprise-key", + null, + url, + null, + (uri, headers) -> { + throw new IOException("connection refused"); + }); + assertEquals(1, logs.messages(Level.WARN, "starting anyway").size()); + } + } + + @Test + public void aJvmErrorBehindAnOutageNeverStartsAnyway() { + // The HTTP client reports an OutOfMemoryError in its selector thread as the cause of an + // IOException. Against the hosted Conductor, reading that as an outage would fail open. + var oom = new OutOfMemoryError("test"); + Entitlements.Http failing = + (uri, headers) -> { + throw new IOException("selector manager closed", oom); + }; + assertSame(oom, assertThrows(OutOfMemoryError.class, () -> checkHosted(null, failing))); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "\n"}) + public void aBlankConductorUrlMeansTheHostedOne(String url) { + var asked = new ArrayList(); + Entitlements.check( + List.of(SOME_FEATURE), + "enterprise-key", + null, + url, + null, + (uri, headers) -> { + asked.add(uri); + return new Answer(200, null, "{\"entitlements\": {\"someFeature\": true}}"); + }); + assertEquals(List.of(URI.create(HOSTED_ROUTE)), asked); + } + + @Test + public void anAnswerThatStallsMidBodyTimesOut() { + // Headers arrive, then the body never finishes: the deadline covers the whole exchange. + conductor.stall = true; + var e = + assertTimeoutPreemptively( + Duration.ofSeconds(10), + () -> + assertThrows( + IllegalStateException.class, + () -> + Entitlements.check( + List.of(SOME_FEATURE), + "enterprise-key", + null, + conductor.url, + null, + Entitlements.http(Duration.ofMillis(500))))); + assertTrue(e.getMessage().contains("no answer within 500 ms"), e.getMessage()); + assertTrue(e.getMessage().contains("Retry once Conductor is reachable"), e.getMessage()); + } + + @Test + public void eachFeatureNeedsItsOwnEntitlement() { + conductor.entitle("{\"someFeature\": true, \"otherFeature\": false, \"sharding\": true}"); + check("enterprise-key", null); + var e = + assertThrows( + IllegalStateException.class, + () -> check(List.of(SOME_FEATURE, OTHER_FEATURE), "enterprise-key", null)); + var message = e.getMessage(); + // Names only what the org lacks, by entitlement and by what it does. + assertTrue(message.contains("not entitled to otherFeature (Doing another thing)"), message); + assertTrue(message.contains("plan does not include it"), message); + assertTrue(message.contains("DBOS Conductor Software License"), message); + assertFalse(message.contains("someFeature"), message); + assertFalse(message.contains("enterprise-key"), message); + // One request answers for every feature. + assertEquals(2, conductor.requests.size()); + } + + @Test + public void aPastLimitOrgIsToldToUpgrade() { + // Conductor refuses a past-limit org outright, tagging the 403 with a code. + conductor.reject( + 403, + "your DBOS account is past the free trial limit", + Map.of("X-DBOS-Error", "past_limit")); + var message = refused("enterprise-key").getMessage(); + assertTrue( + message.contains("past its plan limit, so it is not entitled to someFeature"), message); + assertTrue(message.contains("Upgrade its plan"), message); + // The code, not the status or the prose, marks it: a bare 403 is another refusal. + conductor.reject(403, "your DBOS account is past the free trial limit"); + message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("refused the check (HTTP 403)"), message); + assertFalse(message.contains("Upgrade its plan"), message); + } + + @Test + public void anEntitlementConductorDoesNotReportIsNotGranted() { + // A Conductor older than the feature doesn't list it, which grants nothing. + conductor.entitle("{\"autoUpgrade\": true}"); + var message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("not entitled to someFeature"), message); + // Anything but true grants nothing either. + conductor.entitle("{\"someFeature\": \"yes\"}"); + message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("not entitled to someFeature"), message); + } + + @Test + public void enterpriseFeaturesRequireAKey() { + // DBOS's hosted Conductor, implicit or named, is never asked without a key. + var asked = new ArrayList(); + Entitlements.Http http = + (uri, headers) -> { + asked.add(uri); + return new Answer(200, null, "{\"entitlements\": {\"someFeature\": true}}"); + }; + for (var url : new String[] {null, Entitlements.HOSTED_CONDUCTOR_URL}) { + var e = + assertThrows( + IllegalStateException.class, + () -> Entitlements.check(List.of(SOME_FEATURE), null, null, url, null, http)); + assertTrue( + e.getMessage().startsWith("Some feature requires a DBOS key. Set transactEnterpriseKey"), + e.getMessage()); + } + assertEquals(List.of(), asked); + } + + @Test + public void aSelfHostedConductorIsAskedWithoutAKey() { + // Without OAuth, a self-hosted Conductor grants every feature to any caller. + check(null, null); + assertEquals(1, conductor.requests.size()); + assertEquals("/v2/entitlements", conductor.requests.get(0).getKey()); + assertEquals(null, conductor.requests.get(0).getValue()); + // With OAuth, it wants a key. + conductor.reject(401, "this endpoint requires a DBOS API key"); + var message = refused(null).getMessage(); + assertTrue(message.contains("requires a DBOS key: Conductor at"), message); + assertTrue(message.contains("(HTTP 401): this endpoint requires a DBOS API key"), message); + assertTrue(message.contains("Set transactEnterpriseKey"), message); + } + + @Test + public void conductorCredentialsNeverReachErrors() { + conductor.reject(401, "invalid API key"); + var withCredentials = conductor.url.replace("ws://", "ws://user:secret@"); + var e = + assertThrows( + IllegalStateException.class, + () -> + Entitlements.require( + List.of(SOME_FEATURE), "enterprise-key", null, withCredentials, false)); + assertTrue(e.getMessage().contains("rejected the key (HTTP 401)"), e.getMessage()); + assertFalse(e.getMessage().contains("secret"), e.getMessage()); + assertFalse(e.getMessage().contains("user"), e.getMessage()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "invalid API key", + // A user credential, which the route refuses in favor of an API key. + "this endpoint requires a DBOS API key" + }) + public void aRejectedKeyIsRefused(String detail) { + conductor.reject(401, detail); + var message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("requires a valid DBOS key."), message); + assertTrue(message.contains("rejected the key (HTTP 401): " + detail), message); + assertFalse(message.contains("enterprise-key"), message); + } + + @ParameterizedTest + @CsvSource({"403, forbidden", "404, organization not found", "409, conflict"}) + public void anyOtherRefusalQuotesConductor(int status, String detail) { + conductor.reject(status, detail); + var message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("refused the check (HTTP " + status + "): " + detail), message); + assertFalse(message.contains("rejected the key"), message); + assertFalse(message.contains("enterprise-key"), message); + } + + @Test + public void aConductorWithoutTheRouteIsRefused() { + // An older Conductor's router answers an unknown route with no problem detail. + conductor.status = 404; + conductor.body = "404 page not found"; + var message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("has no entitlements route (HTTP 404)"), message); + } + + @Test + public void aRedirectIsRefusedAndNeverCarriesTheKey() throws IOException { + // Following a redirect would re-send the request, headers included, to the host the Location + // names. That would hand the key to whoever answered. + try (var elsewhere = new FakeConductor()) { + var target = elsewhere.url.replace("ws://", "http://"); + conductor.status = 302; + conductor.headers = Map.of("Location", target + "/v2/entitlements"); + var message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("refused the check (HTTP 302)"), message); + // The redirect target never saw the key, or anything at all. + assertEquals(List.of(), elsewhere.requests); + } + } + + @ParameterizedTest + @ValueSource(strings = {"[]", "{\"entitlements\": null}", "{\"entitled\": true}", "not json", ""}) + public void aMalformedAnswerCountsAsAnOutage(String body) { + conductor.body = body; + var message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("Retry once Conductor is reachable"), message); + } + + @Test + public void anUnreachableConductorBlocksStartup() throws IOException { + conductor.reject(503, "unavailable"); + var message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("checking it with Conductor at"), message); + assertTrue(message.contains("failed (HTTP 503: unavailable)"), message); + // A failure without a problem detail is reported by status alone. + conductor.status = 502; + conductor.body = "Bad Gateway"; + message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("failed (HTTP 502)."), message); + // A port nothing listens on. + int closed; + try (var socket = new ServerSocket(0)) { + closed = socket.getLocalPort(); + } + var e = + assertThrows( + IllegalStateException.class, + () -> + Entitlements.require( + List.of(SOME_FEATURE), + "enterprise-key", + null, + "ws://127.0.0.1:" + closed, + false)); + assertTrue(e.getMessage().contains("checking it with Conductor"), e.getMessage()); + assertTrue(e.getMessage().contains("failed"), e.getMessage()); + assertFalse(e.getMessage().contains("enterprise-key"), e.getMessage()); + } + + @Test + @ResourceLock(ENTITLEMENTS_LOG) + public void aHostedConductorOutageDoesNotBlockStartup() { + var asked = new ArrayList(); + Entitlements.Http unreachable = + (uri, headers) -> { + asked.add(uri); + throw new ConnectException("connection refused"); + }; + // No Conductor URL means DBOS's hosted one, whose outage only warns. + try (var logs = new LogCapture(Entitlements.class)) { + assertDoesNotThrow(() -> checkHosted(null, unreachable)); + var warnings = logs.messages(Level.WARN, "Could not verify the DBOS key"); + assertEquals(1, warnings.size(), warnings.toString()); + assertTrue(warnings.get(0).contains("connection refused"), warnings.get(0)); + assertFalse(warnings.get(0).contains("enterprise-key"), warnings.get(0)); + } + assertEquals(List.of(URI.create(HOSTED_ROUTE)), asked); + // Another domain is not DBOS's hosted Conductor, so its outage still refuses. + var e = + assertThrows( + IllegalStateException.class, () -> checkHosted("unreachable.example.com", unreachable)); + assertTrue(e.getMessage().contains("Retry once Conductor is reachable"), e.getMessage()); + } + + @ParameterizedTest + @ValueSource(ints = {403, 407, 408, 425, 429, 500, 503}) + @ResourceLock(ENTITLEMENTS_LOG) + public void hostedThrottlingAndProxiesCountAsOutages(int status) { + // From the hosted Conductor these mean it couldn't answer, not that it refused the key, so + // startup warns and goes on as for any hosted outage. + try (var logs = new LogCapture(Entitlements.class)) { + assertDoesNotThrow(() -> checkHosted(null, answering(status, null))); + var warnings = logs.messages(Level.WARN, "(HTTP " + status + ")"); + assertEquals(1, warnings.size(), warnings.toString()); + } + } + + @Test + public void hostedRefusalsStillRefuse() { + var e = + assertThrows( + IllegalStateException.class, () -> checkHosted(null, answering(403, "past_limit"))); + assertTrue(e.getMessage().contains("past its plan limit"), e.getMessage()); + e = assertThrows(IllegalStateException.class, () -> checkHosted(null, answering(401, null))); + assertTrue(e.getMessage().contains("rejected the key (HTTP 401)"), e.getMessage()); + } + + @Test + public void anotherConductorsThrottlingStillRefuses() { + conductor.reject(429, "slow down"); + var message = refused("enterprise-key").getMessage(); + assertTrue(message.contains("refused the check (HTTP 429)"), message); + } + + @Test + public void dbosCloudPassesWithoutACheck() { + Entitlements.require(List.of(SOME_FEATURE), null, null, conductor.url, true); + assertEquals(List.of(), conductor.requests); + } + + @Test + public void noFeatureNeedsNoCheck() { + Entitlements.require(List.of(), null, null, conductor.url, false); + Entitlements.require(List.of(), null, null, null, false); + assertEquals(List.of(), conductor.requests); + } + + @Test + public void aDefaultConfigTurnsOnNoEnterpriseFeature() { + assertEquals(List.of(), Entitlements.enabledFeatures(DBOSConfig.defaults("app"))); + } + + @Test + public void theRequestNamesTheSdk() { + var sent = new LinkedHashMap(); + Entitlements.check( + List.of(SOME_FEATURE), + "enterprise-key", + null, + conductor.url, + null, + (uri, headers) -> { + sent.putAll(headers); + return new Answer(200, null, "{\"entitlements\": {\"someFeature\": true}}"); + }); + assertTrue(sent.get("User-Agent").startsWith("dbos-transact-java/"), sent.toString()); + assertEquals("application/json", sent.get("Accept")); + assertEquals("Bearer enterprise-key", sent.get("Authorization")); + } +}