HIP-1202: Entitlement — What an Org May Run
Abstract
entitlement answers what your org may run: which products the plan grants,
and which of those the org has switched on. It is apps/entitlement in
hanzoai/cloud, and its whole discipline is that those are two authorities,
never braided — ENTITLEMENT is the billing truth, read from commerce at
decision time; ENABLEMENT is the org's intent, the one store this capability
owns (apps/entitlement/entitlements.go:1-33).
Motivation
A paywall whose grant check and toggle store are one table cannot say whether an org stopped paying or switched something off, and every consumer grows its own merge of the two. Keeping the billing truth in commerce and only the intent here means enabling never spends new money — a plan upgrade happens in commerce, not here — and disabling is never gated.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
§1 Addresses
| operation | what |
|---|---|
GET /v1/entitlement | the caller's plan projection: { tier, apps: {…} }, the per-app booleans the console shell renders (apps/entitlement/projection.go) |
GET /v1/entitlement/orgs/{org} | { "enabled": [...] } — the org's toggled-on products |
POST /v1/entitlement/orgs/{org} | { "add": [...], "remove": [...] } → { "enabled": [...] }, bounded at 64 products per batch (apps/entitlement/entitlements.go:59-61) |
All three are typed operations. Today's router serves the org pair at
/v1/orgs/{org}/entitlements, under a root this capability does not own; that
pair is carried by hanzoai/cloud openapi/misfiled.txt and closes by fold,
mirroring the /v1/kms/orgs/{org} shape the key service already serves.
§2 Store
One encrypted SQLite database, the deployment's own entitlement, opened
through the one opener (sqlpool.Open("entitlements", dir),
apps/entitlement/store.go:47) and keyed (org, product). Product ids and
org labels are validated at the edge against the one product-id and org-label
shapes (apps/entitlement/entitlements.go:63-72).
§3 Tenancy
{org} MUST equal the caller's validated org (c.Org()), unless the caller
is a SuperAdmin — c.IsAdmin(), minted only for owner == "admin" and never
client-forgeable — who may target any org (HIP-0118). A bearer-less request
with a restored X-Org-Id and no verified user fails the principal.Validated
gate and is refused 403 (apps/entitlement/entitlements.go:26-32). There is
no path by which one org reads or writes another's entitlements.
§4 The gate, and its two fail directions
A product may only be ENABLED if it is ENTITLED: the write reads
commerce.CheckEntitlement and a non-SuperAdmin can only switch on what the
org already pays for; a SuperAdmin bypasses the gate — the operator can comp
any product to any org. With commerce unreachable, a non-SuperAdmin enable
fails closed, 503, never open (apps/entitlement/entitlements.go:74-77).
DISABLING is always allowed.
The two read paths fail in deliberately opposite directions
(apps/entitlement/projection.go:9-17): the UI projection fails
safe-to-locked — commerce down means an app reports false at 200, never a
5xx — while the enforcement leg (RequireProduct,
apps/entitlement/require.go) fails open, so functionality is preserved
during a billing outage even as the UI conservatively shows locked. The plan →
product policy itself lives once, in the catalog commerce resolves; it is
never restated here.
§5 Metering, events, observability, stage
The capability is free (plugin/entitlement/main.go:22, cloud.Free). It
publishes no events on the bus and delivers nothing to customer webhooks.
Beyond the request span, it emits its mount line and one audit-shaped log per
mutation — org, add, remove, whether SuperAdmin, and the actor
(apps/entitlement/entitlements.go:306). Its stage is ga — the manifest
row (manifest/apps.go:366) carries no stage.
§6 Upstream
The capability derives from no forked, embedded or mirrored OSS project. Its
one non-standard-library import beyond the app framework is
github.com/hanzoai/account v0.3.3 (MIT OR Apache-2.0), the billing-account
rule as a library.
Rationale
The alternative to the opposite fail directions is one posture for both reads. Failing everything closed turns a commerce outage into a product outage; failing everything open renders upgrade UI as if every org owned every plan. Splitting by consequence — enforcement preserves function, projection preserves honesty — costs one paragraph of doctrine and buys an outage that degrades instead of cascading.
Security Considerations
The wrong implementation grants product for free or across tenants. Skipping
the entitlement leg on enable turns the toggle store into a self-serve comp
system; the commerce read at write time is the control, and its fail-closed
posture for non-SuperAdmins is what keeps an outage from becoming a giveaway.
Trusting a per-org isAdmin instead of the one SuperAdmin predicate is the
privilege escalation HIP-0118 names. The org label folded into the store key
is validated strictly at the edge, so a crafted {org} cannot address another
tenant's rows.
References
- HIP-0106 — The Hanzo Plugin Contract
- HIP-0118 — SuperAdmin & Tenant Isolation Model
- HIP-0139 — Capability
Copyright
Released under CC0 1.0 Universal Public Domain Dedication.