Skip to content

Check Transact Enterprise entitlements before loading an entitled feature - #664

Merged
devhawk merged 11 commits into
mainfrom
entitlement-check
Nov 14, 2026
Merged

devhawk merged 11 commits into
mainfrom
entitlement-check

Conversation

@devhawk

@devhawk devhawk commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator

Before DBOS loads a Transact Enterprise feature, it now asks DBOS Conductor whether the organization is entitled to it, as Python (dbos/_enterprise.py), TypeScript (src/enterprise.ts) and Go (dbos/entitlements.go) do. No Java feature needs an entitlement yet. Suspend mode, auto-upgrade and Kafka consumers will each add their config switch to Entitlements.enabledFeatures as they land, so in this PR the check never runs at launch.

The check

Entitlements.require runs when DBOSExecutor is constructed, before any enterprise provider is loaded. It sends GET <conductor>/v2/entitlements with the key as a bearer token.

Answer Result
2xx, every feature true pass
2xx, a feature missing or not true refuse
403 with X-DBOS-Error: past_limit refuse: past plan limit
401 refuse: no key or a rejected key
bare 404 refuse: Conductor has no entitlements route
any other 4xx, including a redirect refuse
5xx, network failure, timeout, malformed answer outage

Outages. DBOS's hosted Conductor fails open on an outage: it logs a warning and starts. Its 403, 407, 408, 425 and 429 count as outages too, unless the 403 carries past_limit. Any other Conductor fails closed. DBOS Cloud skips the check.

Keys. The enterprise key is used if set, otherwise the Conductor key. Only an explicitly configured Conductor is asked without a key, never the hosted one.

Every refusal is an IllegalStateException. An entitlement refusal cites the DBOS Conductor Software License.

Where Java goes beyond the other SDKs

  • One deadline. A single 10 s deadline bounds the whole exchange, body included. HttpRequest.timeout stops once headers arrive, so an answer that stalls mid-body would otherwise hang launch.
  • Unsendable requests and JVM errors never fail open. A key that is not printable ASCII is refused before any request, without being quoted. A request the HTTP client refuses to build or send is a refusal, not an outage. A JVM Error anywhere in a failure's cause chain propagates, so it can never be read as an outage.
  • Secrets stay out of messages and logs. The key and any user:password@ in the Conductor URL never reach a message, cause chain or log line. Redirects are never followed.
  • Only usable URLs. A Conductor URL must be ws:// or wss:// with a host java.net.URI can parse, and must have no @ after the host. Those are the URLs the WebSocket-based Conductor client can use. Anything else is refused as invalid, without quoting the URL.

Configuration

  • DBOSConfig.transactEnterpriseKey, with withTransactEnterpriseKey. The Spring Boot starter maps it as dbos.transact-enterprise-key.

  • conductorKey, conductorDomain and transactEnterpriseKey are now handled alike:

    • surrounding whitespace, such as a trailing newline from a secret file, is stripped;
    • a blank value means unset.

    This way the check and the Conductor client always see the same values.

  • toString masks both keys, and any credentials in conductorDomain. Before, conductorKey was printed in clear.

Provider loading

The ServiceLoader lookup behind ConductorProviders moved to EnterpriseProviders.load(type, feature), so each entitled feature's provider interface gets the same class-loader handling and the same "requires dev.dbos:transact-enterprise" error. ConductorProviders.load() keeps its signature.

Behaviour changes for the release notes

  • An empty conductorKey or conductorDomain no longer throws. It means unset. For example, dbos.conductor.key=${KEY:} with KEY unset now starts without Conductor.
  • A whitespace-only conductorKey used to connect to Conductor with that value as its key. It now means no Conductor.
  • Surrounding whitespace is stripped from conductorKey, conductorDomain and transactEnterpriseKey.
  • DBOSConfig.toString masks conductorKey and any credentials in conductorDomain.

Tests

  • EntitlementsTest ports Python's entitlement tests and adds cases for the points above:

    • a stalled body;
    • malformed answers, hosted and not;
    • unsendable keys;
    • credential-bearing and unusable URLs;
    • a JVM Error behind an outage.

    It uses a local HttpServer or stubs, never the hosted Conductor.

  • DBOSConfigKeysTest covers the three settings.

  • The Spring starter tests cover the new property and empty placeholders.

  • None of these tests needs a database.

🤖 Generated with Claude Code

…ture

Before DBOS loads a Transact Enterprise feature, it now asks DBOS
Conductor whether the organization is entitled to it, as Python
(dbos/_enterprise.py), TypeScript (src/enterprise.ts) and Go
(dbos/entitlements.go) do. No Java feature needs an entitlement yet;
suspend mode, auto-upgrade and Kafka consumers will each add their
config switch to Entitlements.enabledFeatures as they land.

- DBOSConfig gains transactEnterpriseKey, with withTransactEnterpriseKey,
  masked in toString. The Conductor key stands in for it when unset. The
  Spring Boot starter maps dbos.transact-enterprise-key.
- Entitlements.require sends the key as a bearer token to
  GET <conductor>/v2/entitlements, with a 10 s timeout and no redirects,
  and refuses launch with an IllegalStateException citing the DBOS
  Conductor Software License when:
  - an entitlement is missing or not true;
  - the key is rejected (401);
  - the org is past its plan limit (403 with X-DBOS-Error: past_limit);
  - the Conductor has no entitlements route (a bare 404);
  - any other 4xx comes back.
- Only an explicitly configured Conductor is asked without a key. An
  unreachable or malformed answer fails closed, except from DBOS's hosted
  Conductor, which warns and starts anyway (as do its 403, 407, 408, 425
  and 429 without the past_limit code). DBOS Cloud skips the check.
  Credentials in the Conductor URL never reach the request or the errors.
- DBOSExecutor runs the check at construction, before the Conductor
  provider is loaded, so a refusal fails launch before anything starts.
- The ServiceLoader lookup behind ConductorProviders moves into
  EnterpriseProviders.load(type, feature), so each entitled feature's
  provider interface gets the same class-loader handling and the same
  "requires dev.dbos:transact-enterprise" error.
… its errors

- The 10 s timeout now covers the body as well as the headers. Before,
  HttpRequest.timeout ended once the headers arrived, so an answer that
  stalled mid-body held launch forever, with neither the hosted Conductor's
  warning nor any other Conductor's refusal. The request now goes through
  sendAsync with a deadline on the whole exchange, as TypeScript's
  AbortSignal.timeout does.
- The key is stripped, and a blank key counts as unset, so a key read from
  a secret file that ends in a newline works (TypeScript's fetch strips
  header values too). A key that still contains a control character is
  refused without being quoted. Before, the HTTP client's
  "invalid header value" error escaped with the whole key in its message.
- An unusable Conductor URL is refused naming the reason, never the URL,
  and without the URISyntaxException as its cause. That exception's message
  quotes the URL, including any user:password in it. Only ws, wss, http and
  https are accepted as schemes. Any other exception the HTTP client throws
  over the request counts as a failure to reach Conductor, without passing
  its message on.
- DBOSConfig treats a blank transactEnterpriseKey as unset instead of
  refusing it, so an empty placeholder such as
  dbos.transact-enterprise-key=${KEY:} falls back to the Conductor key, as
  in Python and TypeScript.
- DBOSConfig.conductorKey now treats a blank value as unset, meaning no
  Conductor connection, instead of refusing an empty one. That matches
  transactEnterpriseKey, and Python and TypeScript, where an empty
  conductor_key / conductorKey is unset. A placeholder such as
  dbos.conductor.key=${CONDUCTOR_KEY:} with the variable unset no longer
  fails startup. Before, " " passed the empty check and was used as a key.
- toString prints conductorKey as *** (or null), as it already did for
  dbPassword and transactEnterpriseKey. It printed the key in clear, and
  the Conductor key also serves as the enterprise key when none is set.
DBOSConfig.conductorDomain now treats a blank value as unset (DBOS's
hosted Conductor), like conductorKey and transactEnterpriseKey. Before,
"" was refused while " " was accepted, and DBOSExecutor quietly treated
" " as unset in two places of its own. Those two checks are gone; the
executor now passes the configured domain unchanged to the entitlement
check and to the Conductor client, so the two always use the same URL.
…check and the client one key

- A key that is not printable ASCII is refused before any request, without
  being quoted. Before, the HTTP client refused a key with a character above
  U+00FF while building the header. The check counted that as an outage, so
  against DBOS's hosted Conductor it warned and started anyway, with no
  verified entitlement.
- Any request the HTTP client refuses to send now fails closed, even against
  the hosted Conductor. The client's message is still never passed on.
- DBOSConfig strips surrounding whitespace from conductorKey, conductorDomain
  and transactEnterpriseKey, not only blank values. Before, the check
  stripped "key\n" but the Conductor client got it as configured, so the
  check passed and the client then failed to connect.
- The answer is read up to 1 MiB again, through a bounded body subscriber
  under the same deadline. An oversized answer counts as an outage instead
  of filling the heap.
- The HTTP request has no timeout of its own. The one deadline covers the
  whole exchange, so the two can no longer race to report a timeout.
- DBOSConfig.toString masks any user:password@ in conductorDomain.
- Entitlements.require treats a blank Conductor URL as unset, as DBOSConfig
  does, instead of refusing it as invalid.
…hout a parseable host

- An exchange that fails in flight is an outage, whatever exception the JDK
  raises. A non-numeric or overflowing Content-Length fails it with a
  NumberFormatException, an IllegalArgumentException. That was reported as
  "the request could not be sent", which failed closed even against the
  hosted Conductor. Only an IllegalArgumentException thrown while building
  or sending the request is a refusal now.
- A Conductor URL whose host java.net.URI cannot identify is refused as
  invalid. With a raw '/' or '@' in the password, the parser left the
  credentials inside the authority, and the refusal message quoted them.
  The same goes for a host with an underscore. The Conductor client cannot
  connect to any of these either: the JDK WebSocket client refuses a URI
  without a host. The entitlement route is built from the parsed host and
  port.
- DBOSConfig.toString masks everything up to the last '@' in the Conductor
  domain, so a raw '/' or '@' in the password no longer leaves part of it
  showing. A path containing '@' is masked too, which is harmless when
  printing.
- Tests:
  - an answer of exactly 1 MiB is read;
  - keys with a tab, DEL and characters 0x80-0xFF are refused.
…ipt do

Drops the custom bounded body subscriber. Conductor's /v2/entitlements
answers with six booleans, or a short problem detail, so the answer is a
few hundred bytes. Only a non-Conductor endpoint could send a large body.
The JDK has no capped body handler before release 23 (this project builds
for 17), and Python and TypeScript read the whole body too. The answer is
now read with BodyHandlers.ofString(), still under the one deadline that
bounds the whole exchange.
… Error as an outage

- A raw '/' in a password can parse as a port and a path: in
  "wss://user:2024/pass@host/...", "user" becomes the host, 2024 the port,
  and the rest of the password the path. The check then quoted the password
  and would have sent the key to the wrong host. A Conductor URL with '@' in
  its path, query or fragment is now refused as invalid. The earlier host
  check only caught the case where java.net.URI found no host.
- A port above 65535 and an IPv6 zone ID are refused up front. The HTTP
  client fails both in flight, which read as an outage reported as "a
  malformed answer".
- A JVM Error raised in flight, such as OutOfMemoryError, now propagates
  instead of being wrapped as an outage, which would have failed open
  against the hosted Conductor. Other in-flight failures are reported as
  "the exchange failed".
- DBOSConfig.conductorDomain's javadoc said "custom hostname". It must be a
  ws:// or wss:// URL, both for the check and for the Conductor client.
- Tests:
  - a malformed answer from the hosted Conductor, through the real HTTP
    client, starts with a warning;
  - the numeric-port password cases;
  - log assertions now match only their own test's warning, since tests
    log to the same logger in parallel.
…nly ws and wss Conductor URLs

- The HTTP client reports an Error in its own threads, such as an
  OutOfMemoryError in the selector manager, as the cause of an IOException
  ("selector manager closed"). The previous fix only caught an Error that
  was the direct cause, so this one still read as an outage and failed open
  against the hosted Conductor. The whole cause chain is checked now.
- A Conductor URL must be ws:// or wss://. The Conductor client is a
  WebSocket client, which rejects http and https, so such a URL passed the
  check and then could never connect.
- An IPv6 zone ID is accepted again. java.net.URI separates its credentials
  correctly, and the client can resolve the scoped address, so refusing it
  only refused a usable URL.
- The tests that read Entitlements' shared log take a JUnit resource lock
  instead of filtering on test-specific text. The malformed-answer tests no
  longer depend on what the JDK calls a non-numeric Content-Length.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved critical issues allow authoritative entitlement refusals to fail open and API keys to leak through diagnostics, while inconsistent endpoint classification can incorrectly block startup or bypass key requirements.

4 open issues
What changed in this PR

Adds Conductor entitlement-checking infrastructure before enterprise provider loading in the Java SDK. No current Java feature enables the check, so launch does not yet make entitlement requests.

Changes:

  • Adds entitlement response handling, outage policies, URL validation, and a body-inclusive timeout.
  • Introduces enterprise-key configuration and Spring Boot binding, normalizes blank settings, and masks credentials in configuration output.
  • Centralizes enterprise provider loading and adds entitlement and configuration tests.
File Description
transact/​src/​test/​java/​dev/​dbos/​transact/​internal/​EntitlementsTest.java Tests entitlement responses, failures, deadlines, and credential handling.
transact/​src/​test/​java/​dev/​dbos/​transact/​config/​DBOSConfigKeysTest.java Tests key defaults, copying, normalization, and masking.
transact/​src/​test/​java/​dev/​dbos/​transact/​config/​ConfigTest.java Updates expectations for empty Conductor settings.
transact/​src/​main/​java/​dev/​dbos/​transact/​internal/​Entitlements.java Implements entitlement checks and response policies.
transact/​src/​main/​java/​dev/​dbos/​transact/​internal/​EnterpriseProviders.java Centralizes enterprise provider discovery and selection.
transact/​src/​main/​java/​dev/​dbos/​transact/​execution/​DBOSExecutor.java Invokes entitlement checks before provider loading.
transact/​src/​main/​java/​dev/​dbos/​transact/​config/​DBOSConfig.java Adds the enterprise key, setting normalization, and credential masking.
transact/​src/​main/​java/​dev/​dbos/​transact/​conductor/​ConductorProviders.java Delegates to the shared provider loader.
transact-spring-boot-starter/​src/​test/​java/​dev/​dbos/​transact/​spring/​DBOSPropertiesTest.java Tests enterprise-key property binding.
transact-spring-boot-starter/​src/​test/​java/​dev/​dbos/​transact/​spring/​DBOSAutoConfigurationTest.java Tests enterprise-key mapping and empty settings.
transact-spring-boot-starter/​src/​main/​java/​dev/​dbos/​transact/​spring/​DBOSProperties.java Exposes the enterprise-key property.
transact-spring-boot-starter/​src/​main/​java/​dev/​dbos/​transact/​spring/​DBOSAutoConfiguration.java Transfers the enterprise-key property into SDK configuration.

Review effort: Balanced

devhawk and others added 2 commits November 13, 2026 19:09
…sted Conductor by its route

- A problem detail from Conductor, and a transport failure's message, are
  quoted in refusals and in the hosted warning. Any occurrence of the key
  in them is now masked, so an answer that echoes the key cannot put it in
  an exception or a log line.
- The hosted Conductor is recognized by the entitlement route the check
  asks, compared case-insensitively, rather than by the exact configured
  string. So "wss://cloud.dbos.dev/conductor/v1alpha1/" (trailing slash) or
  an upper-case host is treated like the default: never asked without a
  key, and failing open on an outage. The URL is validated before the
  no-key check, so an invalid one is reported as invalid.
@devhawk
devhawk merged commit 98e5b91 into main Nov 14, 2026
11 checks passed
@devhawk
devhawk deleted the entitlement-check branch November 14, 2026 00:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants