Skip to content

Type to search pages.

View .md

Issuing a license

This is the vendor side of licensing. That page describes installing a key. This one describes producing one.

An air gapped installation that mints its own licenses against its own signing key follows the same steps.

Issuing is tools/licensegen, a command line program with no caller, run by hand by a person holding a signing key. Wrapping it in a workflow would put the signing key somewhere a workflow can reach.

Before anything: three things that are not true yet

Section titled “Before anything: three things that are not true yet”

No released binary carries a signing key. ee/engine/license/keys.go expects a release to stamp public keys into trustedKeys with a linker flag. Nothing does. tools/release/build.sh builds the community engine and stamps the version, the commit and the build date, and it does not build the enterprise binary at all. So a key signed today verifies only where AF_LICENSE_PUBLIC_KEYS supplies the public half, which is the air gapped arrangement. Issuing to a customer running a downloaded binary is not possible until an enterprise release exists.

Nothing records what was issued. No ledger, no database row, no file. The only record of a license is the customer’s copy and whatever the person who signed it wrote down. Every reissue and every support question depends on that.

There is no revocation list. Verifier.Revoke exists, has no caller outside its own test, and nothing loads a list of withdrawn identifiers. See withdrawing a license for what is actually available.

Once per key, not once per license.

Terminal window
go run ./tools/licensegen keygen -id 2026-09

It prints a key id, a public key and a private key, and writes nothing to disk. Paste the private half into the key vault immediately and nowhere else. The program has no way to recover it.

The key id is a label you choose. Date it.

The public half goes to the verifier as kid=base64, and the key id in that pair has to be the one you just chose. Keep every previous entry: a build that trusts only the newest key cannot verify a license already in the field.

Terminal window
export AF_LICENSE_PUBLIC_KEYS=2026-09=gxvgko3UB27tcxm07XOfJrEDRcAbLzmdtbnCOjEp9yw

Standard and URL safe base64 are both accepted, so a key pasted out of whatever tool produced it works either way.

A JSON file describing what was bought.

{
"org": "acme",
"plan": "enterprise",
"features": ["sso", "scim"],
"seats": 25,
"months": 12
}
Field Meaning
org The organization slug. Required, and inside the signature, so a key cannot be edited to name a different one. It must equal the customer’s AF_ORG exactly, ignoring case and surrounding space.
plan Display only. Nothing branches on it.
features What the license permits, from the closed set below. Refused if it names anything else.
seats The member limit. Required, and zero means unlimited, so it has to be written rather than omitted.
months How long from the issue time. Defaults to 12.
grace_days How long after expiry features keep working. Defaults to 14.
trial Marks an evaluation license, which shows a banner.

The features are air_gapped, audit_stream, billing, cloud_database, cloud_runtime, compliance_packs, enterprise_dashboard, enterprise_secrets, multi_runtime, policy_enforcement, rbac, scim, sso and support_access. Anything else is refused at issue time. The verifier carries an unknown name without acting on it, because a license issued for a newer release names features an older binary has never heard of, so the generator is the only place the set can be closed.

No feature is issued with a warning any more

Section titled “No feature is issued with a warning any more”

The generator used to print a warning beside a key naming rbac, and before that air_gapped. Both were real capabilities the license did not grant: air gapped operation was a property every installation had, and the custom roles library was written and tested with nothing storing a role model. A renewal conversation that treated either as a thing being bought would have been a conversation about nothing, and the warning put that sentence in front of whoever issued the key.

Both are now gated. air_gapped left when the mode that refuses was built. rbac left on 2026-09-11, when the enterprise control plane gained a stored role model, routes to define one, and a resolver that asks the license and the organization’s entitlement before a custom role widens anything. With nothing left for it to name, the warning was deleted rather than kept as a line that can never print. If a feature is ever built and deliberately gated nowhere again, ee/engine/license/license.go says how to bring the warning back.

billing and enterprise_dashboard are in the set above, and a request naming either of them is refused. Nothing in this product enforces them, so a license carrying one would verify, report itself active, print the feature in af license status, and change nothing about what the software does.

Both refusals are in the product rather than in a checklist. The generator will not sign one, and the verifier carries the name and never permits it, exactly as it treats a feature from a release the binary predates. When either capability is built, one entry is removed from notShipped in ee/engine/license/license.go and both refusals lift together.

The private key arrives in the environment from the vault, for the length of one command, and is never read from a file in this repository.

Terminal window
AF_LICENSE_SIGNING_KEY=$(vault-read antifailure/license/2026-09) \
go run ./tools/licensegen issue \
-request ./acme.json \
-key-id 2026-09 \
-id lic-0001

-id is the license identifier. Nothing generates it and nothing checks it is unique, so pick a scheme and keep to it.

The key goes to standard output on one line. A receipt goes to standard error:

signed lic-0001 for acme: 25 seats, 12 months, features sso, scim
key id 2026-09 must name this public key in the verifier: gxvgko3UB27tcxm07XOfJrEDRcAbLzmdtbnCOjEp9yw

Check the second line before you send anything. -key-id is a label this program cannot verify against the key it signed with. Sign with one key and label it as another and the customer’s engine looks the label up, finds a different public key, and reports the license as tampered with. The licensing page tells them that almost always means the token was truncated in transit, so a typo here sends everybody hunting for a paste error that never happened. Comparing that line against the entry in the verifier’s key list is the only thing that catches it.

Two environment variables, and nothing is stored.

Terminal window
export AF_LICENSE_KEY=aflic_eyJleHBpcmVzX2F0IjoiMjAyNy0wOS0wMl...
export AF_ORG=acme
af license status
This is the enterprise edition, licensed to acme.
Licensed to acme on the enterprise plan
Expires 2 September 2027
Features:
scim
sso

Ask them to send that output back. It is the only confirmation available that the key they received is the key you signed.

af license inspect does not exist. To read a key during a support call, use the generator, which decodes without verifying and says so:

Terminal window
go run ./tools/licensegen inspect -token "$AF_LICENSE_KEY"

There is no renewal. Sign a new key from a new request and send it, and the customer replaces the variable. The old key stays valid until its own expiry, which is why a shortened reissue does not shorten anything.

An expired license enters the grace period and then falls back to the community behaviour; see expiry and grace.

The verifier has a Revoke method and a revoked state. Nothing calls it and nothing loads a list of withdrawn identifiers, so a revoked state cannot be reached by any shipped binary. Marking a license as revoked is not something you can currently do.

What is available:

  1. Let it expire. The reason grace periods and short terms exist. A twelve month license issued to somebody who should not have it is a twelve month problem, so keep terms short where the relationship is uncertain.
  2. Rotate the signing key. Removing a public key from the verifier’s list invalidates every license signed with it, not one. That is the blunt instrument, and it means reissuing to every other customer on that key first.
  3. Ask. For a self hosted installation the key sits in the customer’s environment and only they can remove it. Offline verification means there is no other lever, and that is the price of a license that keeps working when the network does not.

The hosted control plane is the vendor’s own installation, and it licenses itself with one signing key, license-signing-key-hosted-2026-09, key id hosted-2026-09. Its public half is license_public_keys in both staging.tfvars and production.tfvars, and it signs both environments’ licences. Turning on the enterprise edition has the command that uses it.

It is kept in afcp-kv-centralus, the staging control plane’s own vault, which contradicts the advice above that a signing key lives somewhere no pipeline can reach. What that costs, read from the vault’s role assignments on 2026-09-12:

Principal Role on the vault What it means for this key
afcp-id, the managed identity Key Vault Secrets User The staging app, its bootstrap job and its maintenance job can read it. Nothing in the control plane does, but anybody who runs code as that identity can.
the operator who runs Terraform Key Vault Secrets Officer Expected: this is the person who issues the licence.
af-infra-ci, the GitHub Actions identity Key Vault Secrets Officer Its federated credentials include pull_request and ref:refs/heads/main as well as both environments, so a workflow run on a pull request from a branch of this repository can read the key. ci.tf records that grant as made by hand on 2026-08-28. Pull requests from forks receive no identity token and cannot.

So the key is exactly as safe as the staging app identity and this repository’s workflows, and the consequence of either being compromised is that somebody can mint a licence both hosted environments accept. It grants nothing on any customer’s self hosted installation, which trusts only the keys its own operator supplies.

Moving it means a vault that holds nothing else and grants a data role to one person, then the same procedure with a new key id: keygen, add the new public half beside the old one in both tfvars files, reissue both licences, and remove hosted-2026-09 only after both environments have started on the new ones.

What goes wrong, and what the customer sees

Section titled “What goes wrong, and what the customer sees”
They see Cause
no licence signing keys, so no licence can be verified The binary carries no stamped keys, which is every build today. Set AF_LICENSE_PUBLIC_KEYS.
The license key’s signature does not verify Usually a truncated paste. Otherwise -key-id named a key that is not the one that signed.
Signed by a key this build does not know The key id is not in the verifier’s list, or a rotation dropped it.
Issued to one organization, installed at another AF_ORG does not match the request’s org.
Active, and no features The request named features that were signed and are not permitted, or named none at all.
Seats all in use with room to spare seats was omitted before it was required, or set from the wrong line of the order.

Related: licensing, compliance.