License and entitlement trust

How the Control Plane verifies plugin licenses offline, and what happens when a license expires or is revoked.

A plugin can declare that some of its features require a license. The Control Plane verifies such licenses offline from files that you install. It never contacts Stellwerk to do so, which keeps licensed plugins usable in air-gapped installations.

Separate trust decisions

  • Package trust decides which code may run. The plugin runtime verifies the package signature against the publisher keys in your trust bootstrap. See Install and operate native plugins.
  • Entitlement trust decides which licensed features may do new work. The Control Plane verifies license grants against the issuer keys in a separate trust document.

Publisher signing keys and license issuer keys are different keys. The plugin runtime never sees license keys or license files. An installation does not imply a license, and a license grants no IAM permission and no Core capability.

What you receive

FileContentYou provide it to Core by
trust.jsonIssuer trust document: issuer IDs, the plugin IDs each issuer may license, and public keys onlyA Secret file that Core reads at start-up
bundle.jsonSigned license grants and, optionally, signed revocation snapshotsThe Plugin Manager API, no restart

Neither file contains a private key. Each grant is canonical CBOR in a COSE_Sign1 envelope; JSON is only the transport. A grant binds:

  • the plugin ID,
  • the installation UUID that Core assigned when you installed the plugin,
  • either one organization UUID or no organization (an installation-wide grant),
  • the licensed features, and for an installation-wide grant optionally a limit on the number of organizations,
  • a validity window and an optional grace period, and
  • an issuance identifier and sequence.

A grant for another installation fails closed. See Grant scope for the two kinds of grants. The installation UUID survives an uninstall. After a reinstall and a new enablement, the same grants match again. A purge retires the UUID: grants bound to it never match again, and a new installation needs new grants.

Grant scope

A grant has one of two scopes, and both kinds stay valid side by side:

ScopeCoversBinding
organizationOne organization of the installationInstallation UUID and the organization UUID
installationEvery organization of the installation, including organizations that you create after the grant was issuedInstallation UUID only. The grant carries no organization
  • A grant of the scope installation is the simplest way to license an installation that serves all organizations. You need no new grant when an organization is added.
  • A valid organization grant takes precedence, including during its grace period. Its feature set applies on its own, without adding features from the installation-wide grant.
  • When the organization grant expires after grace or is revoked, Core uses a valid installation-wide grant if one exists. That grant may also be in grace. Nothing is deleted: the old grant, revocation and audit history remain. A renewed valid organization grant takes precedence again.
  • A grant that fails verification or is not yet valid does not trigger this fallback. License selection never bypasses disabled enablements or IAM checks.
  • An organization that no grant covers is not entitled and stays in the safety-only behavior of Expiry and revocation until you import a grant.
  • An organization grant matches only an installation that is enabled for the grant’s organization, so import it after you enable the organization. An installation-wide grant matches the installation. You can import it before any organization is enabled, for every installation that is installed, active, or disabled and whose package requires a license. An uninstalled or purged installation takes no new grant.
  • A renewal replaces the older grant of the same binding when its sequence is higher. The binding of an installation-wide grant is the plugin and the installation. The binding of an organization grant adds the organization.

Limit the number of organizations

An installation-wide grant can carry limits.organizations, a positive integer that caps the organizations enabled for the installation. Core checks the limit when it enables an organization and never when it serves a request.

  • Core counts the enabled enablements of the installation. A disabled enablement does not count, and an organization that has a grant of its own counts.
  • The current installation-wide grant sets the limit, whatever its state.
  • Core refuses a change that raises the count above the limit with HTTP 409 and plugin_organization_limit. This covers enabling an organization by hand and the switch to the mode all, which Core refuses as a whole. Changing the settings of an enabled organization passes.
  • An organization that you create while the installation serves all organizations and the limit is reached is still created. Core skips its enablement, logs a warning, and lists the organization in pending_organizations. Raise the limit by importing a renewed grant, then run octl plugin-manager organization-mode <id> all again to enable every pending organization.
  • The check happens only at enablement. If you import a renewed grant with a lower limit, or the installation was enabled before the grant, the installation can sit above the limit. Core then refuses further enablements and keeps serving the enabled ones.

Install license files

  1. Store trust.json in a Secret in the Orchestrator namespace. Use a versioned Secret name, such as plugin-license-trust-v1.

  2. Enable the profile. It mounts the issuer trust only:

    control-plane:
      configurationProfiles:
        nativePluginEntitlements:
          enabled: true
          secretFiles:
            - name: native-plugin-entitlement-trust
              secretName: plugin-license-trust-v1
              key: trust.json
              mountPath: /etc/stellwerk/entitlements/trust.json
    
  3. Apply the values with helm upgrade. The Control Plane Pods roll and load the trust.

  4. Import bundle.json through the Plugin Manager API. The request carries the exact bytes of the file, base64-encoded, the Plugin Manager revision you decided on, and a reason. See Plugin Manager API for the permission and the revision rules.

    curl -X POST "${PO_API_URL}/plugin-entitlement-bundles" \
      -H "Authorization: Bearer ${PLATFORM_ORCHESTRATOR_TOKEN}" \
      -H "Idempotency-Key: $(uuidgen)" \
      -H "Content-Type: application/json" \
      -d "{\"expected_revision\": ${REVISION}, \"reason\": \"Import licence for example-plugin\", \"bundle\": \"$(base64 < bundle.json | tr -d '\n')\"}"
    

    The response reports imported_grants, imported_revocations, and one entry in diagnostics for every skipped document. The import applies without a restart. With octl, run octl plugin-manager import-entitlements bundle.json --reason "<reason>".

Keep the /etc/stellwerk/entitlements mount path. The license reader rejects symlinks, including projected Secret directories. To replace the trust file, create a new Secret version, point the profile at it, and roll the Control Plane.

The profile requires the plugin gateway profile. It does not enable a plugin by itself.

Offline verification

Core verifies every grant and revocation snapshot before it stores any of them:

  • The signature must verify against a key in trust.json that is valid for that issuer, plugin ID, and purpose.
  • The grant must match the installation UUID and plugin ID of an installation. An organization grant must also match the organization UUID of an enablement of that installation.
  • Imports are monotonic. Importing the same bundle again is safe. A grant with a lower sequence, changed content at the same sequence, or a different issuer for an existing binding is rejected.

Every signed document stands on its own signature. A document that fails is skipped and reported, and the other documents in the bundle are still imported. This is safe: an unverifiable revocation revokes nothing, and a binding without a newly imported grant keeps its stored decision, which Core verifies again on every use.

Verified grants are stored in the Control Plane database. The Control Plane also stores how far it has evaluated each grant in time. Setting the clock back or restarting the Control Plane never makes an expired grant usable again.

Bad licence material never stops Core

A plugin failure degrades only that plugin, and an expired or missing licence never affects Core functionality. Core therefore starts, and an import succeeds, even when:

  • the trust file is missing, unreadable, a symlink, too large, malformed, or of the wrong trust class for the mode (for example development trust in production mode);
  • a bundle is imported without configured trust, or is malformed;
  • a grant is a development grant in production mode, belongs to another or a removed installation or organization, is signed by an unpinned key, is tampered with, or conflicts with an already imported grant;
  • a revocation snapshot fails verification or import.

The affected plugin reports not_entitled (no grant) or untrusted (a stored grant no longer verifies) in discovery and refuses forward work with plugin_not_entitled. It keeps read, export, cancel, and rollback for features that were activated under a grant. Core-only operations are unaffected.

A trust problem at start-up is logged at error level as Plugin entitlement material was not applied; affected plugins stay unentitled with a stable diagnostic field. An import returns the same values in diagnostics[].code:

diagnosticCause
plugin_entitlement_trust_rejectedThe trust file could not be loaded or parsed for this mode
plugin_entitlement_bundle_rejectedThe bundle could not be parsed, or no trust is configured
plugin_entitlement_grant_rejectedA grant (by index) failed verification, has no exact installed and enabled binding, or was not imported
plugin_entitlement_revocations_rejectedA revocation snapshot (by index) failed verification or import

The detail is deliberately coarse and does not say which binding or which check failed. Uninstalling a plugin or removing an organization no longer requires removing its grant from the bundle first; the stale grant is skipped.

Core configuration that is not licence material still fails fast, because it describes Core itself and affects every plugin equally: an invalid PLUGIN_ENTITLEMENT_MODE, the Plugin Manager refresh interval, the first load of the Plugin Manager state, the plugin assertion signing key, the plugin CA bundle, and PLUGIN_EVENT_JOURNAL_RETENTION. An installation whose stored state no longer validates is not served and is logged; the others are. IAM, not the Control Plane, validates the Plugin Manager permission holders.

Production and development trust

PLUGIN_ENTITLEMENT_MODE is production by default. The trust class in trust.json must equal the mode, and every signed grant carries its own trust class. In production mode, the Control Plane rejects development trust and development grants, even if a development key is copied into a trust document labeled production.

Use PLUGIN_ENTITLEMENT_MODE: development only for development and test installations. Development licenses are not customer licenses. The license projection reports the trust class of every feature, so a development installation is visible as such.

Expiry and revocation

Each licensed feature has one of these effective states after the covering grant is selected:

StateNew forward workReads and safety operations
validAllowedAllowed
not_yet_validNot allowedRetained if activated
graceAllowed, with a warningAllowed
expiredNot allowedRetained if activated
revokedNot allowedRetained if activated
untrustedNot allowedRetained if activated
missingNot allowedNot available

untrusted means that the stored grant no longer verifies against trust.json, for example because its issuer key was removed. missing means that no grant exists for the feature. A feature counts as activated once the Control Plane has evaluated it with a valid grant while the plugin was enabled for the organization. After a license expires or is revoked:

  • No new forward work starts. The Core gateway rejects forward operations with HTTP 409 and the code plugin_not_entitled.
  • Reads, history, audit, and safety operations, such as cancel and rollback, remain available under the users’ normal permissions.
  • The Console shows the plugin’s availability notice.
  • Uninstalling the plugin remains possible once its safety work has settled, and no data is deleted.
  • Core functionality is not affected.

The Control Plane records license boundaries every 15 seconds, even without requests. A grace state is reported between expires_at and grace_until.

To renew a license, import a bundle with a newer issuance through the API. No restart is needed.

Request a license

The issuer needs a request form. It holds reference IDs only, never names or e-mail addresses:

FieldValue
installation_uuidThe installation_id of the installation, from octl get plugin-installations
plugin_idThe plugin ID
organization_uuidFor an organization grant: the organization’s uuid, which GET /orgs/{orgId} returns
installation_wideFor an installation-wide grant: true, instead of organization_uuid
limits.organizationsOptional, for an installation-wide grant only: the most organizations that may be enabled
featuresThe licensed features
not_before, expires_at, grace_untilThe validity window and the optional grace period

A request names exactly one of organization_uuid and installation_wide, so a forgotten organization can never widen a grant. A request for several organizations without an installation-wide grant needs one grant for each organization. If you want licenses for organizations that you create later, ask for an installation-wide grant.

Grace period

While a feature is in grace, forward work still succeeds, and Core adds a machine-readable warning with the stable code plugin_entitlement_grace, a display message, and grace_until, the earliest end of grace among the listed features. The warning never changes authorization. After grace_until the feature is expired and forward work is refused. The same warning object appears in:

WhereField
GET /orgs/{orgId}/plugins (contributions)items[].entitlement.warning
GET /orgs/{orgId}/plugins/{pluginId}entitlement.warning; admin_action carries the message
GET /orgs/{orgId}/plugins/{pluginId}/entitlemententitlement.warning
POST /orgs/{orgId}/plugins/{pluginId}/invokeentitlement_warning, when the operation’s feature is in grace
POST /orgs/{orgId}/plugins/{pluginId}/operations/{operationId}/deploymentsentitlement_warning, when the service action’s feature is in grace

octl prints the warning once per command run to standard error and never fails because of it:

Warning: <message> (plugin_entitlement_grace, grace until <RFC 3339 time>)

The Terraform/OpenTofu provider shows one warning diagnostic per operation with the summary Stellwerk plugin licence in grace period, which carries the message, code, grace_until, and features. It is never an error.

Check the license state

GET /orgs/{orgId}/plugins/{pluginId} returns a license projection for every declared feature. The projection carries no secret and authorizes nothing. It also carries grant_scope, which is organization or installation and says which grant covers the organization. The field is absent while no grant covers it. The discovery list (GET /orgs/{orgId}/plugins) and the plugin’s GET /orgs/{orgId}/plugins/{pluginId}/entitlement report the same field.

{
  "id": "dev.stellwerk.example",
  "display_name": "Example",
  "state": "not_entitled",
  "capabilities": [],
  "admin_action": "Ask an administrator to install or renew a signed licence for this plugin and organization.",
  "entitlement": {
    "features": {
      "example-feature": {
        "state": "expired",
        "trust_class": "production",
        "forward": false,
        "read": true,
        "safety": true,
        "expires_at": "2027-01-01T00:00:00Z",
        "grace_until": "2027-01-15T00:00:00Z"
      }
    }
  }
}

Platform operators with plugin_manager_read see the grants themselves in GET /plugin-installations/{id}, for a package that requires a license:

  • entitlement.installation_grant is the installation-wide grant, with its organization_limit, issuer, sequence, features, validity, and state. entitlement.enabled_organizations is the count that the limit caps.
  • Each enablement carries entitlement_grant, the grant that covers it: its own grant, else the installation-wide one. A scope field names which. The state of a grant is valid, grace, expired, not_yet_valid, revoked, or untrusted.

The Console shows the same data in its read-only Plugin Manager view.

Top