Shop It Docs
WorkflowsSubscription

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:

  • AccessModule
  • AccessService
  • FeatureService
  • AccessGuard + @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):

  1. resolves the active subscription_module by slug
  2. looks for a non-revoked user_access row for the same module
  3. requires expiresAt to be null or future
  4. 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_access rows
  • 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 AccessGuard and FeatureGuard
  • backed by cached reads from user_access
  • updated centrally from SubscriptionService