Feature Entitlements and Plan Flags per Tenant
Entitlements decide what a tenant has bought; release flags decide what code is switched on. Conflating the two produces a system where a marketing rollout accidentally grants a paid feature, and this page keeps them apart. It is a focused part of Subscription & Plan Enforcement: resolving entitlements, overriding them safely, and enforcing them where it counts.
Problem Framing
Both mechanisms answer "is this on for this tenant?", which is why they get merged — usually into one feature-flag service. They then diverge in every way that matters. An entitlement is commercial: it derives from the subscription, changes at checkout and renewal, must be enforced server-side, and getting it wrong is a revenue or contract problem. A release flag is operational: it derives from a rollout plan, changes on an engineer's schedule, is safe to evaluate client-side, and getting it wrong is a bug.
Merging them creates two specific failures. A gradual rollout that ramps a flag to fifty percent silently grants a paid capability to half the customer base. And a customer who upgrades has to wait for a flag-service push before their new plan works, turning a purchase into a support ticket.
The resolution is a single resolved entitlement set per tenant, computed from plan plus explicit overrides, exposed through one function. Release flags stay in the flag service and gate code paths; entitlements gate capabilities, and the two are combined only at the point of use.
Step-by-Step Guide
1. Define entitlements as data, versioned with the plan
CREATE TABLE plan (
key text PRIMARY KEY, -- 'free' | 'team' | 'business' | 'enterprise'
display_name text NOT NULL,
version integer NOT NULL DEFAULT 1
);
CREATE TABLE plan_entitlement (
plan_key text NOT NULL REFERENCES plan(key),
feature_key text NOT NULL, -- 'sso', 'audit_export', 'api_v2'
limit_value bigint, -- NULL = boolean capability
PRIMARY KEY (plan_key, feature_key)
);
CREATE TABLE tenant_entitlement_override (
tenant_id uuid NOT NULL,
feature_key text NOT NULL,
granted boolean NOT NULL,
limit_value bigint,
reason text NOT NULL, -- 'trial', 'contract clause 4.2', 'goodwill'
expires_at timestamptz, -- NULL only for contractual grants
created_by text NOT NULL,
PRIMARY KEY (tenant_id, feature_key)
);
A required reason on every override is worth more than it looks. Six months later, the question is always "why does this customer have SSO on the team plan?", and the answer needs to be in the row rather than in someone's memory.
2. Resolve once, in one place
// entitlements.ts
export interface Entitlements {
has(feature: string): boolean;
limit(feature: string): number | null;
}
export async function resolve(tenantId: string): Promise<Entitlements> {
const [planRows, overrides] = await Promise.all([
db.query(`SELECT e.feature_key, e.limit_value
FROM tenant t JOIN plan_entitlement e ON e.plan_key = t.plan_key
WHERE t.id = $1`, [tenantId]),
db.query(`SELECT feature_key, granted, limit_value
FROM tenant_entitlement_override
WHERE tenant_id = $1 AND (expires_at IS NULL OR expires_at > now())`, [tenantId]),
]);
const map = new Map<string, number | null>();
for (const r of planRows.rows) map.set(r.feature_key, r.limit_value);
for (const o of overrides.rows) {
if (o.granted) map.set(o.feature_key, o.limit_value);
else map.delete(o.feature_key); // an override can remove, not only add
}
return {
has: (f) => map.has(f),
limit: (f) => map.get(f) ?? null,
};
}
Expired overrides simply stop matching the query, so a trial ends without a job having to run — which means it cannot fail to run.
3. Enforce on the server, at the capability boundary
// guard.ts
export function requireEntitlement(feature: string): RequestHandler {
return async (req, res, next) => {
const ent = await entitlementsFor(req.tenantId); // cached, see step 4
if (!ent.has(feature)) {
return res.status(402).json({
error: 'plan_upgrade_required',
feature,
upgrade_url: `/settings/billing?feature=${encodeURIComponent(feature)}`,
});
}
next();
};
}
router.post('/api/audit/export', requireEntitlement('audit_export'), exportHandler);
router.post('/api/sso/connections', requireEntitlement('sso'), createConnection);
Hiding a button in the user interface is a product decision, not a control. Every gated capability needs the server-side check, because the interesting customers are precisely the ones who will call the API directly.
4. Cache with a version, and bust it on plan change
// cache.ts
const cache = new LRUCache<string, { v: number; ent: Entitlements }>({ max: 50_000, ttl: 60_000 });
export async function entitlementsFor(tenantId: string): Promise<Entitlements> {
const version = await redis.get(`ent:v:${tenantId}`); // cheap, ~0.2ms
const hit = cache.get(tenantId);
if (hit && String(hit.v) === version) return hit.ent;
const ent = await resolve(tenantId);
cache.set(tenantId, { v: Number(version ?? 0), ent });
return ent;
}
// Called by the billing webhook handler on any subscription change.
export async function invalidate(tenantId: string) {
await redis.incr(`ent:v:${tenantId}`); // every node sees it on next read
}
A version counter beats a plain TTL here: an upgrade takes effect on the next request rather than up to a minute later, and there is no fan-out message to lose. The webhook path that triggers it is the one described in reconciling Stripe webhooks per tenant.
5. Combine with the release flag only at the point of use
// available.ts
export async function featureAvailable(tenantId: string, feature: string): Promise<boolean> {
const ent = await entitlementsFor(tenantId);
if (!ent.has(feature)) return false; // commercial gate: hard
return flags.isEnabled(`release.${feature}`, { tenantId }); // operational gate: independent
}
Order matters for a subtle reason: checking the entitlement first means the flag service is never asked about tenants who could not use the feature anyway, and an outage in the flag service degrades to "feature not yet released" rather than "feature granted to everyone".
Verification
// entitlement.spec.ts
it('rejects a gated endpoint with 402 on a plan without the feature', async () => {
await setPlan('acme', 'team');
await asTenant('acme').post('/api/audit/export').expect(402);
});
it('grants immediately after an upgrade, without waiting for a TTL', async () => {
await setPlan('acme', 'business');
await handleBillingWebhook(subscriptionUpdated('acme', 'business')); // calls invalidate()
await asTenant('acme').post('/api/audit/export').expect(200);
});
it('drops an override the moment it expires', async () => {
await addOverride('acme', 'sso', { expiresAt: minutesFromNow(1), reason: 'trial' });
await asTenant('acme').post('/api/sso/connections').expect(201);
await advanceTime(minutes(2));
await asTenant('acme').post('/api/sso/connections').expect(402);
});
it('a release flag at 100% does not grant an unentitled feature', async () => {
await flags.set('release.audit_export', { rollout: 100 });
await setPlan('acme', 'free');
await asTenant('acme').post('/api/audit/export').expect(402);
});
The last test is the one that justifies the whole separation, and it is worth writing for every gated capability.
-- Overrides that have quietly become permanent.
SELECT tenant_id, feature_key, reason, created_by,
age(now(), created_at) AS age
FROM tenant_entitlement_override
WHERE expires_at IS NULL AND reason NOT LIKE 'contract%'
ORDER BY created_at;
-- every row here is a feature being given away without a contract behind it
Failure Modes & Gotchas
-
Symptom: a rollout grants a paid feature to unentitled tenants. Cause: entitlement and release are the same flag. Fix: separate the systems and require both, with the entitlement checked first.
-
Symptom: a customer upgrades and the feature does not appear for a minute. Cause: the entitlement cache uses only a TTL. Fix: version the cache per tenant and bump the version from the billing webhook handler.
-
Symptom: a trial never ends. Cause: expiry is enforced by a scheduled job that silently failed. Fix: filter on
expires_atin the resolution query, so expiry is a property of the read rather than of a job. -
Symptom: the API allows what the interface hides. Cause: gating is implemented only in the front end. Fix: put the check in server-side middleware on the route; treat the interface as a hint, not a control.
FAQ
Should entitlements be embedded in the session token? Tempting, and usually wrong. A token minted before an upgrade carries stale entitlements until it expires, so a customer who just paid still sees a paywall — and shortening token lifetimes to fix that costs far more than a cached lookup. Keep entitlements server-side behind a version-busted cache; the lookup is sub-millisecond and always current.
How should quantitative limits be modelled — seats, projects, API calls?
The same table, with limit_value set instead of null, and enforcement at the point where the count changes rather than at read time. Checking "is this tenant over its seat limit" on every request is wasteful; checking it when a seat is added is exact and cheap. The interaction with usage-based enforcement is covered in enforcing plan limits with tenant quotas.
What should happen when a subscription lapses?
Degrade rather than delete. Remove the entitlement so the capability stops working, keep the data intact, and make the upgrade path obvious in the error response — a 402 naming the feature and linking to billing is far more useful than a generic 403. Destroying data on non-payment converts a recoverable billing problem into an unrecoverable one, and the retention rules for a genuine termination belong in Tenant Offboarding & Data Retention Workflows.
How should entitlements be surfaced to the front end? As a resolved list on the session bootstrap response, refreshed when the entitlement version changes, and treated purely as a rendering hint. The interface uses it to decide what to show and what to present as an upgrade prompt; the server decides what is actually permitted. Sending the whole plan definition instead of the resolved set makes the client reimplement resolution, which guarantees the two eventually disagree — usually in the direction of showing a feature that then fails on use.