Access Control
Current subscription entitlement model, access services, feature services, and the guard/decorator pair used by consumers.
Access Control
Audience: backend developers integrating entitlement checks Scope:
AccessModule,AccessService,FeatureService,AccessGuard,FeatureGuard
Current Model
The access system no longer revolves around a dedicated SubscriptionAccessGuard.
Current access control is built from:
AccessModuleAccessServiceFeatureServiceAccessGuard+@RequiresAccess()FeatureGuard+@RequiresFeature()
This is the current replacement for the older subscription-specific guard naming.
Access Source of Truth
Runtime authorization uses user_access.
AccessService.checkAccess(userId, moduleSlug):
- resolves the active
subscription_moduleby slug - looks for a non-revoked
user_accessrow for the same module - requires
expiresAtto be null or future - caches the result under
sub:access:{userId}:{moduleSlug}
This means access can come from:
- paid subscription
- trial
- admin grant
The authorization layer does not need to know which one produced the entitlement.
Feature Source of Truth
FeatureService reads active user_access rows and joins through plan -> features.
Available methods:
loadUserFeatures(userId)checkFeature(userId, featureKey)getFeatureValue(userId, featureKey)
Feature results are cached under sub:features:{userId}.
Guard and Decorator Pair
Module-Level Access
Use:
AccessGuard@RequiresAccess("module-slug")
Example:
import { Controller, Get, UseGuards } from "@nestjs/common";
import { JwtAuthGuard } from "@/modules/auth/guards/jwt-auth.guard";
import { AccessGuard } from "@/common/authorization/access.guard";
import { RequiresAccess } from "@/common/authorization/requires-access.decorator";
@Controller("chat")
@UseGuards(JwtAuthGuard, AccessGuard)
@RequiresAccess("chat")
export class ChatController {
@Get("messages")
async listMessages() {
return [];
}
}Dynamic route param support still exists:
@Controller("modules/:moduleSlug")
@UseGuards(JwtAuthGuard, AccessGuard)
@RequiresAccess(":moduleSlug")
export class ModuleContentController {}Feature-Level Access
Use:
FeatureGuard@RequiresFeature("feature.key")
Example:
import { Controller, Post, UseGuards } from "@nestjs/common";
import { JwtAuthGuard } from "@/modules/auth/guards/jwt-auth.guard";
import { FeatureGuard } from "@/common/authorization/feature.guard";
import { RequiresFeature } from "@/common/authorization/requires-feature.decorator";
@Controller("chat")
@UseGuards(JwtAuthGuard, FeatureGuard)
export class ChatController {
@Post("broadcast")
@RequiresFeature("chat.broadcast")
async broadcast() {
return { ok: true };
}
}Direct Service Usage
Use the services directly when access is conditional inside a method rather than enforced at the route boundary.
const hasAccess = await this.accessService.checkAccess(userId, "chat");
if (!hasAccess) {
throw new ForbiddenException("Access denied.");
}
const hasFeature = await this.featureService.checkFeature(
userId,
"chat.broadcast",
);Important Behavior Notes
Cancellation Does Not Always Mean Immediate Access Loss
Customer cancellation updates the subscription to cancelled, but access can remain active until user_access.expiresAt.
Scheduled Expiry Matters
AccessExpirationTask is responsible for:
- revoking expired
user_accessrows - marking active/trial subscriptions as
expired
Mobile Access Endpoint Is Broader
GET /subscriptions/access returns non-revoked access rows and computes isActive in the response. It is not the same thing as route authorization, which only considers active non-expired access.
Summary
The current access system is:
- entitlement-driven, not subscription-status-driven
- generalized under
AccessGuardandFeatureGuard - backed by cached reads from
user_access - updated centrally from
SubscriptionService