04 β Feature Isolation: Licensing & RBAC
Beyond compute and data isolation (02, 03), tenants are isolated at the feature level: which modules a tenant may use, and which a given user may touch. This is enforced by two orthogonal gates β the license (tenant-level) and RBAC permissions (principal-level) β both of which must pass on every request.
1. The license model
Defined in src/core-api/src/types/license.ts.
Moduleenum (license.ts:6-190) β the canonical, exhaustive list of grantable capabilities, keyed by colon-delimited strings. The segment before the first:is the base module; deeper segments are sub-modules. Examples:members(base) βmembers:ranks,members:requests,members:primary_location;financialsβfinancials:collectionsβfinancials:collections:stripe. A module is any enum entry; a base is its root prefix.Licenseinterface (license.ts:224-237) β the per-tenant grant. The key field isenabled_modules: Module[]. It also carriesplan_name,module_limits[](per-module record/API/storage caps with anEnforcementStrategyofhard_block | soft_warning | overage_charge),features: string[](global flags likewhite_label,sso),valid_from/valid_until,max_users.EncryptedLicense(license.ts:242-247) β the at-rest form: AES-256-GCM-encrypted JSON of theLicense.
Authoring & storage (control plane)
During provisioning (TenantProvisioningService, :134-135):
resolveEnabledModulesForCreateRequest(:983) takes the request'scustom_modules, filters them againstDEPLOYMENT_AVAILABLE_MODULES(the vertical's allowed set), and always unions inEXPOSABLE_STANDARD_MODULES=users,roles,settings(src/core-api/src/constants/available-modules.ts:129). A tenant with no custom modules gets only those three baselines.generateAndEncryptLicense(:1055) builds theLicenseand encrypts it;storeLicense(:1084) writes the blob toTenant.encrypted_licensein the core DB.
Consumption (data plane)
src/tenant-api/src/services/license/TenantLicenseService.ts:
- In-memory cache, ~1-hour TTL, keyed by tenant id/slug/uuid aliases.
- Resolution order (
getLicense,:165): cache βGET {CORE_API_URL}/v1/license/validate/{tenantId}β slugβuuid re-fetch β local-dev fallback decryptingTenant.encrypted_licensedirectly from the core DB. checkModuleAccess(tenantId, module)(:240) is the core gate:allowed:falseif there's no license or the module isn't inenabled_modules.
β οΈ Two behaviours to know. (1) License expiry is not enforced β thevalid_untilcheck is commented out (:259-265). (2) The check fails open: on any error it returnsallowed:true(:298-305), andlicenseMiddlewaredoes the same. So a core-api outage degrades to permissive access, not denial.
2. Module/base gating on the API
The middleware that enforces the license is wired per route in src/tenant-api/src/routes/index.ts. A representative route:
router.get('/member-requests',
tenantMiddleware, // resolve req.tenantId
authenticateJWT, // identity + req.user.permissions
requireModule('members:requests'), // LICENSE gate
requirePermission(MEMBER_REQUESTS_PERMISSIONS.MEMBERS_REQUESTS_READ), // RBAC gate
memberRequestController.list);
The license gates come from licenseMiddleware.ts:
requireModule(m)β the tenant must haveminenabled_modules.requireAnyModule([a, b])β passes if any listed module is enabled.requireFeature(f)β checks thefeatures[]array.
The API catalog and base filtering
src/tenant-api/src/docs/api-catalog.generated.ts is a generated manifest of all routes (β1657), each annotated with modules, anyModules, permissions, requiredBases, anyGroups, auth, tenant, core. The catalog does not enforce anything at request time β it's metadata. Its requiredBases / anyGroups are used by apiDocs.ts to filter the published OpenAPI surface to the bases the deployment/vertical supports (isShown, :50-56), independent of any one tenant's license. So there are two filters with different scopes: per-tenant license gating (runtime) and per-vertical base filtering (docs/surface).
A separate gate, functionAuthMiddleware.ts (requireFunction), governs public/unauthenticated endpoints by checking the function id against a domain's unauthenticatedFunctions allowlist (and validating the domain belongs to the tenant β anti-spoofing).
3. Exposable modules
"Exposable modules" govern what a root/reseller tenant (one with can_create_tenants) may grant to the child tenants it provisions, and what each tenant surfaces to its portals.
src/core-api/src/constants/exposable-module-keys.ts(+ tenant-api mirrorsrc/tenant-api/src/utils/exposableModuleKeys.ts) canonicalize legacy keys (e.g.feasibilityβfeasibilities), drop the always-on standard modules, and de-dupe.- The core endpoint
/modules/available(TenantController.ts:368) returns the deployment's available modules split intobase_modules(no:),sub_modules(has:), andgrouped_by_parent. - A tenant's chosen set is stored as
Tenant.exposable_modules.
Portal reflection
The runtime enabled_modules array reaches the portal on the authenticated user object. A portal ModuleService.isModuleEnabled(name) does prefix-aware matching β enabling a base (e.g. members) implicitly enables its sub-modules (members:ranks) β with a set of always-available CORE_MODULES. The portal shows/hides navigation items and route guards off this, so a tenant only sees the features its license grants. The tenant-api applies the identical prefix rule server-side (services/tenant-setup/moduleScope.ts).
4. Permissions / RBAC
- Definition:
src/tenant-api/src/constants/permissions.tsβ permission strings grouped per module, formatmodule:action:resource(e.g.members:read:all), with granularity such as:allvs:own/:own_family. - Check:
requirePermission(perms)inauthMiddleware.ts(:269). It readsreq.user.permissions(from the JWT or expanded fromrole_idsduringauthenticateJWT). Logic: an admin bypass β holdingadmin:full:accessshort-circuits every check (:285) β otherwise grant if the user has any of the required permissions (OR semantics), else403. - Member vs admin/operator: purely which permission set the principal's roles carry. Operators/admins hold broad
:allpermissions (or theadmin:full:accesswildcard); members hold scoped:own/:own_familypermissions and self-routes (/members/me/...).
The full gate, end to end
core-api: createTenant β resolveEnabledModules (β© DEPLOYMENT_AVAILABLE_MODULES, βͺ users/roles/settings)
β createLicense β encrypt β store (Tenant.encrypted_license)
β
GET /v1/license/validate/{tenant} (or DB-decrypt fallback)
βΌ
tenant-api: TenantLicenseService cache (1h) β checkModuleAccess(enabled_modules)
per request: tenantMiddleware β authenticateJWT
β requireModule / requireAnyModule (LICENSE gate)
β requirePermission (RBAC gate)
β controller
β enabled_modules also embedded in user.license on the JWT
βΌ
portal: ModuleService.isModuleEnabled (prefix-aware) β show/hide nav & guard routes
docs: apiDocs.isShown filters the OpenAPI surface by requiredBases/anyGroups vs the vertical's bases
Key source paths: src/core-api/src/types/license.ts, src/core-api/src/services/tenant/TenantProvisioningService.ts (:983-1099), src/core-api/src/constants/available-modules.ts, src/tenant-api/src/services/license/TenantLicenseService.ts, src/tenant-api/src/middleware/{licenseMiddleware,authMiddleware,functionAuthMiddleware}.ts, src/tenant-api/src/constants/permissions.ts, src/tenant-api/src/docs/{api-catalog.generated.ts,apiDocs.ts}, src/tenant-api/src/routes/index.ts.