Compare commits

...
4 Commits
Author SHA1 Message Date
mrhid6andClaude Opus 5 3fc726da9e feat(license): two deployments times three tiers, and two new limits
Plans are keyed on (deployment, tier) rather than tier alone, which is what
ends Free being cloud-only by construction - there is a self-hosted Free
plan now, so the one-per-account rule has to be enforced per deployment
instead of falling out of the plan table.

tier self_hosted becomes a legacy value no new licence carries.
NormaliseTier maps it to self-hosted Professional, which is what it always
granted, so blobs we cannot re-sign keep working.

Limits.FillUnset exists because a licence signed before a field existed
decodes it as 0, and 0 would read as no monitors and an audit log trimmed
to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:41:43 +01:00
mrhid6andClaude Opus 5 73b6b548f0 docs: implementation plan for spec 7, metered licensing
Ten tasks, each ending in something independently verifiable. The plan
stops at the Paddle boundary on purpose: spec 7 lands before plan 5, so
there is no client, no webhook and no checkout here, and the configurator
is built as a component mounted for staff first rather than as a customer
screen with nothing behind it.

Corrects two things the spec got wrong about the control plane. Feature
gating is already built and mounted, so Free tenants have already lost the
console and no customer email is owed. The only real gap is that
HandleOIDCCallback lacks the check HandleOIDCStart already has, which is
the half that completes a sign-in.

Adds the Free self-hosted lifecycle the spec called for and the first draft
of the plan missed: linking issues nothing today, and renewInstance
hardcodes a monthly term that would hand a self-hosted install a one-month
licence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:34:34 +01:00
mrhid6andClaude Opus 5 ff0caf5a90 docs: spec 7, metered licensing and the two-by-three plan matrix
Two deployments times three tiers, servers metered per month, console and
SSO opted into individually. plans is re-keyed on (deployment, tier); every
Paddle price ID moves out of plans into a new catalogue collection; a new
entitlements collection holds desired beside granted, and a licence is only
ever signed from granted.

Spec 5's plan is revised rather than followed: it assumes one price per
subscription and a metered plan has three or more. Nothing of it has
shipped, so the revision costs a rewrite of an unstarted plan.

Free stops being cloud-only by construction, which means the plan/instance
deployment comparison in licensing.Issue no longer enforces it and
checkFreeLimit has to count per deployment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:16:35 +01:00
mrhid6 87dc9fc858 inital paddle billing docs 2026-07-26 22:57:57 +01:00
7 changed files with 8284 additions and 42 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,455 @@
# Metered Licensing — Design
**Status:** designed 2026-07-26. Supersedes parts of spec 5 (paddle-billing) and
the tier table in [`README.md`](README.md).
**Goal:** turn the licence from a snapshot of a fixed tier into a snapshot of
what one customer configured and paid for. Two deployments times three tiers,
servers metered per month, features opted into individually, all of it
self-service in Vantage HQ.
**Why now:** spec 5 is designed but not implemented — `admin/internal/paddle`
and `admin/internal/billing` do not exist. Its `Subscription` struct, its
`plans.paddle_price_ids` shape, its single-price checkout and its
`ApplySubscription` all assume one price per subscription, and a metered plan has
several. Folding this in now costs a revision of an unstarted plan; folding it in
later would cost a rewrite of shipped billing code.
---
## The pricing model
Two deployments, three tiers, six plans.
| | servers | monitors | secret groups | channels | audit history | console | SSO | support |
|---|---|---|---|---|---|---|---|---|
| **Free** | 3 | 3 | 1 | 1 | 30 days | — | — | community |
| **Professional** | 3 + N | ∞ | ∞ | ∞ | 365 days | opt-in | opt-in | email, 24/5 |
| **Enterprise** | 10 + N | ∞ | ∞ | ∞ | ∞ | opt-in | opt-in | email + call, 24/7 |
The allowances are identical in both deployments. What differs is the term:
| | monthly | annual |
|---|---|---|
| Cloud Free | — | yes, renewed from HQ |
| Cloud Professional | yes | yes |
| Cloud Enterprise | yes | yes |
| Self-Hosted Free | — | yes, renewed from HQ |
| Self-Hosted Professional | — | yes |
| Self-Hosted Enterprise | — | yes |
**Self-Hosted stays annual-only, for the reason already written into
`shared/license/license.go`:** an offline licence cannot be revoked, so the term
length *is* the revocation window. A self-hosted monthly licence would renew that
unrevokable window twelve times a year for no commercial gain. A resolved
self-hosted monthly price is therefore a configuration error and must fail loudly
rather than issue.
**Servers are the only metered dimension.** Everything above Free is unlimited
except audit history. This was a deliberate narrowing: an earlier draft sold
secret groups in blocks of five, and dropping it leaves one number for a customer
to understand and one line item on an invoice.
**Enterprise is self-service at a published price**, bought through the same
configurator as Professional. The 24/7 phone commitment is an operational promise
we make, not a technical gate we build.
**Support level is not enforced by anything.** It is carried for display, and
that is the whole of its job.
---
## What breaks, and must be fixed in the same change
Three invariants stop being true. Each is load-bearing today.
**`plans` is keyed on `tier` alone.** It becomes `(deployment, tier)` with a
unique index on the pair. `license.PlanFor(tier)` becomes
`PlanFor(deployment, tier)`.
**Free is cloud-only by construction.** The single comparison in
`licensing.Issue``plan.Deployment != inst.Deployment` — is what enforces it
today, because Free's only plan row says `cloud`. With a self-hosted Free row
that comparison stops meaning "Free is cloud-only" and starts meaning only "the
plan row matches the instance". The paragraph in `shared/license/plans.go`
claiming construction-level enforcement must go, because it is no longer true.
**`checkFreeLimit` counts Free instances per account.** It must count per account
*and deployment*, or a customer holding a cloud Free instance is refused a
self-hosted Free one with a message about a limit they have not reached.
---
## Data model
### `plans` — the tier definition
Loses `paddle_product_id` and `paddle_price_ids` entirely; those move to
`catalogue`. Safe to delete because nothing has ever written to them.
```
{deployment: "cloud", tier: "professional", name: "Professional",
base_limits: {max_servers: 3, max_monitors: -1, max_secret_groups: -1,
max_channels: -1, audit_retention_days: 365},
base_features: [], support_level: "email_24_5", active: true}
```
`base_limits` replaces `limits`: it is the allowance before anything is bought,
which is a different claim from the one the old field made. `base_features` is
what the tier includes without opting in — empty for all six plans today, because
console and SSO are both opt-in, but the field is what lets a future tier bundle
one.
### `catalogue` — every priceable component
The only place a Paddle price ID appears anywhere in the system.
```
{kind: "base", deployment: "cloud", tier: "professional",
price_ids: {sandbox: {monthly: "pri_…", annual: "pri_…"},
production: {monthly: "pri_…", annual: "pri_…"}}}
{kind: "limit", deployment: "cloud", tier: "professional", limit_key: "max_servers",
price_ids: {sandbox: {monthly: "pri_…", annual: "pri_…"}, production: {…}}}
{kind: "feature", deployment: "cloud", tier: "professional", feature_key: "console",
price_ids: {}}
{kind: "feature", deployment: "cloud", tier: "professional", feature_key: "oidc",
price_ids: {}}
```
Unique index on `(deployment, tier, kind, limit_key, feature_key)`.
- **`kind: "base"`** is the plan's own fee, always quantity 1.
- **`kind: "limit"`** raises a named limit by one per quantity. `limit_key` is a
field name in `license.Limits`, so adding metered channels later is a catalogue
row and no code. There is deliberately **no `block_size` field**: with
secret-group blocks dropped it would be `1` in every row that will ever exist.
- **`kind: "feature"`** is a feature key. **An empty `price_ids` means free to
toggle.** A price appearing later is a staff edit in the plans UI, not a
migration and not a deploy — which is the whole reason features are catalogue
rows rather than a list on the plan.
A self-hosted row simply has no `monthly` key. Nesting by environment before term
keeps promoting sandbox to production a configuration change, as spec 5 already
established.
### `entitlements` — one row per instance
The customer's configuration. Both the subscription and the licence are derived
from it; it is derived from nothing.
```
{instance_id: "uuid", account_id: "uuid",
deployment: "cloud", tier: "professional", term: "monthly",
desired: {servers: 10, features: ["console"]},
granted: {servers: 5, features: []},
resolved_limits: {max_servers: 5, max_monitors: -1, max_secret_groups: -1,
max_channels: -1, audit_retention_days: 365},
granted_at, updated_at, scheduled_change_at}
```
Unique index on `instance_id`.
**`desired` is what they asked for; `granted` is what a payment confirmed.** The
checkout and the subscription update are built from `desired`. A licence is only
ever signed from `granted`. An abandoned checkout therefore leaves a `desired`
that reached no licence, which is harmless, and HQ can say "pending change"
truthfully instead of guessing.
**`resolved_limits` is stored, not derived on read.** It is `plan.base_limits`
with `granted.servers` folded in, and it is what `Issue` snapshots. Storing it
keeps the fold in exactly one place; deriving it at every read would put the
arithmetic in the issuer, the portal and the staff console.
**Free gets a row at instance creation** with `desired == granted` and no
subscription. Every one of the six cases then reads the same shape, and licence
issuance has one path rather than a Free branch.
### `license.Limits` gains two fields
```go
type Limits struct {
MaxServers int `json:"max_servers"`
MaxMonitors int `json:"max_monitors"`
MaxSecretGroups int `json:"max_secret_groups"`
MaxChannels int `json:"max_channels"`
AuditRetentionDays int `json:"audit_retention_days"`
}
```
`MaxMonitors` behaves exactly like the existing counts. `AuditRetentionDays` is a
new kind of limit — a duration rather than a cap — and `Unlimited` means never
trim.
### `license.License` gains `SupportLevel string`
Display-only, exactly as `InstanceName` already is. It goes in the signed payload
rather than being fetched from HQ so that `/settings/license` can state the
support level on an air-gapped install, which is the one deployment most likely
to need to know who to call.
### `models` additions
`ReasonEntitlementChange = "entitlement_change"` joins the issuance reasons.
Reasons end up in support conversations, so a mid-term server addition must not
be filed as a renewal — a renewal resets `relink_count`, and adding a server is
not a new term.
---
## Resolution
Two folds, in one package (`admin/internal/catalogue`), so the arithmetic exists
once.
**To a licence.** `Resolve(plan, granted) → (license.Limits, []string)`:
start from `plan.base_limits`, and for each `kind: "limit"` row add the
configured quantity to `limit_key`. `granted.servers` is the *total* the customer
sees, so the quantity billed is `servers - plan.base_limits.max_servers` and the
resolved limit is `servers`. Features are `plan.base_features` plus
`granted.features`, deduplicated, filtered to keys the catalogue actually offers
for that `(deployment, tier)` — a stale feature key in a stored entitlement must
not survive into a signed payload.
**To Paddle line items.** `LineItems(env, deployment, tier, term, desired) → []Item`:
the base row at quantity 1, the server row at quantity
`desired.servers - base_limits.max_servers`, and one item per desired feature
that has a price ID in this environment and term. A feature with no price ID
produces no line item and is granted for free. A quantity of zero produces no
line item at all, so a Professional customer at exactly 3 servers has a
single-item subscription.
**Reverse resolution replaces spec 5's `ResolvePriceID`.** A metered subscription
has several prices, and only one of them identifies the plan. Given the full item
list from a webhook:
1. Find the item whose price ID matches a `kind: "base"` row. That row gives
`deployment`, `tier` and — by which term key matched — `term`.
2. Sum the quantities of items matching that plan's `kind: "limit"` rows.
3. Collect the feature keys of items matching its `kind: "feature"` rows.
4. Any item matching nothing is a configuration error: fail the event loudly so
it lands on the staff dashboard. Guessing a tier from a price we cannot map is
how a customer ends up with the wrong licence and no record of why.
Only the running `PADDLE_ENV`'s IDs are consulted, so a production process cannot
be talked into resolving a sandbox price. That property is spec 5's and survives
unchanged.
**Out-of-order delivery is still handled by construction.** Paddle sends the
complete item list on every subscription event, so a handler that reads the whole
list is still a function of current state rather than of a transition. Nothing
about metering weakens this.
---
## Issuance
`licensing.Issue` reads the entitlement row for the instance and snapshots
`resolved_limits` and `granted.features`. When no row exists it falls back to the
plan's base — which covers staff manual issuance and any instance predating the
backfill.
`Issue` stays the only signer, and it stays the thing that does not deliver.
**Upgrades preserve the expiry.** A mid-term server addition passes
`ExpiresAt` = the current licence's expiry, so the licence is reissued with a
larger cap and the same end date. It must not extend the term: the customer paid
a prorated amount for the rest of this period, not for a new one. Note that the
current expiry already includes `GracePeriod`, so nothing adds it again —
`ExpiresAt` overriding `Term` is exactly the existing contract.
**Reductions issue nothing.** They live in `desired` with `scheduled_change_at`
set until the renewal webhook promotes `desired` into `granted` and issues the
next term at the lower cap. The customer keeps what they paid for to the end of
the period, there is no refund to reason about, and no licence ever shortens —
which is the rule spec 5 states and this design does not touch.
---
## Changing a live subscription
`PUT /api/instances/:id/entitlement` writes `desired`, then calls Paddle:
- **An increase** updates the subscription items prorated immediately. The
resulting `subscription.updated` webhook promotes `granted` and reissues.
- **A decrease** schedules the item change for the next billing period and sets
`scheduled_change_at`. No licence action now.
This is admin's **first outbound Paddle call beyond the portal session**, and
spec 5 currently states it has none. That statement changes. The important part
does not: **the webhook remains the only thing that promotes `granted` or issues
a licence.** The endpoint writes `desired` and asks Paddle for a change; it never
grants anything itself. A customer whose card is declined on a prorated upgrade
gets no licence, which is correct, and admin needs no compensating logic to
achieve it.
A tier change (Professional to Enterprise) is the same call with a different base
price, and issues with `ReasonTierChange` as it already would.
---
## Control-plane enforcement
**Feature gating already exists and is already mounted.** `RequireFeature` in
`server/internal/api/licence.go` answers 403 `feature_unavailable`, and
`server/internal/api/handlers.go` already wraps `POST /api/console/connect`,
`GET /api/console/tunnel` and `GET`/`PUT /api/org/oidc` in it. Free's feature list
is empty, so a Free instance already cannot open the console. **No capability is
taken away from an existing tenant by this spec, and no customer email is owed.**
**One gap remains, and it is a single check.** `HandleOIDCStart` already tests
`Feature("oidc")` and redirects to `/login?error=oidc_unavailable`.
`HandleOIDCCallback` does not test it at all. A start that 403s is a dead end; an
ungated callback completes a sign-in, so the unguarded half is the half that
matters.
The callback cannot copy the start's instance resolution: the start reads
`InstanceFromHost(c)`, while the callback resolves the instance from the OAuth
state it consumes, and by then it holds `instanceID` directly. The check goes
after `ConsumeStateInstance` and before `providerForInstance`, so a licence that
lapsed mid-flow stops the exchange rather than completing it.
`web/` hides the Console button and the SSO card when the feature is absent, but
as everywhere else in this codebase the API is the boundary and the UI is the
courtesy.
**`CheckMonitorLimit`** joins the three existing checks in
`server/internal/services/licence_limits.go`, counting `monitors` for the
instance. Same shape: refuse a new one at the cap, never truncate what exists.
`LicenseUsage` reports monitors alongside the other counts.
**Audit retention is new work.** Nothing trims `audit_logs` today. A daily sweep
deletes entries older than the licence's `AuditRetentionDays` per instance;
`Unlimited` skips the instance entirely. It is modelled on the existing workflow
log retention sweep, and it is the one item in this design that deletes customer
data — so it must read the *current* licence's value each run rather than caching
it, and an instance whose licence has lapsed must not be swept on the expired
term's allowance.
**Degraded mode is unchanged.** Expiry still stops mutations and leaves monitors
executing, alerts firing and agents keyed. A feature gate is a mutation gate for
console and SSO, so it behaves the same way.
---
## HQ, the configurator
One screen, reached from an instance in `InstanceRecord` and from the
self-hosted purchase page.
```
Deployment ( ) Cloud (•) Self-Hosted ← fixed after creation
Tier ( ) Free (•) Professional ( ) Enterprise
Term (•) Annual ← monthly hidden for self-hosted
Servers [ 10 ] base 3 included, 7 extra
Features [x] Browser console
[ ] Single sign-on
─────────────────────────────────────────────
£B + 7 × £S per year
[ Continue to payment ]
```
It is one component in both places, driven by the catalogue rather than by
anything hardcoded — a feature that gains a price shows its price with no
frontend change, which is the point of the catalogue being data.
**Existing subscriptions show `desired` and `granted` when they differ:** "10
servers, dropping to 5 on 12 August". A pending reduction is a fact about the
account and belongs on the screen, not only in Paddle.
**Choosing Free skips payment entirely.** With no catalogue rows there is no
checkout to open, so the configurator's Continue button links a UUID and issues
directly. For cloud that is the shipped `POST /api/instances`, untouched. For
self-hosted Free it is the existing link flow with no subscription attached — a
new path, and the only place in the system where an instance is licensed without
either a payment or a staff action. It is bounded by the same one-Free-per-account
rule, now scoped per deployment.
**The staff plans editor** edits `plans` (allowances, support level, active) and
`catalogue` (price IDs per environment and term) as two tables. This replaces
spec 5's price-ID editor, which was built for a single map on the plan row.
Follows `adminsite/`'s existing shell without exception: `PageHeader` with its
record line, `PageFrame`'s main-plus-rail split, tokens only and no hex values,
light default. Price and server count read as text as well as position, since
state never reads by colour alone here.
---
## Migration
Admin has no migrations collection: `models.Backfill` runs every boot and is
idempotent by filtering on the absence of what it writes. This all goes there.
1. **Seed six plan rows** from `shared/license/plans.go`, `$setOnInsert` only, so
staff edits to allowances survive a redeploy — the existing `SeedPlans` rule.
2. **Re-key existing plan rows.** The three current rows are keyed by tier alone.
`free` and `professional` gain `deployment: "cloud"`. The row with tier
`self_hosted` becomes `deployment: "self_hosted", tier: "professional"`.
3. **Re-tier existing self-hosted instances and their entitlements.** Instances
holding `tier: "self_hosted"` become `tier: "professional"`; their deployment
already says so.
4. **`license.TierSelfHosted` is kept as a legacy constant** that no new licence
uses. Licences already issued carry `tier: "self_hosted"` in a signed payload
we cannot rewrite, and the server reads limits and features from the payload
rather than from the tier name — so they keep working untouched. This is
exactly what "the server never branches on tier name" was for.
5. **Backfill an entitlement row per instance** from its current licence:
`granted.servers` from `limits.max_servers` (`Unlimited` maps to the plan
base, since an unlimited licence bought no server units), `granted.features`
from the licence's features, `desired` equal to `granted`.
6. **Seed the catalogue** with sixteen rows — the four paid plans times a `base`,
a `limit: max_servers`, a `feature: console` and a `feature: oidc` — price IDs
empty. **The two Free plans get no catalogue rows at all**, which is what keeps
Free outside Paddle: there is nothing to price, so no checkout can be built. Empty price IDs mean checkout refuses until staff paste them, which is
the correct failure: a checkout that silently picks the wrong price is worse
than one that will not open.
Existing licences are not reissued. `MaxMonitors` and `AuditRetentionDays` are
absent from their payloads and decode as `0`, which would read as "no monitors,
trim everything". **Zero must therefore be treated as unset on decode** and
filled from the plan base — a licence signed before a field existed cannot be
allowed to mean the most restrictive possible value of it. This is the one
sharp edge in the whole migration and it is worth a comment at the decode site.
---
## Out of scope
- **Paid feature add-ons.** The model supports one — a `price_ids` entry on a
`kind: "feature"` row — but no feature has a price at launch.
- **Metered channels, monitors or secret groups.** A catalogue row away, and
deliberately not taken.
- **Usage-based billing.** Servers are a configured cap, not a measured count. We
never bill for what an instance ran; we bill for what it is allowed to run.
- **Refunds and credits.** Paddle's, and only Paddle's.
- **Enterprise contract terms, POs and invoicing.** Card only at launch.
- **Anything that revokes or shortens a licence.** Offline verification means
this is not that kind of system, and no part of this design changes it.
---
## Done when
- Six plan rows exist, keyed on `(deployment, tier)`, and a customer can buy any
of the four paid combinations from the configurator.
- A Professional cloud customer can go from 3 to 10 servers and see the new cap
in `web/` without waiting for a renewal.
- The same customer can reduce to 5 and see both the current cap and the date it
drops, with their licence untouched until then.
- Free self-hosted can be created, renewed from HQ, and lapses to read-only
without being reaped.
- Unticking Browser console removes it from the next issued licence, and
`POST /api/console/connect` answers 403 on an instance whose licence lacks it
(already true; the new part is that a customer controls the tick).
- `/auth/oidc/callback` answers 403 on an instance whose licence lacks `oidc`.
- A monitor beyond the cap is refused with a machine-readable 403.
- `audit_logs` older than the licence's retention are gone, and an unlimited
licence's are not.
- Every price ID in the running environment resolves to a plan, and a webhook
naming one that does not fails loudly onto the staff dashboard.
+16 -2
View File
@@ -10,13 +10,18 @@ Build in this order. Specs 0a5 were designed 2026-07-24; spec 6 on 2026-07-26
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | **shipped**, no grandfathering — existing cloud instances are read-only until admin backfills |
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | **shipped**, verified end to end against scratch databases |
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | ready to start |
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | ready to start; its "signup migration off sitesvc" section is superseded by 6 |
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | [plan](../plans/2026-07-26-paddle-billing.md) | ready to start, **but revised by 7** its "signup migration off sitesvc" section is superseded by 6, and its single-price-per-subscription assumption by 7 |
| 6 | [cloud-instance-creation](2026-07-26-cloud-instance-creation-design.md) | — | ready to start |
| 7 | [metered-licensing](2026-07-26-metered-licensing-design.md) | — | designed; **build before 5**, whose plan it revises |
Specs 1 and 2 together give working licensing with licences cut by hand with
`lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands.
4 and 5 can run in parallel once 3 lands.
7 lands before 5. It re-keys `plans` on `(deployment, tier)`, moves every Paddle
price ID out of `plans` into a new `catalogue` collection, and adds the
`entitlements` collection that both a subscription and a licence are derived from
— all of which plan 5 builds on top of, so building 5 first would mean writing
its billing code twice.
## The shape
@@ -53,6 +58,11 @@ branches on tier name. Tier contents live in the admin `plans` table and are
snapshotted into each issued licence, so editing a plan never rewrites history —
the same rule as `workflow_runs.steps_snapshot`.
Spec 7 replaces the three-tier table below with two deployments times three
tiers, and makes the server count a metered quantity rather than a fixed
allowance. See [metered-licensing](2026-07-26-metered-licensing-design.md) for
the current grid. As shipped through spec 3, the table is:
| | Free | Professional | Self Hosted |
|---|---|---|---|
| deployment | cloud only | cloud | self-hosted |
@@ -67,6 +77,10 @@ Free is cloud-only by construction: it is only ever signed with
`deployment: "cloud"`, and verification rejects a deployment mismatch. There is
no server-side flag to edit. One Free instance per account.
**Spec 7 ends that construction-level guarantee** — there is a self-hosted Free
plan, so `plan.Deployment != inst.Deployment` no longer implies it, and the Free
limit becomes one per account *per deployment*.
**Existing cloud tenants are not grandfathered.** The migration that would have
done it was removed before plan 2 shipped, so every existing cloud instance is
read-only until it is licensed by hand through the admin service: attach it to an
+6 -4
View File
@@ -68,7 +68,8 @@ func issue(args []string) {
instanceID := fs.String("instance-id", "", "instance UUID the licence is bound to (required)")
instanceName := fs.String("instance-name", "", "display name")
accountID := fs.String("account-id", "", "admin-side account id, optional")
tier := fs.String("tier", "", "free | professional | self_hosted (required)")
tier := fs.String("tier", "", "free | professional | enterprise (required)")
deployment := fs.String("deployment", "cloud", "cloud | self_hosted")
term := fs.String("term", "1y", "1m or 1y")
expires := fs.String("expires", "", "explicit RFC3339 expiry, overrides --term")
out := fs.String("out", "", "write the blob to this file instead of stdout")
@@ -78,9 +79,9 @@ func issue(args []string) {
fatal("--instance-id and --tier are required")
}
plan, ok := license.PlanFor(*tier)
plan, ok := license.PlanFor(*deployment, *tier)
if !ok {
fatal("unknown tier %q", *tier)
fatal("no plan for deployment %q tier %q", *deployment, *tier)
}
key := os.Getenv("LICENSE_SIGNING_KEY")
@@ -107,7 +108,7 @@ func issue(args []string) {
// Self Hosted is sold annually only, so the window in which a cancelled
// licence keeps working is bounded at a year.
if plan.Tier == license.TierSelfHosted && *term == "1m" && *expires == "" {
if plan.Deployment == license.DeploymentSelfHosted && *term == "1m" && *expires == "" {
fatal("self_hosted is annual only; use --term=1y or an explicit --expires")
}
@@ -123,6 +124,7 @@ func issue(args []string) {
InstanceName: name,
Tier: plan.Tier,
Deployment: plan.Deployment,
SupportLevel: plan.SupportLevel,
IssuedAt: now,
ExpiresAt: exp,
Limits: plan.Limits,
+58 -4
View File
@@ -16,7 +16,15 @@ import "time"
const (
TierFree = "free"
TierProfessional = "professional"
TierSelfHosted = "self_hosted"
TierEnterprise = "enterprise"
// TierSelfHosted is LEGACY and no new licence carries it.
//
// It was a tier when self-hosting was a tier rather than a deployment. Blobs
// already signed with it exist and cannot be rewritten, so it stays a
// recognised value that NormaliseTier maps forward. Never put it in a plan
// row and never offer it in a UI.
TierSelfHosted = "self_hosted"
DeploymentCloud = "cloud"
DeploymentSelfHosted = "self_hosted"
@@ -25,14 +33,59 @@ const (
FeatureOIDC = "oidc" // per-instance single sign-on
)
// Support levels. Carried for display and enforced by nothing — there is no code
// path anywhere that branches on these, and there must not be one. They are here
// so an air-gapped install can tell its operator who to call without reaching
// Vantage HQ.
const (
SupportCommunity = "community"
SupportEmail24x5 = "email_24_5"
SupportEmailCall24x7 = "email_call_24_7"
)
// Unlimited is the sentinel for "no cap" in every Limits field.
const Unlimited = -1
// Limits are the countable caps a licence grants.
//
// Every field is a plain int with Unlimited as the sentinel. AuditRetentionDays
// is the odd one out: it bounds a duration rather than a count, and Unlimited
// there means "never trim" rather than "no cap".
type Limits struct {
MaxServers int `json:"max_servers"`
MaxSecretGroups int `json:"max_secret_groups"`
MaxChannels int `json:"max_channels"`
MaxServers int `json:"max_servers"`
MaxMonitors int `json:"max_monitors"`
MaxSecretGroups int `json:"max_secret_groups"`
MaxChannels int `json:"max_channels"`
AuditRetentionDays int `json:"audit_retention_days"`
}
// FillUnset replaces any zero field with the same field from base.
//
// This exists for one reason: a licence signed before a field existed decodes it
// as 0, and 0 would read as the most restrictive possible value — no monitors,
// and an audit log trimmed to nothing. A blob we cannot re-sign must not be
// allowed to mean that.
//
// The cost is that 0 stops being expressible as a real allowance. No plan grants
// zero of anything, so nothing is lost today; a plan that genuinely means zero
// must use a negative-free sentinel of its own rather than reintroducing 0 here.
func (l Limits) FillUnset(base Limits) Limits {
if l.MaxServers == 0 {
l.MaxServers = base.MaxServers
}
if l.MaxMonitors == 0 {
l.MaxMonitors = base.MaxMonitors
}
if l.MaxSecretGroups == 0 {
l.MaxSecretGroups = base.MaxSecretGroups
}
if l.MaxChannels == 0 {
l.MaxChannels = base.MaxChannels
}
if l.AuditRetentionDays == 0 {
l.AuditRetentionDays = base.AuditRetentionDays
}
return l
}
// License is the signed payload.
@@ -47,6 +100,7 @@ type License struct {
InstanceName string `json:"instance_name"` // display only
Tier string `json:"tier"`
Deployment string `json:"deployment"`
SupportLevel string `json:"support_level,omitempty"` // display only
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
Limits Limits `json:"limits"`
+112 -32
View File
@@ -1,49 +1,129 @@
package license
// Plan is the contents of a tier at issue time.
// Plan is the contents of one (deployment, tier) pair at issue time.
//
// This table is the seed. Once the admin service exists (spec 3) it owns the
// authoritative copy in its `plans` collection, and every issued licence
// snapshots the plan it was cut from — so editing a plan never rewrites an
// existing licence, the same rule as workflow_runs.steps_snapshot.
// This table is the seed. The admin service owns the authoritative copy in its
// `plans` collection, and every issued licence snapshots the plan it was cut
// from — so editing a plan never rewrites an existing licence, the same rule as
// workflow_runs.steps_snapshot.
//
// lkctl uses this table to issue by hand until then.
// Limits here are the BASE allowance: what the tier grants before anything is
// bought. A metered dimension adds to it, which is why max_servers is a real
// number at Professional and Enterprise rather than Unlimited.
type Plan struct {
Tier string
Name string
Deployment string
Limits Limits
Features []string
Tier string
Name string
Deployment string
SupportLevel string
Limits Limits
Features []string
}
var plans = map[string]Plan{
TierFree: {
Tier: TierFree,
Name: "Free",
Deployment: DeploymentCloud, // cloud only, by construction
Limits: Limits{MaxServers: 3, MaxSecretGroups: 1, MaxChannels: 1},
// planKey is the composite the table is keyed on.
//
// Keying on tier alone was what made Free cloud-only by construction. There is
// now a self-hosted Free plan, so that guarantee is gone and the Free limit is
// enforced per account AND deployment instead. See licensing.checkFreeLimit.
type planKey struct {
Deployment string
Tier string
}
// baseFree, baseProfessional and baseEnterprise are shared by both deployments.
//
// The allowances are deliberately identical across cloud and self-hosted: what
// differs between the two is the term on offer, not what you get. Duplicating
// them per deployment would be four places to forget.
var (
baseFree = Limits{
MaxServers: 3, MaxMonitors: 3, MaxSecretGroups: 1,
MaxChannels: 1, AuditRetentionDays: 30,
}
baseProfessional = Limits{
MaxServers: 3, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited,
MaxChannels: Unlimited, AuditRetentionDays: 365,
}
baseEnterprise = Limits{
MaxServers: 10, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited,
MaxChannels: Unlimited, AuditRetentionDays: Unlimited,
}
)
var plans = map[planKey]Plan{
planKey{DeploymentCloud, TierFree}: {
Tier: TierFree, Name: "Free", Deployment: DeploymentCloud,
SupportLevel: SupportCommunity, Limits: baseFree,
// Empty rather than nil: nil marshals as JSON null, and this table is
// the seed every plan and licence is cut from.
Features: []string{},
},
TierProfessional: {
Tier: TierProfessional,
Name: "Professional",
Deployment: DeploymentCloud,
Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
Features: []string{FeatureConsole, FeatureOIDC},
planKey{DeploymentCloud, TierProfessional}: {
Tier: TierProfessional, Name: "Professional", Deployment: DeploymentCloud,
SupportLevel: SupportEmail24x5, Limits: baseProfessional,
// Console and SSO are opt-in per customer, so no tier bundles them. The
// field stays because a future tier might.
Features: []string{},
},
TierSelfHosted: {
Tier: TierSelfHosted,
Name: "Self Hosted",
Deployment: DeploymentSelfHosted,
Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
Features: []string{FeatureConsole, FeatureOIDC},
planKey{DeploymentCloud, TierEnterprise}: {
Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentCloud,
SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise,
Features: []string{},
},
planKey{DeploymentSelfHosted, TierFree}: {
Tier: TierFree, Name: "Free", Deployment: DeploymentSelfHosted,
SupportLevel: SupportCommunity, Limits: baseFree,
Features: []string{},
},
planKey{DeploymentSelfHosted, TierProfessional}: {
Tier: TierProfessional, Name: "Professional", Deployment: DeploymentSelfHosted,
SupportLevel: SupportEmail24x5, Limits: baseProfessional,
Features: []string{},
},
planKey{DeploymentSelfHosted, TierEnterprise}: {
Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentSelfHosted,
SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise,
Features: []string{},
},
}
// PlanFor returns the seed plan for a tier.
func PlanFor(tier string) (Plan, bool) {
p, ok := plans[tier]
// PlanFor returns the seed plan for one deployment and tier.
//
// It normalises first, so a legacy self_hosted licence resolves to the plan that
// replaced it rather than to nothing.
func PlanFor(deployment, tier string) (Plan, bool) {
deployment, tier = NormaliseTier(deployment, tier)
p, ok := plans[planKey{deployment, tier}]
return p, ok
}
// NormaliseTier maps a legacy tier forward.
//
// tier "self_hosted" predates deployments being separate from tiers. Such a
// licence granted what Professional now grants, on a self-hosted install, so it
// maps to exactly that. Called by PlanFor and by anything reading a tier off an
// already-signed payload.
func NormaliseTier(deployment, tier string) (string, string) {
if tier == TierSelfHosted {
return DeploymentSelfHosted, TierProfessional
}
return deployment, tier
}
// Tiers is the offer order, for any UI that lists them.
func Tiers() []string { return []string{TierFree, TierProfessional, TierEnterprise} }
// Deployments is the offer order.
func Deployments() []string { return []string{DeploymentCloud, DeploymentSelfHosted} }
// TermsFor reports which billing terms a deployment sells.
//
// Self-hosted is annual only, and the reason is in this package's doc comment: an
// offline licence cannot be revoked, so the term length IS the revocation
// window. A self-hosted monthly licence would renew that window twelve times a
// year for no commercial gain.
func TermsFor(deployment string) []string {
if deployment == DeploymentSelfHosted {
return []string{"annual"}
}
return []string{"monthly", "annual"}
}