Role-based access control handles permissions within your application — who can read, write, or administer which resources. But it does not answer a different question: which features does this user or organization have access to at all? That is the entitlements problem. An enterprise customer on your Pro plan gets SSO. A customer on the Free plan does not. A user who received a promotional feature grant can access advanced analytics even though their plan does not include it. These distinctions live in an entitlements layer that sits alongside, but separately from, your RBAC system.
The entitlement data model
A minimal entitlements model has three tables: features, plan entitlements (which features belong to which plan), and user/org overrides (one-off grants or revocations that override the plan defaults).
-- Features catalog: all possible gated capabilities CREATE TABLE features ( id TEXT PRIMARY KEY, -- 'sso', 'advanced_analytics', 'audit_logs' name TEXT NOT NULL, description TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); -- Which features are included in each plan CREATE TABLE plan_entitlements ( plan_id TEXT NOT NULL, -- 'free', 'pro', 'enterprise' feature_id TEXT NOT NULL REFERENCES features(id), limit_value INTEGER, -- NULL = unlimited, N = hard limit PRIMARY KEY (plan_id, feature_id) ); -- Per-org overrides: grant or revoke specific features CREATE TABLE org_entitlement_overrides ( org_id TEXT NOT NULL, feature_id TEXT NOT NULL REFERENCES features(id), granted BOOLEAN NOT NULL, -- true = grant, false = revoke limit_value INTEGER, expires_at TIMESTAMPTZ, -- NULL = permanent reason TEXT, -- audit trail granted_by TEXT, -- user ID who set this override created_at TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (org_id, feature_id) );
Resolving entitlements with inheritance and overrides
The resolution algorithm: start with the plan's entitlements, then apply org-level overrides. An override that grants a feature a free-plan org does not have adds it. An override that revokes a feature the plan includes removes it. Expired overrides are treated as non-existent.
interface EntitlementResult {
granted: boolean;
limit: number | null;
source: 'plan' | 'override';
expiresAt: Date | null;
}
async function resolveEntitlement(
orgId: string,
featureId: string
): Promise {
// Check for an active org-level override first (highest priority)
const override = await db.query(`
SELECT granted, limit_value, expires_at
FROM org_entitlement_overrides
WHERE org_id = $1
AND feature_id = $2
AND (expires_at IS NULL OR expires_at > NOW())
`, [orgId, featureId]);
if (override.rows.length > 0) {
const o = override.rows[0];
return {
granted: o.granted,
limit: o.limit_value,
source: 'override',
expiresAt: o.expires_at,
};
}
// Fall back to plan entitlement
const org = await db.orgs.findById(orgId);
const planEntitlement = await db.query(`
SELECT limit_value
FROM plan_entitlements
WHERE plan_id = $1 AND feature_id = $2
`, [org.planId, featureId]);
if (planEntitlement.rows.length > 0) {
return {
granted: true,
limit: planEntitlement.rows[0].limit_value,
source: 'plan',
expiresAt: null,
};
}
return { granted: false, limit: null, source: 'plan', expiresAt: null };
}
// Usage in route middleware
async function requireFeature(featureId: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const result = await resolveEntitlement(req.org.id, featureId);
if (!result.granted) {
return res.status(402).json({
error: 'feature_not_available',
feature: featureId,
upgradeUrl: '/billing/upgrade',
});
}
req.entitlement = result;
next();
};
}
router.get('/sso/configure', requireFeature('sso'), ssoConfigureHandler);
Grace periods
When a subscription downgrades or expires, immediately revoking access to all premium features creates a harsh UX and support burden. A grace period allows continued access for a fixed window while surfacing a clear message to the org admin that they need to act.
// When a subscription expires, create a time-limited grace override
async function applyDowngradeGracePeriod(orgId: string, fromPlan: string, toPlan: string) {
const fromFeatures = await getPlanFeatures(fromPlan);
const toFeatures = await getPlanFeatures(toPlan);
const lostFeatures = fromFeatures.filter(f => !toFeatures.includes(f));
// Grant lost features for a 14-day grace period
const expiresAt = new Date(Date.now() + 14 * 24 * 3600 * 1000);
for (const featureId of lostFeatures) {
await db.orgEntitlementOverrides.upsert({
orgId,
featureId,
granted: true,
expiresAt,
reason: `grace_period_after_downgrade_from_${fromPlan}`,
grantedBy: 'system',
});
}
// Emit event for notification email
await eventBus.publish('entitlement.grace_period_started', {
orgId,
lostFeatures,
expiresAt,
});
}
Entitlement events and audit trail
Every entitlement change — plan upgrade, plan downgrade, feature grant, feature revocation — should emit an event. These events drive email notifications, in-app banners, and usage analytics. They also form an audit trail that makes it possible to understand why an org has or lacks a particular feature at any point in time.
// Entitlement event schema
interface EntitlementEvent {
type: 'granted' | 'revoked' | 'plan_changed' | 'grace_period_started' | 'grace_period_expired';
orgId: string;
featureId?: string;
fromPlan?: string;
toPlan?: string;
actor: string; // 'system', 'billing_webhook', or user ID
timestamp: Date;
metadata: Record;
}
// Consuming events to send notifications
eventBus.subscribe('entitlement.grace_period_started', async (event) => {
const org = await db.orgs.findById(event.orgId);
const admins = await db.orgMembers.findAdmins(event.orgId);
for (const admin of admins) {
await emailService.send({
to: admin.email,
template: 'grace_period_warning',
data: {
orgName: org.name,
features: event.lostFeatures,
expiresAt: event.expiresAt,
upgradeUrl: `https://app.example.com/billing/upgrade`,
},
});
}
});
Limit-based entitlements
Beyond boolean feature access, some entitlements are numeric limits: Free plan gets 3 team members, Pro plan gets 25, Enterprise is unlimited. The same resolution model applies, with limit_value carrying the number (or NULL for unlimited). At enforcement time, compare the current count against the limit before allowing the action.
async function checkMemberLimit(orgId: string): Promise{ const entitlement = await resolveEntitlement(orgId, 'team_members'); if (!entitlement.granted) throw new Error('feature_not_available'); if (entitlement.limit === null) return; // Unlimited const currentCount = await db.orgMembers.count({ orgId }); if (currentCount >= entitlement.limit) { throw new Error(`member_limit_reached:${entitlement.limit}`); } }