Licensing
How to obtain, install and renew a licence, what happens when one lapses, and what a build with no key can do.
A licence is an ed25519-signed file issued to your organisation, installed with one command on your own server. It says which modules are open, how many AWS accounts may be connected, and until when. Nothing phones home; nothing is deleted when it lapses. Three tiers — essentials, professional and enterprise — bundle the modules; a thirty-day trial is a licence like any other.
Obtain, install, renew
A tenant's modules come from a licence (p5_1): an ed25519-signed JSON file
the owner issues and an admin installs. Nothing phones home. The binary
carries the verifying public key (LICENSE_PUBLIC_KEY at build time — the
Makefile, the Dockerfile and CI all pass it as the same ldflag); the
signing key never enters the product, this repository, or any machine the
product runs on.
What the customer sees. GET /entitlements carries one word, state,
and the portal turns it into a banner on every page: expiring (thirty
days out, warning, nothing locked), lapsed (past valid_until,
destructive, every module locked, collection continues for fourteen days),
paused (past that grace, collection stopped), none (no licence at all),
unrestricted (the development exception). A valid licence has no banner.
Nothing is ever deleted because of a lapse; a new licence reopens the
modules the moment it is installed and the scheduler enqueues the tenant
again on its next tick.
Two things the release exercise taught (p5_1, 2026-09-17). A renewal
is a file issued after the one installed: the anti-rollback rule compares
issued_at, so a renewal issued before a short-lived file that came later
is refused as the older one — issue the renewal fresh. And a file in the
licences mount is read by the container as another user: copy it at 644 (a
600 file is "permission denied" from inside).
Install, or replace.
panorama admin install-license acme.license # slug comes from the file
panorama admin install-license acme.license --tenant-slug acme # belt and braces
panorama admin show-license --tenant-slug acme
Install verifies the signature before it reads a byte of the payload,
refuses an expired file, a file for another tenant, and a build with no
public key, and is a no-op for a licence already installed. A newer licence
(by issued_at) replaces the row; an older file, however valid, is
refused with both licence ids and issue times, so re-installing a stale
file cannot roll a renewal back. A licence installed before schema 137
recorded issue times is handled the same way with what the row still
knows: re-installing its own file records the issue time ("recorded its
issue time"), a different file installs only if it was issued after the
row was written, and an older one is refused with the way out spelled
out. After upgrading such a deployment, re-install the current file once. show-license says which license_id is
live; a hand-typed grant with no expiry shows as "no expiry". The
server refuses to start with ENTITLEMENTS_UNRESTRICTED=true once any
licence is installed: the flag and the licence describe different
deployments, so a stack that had the flag set (compose defaults it on)
must drop it before the first install.
Which key does this build carry? panorama version prints it:
docker run --rm ghcr.io/dawidper/ccc/panorama@sha256:<the digest you deploy> version
# panorama v1.4.0 (c0f91d6)
# licence key: 57023e96…
"licence key: none" is a build with an empty LICENSE_PUBLIC_KEY: it
accepts no licence and runs only with ENTITLEMENTS_UNRESTRICTED. CI
refuses to build the image unless the repository variable
LICENSE_PUBLIC_KEY is exactly 64 lowercase hex characters, reads the
line above off the image it built on every run, and pushes only the image
that passed — main included. The Dockerfile itself refuses to finish a
build whose binary did not take the key it was given, so a moved linker
target fails every builder, not only CI. Setting
the variable is gh variable set LICENSE_PUBLIC_KEY --body "$(cat license-signing.pub)".
Issue — the owner's side, on the owner's machine, never in the image:
go build -o cloudpanorama-license ./cmd/cloudpanorama-license # from observability/
cloudpanorama-license keygen --out ~/keys # once; refuses a git tree
cloudpanorama-license issue --key ~/keys/license-signing.key \
--tenant acme --licensee 'Acme GmbH' --tier professional \
--max-accounts 50 --valid-until 2027-09-01 --out acme.license
cloudpanorama-license issue --key ~/keys/license-signing.key \
--tenant acme --licensee 'Acme GmbH' --trial --out acme-trial.license
cloudpanorama-license inspect acme.license --pub ~/keys/license-signing.pub
Tiers are ids (essentials, professional, enterprise) with module and
capability defaults (PLATFORM.md §7); --modules, --capabilities and
--max-accounts override them. A trial is an ordinary licence: every
module, ten accounts, thirty days, licensee suffixed "(trial)". The private
key lives in the owner's password manager as the hex seed keygen wrote;
the .pub beside it is the value for LICENSE_PUBLIC_KEY.
Rotate the key when it may have leaked, or on a schedule if you prefer
one. New keypair; the new public key goes into the next release; every live
licence is re-issued with the new key (each has a license_id and an
issued_at, so the re-issue is auditable against the old list) and
re-installed by each customer as part of upgrading. Until a customer
installs the re-issued file, the old row stays valid — the new build only
refuses new installs signed by the old key, it does not re-verify what is
already in the database.
A customer's licence lapsed and they renewed — install the new file;
that is the whole procedure. If the tenant sat paused for longer than
fourteen days, the first collection after the install is a normal
reconciliation: resources that disappeared meanwhile are tombstoned with
that run's run_id in the finalize log, as any run would.