Skip to content

Commit 228c706

Browse files
authored
refactor: tenant usage handling (#7923)
1 parent c3c64c7 commit 228c706

File tree

11 files changed

+751
-513
lines changed

11 files changed

+751
-513
lines changed

packages/core/src/libraries/quota.test.ts

Lines changed: 425 additions & 0 deletions
Large diffs are not rendered by default.

packages/core/src/libraries/quota.ts

Lines changed: 45 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ import assertThat from '#src/utils/assert-that.js';
77
import {
88
reportSubscriptionUpdates,
99
isReportSubscriptionUpdatesUsageKey,
10-
getTenantUsageData,
10+
getTenantUsageData as getTenantUsageFromCloud,
1111
} from '#src/utils/subscription/index.js';
1212
import { type SubscriptionQuota, type SubscriptionUsage } from '#src/utils/subscription/types.js';
1313

1414
import {
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';
1821
import 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+
2937
export 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

packages/core/src/middleware/koa-quota-guard.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import { type Nullable } from '@silverhand/essentials';
22
import type { MiddlewareType } from 'koa';
33

44
import { type QuotaLibrary } from '#src/libraries/quota.js';
5+
import { type EntityBasedUsageKey, type AllLimitKey } from '#src/queries/tenant-usage/types.js';
56
import { type SubscriptionUsage } from '#src/utils/subscription/types.js';
67

78
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'COPY' | 'HEAD' | 'OPTIONS';
89

910
type UsageGuardConfig = {
10-
key: keyof SubscriptionUsage;
11+
key: Exclude<AllLimitKey, EntityBasedUsageKey>;
1112
quota: QuotaLibrary;
1213
/** Guard usage only for the specified method types. Guard all if not provided. */
1314
methods?: Method[];
@@ -27,11 +28,18 @@ export function koaQuotaGuard<StateT, ContextT, ResponseBodyT>({
2728
};
2829
}
2930

31+
type UsageReportConfig = {
32+
key: keyof SubscriptionUsage;
33+
quota: QuotaLibrary;
34+
/** Report usage only for the specified method types. Report for all if not provided. */
35+
methods?: Method[];
36+
};
37+
3038
export function koaReportSubscriptionUpdates<StateT, ContextT, ResponseBodyT>({
3139
key,
3240
quota,
3341
methods = ['POST', 'PUT', 'DELETE'],
34-
}: UsageGuardConfig): MiddlewareType<StateT, ContextT, Nullable<ResponseBodyT>> {
42+
}: UsageReportConfig): MiddlewareType<StateT, ContextT, Nullable<ResponseBodyT>> {
3543
return async (ctx, next) => {
3644
await next();
3745

0 commit comments

Comments
 (0)