CloudPanorama

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.