Building an entitlements system: feature access beyond simple roles

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`,
      },
    });
  }
});
Cache entitlement resolution results aggressively. Resolving entitlements on every API request involves at least two database reads. Cache the result in Redis with a TTL of 5 minutes, keyed by org ID and feature ID. When an entitlement changes, invalidate the affected cache keys. The 5-minute window is acceptable for plan changes — the org admin is not watching the second the feature turns off.

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}`);
  }
}
← Back to blog Try Bastionary free →