Skip to content

Commit 7c14743

Browse files
authored
fix(core): validate batch usage consumption in quota guard (#7939)
1 parent c2e491e commit 7c14743

File tree

4 files changed

+265
-10
lines changed

4 files changed

+265
-10
lines changed

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

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable max-lines */
12
import { ConnectorType, ReservedPlanId } from '@logto/schemas';
23
import { createMockUtils } from '@logto/shared/esm';
34

@@ -313,7 +314,7 @@ describe('guardTenantUsageByKey', () => {
313314
},
314315
});
315316

316-
await quotaLibrary.guardTenantUsageByKey('scopesPerResourceLimit', 'resource_1');
317+
await quotaLibrary.guardTenantUsageByKey('scopesPerResourceLimit', { entityId: 'resource_1' });
317318

318319
expect(getSelfComputedUsageByKey).toHaveBeenCalledWith(
319320
tenant.id,
@@ -340,7 +341,7 @@ describe('guardTenantUsageByKey', () => {
340341
});
341342

342343
await expect(
343-
quotaLibrary.guardTenantUsageByKey('scopesPerRoleLimit', 'role_1')
344+
quotaLibrary.guardTenantUsageByKey('scopesPerRoleLimit', { entityId: 'role_1' })
344345
).rejects.toMatchObject({
345346
code: 'subscription.limit_exceeded',
346347
status: 403,
@@ -399,6 +400,141 @@ describe('guardTenantUsageByKey', () => {
399400
// The key point: usage should be fetched only once, not twice
400401
expect(getSelfComputedUsageByKey).toHaveBeenCalledTimes(1);
401402
});
403+
404+
it('allows usage when usage + consumeUsageCount equals limit', async () => {
405+
mockGetTenantSubscription.mockResolvedValueOnce({
406+
...mockSubscriptionData,
407+
quota: {
408+
...mockSubscriptionData.quota,
409+
applicationsLimit: 10,
410+
},
411+
});
412+
413+
const getSelfComputedUsageByKey = jest.fn().mockResolvedValue(7);
414+
415+
const { quotaLibrary } = createQuotaLibrary({
416+
queriesOverride: {
417+
tenantUsage: { getSelfComputedUsageByKey },
418+
},
419+
});
420+
421+
// 7 + 3 = 10, should be allowed (<=)
422+
await expect(
423+
quotaLibrary.guardTenantUsageByKey('applicationsLimit', { consumeUsageCount: 3 })
424+
).resolves.not.toThrow();
425+
});
426+
427+
it('throws when usage + consumeUsageCount exceeds limit', async () => {
428+
mockGetTenantSubscription.mockResolvedValueOnce({
429+
...mockSubscriptionData,
430+
quota: {
431+
...mockSubscriptionData.quota,
432+
applicationsLimit: 10,
433+
},
434+
});
435+
436+
const getSelfComputedUsageByKey = jest.fn().mockResolvedValue(7);
437+
438+
const { quotaLibrary } = createQuotaLibrary({
439+
queriesOverride: {
440+
tenantUsage: { getSelfComputedUsageByKey },
441+
},
442+
});
443+
444+
// 7 + 4 = 11 > 10, should throw
445+
await expect(
446+
quotaLibrary.guardTenantUsageByKey('applicationsLimit', { consumeUsageCount: 4 })
447+
).rejects.toMatchObject({
448+
code: 'subscription.limit_exceeded',
449+
status: 403,
450+
});
451+
});
452+
453+
it('allows batch operation when total usage equals limit for entity-based quota', async () => {
454+
mockGetTenantSubscription.mockResolvedValueOnce({
455+
...mockSubscriptionData,
456+
quota: {
457+
...mockSubscriptionData.quota,
458+
scopesPerRoleLimit: 10,
459+
},
460+
});
461+
462+
const getSelfComputedUsageByKey = jest.fn().mockResolvedValue(5);
463+
464+
const { quotaLibrary } = createQuotaLibrary({
465+
queriesOverride: {
466+
tenantUsage: { getSelfComputedUsageByKey },
467+
},
468+
});
469+
470+
// Simulating adding 5 scopes at once: 5 + 5 = 10, should be allowed
471+
await expect(
472+
quotaLibrary.guardTenantUsageByKey('scopesPerRoleLimit', {
473+
entityId: 'role_1',
474+
consumeUsageCount: 5,
475+
})
476+
).resolves.not.toThrow();
477+
});
478+
479+
it('blocks batch operation that would exceed entity-based quota limit', async () => {
480+
mockGetTenantSubscription.mockResolvedValueOnce({
481+
...mockSubscriptionData,
482+
quota: {
483+
...mockSubscriptionData.quota,
484+
scopesPerRoleLimit: 10,
485+
},
486+
});
487+
488+
const getSelfComputedUsageByKey = jest.fn().mockResolvedValue(5);
489+
490+
const { quotaLibrary } = createQuotaLibrary({
491+
queriesOverride: {
492+
tenantUsage: { getSelfComputedUsageByKey },
493+
},
494+
});
495+
496+
// Simulating adding 100 scopes at once: 5 + 100 = 105 > 10, should throw
497+
await expect(
498+
quotaLibrary.guardTenantUsageByKey('scopesPerRoleLimit', {
499+
entityId: 'role_1',
500+
consumeUsageCount: 100,
501+
})
502+
).rejects.toMatchObject({
503+
code: 'subscription.limit_exceeded',
504+
status: 403,
505+
});
506+
});
507+
508+
it('respects system limit with batch consumption', async () => {
509+
setEnvFlag('isDevFeaturesEnabled', true);
510+
mockGetTenantSubscription.mockResolvedValueOnce({
511+
...mockSubscriptionData,
512+
quota: {
513+
...mockSubscriptionData.quota,
514+
applicationsLimit: null,
515+
},
516+
systemLimit: {
517+
...mockSubscriptionData.systemLimit,
518+
applicationsLimit: 10,
519+
},
520+
});
521+
522+
const getSelfComputedUsageByKey = jest.fn().mockResolvedValue(8);
523+
524+
const { quotaLibrary } = createQuotaLibrary({
525+
queriesOverride: {
526+
tenantUsage: { getSelfComputedUsageByKey },
527+
},
528+
});
529+
530+
// 8 + 3 = 11 > 10, should throw system limit error
531+
await expect(
532+
quotaLibrary.guardTenantUsageByKey('applicationsLimit', { consumeUsageCount: 3 })
533+
).rejects.toMatchObject({
534+
code: 'system_limit.limit_exceeded',
535+
status: 403,
536+
});
537+
});
402538
});
403539

404540
describe('reportSubscriptionUpdatesUsage', () => {
@@ -485,3 +621,4 @@ describe('reportSubscriptionUpdatesUsage', () => {
485621
expect(mockReportSubscriptionUpdates).not.toHaveBeenCalled();
486622
});
487623
});
624+
/* eslint-enable max-lines */

packages/core/src/libraries/quota.ts

Lines changed: 121 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,77 @@ const paidReservedPlans = new Set<string>([
3636
ReservedPlanId.Pro202509,
3737
]);
3838

39+
/**
40+
* Options for quota guard operations.
41+
*/
42+
type GuardTenantUsageOptions = {
43+
/**
44+
* Entity ID for entity-based quota limits.
45+
*
46+
* @remarks
47+
* Required for entity-based quotas like `scopesPerRoleLimit`, `scopesPerResourceLimit`, etc.
48+
* Optional for tenant-level quotas like `applicationsLimit`, `resourcesLimit`, etc.
49+
*
50+
* @example
51+
* ```ts
52+
* // Entity-based quota: entityId is required
53+
* await quota.guardTenantUsageByKey('scopesPerRoleLimit', { entityId: roleId });
54+
*
55+
* // Tenant-level quota: entityId not needed
56+
* await quota.guardTenantUsageByKey('applicationsLimit');
57+
* ```
58+
*/
59+
entityId?: string;
60+
61+
/**
62+
* The number of resources to consume in this operation.
63+
*
64+
* @remarks
65+
* This is used to validate batch operations where multiple resources are added at once.
66+
* For example, when assigning 10 scopes to a role in a single request, set this to 10.
67+
*
68+
* The guard will check: `currentUsage + consumeUsageCount <= limit`
69+
*
70+
* @default 1 - Assumes single resource consumption if not specified
71+
*
72+
* @example
73+
* ```ts
74+
* // Adding a single application (default behavior)
75+
* await quota.guardTenantUsageByKey('applicationsLimit');
76+
*
77+
* // Adding 5 scopes to a role at once
78+
* await quota.guardTenantUsageByKey('scopesPerRoleLimit', {
79+
* entityId: roleId,
80+
* consumeUsageCount: 5,
81+
* });
82+
* ```
83+
*/
84+
consumeUsageCount?: number;
85+
};
86+
87+
/**
88+
* Function type for guarding tenant usage against quota limits.
89+
* Provides type-safe overloads for tenant-level and entity-based quotas.
90+
*/
3991
type GuardTenantUsageByKeyFunction = {
40-
(key: Exclude<UsageKey, EntityBasedUsageKey>, entityId?: string): Promise<void>;
41-
(key: EntityBasedUsageKey, entityId: string): Promise<void>;
92+
/**
93+
* Guard tenant-level quota (e.g., applicationsLimit, resourcesLimit)
94+
* @param key - Tenant-level quota key
95+
* @param options - Optional guard options
96+
*/
97+
(key: Exclude<UsageKey, EntityBasedUsageKey>, options?: GuardTenantUsageOptions): Promise<void>;
98+
99+
/**
100+
* Guard entity-based quota (e.g., scopesPerRoleLimit, scopesPerResourceLimit)
101+
* @param key - Entity-based quota key
102+
* @param options - Guard options with required entityId
103+
*/
104+
(
105+
key: EntityBasedUsageKey,
106+
options: GuardTenantUsageOptions & { entityId: string }
107+
): Promise<void>;
42108
};
109+
43110
export class QuotaLibrary {
44111
constructor(
45112
public readonly tenantId: string,
@@ -49,7 +116,44 @@ export class QuotaLibrary {
49116
private readonly subscription: SubscriptionLibrary
50117
) {}
51118

52-
guardTenantUsageByKey: GuardTenantUsageByKeyFunction = async (key, entityId) => {
119+
/**
120+
* Guards tenant usage against quota and system limits before performing an operation.
121+
*
122+
* @remarks
123+
* This method checks if the current usage plus the resources to be consumed would exceed
124+
* the configured limits. It validates against both system limits (hard caps) and
125+
* subscription quota limits.
126+
*
127+
* The check formula is: `currentUsage + consumeUsageCount <= limit`
128+
*
129+
* If the limit would be exceeded, this method throws a RequestError. Otherwise, it
130+
* returns silently, allowing the operation to proceed.
131+
*
132+
* @param key - The quota key to check (e.g., 'applicationsLimit', 'scopesPerRoleLimit')
133+
* @param options - Optional configuration for the guard operation
134+
* @param options.entityId - Entity ID for entity-based quotas (required for entity-based keys)
135+
* @param options.consumeUsageCount - Number of resources to consume (default: 1)
136+
*
137+
* @throws {RequestError} With code 'system_limit.limit_exceeded' if system limit is exceeded
138+
* @throws {RequestError} With code 'subscription.limit_exceeded' if quota limit is exceeded
139+
*
140+
* @example
141+
* ```ts
142+
* // Guard before adding a single application
143+
* await quota.guardTenantUsageByKey('applicationsLimit');
144+
*
145+
* // Guard before adding multiple scopes to a role
146+
* const scopeIds = ['scope1', 'scope2', 'scope3'];
147+
* await quota.guardTenantUsageByKey('scopesPerRoleLimit', {
148+
* entityId: 'role_id',
149+
* consumeUsageCount: scopeIds.length,
150+
* });
151+
* ```
152+
*/
153+
guardTenantUsageByKey: GuardTenantUsageByKeyFunction = async (
154+
key,
155+
{ entityId, consumeUsageCount = 1 } = {}
156+
) => {
53157
const { isCloud } = EnvSet.values;
54158

55159
// Cloud only feature, skip in non-cloud environments
@@ -67,7 +171,13 @@ export class QuotaLibrary {
67171

68172
// Todo: @xiaoyijun: remove dev feature flag
69173
if (EnvSet.values.isDevFeaturesEnabled && isSystemUsageKey(key)) {
70-
await this.assertSystemLimit({ key, entityId, subscriptionData, tenantUsageQuery });
174+
await this.assertSystemLimit({
175+
key,
176+
entityId,
177+
subscriptionData,
178+
tenantUsageQuery,
179+
consumeUsageCount,
180+
});
71181
}
72182

73183
if (isQuotaUsageKey(key)) {
@@ -76,6 +186,7 @@ export class QuotaLibrary {
76186
entityId,
77187
subscriptionData,
78188
tenantUsageQuery,
189+
consumeUsageCount,
79190
});
80191
}
81192
};
@@ -118,11 +229,13 @@ export class QuotaLibrary {
118229
entityId,
119230
subscriptionData,
120231
tenantUsageQuery,
232+
consumeUsageCount,
121233
}: {
122234
key: SystemUsageKey;
123235
entityId?: string;
124236
subscriptionData: Subscription;
125237
tenantUsageQuery: TenantUsageQuery;
238+
consumeUsageCount: number;
126239
}) => {
127240
const { systemLimit } = subscriptionData;
128241

@@ -139,7 +252,7 @@ export class QuotaLibrary {
139252
const usage = await tenantUsageQuery.get(key, entityId);
140253

141254
assertThat(
142-
usage < limit,
255+
usage + consumeUsageCount <= limit,
143256
new RequestError(
144257
{
145258
code: 'system_limit.limit_exceeded',
@@ -155,11 +268,13 @@ export class QuotaLibrary {
155268
entityId,
156269
subscriptionData,
157270
tenantUsageQuery,
271+
consumeUsageCount,
158272
}: {
159273
key: QuotaUsageKey;
160274
entityId?: string;
161275
subscriptionData: Subscription;
162276
tenantUsageQuery: TenantUsageQuery;
277+
consumeUsageCount: number;
163278
}) => {
164279
const { planId, isEnterprisePlan, quota } = subscriptionData;
165280

@@ -205,7 +320,7 @@ export class QuotaLibrary {
205320
const usage = await tenantUsageQuery.get(key, entityId);
206321

207322
assertThat(
208-
usage < limit,
323+
usage + consumeUsageCount <= limit,
209324
new RequestError({
210325
code: 'subscription.limit_exceeded',
211326
status: 403,

packages/core/src/routes/resource.scope.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export default function resourceScopeRoutes<T extends ManagementApiRouter>(
8989
body,
9090
} = ctx.guard;
9191

92-
await quota.guardTenantUsageByKey('scopesPerResourceLimit', resourceId);
92+
await quota.guardTenantUsageByKey('scopesPerResourceLimit', { entityId: resourceId });
9393

9494
assertThat(!/\s/.test(body.name), 'scope.name_with_space');
9595

packages/core/src/routes/role.scope.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,10 @@ export default function roleScopeRoutes<T extends ManagementApiRouter>(
9393
body: { scopeIds },
9494
} = ctx.guard;
9595

96-
await quota.guardTenantUsageByKey('scopesPerRoleLimit', id);
96+
await quota.guardTenantUsageByKey('scopesPerRoleLimit', {
97+
entityId: id,
98+
consumeUsageCount: scopeIds.length,
99+
});
97100

98101
await validateRoleScopeAssignment(scopeIds, id);
99102
await insertRolesScopes(

0 commit comments

Comments
 (0)