0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

Merge pull request #6259 from logto-io/yemq-update-cloud-api-usages-core

refactor: update @logto/core quota guard logics
This commit is contained in:
Darcy Ye 2024-07-26 11:06:40 +08:00 committed by GitHub
commit b322b9a037
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 339 additions and 41 deletions

View file

@ -5,8 +5,12 @@ import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { getTenantSubscriptionPlan } from '#src/utils/subscription/index.js'; import {
import { type FeatureQuota } from '#src/utils/subscription/types.js'; getTenantSubscriptionPlan,
getTenantSubscriptionQuotaAndUsage,
getTenantSubscriptionScopeUsage,
} from '#src/utils/subscription/index.js';
import { type SubscriptionQuota, type FeatureQuota } from '#src/utils/subscription/types.js';
import { type CloudConnectionLibrary } from './cloud-connection.js'; import { type CloudConnectionLibrary } from './cloud-connection.js';
import { type ConnectorLibrary } from './connector.js'; import { type ConnectorLibrary } from './connector.js';
@ -33,6 +37,7 @@ export const createQuotaLibrary = (
const { getLogtoConnectors } = connectorLibrary; const { getLogtoConnectors } = connectorLibrary;
/** @deprecated */
const tenantUsageQueries: Record< const tenantUsageQueries: Record<
keyof FeatureQuota, keyof FeatureQuota,
(queryKey?: string) => Promise<{ count: number }> (queryKey?: string) => Promise<{ count: number }>
@ -77,6 +82,7 @@ export const createQuotaLibrary = (
bringYourUiEnabled: notNumber, bringYourUiEnabled: notNumber,
}; };
/** @deprecated */
const getTenantUsage = async (key: keyof FeatureQuota, queryKey?: string): Promise<number> => { const getTenantUsage = async (key: keyof FeatureQuota, queryKey?: string): Promise<number> => {
const query = tenantUsageQueries[key]; const query = tenantUsageQueries[key];
const { count } = await query(queryKey); const { count } = await query(queryKey);
@ -84,6 +90,7 @@ export const createQuotaLibrary = (
return count; return count;
}; };
/** @deprecated */
const guardKey = async (key: keyof FeatureQuota, queryKey?: string) => { const guardKey = async (key: keyof FeatureQuota, queryKey?: string) => {
const { isCloud, isIntegrationTest } = EnvSet.values; const { isCloud, isIntegrationTest } = EnvSet.values;
@ -136,5 +143,124 @@ export const createQuotaLibrary = (
} }
}; };
return { guardKey }; const guardTenantUsageByKey = async (key: keyof SubscriptionQuota) => {
const { isCloud, isIntegrationTest } = EnvSet.values;
// Cloud only feature, skip in non-cloud environments
if (!isCloud) {
return;
}
// Disable in integration tests
if (isIntegrationTest) {
return;
}
const { quota: fullQuota, usage: fullUsage } = await getTenantSubscriptionQuotaAndUsage(
cloudConnection
);
// Type `SubscriptionQuota` and type `SubscriptionUsage` are sharing keys, this design helps us to compare the usage with the quota limit in a easier way.
const { [key]: limit } = fullQuota;
const { [key]: usage } = fullUsage;
if (limit === null) {
return;
}
if (typeof limit === 'boolean') {
assertThat(
limit,
new RequestError({
code: 'subscription.limit_exceeded',
status: 403,
data: {
key,
},
})
);
return;
}
if (typeof limit === 'number') {
// See the definition of `SubscriptionQuota` and `SubscriptionUsage` in `types.ts`, this should never happen.
assertThat(
typeof usage === 'number',
new TypeError('Usage must be with the same type as the limit.')
);
assertThat(
usage < limit,
new RequestError({
code: 'subscription.limit_exceeded',
status: 403,
data: {
key,
limit,
usage,
},
})
);
return;
}
throw new TypeError('Unsupported subscription quota type');
};
const guardEntityScopesUsage = async (entityName: 'resources' | 'roles', entityId: string) => {
const { isCloud, isIntegrationTest } = EnvSet.values;
// Cloud only feature, skip in non-cloud environments
if (!isCloud) {
return;
}
// Disable in integration tests
if (isIntegrationTest) {
return;
}
const [
{
quota: { scopesPerResourceLimit, scopesPerRoleLimit },
},
scopeUsages,
] = await Promise.all([
getTenantSubscriptionQuotaAndUsage(cloudConnection),
getTenantSubscriptionScopeUsage(cloudConnection, entityName),
]);
const usage = scopeUsages[entityId] ?? 0;
if (entityName === 'resources') {
assertThat(
scopesPerResourceLimit === null || scopesPerResourceLimit > usage,
new RequestError({
code: 'subscription.limit_exceeded',
status: 403,
data: {
key: 'scopesPerResourceLimit',
limit: scopesPerResourceLimit,
usage,
},
})
);
return;
}
assertThat(
scopesPerRoleLimit === null || scopesPerRoleLimit > usage,
new RequestError({
code: 'subscription.limit_exceeded',
status: 403,
data: {
key: 'scopesPerRoleLimit',
limit: scopesPerRoleLimit,
usage,
},
})
);
};
return { guardKey, guardTenantUsageByKey, guardEntityScopesUsage };
}; };

View file

@ -7,7 +7,7 @@ import type {
SignInExperience, SignInExperience,
SsoConnectorMetadata, SsoConnectorMetadata,
} from '@logto/schemas'; } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas'; import { ConnectorType, ReservedPlanId } from '@logto/schemas';
import { deduplicate, pick, trySafe } from '@silverhand/essentials'; import { deduplicate, pick, trySafe } from '@silverhand/essentials';
import deepmerge from 'deepmerge'; import deepmerge from 'deepmerge';
@ -19,7 +19,7 @@ import type { SsoConnectorLibrary } from '#src/libraries/sso-connector.js';
import { ssoConnectorFactories } from '#src/sso/index.js'; import { ssoConnectorFactories } from '#src/sso/index.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { getTenantSubscriptionPlan } from '#src/utils/subscription/index.js'; import { getTenantSubscription, getTenantSubscriptionPlan } from '#src/utils/subscription/index.js';
import { isKeyOfI18nPhrases } from '#src/utils/translation.js'; import { isKeyOfI18nPhrases } from '#src/utils/translation.js';
import { type CloudConnectionLibrary } from '../cloud-connection.js'; import { type CloudConnectionLibrary } from '../cloud-connection.js';
@ -113,8 +113,12 @@ export const createSignInExperienceLibrary = (
return false; return false;
} }
const plan = await getTenantSubscriptionPlan(cloudConnection); if (EnvSet.values.isDevFeaturesEnabled) {
const subscription = await getTenantSubscription(cloudConnection);
return subscription.planId === ReservedPlanId.Development;
}
const plan = await getTenantSubscriptionPlan(cloudConnection);
return plan.id === developmentTenantPlanId; return plan.id === developmentTenantPlanId;
}, ['is-development-tenant']); }, ['is-development-tenant']);

View file

@ -1,10 +1,11 @@
import type { MiddlewareType } from 'koa'; import type { MiddlewareType } from 'koa';
import { type QuotaLibrary } from '#src/libraries/quota.js'; import { type QuotaLibrary } from '#src/libraries/quota.js';
import { type FeatureQuota } from '#src/utils/subscription/types.js'; import { type SubscriptionQuota, type FeatureQuota } from '#src/utils/subscription/types.js';
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'COPY' | 'HEAD' | 'OPTIONS'; type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'COPY' | 'HEAD' | 'OPTIONS';
/** @deprecated */
type UsageGuardConfig = { type UsageGuardConfig = {
key: keyof FeatureQuota; key: keyof FeatureQuota;
quota: QuotaLibrary; quota: QuotaLibrary;
@ -12,6 +13,11 @@ type UsageGuardConfig = {
methods?: Method[]; methods?: Method[];
}; };
type NewUsageGuardConfig = Omit<UsageGuardConfig, 'key'> & {
key: keyof SubscriptionQuota;
};
/** @deprecated */
export default function koaQuotaGuard<StateT, ContextT, ResponseBodyT>({ export default function koaQuotaGuard<StateT, ContextT, ResponseBodyT>({
key, key,
quota, quota,
@ -25,3 +31,17 @@ export default function koaQuotaGuard<StateT, ContextT, ResponseBodyT>({
return next(); return next();
}; };
} }
export function newKoaQuotaGuard<StateT, ContextT, ResponseBodyT>({
key,
quota,
methods,
}: NewUsageGuardConfig): MiddlewareType<StateT, ContextT, ResponseBodyT> {
return async (ctx, next) => {
// eslint-disable-next-line no-restricted-syntax
if (!methods || methods.includes(ctx.method.toUpperCase() as Method)) {
await quota.guardTenantUsageByKey(key);
}
return next();
};
}

View file

@ -1,3 +1,5 @@
// TODO: @darcyYe refactor this file later to remove disable max line comment
/* eslint-disable max-lines */
import type { Role } from '@logto/schemas'; import type { Role } from '@logto/schemas';
import { import {
Applications, Applications,
@ -146,13 +148,26 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
response: Applications.guard, response: Applications.guard,
status: [200, 400, 422, 500], status: [200, 400, 422, 500],
}), }),
// eslint-disable-next-line complexity
async (ctx, next) => { async (ctx, next) => {
const { oidcClientMetadata, protectedAppMetadata, ...rest } = ctx.guard.body; const { oidcClientMetadata, protectedAppMetadata, ...rest } = ctx.guard.body;
const {
values: { isDevFeaturesEnabled },
} = EnvSet;
await Promise.all([ await Promise.all([
rest.type === ApplicationType.MachineToMachine && quota.guardKey('machineToMachineLimit'), rest.type === ApplicationType.MachineToMachine &&
rest.isThirdParty && quota.guardKey('thirdPartyApplicationsLimit'), (isDevFeaturesEnabled
quota.guardKey('applicationsLimit'), ? quota.guardTenantUsageByKey('machineToMachineLimit')
: quota.guardKey('machineToMachineLimit')),
rest.isThirdParty &&
(isDevFeaturesEnabled
? quota.guardTenantUsageByKey('thirdPartyApplicationsLimit')
: quota.guardKey('thirdPartyApplicationsLimit')),
isDevFeaturesEnabled
? quota.guardTenantUsageByKey('applicationsLimit')
: quota.guardKey('applicationsLimit'),
]); ]);
assertThat( assertThat(
@ -349,3 +364,4 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
applicationCustomDataRoutes(router, tenant); applicationCustomDataRoutes(router, tenant);
} }
/* eslint-enable max-lines */

View file

@ -7,6 +7,7 @@ import { conditional } from '@silverhand/essentials';
import cleanDeep from 'clean-deep'; import cleanDeep from 'clean-deep';
import { string, object } from 'zod'; import { string, object } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { type QuotaLibrary } from '#src/libraries/quota.js'; import { type QuotaLibrary } from '#src/libraries/quota.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
@ -26,7 +27,9 @@ const guardConnectorsQuota = async (
quota: QuotaLibrary quota: QuotaLibrary
) => { ) => {
if (factory.type === ConnectorType.Social) { if (factory.type === ConnectorType.Social) {
await quota.guardKey('socialConnectorsLimit'); await (EnvSet.values.isDevFeaturesEnabled
? quota.guardTenantUsageByKey('socialConnectorsLimit')
: quota.guardKey('socialConnectorsLimit'));
} }
}; };

View file

@ -2,6 +2,7 @@ import { Domains, domainResponseGuard, domainSelectFields } from '@logto/schemas
import { pick } from '@silverhand/essentials'; import { pick } from '@silverhand/essentials';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
@ -56,7 +57,12 @@ export default function domainRoutes<T extends ManagementApiRouter>(
router.post( router.post(
'/domains', '/domains',
koaQuotaGuard({ key: 'customDomainEnabled', quota }), EnvSet.values.isDevFeaturesEnabled
? // We removed custom domain paywall in new pricing model
async (ctx, next) => {
return next();
}
: koaQuotaGuard({ key: 'customDomainEnabled', quota }),
koaGuard({ koaGuard({
body: Domains.createGuard.pick({ domain: true }), body: Domains.createGuard.pick({ domain: true }),
response: domainResponseGuard, response: domainResponseGuard,

View file

@ -14,10 +14,11 @@ import { conditional, deduplicate, yes } from '@silverhand/essentials';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import { type AllowedKeyPrefix } from '#src/queries/log.js'; import { type AllowedKeyPrefix } from '#src/queries/log.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
@ -157,7 +158,9 @@ export default function hookRoutes<T extends ManagementApiRouter>(
router.post( router.post(
'/hooks', '/hooks',
koaQuotaGuard({ key: 'hooksLimit', quota }), EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'hooksLimit', quota })
: koaQuotaGuard({ key: 'hooksLimit', quota }),
koaGuard({ koaGuard({
body: Hooks.createGuard.omit({ id: true, signingKey: true }).extend({ body: Hooks.createGuard.omit({ id: true, signingKey: true }).extend({
event: hookEventGuard.optional(), event: hookEventGuard.optional(),

View file

@ -16,7 +16,7 @@ import { EnvSet } from '#src/env-set/index.js';
import RequestError, { formatZodError } from '#src/errors/RequestError/index.js'; import RequestError, { formatZodError } from '#src/errors/RequestError/index.js';
import { JwtCustomizerLibrary } from '#src/libraries/jwt-customizer.js'; import { JwtCustomizerLibrary } from '#src/libraries/jwt-customizer.js';
import koaGuard, { parse } from '#src/middleware/koa-guard.js'; import koaGuard, { parse } from '#src/middleware/koa-guard.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import { getConsoleLogFromContext } from '#src/utils/console.js'; import { getConsoleLogFromContext } from '#src/utils/console.js';
import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
@ -61,7 +61,9 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard), response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard),
status: [200, 201, 400, 403], status: [200, 201, 400, 403],
}), }),
koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }), EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota })
: koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
async (ctx, next) => { async (ctx, next) => {
const { isCloud, isIntegrationTest } = EnvSet.values; const { isCloud, isIntegrationTest } = EnvSet.values;
if (tenantId === adminTenantId && isCloud && !isIntegrationTest) { if (tenantId === adminTenantId && isCloud && !isIntegrationTest) {
@ -112,7 +114,9 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard), response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard),
status: [200, 400, 404], status: [200, 400, 404],
}), }),
koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }), EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota })
: koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
async (ctx, next) => { async (ctx, next) => {
const { isIntegrationTest } = EnvSet.values; const { isIntegrationTest } = EnvSet.values;
@ -215,7 +219,9 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
response: jsonObjectGuard, response: jsonObjectGuard,
status: [200, 400, 403, 422], status: [200, 400, 403, 422],
}), }),
koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }), EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota })
: koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
async (ctx, next) => { async (ctx, next) => {
const { body } = ctx.guard; const { body } = ctx.guard;

View file

@ -8,10 +8,11 @@ import {
import { generateStandardId } from '@logto/shared'; import { generateStandardId } from '@logto/shared';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import { buildManagementApiContext } from '#src/libraries/hook/utils.js'; import { buildManagementApiContext } from '#src/libraries/hook/utils.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import { organizationRoleSearchKeys } from '#src/queries/organization/index.js'; import { organizationRoleSearchKeys } from '#src/queries/organization/index.js';
import SchemaRouter from '#src/utils/SchemaRouter.js'; import SchemaRouter from '#src/utils/SchemaRouter.js';
import { parseSearchOptions } from '#src/utils/search.js'; import { parseSearchOptions } from '#src/utils/search.js';
@ -44,7 +45,11 @@ export default function organizationRoleRoutes<T extends ManagementApiRouter>(
unknown, unknown,
ManagementApiRouterContext ManagementApiRouterContext
>(OrganizationRoles, roles, { >(OrganizationRoles, roles, {
middlewares: [koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] })], middlewares: [
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] })
: koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }),
],
disabled: { get: true, post: true }, disabled: { get: true, post: true },
errorHandler, errorHandler,
searchFields: ['name'], searchFields: ['name'],

View file

@ -1,6 +1,7 @@
import { OrganizationScopes } from '@logto/schemas'; import { OrganizationScopes } from '@logto/schemas';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; import { EnvSet } from '#src/env-set/index.js';
import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import SchemaRouter from '#src/utils/SchemaRouter.js'; import SchemaRouter from '#src/utils/SchemaRouter.js';
import { errorHandler } from '../organization/utils.js'; import { errorHandler } from '../organization/utils.js';
@ -18,7 +19,11 @@ export default function organizationScopeRoutes<T extends ManagementApiRouter>(
]: RouterInitArgs<T> ]: RouterInitArgs<T>
) { ) {
const router = new SchemaRouter(OrganizationScopes, scopes, { const router = new SchemaRouter(OrganizationScopes, scopes, {
middlewares: [koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] })], middlewares: [
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] })
: koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }),
],
errorHandler, errorHandler,
searchFields: ['name'], searchFields: ['name'],
}); });

View file

@ -2,9 +2,10 @@ import { type OrganizationWithFeatured, Organizations, featuredUserGuard } from
import { yes } from '@silverhand/essentials'; import { yes } from '@silverhand/essentials';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import SchemaRouter from '#src/utils/SchemaRouter.js'; import SchemaRouter from '#src/utils/SchemaRouter.js';
import { parseSearchOptions } from '#src/utils/search.js'; import { parseSearchOptions } from '#src/utils/search.js';
@ -30,7 +31,11 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
] = args; ] = args;
const router = new SchemaRouter(Organizations, organizations, { const router = new SchemaRouter(Organizations, organizations, {
middlewares: [koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] })], middlewares: [
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] })
: koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }),
],
errorHandler, errorHandler,
searchFields: ['name'], searchFields: ['name'],
disabled: { get: true }, disabled: { get: true },

View file

@ -3,6 +3,7 @@ import { generateStandardId } from '@logto/shared';
import { tryThat } from '@silverhand/essentials'; import { tryThat } from '@silverhand/essentials';
import { object, string } from 'zod'; import { object, string } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
@ -89,7 +90,9 @@ export default function resourceScopeRoutes<T extends ManagementApiRouter>(
body, body,
} = ctx.guard; } = ctx.guard;
await quota.guardKey('scopesPerResourceLimit', resourceId); await (EnvSet.values.isDevFeaturesEnabled
? quota.guardEntityScopesUsage('resources', resourceId)
: quota.guardKey('scopesPerResourceLimit', resourceId));
assertThat(!/\s/.test(body.name), 'scope.name_with_space'); assertThat(!/\s/.test(body.name), 'scope.name_with_space');

View file

@ -3,10 +3,11 @@ import { generateStandardId } from '@logto/shared';
import { yes } from '@silverhand/essentials'; import { yes } from '@silverhand/essentials';
import { boolean, object, string } from 'zod'; import { boolean, object, string } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { attachScopesToResources } from '#src/utils/resource.js'; import { attachScopesToResources } from '#src/utils/resource.js';
@ -76,7 +77,9 @@ export default function resourceRoutes<T extends ManagementApiRouter>(
router.post( router.post(
'/resources', '/resources',
koaQuotaGuard({ key: 'resourcesLimit', quota }), EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'resourcesLimit', quota })
: koaQuotaGuard({ key: 'resourcesLimit', quota }),
koaGuard({ koaGuard({
// Intentionally omit `isDefault` since it'll affect other rows. // Intentionally omit `isDefault` since it'll affect other rows.
// Use the dedicated API `PATCH /resources/:id/is-default` to update. // Use the dedicated API `PATCH /resources/:id/is-default` to update.

View file

@ -3,6 +3,7 @@ import { generateStandardId } from '@logto/shared';
import { tryThat } from '@silverhand/essentials'; import { tryThat } from '@silverhand/essentials';
import { object, string } from 'zod'; import { object, string } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
@ -93,7 +94,9 @@ export default function roleScopeRoutes<T extends ManagementApiRouter>(
body: { scopeIds }, body: { scopeIds },
} = ctx.guard; } = ctx.guard;
await quota.guardKey('scopesPerRoleLimit', id); await (EnvSet.values.isDevFeaturesEnabled
? quota.guardEntityScopesUsage('roles', id)
: quota.guardKey('scopesPerRoleLimit', id));
await validateRoleScopeAssignment(scopeIds, id); await validateRoleScopeAssignment(scopeIds, id);
await insertRolesScopes( await insertRolesScopes(

View file

@ -4,6 +4,7 @@ import { generateStandardId } from '@logto/shared';
import { pickState, trySafe, tryThat } from '@silverhand/essentials'; import { pickState, trySafe, tryThat } from '@silverhand/essentials';
import { number, object, string, z } from 'zod'; import { number, object, string, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { buildManagementApiContext } from '#src/libraries/hook/utils.js'; import { buildManagementApiContext } from '#src/libraries/hook/utils.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
@ -150,9 +151,18 @@ export default function roleRoutes<T extends ManagementApiRouter>(
// `rolesLimit` is actually the limit of user roles, keep this name for backward compatibility. // `rolesLimit` is actually the limit of user roles, keep this name for backward compatibility.
// We have optional `type` when creating a new role, if `type` is not provided, use `User` as default. // We have optional `type` when creating a new role, if `type` is not provided, use `User` as default.
// `machineToMachineRolesLimit` is the limit of machine to machine roles, and is independent to `rolesLimit`. // `machineToMachineRolesLimit` is the limit of machine to machine roles, and is independent to `rolesLimit`.
await quota.guardKey( await (EnvSet.values.isDevFeaturesEnabled
roleBody.type === RoleType.MachineToMachine ? 'machineToMachineRolesLimit' : 'rolesLimit' ? quota.guardTenantUsageByKey(
); roleBody.type === RoleType.MachineToMachine
? 'machineToMachineRolesLimit'
: // In new pricing model, we rename `rolesLimit` to `userRolesLimit`, which is easier to be distinguished from `machineToMachineRolesLimit`.
'userRolesLimit'
)
: quota.guardKey(
roleBody.type === RoleType.MachineToMachine
? 'machineToMachineRolesLimit'
: 'rolesLimit'
));
assertThat( assertThat(
!(await findRoleByRoleName(roleBody.name)), !(await findRoleByRoleName(roleBody.name)),

View file

@ -8,7 +8,7 @@ import { object, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import SystemContext from '#src/tenants/SystemContext.js'; import SystemContext from '#src/tenants/SystemContext.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { getConsoleLogFromContext } from '#src/utils/console.js'; import { getConsoleLogFromContext } from '#src/utils/console.js';
@ -35,7 +35,11 @@ export default function customUiAssetsRoutes<T extends ManagementApiRouter>(
router.post( router.post(
'/sign-in-exp/default/custom-ui-assets', '/sign-in-exp/default/custom-ui-assets',
koaQuotaGuard({ key: 'bringYourUiEnabled', quota }), // Manually add this to avoid the case that the dev feature guard is removed but the quota guard is not being updated accordingly.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'bringYourUiEnabled', quota })
: koaQuotaGuard({ key: 'bringYourUiEnabled', quota }),
koaGuard({ koaGuard({
files: object({ files: object({
file: uploadFileGuard.array().min(1).max(1), file: uploadFileGuard.array().min(1).max(1),

View file

@ -2,6 +2,7 @@ import { DemoConnector } from '@logto/connector-kit';
import { ConnectorType, SignInExperiences } from '@logto/schemas'; import { ConnectorType, SignInExperiences } from '@logto/schemas';
import { literal, object, string, z } from 'zod'; import { literal, object, string, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import { validateSignUp, validateSignIn } from '#src/libraries/sign-in-experience/index.js'; import { validateSignUp, validateSignIn } from '#src/libraries/sign-in-experience/index.js';
import { validateMfa } from '#src/libraries/sign-in-experience/mfa.js'; import { validateMfa } from '#src/libraries/sign-in-experience/mfa.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
@ -18,7 +19,7 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
const { deleteConnectorById } = queries.connectors; const { deleteConnectorById } = queries.connectors;
const { const {
signInExperiences: { validateLanguageInfo }, signInExperiences: { validateLanguageInfo },
quota: { guardKey }, quota: { guardKey, guardTenantUsageByKey },
} = libraries; } = libraries;
const { getLogtoConnectors } = connectors; const { getLogtoConnectors } = connectors;
@ -55,6 +56,7 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
response: SignInExperiences.guard, response: SignInExperiences.guard,
status: [200, 400, 404, 422], status: [200, 400, 404, 422],
}), }),
// eslint-disable-next-line complexity
async (ctx, next) => { async (ctx, next) => {
const { const {
query: { removeUnusedDemoSocialConnector }, query: { removeUnusedDemoSocialConnector },
@ -89,7 +91,9 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
if (mfa) { if (mfa) {
if (mfa.factors.length > 0) { if (mfa.factors.length > 0) {
await guardKey('mfaEnabled'); await (EnvSet.values.isDevFeaturesEnabled
? guardTenantUsageByKey('mfaEnabled')
: guardKey('mfaEnabled'));
} }
validateMfa(mfa); validateMfa(mfa);
} }

View file

@ -7,10 +7,11 @@ import { generateStandardShortId } from '@logto/shared';
import { assert, conditional } from '@silverhand/essentials'; import { assert, conditional } from '@silverhand/essentials';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import { ssoConnectorCreateGuard, ssoConnectorPatchGuard } from '#src/routes/sso-connector/type.js'; import { ssoConnectorCreateGuard, ssoConnectorPatchGuard } from '#src/routes/sso-connector/type.js';
import { ssoConnectorFactories } from '#src/sso/index.js'; import { ssoConnectorFactories } from '#src/sso/index.js';
import { isSupportedSsoConnector, isSupportedSsoProvider } from '#src/sso/utils.js'; import { isSupportedSsoConnector, isSupportedSsoProvider } from '#src/sso/utils.js';
@ -68,7 +69,9 @@ export default function singleSignOnConnectorsRoutes<T extends ManagementApiRout
/* Create a new single sign on connector */ /* Create a new single sign on connector */
router.post( router.post(
pathname, pathname,
koaQuotaGuard({ key: 'ssoEnabled', quota }), EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'enterpriseSsoLimit', quota })
: koaQuotaGuard({ key: 'ssoEnabled', quota }),
koaGuard({ koaGuard({
body: ssoConnectorCreateGuard, body: ssoConnectorCreateGuard,
response: SsoConnectors.guard, response: SsoConnectors.guard,

View file

@ -4,8 +4,9 @@ import { addSeconds } from 'date-fns';
import { object, string } from 'zod'; import { object, string } from 'zod';
import { subjectTokenExpiresIn, subjectTokenPrefix } from '#src/constants/index.js'; import { subjectTokenExpiresIn, subjectTokenPrefix } from '#src/constants/index.js';
import { EnvSet } from '#src/env-set/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import { type RouterInitArgs, type ManagementApiRouter } from './types.js'; import { type RouterInitArgs, type ManagementApiRouter } from './types.js';
@ -25,7 +26,9 @@ export default function subjectTokenRoutes<T extends ManagementApiRouter>(
router.post( router.post(
'/subject-tokens', '/subject-tokens',
koaQuotaGuard({ key: 'subjectTokenEnabled', quota }), EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'subjectTokenEnabled', quota })
: koaQuotaGuard({ key: 'subjectTokenEnabled', quota }),
koaGuard({ koaGuard({
body: object({ body: object({
userId: string(), userId: string(),

View file

@ -5,5 +5,7 @@ const { jest } = import.meta;
export const createMockQuotaLibrary = (): QuotaLibrary => { export const createMockQuotaLibrary = (): QuotaLibrary => {
return { return {
guardKey: jest.fn(), guardKey: jest.fn(),
guardTenantUsageByKey: jest.fn(),
guardEntityScopesUsage: jest.fn(),
}; };
}; };

View file

@ -2,14 +2,28 @@ import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js'
import assertThat from '../assert-that.js'; import assertThat from '../assert-that.js';
import { type SubscriptionPlan } from './types.js'; import {
type SubscriptionQuota,
type SubscriptionUsage,
type SubscriptionPlan,
type Subscription,
} from './types.js';
export const getTenantSubscription = async (
cloudConnection: CloudConnectionLibrary
): Promise<Subscription> => {
const client = await cloudConnection.getClient();
const subscription = await client.get('/api/tenants/my/subscription');
return subscription;
};
export const getTenantSubscriptionPlan = async ( export const getTenantSubscriptionPlan = async (
cloudConnection: CloudConnectionLibrary cloudConnection: CloudConnectionLibrary
): Promise<SubscriptionPlan> => { ): Promise<SubscriptionPlan> => {
const client = await cloudConnection.getClient(); const client = await cloudConnection.getClient();
const [subscription, plans] = await Promise.all([ const [subscription, plans] = await Promise.all([
client.get('/api/tenants/my/subscription'), getTenantSubscription(cloudConnection),
client.get('/api/subscription-plans'), client.get('/api/subscription-plans'),
]); ]);
const plan = plans.find(({ id }) => id === subscription.planId); const plan = plans.find(({ id }) => id === subscription.planId);
@ -18,3 +32,31 @@ export const getTenantSubscriptionPlan = async (
return plan; return plan;
}; };
export const getTenantSubscriptionQuotaAndUsage = async (
cloudConnection: CloudConnectionLibrary
): Promise<{
quota: SubscriptionQuota;
usage: SubscriptionUsage;
}> => {
const client = await cloudConnection.getClient();
const [quota, usage] = await Promise.all([
client.get('/api/tenants/my/subscription/quota'),
client.get('/api/tenants/my/subscription/usage'),
]);
return { quota, usage };
};
export const getTenantSubscriptionScopeUsage = async (
cloudConnection: CloudConnectionLibrary,
entityName: 'resources' | 'roles'
): Promise<Record<string, number>> => {
const client = await cloudConnection.getClient();
const scopeUsages = await client.get('/api/tenants/my/subscription/usage/:entityName/scopes', {
params: { entityName },
search: {},
});
return scopeUsages;
};

View file

@ -9,9 +9,31 @@ type RouteResponseType<T extends { search?: unknown; body?: unknown; response?:
export type SubscriptionPlan = RouteResponseType<GetRoutes['/api/subscription-plans']>[number]; export type SubscriptionPlan = RouteResponseType<GetRoutes['/api/subscription-plans']>[number];
export type Subscription = RouteResponseType<GetRoutes['/api/tenants/:tenantId/subscription']>;
// Since `standardConnectorsLimit` will be removed in the upcoming pricing V2, no need to guard it. // Since `standardConnectorsLimit` will be removed in the upcoming pricing V2, no need to guard it.
// `tokenLimit` is not guarded in backend. // `tokenLimit` is not guarded in backend.
export type FeatureQuota = Omit< export type FeatureQuota = Omit<
SubscriptionPlan['quota'], SubscriptionPlan['quota'],
'tenantLimit' | 'mauLimit' | 'auditLogsRetentionDays' | 'standardConnectorsLimit' | 'tokenLimit' 'tenantLimit' | 'mauLimit' | 'auditLogsRetentionDays' | 'standardConnectorsLimit' | 'tokenLimit'
>; >;
/**
* The type of the response of the `GET /api/tenants/:tenantId/subscription/quota` endpoint.
* It is the same as the response type of `GET /api/tenants/my/subscription/quota` endpoint.
*
* @remarks
* The `auditLogsRetentionDays` will be handled by cron job in Azure Functions, outdated audit logs will be removed automatically.
*/
export type SubscriptionQuota = Omit<
RouteResponseType<GetRoutes['/api/tenants/:tenantId/subscription/quota']>,
'auditLogsRetentionDays'
>;
/**
* The type of the response of the `GET /api/tenants/:tenantId/subscription/usage` endpoint.
* It is the same as the response type of `GET /api/tenants/my/subscription/usage` endpoint.
*/
export type SubscriptionUsage = RouteResponseType<
GetRoutes['/api/tenants/:tenantId/subscription/usage']
>;