@@ -7,13 +7,16 @@ import assertThat from '#src/utils/assert-that.js';
77import {
88 reportSubscriptionUpdates ,
99 isReportSubscriptionUpdatesUsageKey ,
10- getTenantUsageData ,
10+ getTenantUsageData as getTenantUsageFromCloud ,
1111} from '#src/utils/subscription/index.js' ;
1212import { type SubscriptionQuota , type SubscriptionUsage } from '#src/utils/subscription/types.js' ;
1313
1414import {
15- selfComputedSubscriptionUsageGuard ,
16- type SelfComputedTenantUsage ,
15+ isBooleanFeatureKey ,
16+ isNumericLimitKey ,
17+ type AllLimitKey ,
18+ type EntityBasedUsageKey ,
19+ type NumericLimitKey ,
1720} from '../queries/tenant-usage/types.js' ;
1821import type Queries from '../tenants/Queries.js' ;
1922
@@ -26,6 +29,11 @@ const paidReservedPlans = new Set<string>([
2629 ReservedPlanId . Pro202509 ,
2730] ) ;
2831
32+ type GuardTenantUsageByKeyFunction = {
33+ ( key : Exclude < AllLimitKey , EntityBasedUsageKey > , context ?: { entityId : string } ) : Promise < void > ;
34+ ( key : EntityBasedUsageKey , context : { entityId : string } ) : Promise < void > ;
35+ } ;
36+
2937export class QuotaLibrary {
3038 constructor (
3139 public readonly tenantId : string ,
@@ -35,7 +43,7 @@ export class QuotaLibrary {
3543 private readonly subscription : SubscriptionLibrary
3644 ) { }
3745
38- guardTenantUsageByKey = async ( key : keyof SubscriptionUsage ) => {
46+ guardTenantUsageByKey : GuardTenantUsageByKeyFunction = async ( key , context ) => {
3947 const { isCloud } = EnvSet . values ;
4048
4149 // Cloud only feature, skip in non-cloud environments
@@ -54,26 +62,19 @@ export class QuotaLibrary {
5462 return ;
5563 }
5664
57- const { usage : fullUsage } =
58- key === 'tenantMembersLimit'
59- ? await getTenantUsageData ( this . cloudConnection )
60- : // `tenantMembersLimit` need to compute from admin tenant level
61- await this . getTenantUsage ( ) ;
62-
63- // Type `SubscriptionQuota` and type `SubscriptionUsage` are sharing keys, this design helps us to compare the usage with the quota limit in a easier way.
6465 const { [ key ] : limit } = fullQuota ;
65- /**
66- * `tenantMembersLimit` need to compute from admin tenant level, in previous code, we use cloud API to request tenant members count when necessary,
67- * otherwise, we use self computed usage.
68- * Since self computed usage do not include `tenantMembersLimit`, we need to manually add it to the usage object.
69- */
70- const { [ key ] : usage } = { tenantMembersLimit : 0 , ...fullUsage } ;
7166
7267 if ( limit === null ) {
68+ // Skip unlimited quotas
7369 return ;
7470 }
7571
76- if ( typeof limit === 'boolean' ) {
72+ // Boolean features: enforce directly by plan quota configuration (true = allowed, false = blocked)
73+ if ( isBooleanFeatureKey ( key ) ) {
74+ assertThat (
75+ typeof limit === 'boolean' ,
76+ new TypeError ( 'Feature availability settings must be boolean type.' )
77+ ) ;
7778 assertThat (
7879 limit ,
7980 new RequestError ( {
@@ -87,13 +88,16 @@ export class QuotaLibrary {
8788 return ;
8889 }
8990
90- if ( typeof limit === 'number' ) {
91+ // Number-based limits: query current usage and compare with limit
92+ if ( isNumericLimitKey ( key ) ) {
9193 // See the definition of `SubscriptionQuota` and `SubscriptionUsage` in `types.ts`, this should never happen.
9294 assertThat (
93- typeof usage === 'number' ,
94- new TypeError ( 'Usage must be with the same type as the limit .' )
95+ typeof limit === 'number' ,
96+ new TypeError ( 'Usage limit must be number type for numeric limits .' )
9597 ) ;
9698
99+ const usage = await this . getTenantUsageByKey ( key , context ) ;
100+
97101 assertThat (
98102 usage < limit ,
99103 new RequestError ( {
@@ -106,60 +110,12 @@ export class QuotaLibrary {
106110 } ,
107111 } )
108112 ) ;
109-
110113 return ;
111114 }
112115
113116 throw new TypeError ( 'Unsupported subscription quota type' ) ;
114117 } ;
115118
116- guardEntityScopesUsage = async ( entityName : 'resources' | 'roles' , entityId : string ) => {
117- const { isCloud } = EnvSet . values ;
118-
119- // Cloud only feature, skip in non-cloud environments
120- if ( ! isCloud ) {
121- return ;
122- }
123-
124- const {
125- quota : { scopesPerResourceLimit, scopesPerRoleLimit } ,
126- } = await this . subscription . getSubscriptionData ( ) ;
127-
128- const { [ entityId ] : usage = 0 } =
129- entityName === 'resources'
130- ? await this . queries . tenantUsage . getScopesForResourcesTenantUsage ( this . tenantId )
131- : await this . queries . tenantUsage . getScopesForRolesTenantUsage ( ) ;
132-
133- if ( entityName === 'resources' ) {
134- assertThat (
135- scopesPerResourceLimit === null || scopesPerResourceLimit > usage ,
136- new RequestError ( {
137- code : 'subscription.limit_exceeded' ,
138- status : 403 ,
139- data : {
140- key : 'scopesPerResourceLimit' ,
141- limit : scopesPerResourceLimit ,
142- usage,
143- } ,
144- } )
145- ) ;
146- return ;
147- }
148-
149- assertThat (
150- scopesPerRoleLimit === null || scopesPerRoleLimit > usage ,
151- new RequestError ( {
152- code : 'subscription.limit_exceeded' ,
153- status : 403 ,
154- data : {
155- key : 'scopesPerRoleLimit' ,
156- limit : scopesPerRoleLimit ,
157- usage,
158- } ,
159- } )
160- ) ;
161- } ;
162-
163119 reportSubscriptionUpdatesUsage = async ( key : keyof SubscriptionUsage ) => {
164120 const { isCloud, isIntegrationTest } = EnvSet . values ;
165121
@@ -180,48 +136,26 @@ export class QuotaLibrary {
180136 }
181137 } ;
182138
183- public async getTenantUsage ( ) : Promise < { usage : SelfComputedTenantUsage } > {
184- const [ rawUsage , connectors ] = await Promise . all ( [
185- this . queries . tenantUsage . getRawTenantUsage ( this . tenantId ) ,
186- this . connectorLibrary . getLogtoConnectors ( ) ,
187- ] ) ;
188-
189- const socialConnectors = connectors . filter (
190- ( connector ) => connector . type === ConnectorType . Social
191- ) ;
192-
193- const unparsedUsage : SelfComputedTenantUsage = {
194- applicationsLimit : rawUsage . applicationsLimit ,
195- thirdPartyApplicationsLimit : rawUsage . thirdPartyApplicationsLimit ,
196- scopesPerResourceLimit : rawUsage . scopesPerResourceLimit , // Max scopes per resource
197- userRolesLimit : rawUsage . userRolesLimit ,
198- machineToMachineRolesLimit : rawUsage . machineToMachineRolesLimit ,
199- scopesPerRoleLimit : rawUsage . scopesPerRoleLimit , // Max scopes per role
200- hooksLimit : rawUsage . hooksLimit ,
201- customJwtEnabled : rawUsage . customJwtEnabled ,
202- bringYourUiEnabled : rawUsage . bringYourUiEnabled ,
203- collectUserProfileEnabled : rawUsage . collectUserProfileEnabled ,
204- /** Add-on quotas start */
205- machineToMachineLimit : rawUsage . machineToMachineLimit ,
206- resourcesLimit : rawUsage . resourcesLimit ,
207- enterpriseSsoLimit : rawUsage . enterpriseSsoLimit ,
208- mfaEnabled : rawUsage . mfaEnabled ,
209- securityFeaturesEnabled : rawUsage . securityFeaturesEnabled ,
210- /** Enterprise only add-on quotas */
211- idpInitiatedSsoEnabled : rawUsage . idpInitiatedSsoEnabled ,
212- samlApplicationsLimit : rawUsage . samlApplicationsLimit ,
213- socialConnectorsLimit : socialConnectors . length ,
214- organizationsLimit : rawUsage . organizationsLimit ,
215- /**
216- * We can not calculate the quota usage since there is no related DB configuration for such feature.
217- * Whether the feature is enabled depends on the `quota` defined for each plan/SKU.
218- * If we mark this value as always `true`, it could block the subscription downgrade (to free plan) since in free plan we do not allow impersonation feature.
219- */
220- subjectTokenEnabled : false ,
221- } ;
222-
223- return { usage : selfComputedSubscriptionUsageGuard . parse ( unparsedUsage ) } ;
224- }
139+ private readonly getTenantUsageByKey = async (
140+ key : NumericLimitKey ,
141+ context ?: { entityId : string }
142+ ) => {
143+ if ( key === 'tenantMembersLimit' ) {
144+ const {
145+ usage : { tenantMembersLimit } ,
146+ } = await getTenantUsageFromCloud ( this . cloudConnection ) ;
147+
148+ return tenantMembersLimit ;
149+ }
150+
151+ if ( key === 'socialConnectorsLimit' ) {
152+ const connectors = await this . connectorLibrary . getLogtoConnectors ( ) ;
153+
154+ return connectors . filter ( ( connector ) => connector . type === ConnectorType . Social ) . length ;
155+ }
156+
157+ return this . queries . tenantUsage . getSelfComputedUsageByKey ( this . tenantId , key , context ) ;
158+ } ;
225159
226160 /**
227161 * @remarks
0 commit comments