From 9dac30b6600666c98352f3de82eaa57120819c1d Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Mon, 15 Jul 2024 11:32:24 +0800 Subject: [PATCH 1/2] refactor: update logto/core cloud API usage --- packages/core/src/libraries/quota.ts | 121 +++++++++++++++++- .../core/src/middleware/koa-quota-guard.ts | 22 +++- .../src/routes/applications/application.ts | 22 +++- packages/core/src/routes/connector/index.ts | 5 +- packages/core/src/routes/domain.ts | 8 +- packages/core/src/routes/hook.ts | 7 +- .../src/routes/logto-config/jwt-customizer.ts | 14 +- .../src/routes/organization-role/index.ts | 9 +- .../src/routes/organization-scope/index.ts | 9 +- .../core/src/routes/organization/index.ts | 9 +- packages/core/src/routes/resource.scope.ts | 5 +- packages/core/src/routes/resource.ts | 7 +- packages/core/src/routes/role.scope.ts | 5 +- packages/core/src/routes/role.ts | 16 ++- .../src/routes/sign-in-experience/index.ts | 8 +- .../core/src/routes/sso-connector/index.ts | 7 +- packages/core/src/routes/subject-token.ts | 4 +- packages/core/src/test-utils/quota.ts | 2 + packages/core/src/utils/subscription/index.ts | 30 ++++- packages/core/src/utils/subscription/types.ts | 20 +++ 20 files changed, 295 insertions(+), 35 deletions(-) diff --git a/packages/core/src/libraries/quota.ts b/packages/core/src/libraries/quota.ts index 13f69429c..e82b67569 100644 --- a/packages/core/src/libraries/quota.ts +++ b/packages/core/src/libraries/quota.ts @@ -5,8 +5,12 @@ import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; -import { getTenantSubscriptionPlan } from '#src/utils/subscription/index.js'; -import { type FeatureQuota } from '#src/utils/subscription/types.js'; +import { + 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 ConnectorLibrary } from './connector.js'; @@ -33,6 +37,7 @@ export const createQuotaLibrary = ( const { getLogtoConnectors } = connectorLibrary; + /** @deprecated */ const tenantUsageQueries: Record< keyof FeatureQuota, (queryKey?: string) => Promise<{ count: number }> @@ -77,6 +82,7 @@ export const createQuotaLibrary = ( bringYourUiEnabled: notNumber, }; + /** @deprecated */ const getTenantUsage = async (key: keyof FeatureQuota, queryKey?: string): Promise => { const query = tenantUsageQueries[key]; const { count } = await query(queryKey); @@ -84,6 +90,7 @@ export const createQuotaLibrary = ( return count; }; + /** @deprecated */ const guardKey = async (key: keyof FeatureQuota, queryKey?: string) => { const { isCloud, isIntegrationTest } = EnvSet.values; @@ -136,5 +143,113 @@ export const createQuotaLibrary = ( } }; - return { guardKey }; + // `SubscriptionQuota` and `SubscriptionUsage` are sharing keys. + const newGuardKey = 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 + ); + 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, + }, + }) + ); + } else 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, + }, + }) + ); + } else { + throw new TypeError('Unsupported subscription quota type'); + } + }; + + const scopesGuardKey = 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 }, + } = await getTenantSubscriptionQuotaAndUsage(cloudConnection); + const scopeUsages = await 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, newGuardKey, scopesGuardKey }; }; diff --git a/packages/core/src/middleware/koa-quota-guard.ts b/packages/core/src/middleware/koa-quota-guard.ts index 7790b8577..9241e8b7e 100644 --- a/packages/core/src/middleware/koa-quota-guard.ts +++ b/packages/core/src/middleware/koa-quota-guard.ts @@ -1,10 +1,11 @@ import type { MiddlewareType } from 'koa'; 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'; +/** @deprecated */ type UsageGuardConfig = { key: keyof FeatureQuota; quota: QuotaLibrary; @@ -12,6 +13,11 @@ type UsageGuardConfig = { methods?: Method[]; }; +type NewUsageGuardConfig = Omit & { + key: keyof SubscriptionQuota; +}; + +/** @deprecated */ export default function koaQuotaGuard({ key, quota, @@ -25,3 +31,17 @@ export default function koaQuotaGuard({ return next(); }; } + +export function newKoaQuotaGuard({ + key, + quota, + methods, +}: NewUsageGuardConfig): MiddlewareType { + return async (ctx, next) => { + // eslint-disable-next-line no-restricted-syntax + if (!methods || methods.includes(ctx.method.toUpperCase() as Method)) { + await quota.newGuardKey(key); + } + return next(); + }; +} diff --git a/packages/core/src/routes/applications/application.ts b/packages/core/src/routes/applications/application.ts index 6afa1b252..711658227 100644 --- a/packages/core/src/routes/applications/application.ts +++ b/packages/core/src/routes/applications/application.ts @@ -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 { Applications, @@ -146,13 +148,26 @@ export default function applicationRoutes( response: Applications.guard, status: [200, 400, 422, 500], }), + // eslint-disable-next-line complexity async (ctx, next) => { const { oidcClientMetadata, protectedAppMetadata, ...rest } = ctx.guard.body; + const { + values: { isDevFeaturesEnabled }, + } = EnvSet; + await Promise.all([ - rest.type === ApplicationType.MachineToMachine && quota.guardKey('machineToMachineLimit'), - rest.isThirdParty && quota.guardKey('thirdPartyApplicationsLimit'), - quota.guardKey('applicationsLimit'), + rest.type === ApplicationType.MachineToMachine && + (isDevFeaturesEnabled + ? quota.newGuardKey('machineToMachineLimit') + : quota.guardKey('machineToMachineLimit')), + rest.isThirdParty && + (isDevFeaturesEnabled + ? quota.newGuardKey('thirdPartyApplicationsLimit') + : quota.guardKey('thirdPartyApplicationsLimit')), + isDevFeaturesEnabled + ? quota.newGuardKey('applicationsLimit') + : quota.guardKey('applicationsLimit'), ]); assertThat( @@ -349,3 +364,4 @@ export default function applicationRoutes( applicationCustomDataRoutes(router, tenant); } +/* eslint-enable max-lines */ diff --git a/packages/core/src/routes/connector/index.ts b/packages/core/src/routes/connector/index.ts index 2cb3c9a68..76a2e90d1 100644 --- a/packages/core/src/routes/connector/index.ts +++ b/packages/core/src/routes/connector/index.ts @@ -7,6 +7,7 @@ import { conditional } from '@silverhand/essentials'; import cleanDeep from 'clean-deep'; import { string, object } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { type QuotaLibrary } from '#src/libraries/quota.js'; import koaGuard from '#src/middleware/koa-guard.js'; @@ -26,7 +27,9 @@ const guardConnectorsQuota = async ( quota: QuotaLibrary ) => { if (factory.type === ConnectorType.Social) { - await quota.guardKey('socialConnectorsLimit'); + await (EnvSet.values.isDevFeaturesEnabled + ? quota.newGuardKey('socialConnectorsLimit') + : quota.guardKey('socialConnectorsLimit')); } }; diff --git a/packages/core/src/routes/domain.ts b/packages/core/src/routes/domain.ts index 6a394d3af..1b88ee896 100644 --- a/packages/core/src/routes/domain.ts +++ b/packages/core/src/routes/domain.ts @@ -2,6 +2,7 @@ import { Domains, domainResponseGuard, domainSelectFields } from '@logto/schemas import { pick } from '@silverhand/essentials'; import { z } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; @@ -56,7 +57,12 @@ export default function domainRoutes( router.post( '/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({ body: Domains.createGuard.pick({ domain: true }), response: domainResponseGuard, diff --git a/packages/core/src/routes/hook.ts b/packages/core/src/routes/hook.ts index 34755c6c7..e3dc0920b 100644 --- a/packages/core/src/routes/hook.ts +++ b/packages/core/src/routes/hook.ts @@ -14,10 +14,11 @@ import { conditional, deduplicate, yes } from '@silverhand/essentials'; import { subDays } from 'date-fns'; import { z } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.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 assertThat from '#src/utils/assert-that.js'; @@ -157,7 +158,9 @@ export default function hookRoutes( router.post( '/hooks', - koaQuotaGuard({ key: 'hooksLimit', quota }), + EnvSet.values.isDevFeaturesEnabled + ? newKoaQuotaGuard({ key: 'hooksLimit', quota }) + : koaQuotaGuard({ key: 'hooksLimit', quota }), koaGuard({ body: Hooks.createGuard.omit({ id: true, signingKey: true }).extend({ event: hookEventGuard.optional(), diff --git a/packages/core/src/routes/logto-config/jwt-customizer.ts b/packages/core/src/routes/logto-config/jwt-customizer.ts index 437601a0a..31c63d38b 100644 --- a/packages/core/src/routes/logto-config/jwt-customizer.ts +++ b/packages/core/src/routes/logto-config/jwt-customizer.ts @@ -16,7 +16,7 @@ import { EnvSet } from '#src/env-set/index.js'; import RequestError, { formatZodError } from '#src/errors/RequestError/index.js'; import { JwtCustomizerLibrary } from '#src/libraries/jwt-customizer.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 type { ManagementApiRouter, RouterInitArgs } from '../types.js'; @@ -61,7 +61,9 @@ export default function logtoConfigJwtCustomizerRoutes { const { isCloud, isIntegrationTest } = EnvSet.values; if (tenantId === adminTenantId && isCloud && !isIntegrationTest) { @@ -112,7 +114,9 @@ export default function logtoConfigJwtCustomizerRoutes { const { isIntegrationTest } = EnvSet.values; @@ -215,7 +219,9 @@ export default function logtoConfigJwtCustomizerRoutes { const { body } = ctx.guard; diff --git a/packages/core/src/routes/organization-role/index.ts b/packages/core/src/routes/organization-role/index.ts index 44ba5404b..84fef012c 100644 --- a/packages/core/src/routes/organization-role/index.ts +++ b/packages/core/src/routes/organization-role/index.ts @@ -8,10 +8,11 @@ import { import { generateStandardId } from '@logto/shared'; import { z } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import { buildManagementApiContext } from '#src/libraries/hook/utils.js'; import koaGuard from '#src/middleware/koa-guard.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 SchemaRouter from '#src/utils/SchemaRouter.js'; import { parseSearchOptions } from '#src/utils/search.js'; @@ -44,7 +45,11 @@ export default function organizationRoleRoutes( unknown, ManagementApiRouterContext >(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 }, errorHandler, searchFields: ['name'], diff --git a/packages/core/src/routes/organization-scope/index.ts b/packages/core/src/routes/organization-scope/index.ts index f86f07ab5..5d40fc6c5 100644 --- a/packages/core/src/routes/organization-scope/index.ts +++ b/packages/core/src/routes/organization-scope/index.ts @@ -1,6 +1,7 @@ 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 { errorHandler } from '../organization/utils.js'; @@ -18,7 +19,11 @@ export default function organizationScopeRoutes( ]: RouterInitArgs ) { 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, searchFields: ['name'], }); diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index cf0916813..b548485a7 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -2,9 +2,10 @@ import { type OrganizationWithFeatured, Organizations, featuredUserGuard } from import { yes } from '@silverhand/essentials'; import { z } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import koaGuard from '#src/middleware/koa-guard.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 { parseSearchOptions } from '#src/utils/search.js'; @@ -30,7 +31,11 @@ export default function organizationRoutes( ] = args; 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, searchFields: ['name'], disabled: { get: true }, diff --git a/packages/core/src/routes/resource.scope.ts b/packages/core/src/routes/resource.scope.ts index f3905bdbb..584a527c5 100644 --- a/packages/core/src/routes/resource.scope.ts +++ b/packages/core/src/routes/resource.scope.ts @@ -3,6 +3,7 @@ import { generateStandardId } from '@logto/shared'; import { tryThat } from '@silverhand/essentials'; import { object, string } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; @@ -89,7 +90,9 @@ export default function resourceScopeRoutes( body, } = ctx.guard; - await quota.guardKey('scopesPerResourceLimit', resourceId); + await (EnvSet.values.isDevFeaturesEnabled + ? quota.scopesGuardKey('resources', resourceId) + : quota.guardKey('scopesPerResourceLimit', resourceId)); assertThat(!/\s/.test(body.name), 'scope.name_with_space'); diff --git a/packages/core/src/routes/resource.ts b/packages/core/src/routes/resource.ts index 6d2bd88ca..1553cb5c7 100644 --- a/packages/core/src/routes/resource.ts +++ b/packages/core/src/routes/resource.ts @@ -3,10 +3,11 @@ import { generateStandardId } from '@logto/shared'; import { yes } from '@silverhand/essentials'; import { boolean, object, string } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.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 { attachScopesToResources } from '#src/utils/resource.js'; @@ -76,7 +77,9 @@ export default function resourceRoutes( router.post( '/resources', - koaQuotaGuard({ key: 'resourcesLimit', quota }), + EnvSet.values.isDevFeaturesEnabled + ? newKoaQuotaGuard({ key: 'resourcesLimit', quota }) + : koaQuotaGuard({ key: 'resourcesLimit', quota }), koaGuard({ // Intentionally omit `isDefault` since it'll affect other rows. // Use the dedicated API `PATCH /resources/:id/is-default` to update. diff --git a/packages/core/src/routes/role.scope.ts b/packages/core/src/routes/role.scope.ts index e5d97065a..c716cbba8 100644 --- a/packages/core/src/routes/role.scope.ts +++ b/packages/core/src/routes/role.scope.ts @@ -3,6 +3,7 @@ import { generateStandardId } from '@logto/shared'; import { tryThat } from '@silverhand/essentials'; import { object, string } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; @@ -93,7 +94,9 @@ export default function roleScopeRoutes( body: { scopeIds }, } = ctx.guard; - await quota.guardKey('scopesPerRoleLimit', id); + await (EnvSet.values.isDevFeaturesEnabled + ? quota.scopesGuardKey('roles', id) + : quota.guardKey('scopesPerRoleLimit', id)); await validateRoleScopeAssignment(scopeIds, id); await insertRolesScopes( diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index 5d072f9fc..5f30519af 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -4,6 +4,7 @@ import { generateStandardId } from '@logto/shared'; import { pickState, trySafe, tryThat } from '@silverhand/essentials'; import { number, object, string, z } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { buildManagementApiContext } from '#src/libraries/hook/utils.js'; import koaGuard from '#src/middleware/koa-guard.js'; @@ -150,9 +151,18 @@ export default function roleRoutes( // `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. // `machineToMachineRolesLimit` is the limit of machine to machine roles, and is independent to `rolesLimit`. - await quota.guardKey( - roleBody.type === RoleType.MachineToMachine ? 'machineToMachineRolesLimit' : 'rolesLimit' - ); + await (EnvSet.values.isDevFeaturesEnabled + ? quota.newGuardKey( + 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( !(await findRoleByRoleName(roleBody.name)), diff --git a/packages/core/src/routes/sign-in-experience/index.ts b/packages/core/src/routes/sign-in-experience/index.ts index 9700158cd..46c9d32c7 100644 --- a/packages/core/src/routes/sign-in-experience/index.ts +++ b/packages/core/src/routes/sign-in-experience/index.ts @@ -2,6 +2,7 @@ import { DemoConnector } from '@logto/connector-kit'; import { ConnectorType, SignInExperiences } from '@logto/schemas'; 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 { validateMfa } from '#src/libraries/sign-in-experience/mfa.js'; import koaGuard from '#src/middleware/koa-guard.js'; @@ -18,7 +19,7 @@ export default function signInExperiencesRoutes( const { deleteConnectorById } = queries.connectors; const { signInExperiences: { validateLanguageInfo }, - quota: { guardKey }, + quota: { guardKey, newGuardKey }, } = libraries; const { getLogtoConnectors } = connectors; @@ -55,6 +56,7 @@ export default function signInExperiencesRoutes( response: SignInExperiences.guard, status: [200, 400, 404, 422], }), + // eslint-disable-next-line complexity async (ctx, next) => { const { query: { removeUnusedDemoSocialConnector }, @@ -89,7 +91,9 @@ export default function signInExperiencesRoutes( if (mfa) { if (mfa.factors.length > 0) { - await guardKey('mfaEnabled'); + await (EnvSet.values.isDevFeaturesEnabled + ? newGuardKey('mfaEnabled') + : guardKey('mfaEnabled')); } validateMfa(mfa); } diff --git a/packages/core/src/routes/sso-connector/index.ts b/packages/core/src/routes/sso-connector/index.ts index db95ed902..cf611fbc6 100644 --- a/packages/core/src/routes/sso-connector/index.ts +++ b/packages/core/src/routes/sso-connector/index.ts @@ -7,10 +7,11 @@ import { generateStandardShortId } from '@logto/shared'; import { assert, conditional } from '@silverhand/essentials'; import { z } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.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 { ssoConnectorFactories } from '#src/sso/index.js'; import { isSupportedSsoConnector, isSupportedSsoProvider } from '#src/sso/utils.js'; @@ -68,7 +69,9 @@ export default function singleSignOnConnectorsRoutes( router.post( '/subject-tokens', - koaQuotaGuard({ key: 'subjectTokenEnabled', quota }), + newKoaQuotaGuard({ key: 'subjectTokenEnabled', quota }), koaGuard({ body: object({ userId: string(), diff --git a/packages/core/src/test-utils/quota.ts b/packages/core/src/test-utils/quota.ts index 3dbb88b80..7413eddbb 100644 --- a/packages/core/src/test-utils/quota.ts +++ b/packages/core/src/test-utils/quota.ts @@ -5,5 +5,7 @@ const { jest } = import.meta; export const createMockQuotaLibrary = (): QuotaLibrary => { return { guardKey: jest.fn(), + newGuardKey: jest.fn(), + scopesGuardKey: jest.fn(), }; }; diff --git a/packages/core/src/utils/subscription/index.ts b/packages/core/src/utils/subscription/index.ts index 884abb553..b39868884 100644 --- a/packages/core/src/utils/subscription/index.ts +++ b/packages/core/src/utils/subscription/index.ts @@ -2,7 +2,7 @@ import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js' import assertThat from '../assert-that.js'; -import { type SubscriptionPlan } from './types.js'; +import { type SubscriptionQuota, type SubscriptionUsage, type SubscriptionPlan } from './types.js'; export const getTenantSubscriptionPlan = async ( cloudConnection: CloudConnectionLibrary @@ -18,3 +18,31 @@ export const getTenantSubscriptionPlan = async ( 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> => { + const client = await cloudConnection.getClient(); + const scopeUsages = await client.get('/api/tenants/my/subscription/usage/:entityName/scopes', { + params: { entityName }, + search: {}, + }); + + return scopeUsages; +}; diff --git a/packages/core/src/utils/subscription/types.ts b/packages/core/src/utils/subscription/types.ts index 86153882a..1bbaf386e 100644 --- a/packages/core/src/utils/subscription/types.ts +++ b/packages/core/src/utils/subscription/types.ts @@ -15,3 +15,23 @@ export type FeatureQuota = Omit< SubscriptionPlan['quota'], '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, + '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'] +>; From 5f98d67754a5c08fea09cae25e1392e19b18da58 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 18 Jul 2024 11:29:29 +0800 Subject: [PATCH 2/2] refactor: update code according to CR --- packages/core/src/libraries/quota.ts | 33 ++++++++++++------- .../src/libraries/sign-in-experience/index.ts | 10 ++++-- .../core/src/middleware/koa-quota-guard.ts | 2 +- .../src/routes/applications/application.ts | 6 ++-- packages/core/src/routes/connector/index.ts | 2 +- packages/core/src/routes/resource.scope.ts | 2 +- packages/core/src/routes/role.scope.ts | 2 +- packages/core/src/routes/role.ts | 2 +- .../custom-ui-assets/index.ts | 8 +++-- .../src/routes/sign-in-experience/index.ts | 4 +-- packages/core/src/routes/subject-token.ts | 7 ++-- packages/core/src/test-utils/quota.ts | 4 +-- packages/core/src/utils/subscription/index.ts | 18 ++++++++-- packages/core/src/utils/subscription/types.ts | 2 ++ 14 files changed, 70 insertions(+), 32 deletions(-) diff --git a/packages/core/src/libraries/quota.ts b/packages/core/src/libraries/quota.ts index e82b67569..d4da96ab4 100644 --- a/packages/core/src/libraries/quota.ts +++ b/packages/core/src/libraries/quota.ts @@ -143,8 +143,7 @@ export const createQuotaLibrary = ( } }; - // `SubscriptionQuota` and `SubscriptionUsage` are sharing keys. - const newGuardKey = async (key: keyof SubscriptionQuota) => { + const guardTenantUsageByKey = async (key: keyof SubscriptionQuota) => { const { isCloud, isIntegrationTest } = EnvSet.values; // Cloud only feature, skip in non-cloud environments @@ -160,6 +159,8 @@ export const createQuotaLibrary = ( 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; @@ -178,7 +179,10 @@ export const createQuotaLibrary = ( }, }) ); - } else if (typeof limit === 'number') { + return; + } + + if (typeof limit === 'number') { // See the definition of `SubscriptionQuota` and `SubscriptionUsage` in `types.ts`, this should never happen. assertThat( typeof usage === 'number', @@ -197,12 +201,14 @@ export const createQuotaLibrary = ( }, }) ); - } else { - throw new TypeError('Unsupported subscription quota type'); + + return; } + + throw new TypeError('Unsupported subscription quota type'); }; - const scopesGuardKey = async (entityName: 'resources' | 'roles', entityId: string) => { + const guardEntityScopesUsage = async (entityName: 'resources' | 'roles', entityId: string) => { const { isCloud, isIntegrationTest } = EnvSet.values; // Cloud only feature, skip in non-cloud environments @@ -215,10 +221,15 @@ export const createQuotaLibrary = ( return; } - const { - quota: { scopesPerResourceLimit, scopesPerRoleLimit }, - } = await getTenantSubscriptionQuotaAndUsage(cloudConnection); - const scopeUsages = await getTenantSubscriptionScopeUsage(cloudConnection, entityName); + const [ + { + quota: { scopesPerResourceLimit, scopesPerRoleLimit }, + }, + scopeUsages, + ] = await Promise.all([ + getTenantSubscriptionQuotaAndUsage(cloudConnection), + getTenantSubscriptionScopeUsage(cloudConnection, entityName), + ]); const usage = scopeUsages[entityId] ?? 0; if (entityName === 'resources') { @@ -251,5 +262,5 @@ export const createQuotaLibrary = ( ); }; - return { guardKey, newGuardKey, scopesGuardKey }; + return { guardKey, guardTenantUsageByKey, guardEntityScopesUsage }; }; diff --git a/packages/core/src/libraries/sign-in-experience/index.ts b/packages/core/src/libraries/sign-in-experience/index.ts index a98f80c63..fa19513b7 100644 --- a/packages/core/src/libraries/sign-in-experience/index.ts +++ b/packages/core/src/libraries/sign-in-experience/index.ts @@ -7,7 +7,7 @@ import type { SignInExperience, SsoConnectorMetadata, } from '@logto/schemas'; -import { ConnectorType } from '@logto/schemas'; +import { ConnectorType, ReservedPlanId } from '@logto/schemas'; import { deduplicate, pick, trySafe } from '@silverhand/essentials'; 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 type Queries from '#src/tenants/Queries.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 { type CloudConnectionLibrary } from '../cloud-connection.js'; @@ -113,8 +113,12 @@ export const createSignInExperienceLibrary = ( 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; }, ['is-development-tenant']); diff --git a/packages/core/src/middleware/koa-quota-guard.ts b/packages/core/src/middleware/koa-quota-guard.ts index 9241e8b7e..e17f875e7 100644 --- a/packages/core/src/middleware/koa-quota-guard.ts +++ b/packages/core/src/middleware/koa-quota-guard.ts @@ -40,7 +40,7 @@ export function newKoaQuotaGuard({ return async (ctx, next) => { // eslint-disable-next-line no-restricted-syntax if (!methods || methods.includes(ctx.method.toUpperCase() as Method)) { - await quota.newGuardKey(key); + await quota.guardTenantUsageByKey(key); } return next(); }; diff --git a/packages/core/src/routes/applications/application.ts b/packages/core/src/routes/applications/application.ts index 711658227..a855aca49 100644 --- a/packages/core/src/routes/applications/application.ts +++ b/packages/core/src/routes/applications/application.ts @@ -159,14 +159,14 @@ export default function applicationRoutes( await Promise.all([ rest.type === ApplicationType.MachineToMachine && (isDevFeaturesEnabled - ? quota.newGuardKey('machineToMachineLimit') + ? quota.guardTenantUsageByKey('machineToMachineLimit') : quota.guardKey('machineToMachineLimit')), rest.isThirdParty && (isDevFeaturesEnabled - ? quota.newGuardKey('thirdPartyApplicationsLimit') + ? quota.guardTenantUsageByKey('thirdPartyApplicationsLimit') : quota.guardKey('thirdPartyApplicationsLimit')), isDevFeaturesEnabled - ? quota.newGuardKey('applicationsLimit') + ? quota.guardTenantUsageByKey('applicationsLimit') : quota.guardKey('applicationsLimit'), ]); diff --git a/packages/core/src/routes/connector/index.ts b/packages/core/src/routes/connector/index.ts index 76a2e90d1..9a65d3328 100644 --- a/packages/core/src/routes/connector/index.ts +++ b/packages/core/src/routes/connector/index.ts @@ -28,7 +28,7 @@ const guardConnectorsQuota = async ( ) => { if (factory.type === ConnectorType.Social) { await (EnvSet.values.isDevFeaturesEnabled - ? quota.newGuardKey('socialConnectorsLimit') + ? quota.guardTenantUsageByKey('socialConnectorsLimit') : quota.guardKey('socialConnectorsLimit')); } }; diff --git a/packages/core/src/routes/resource.scope.ts b/packages/core/src/routes/resource.scope.ts index 584a527c5..93991fd34 100644 --- a/packages/core/src/routes/resource.scope.ts +++ b/packages/core/src/routes/resource.scope.ts @@ -91,7 +91,7 @@ export default function resourceScopeRoutes( } = ctx.guard; await (EnvSet.values.isDevFeaturesEnabled - ? quota.scopesGuardKey('resources', resourceId) + ? quota.guardEntityScopesUsage('resources', resourceId) : quota.guardKey('scopesPerResourceLimit', resourceId)); assertThat(!/\s/.test(body.name), 'scope.name_with_space'); diff --git a/packages/core/src/routes/role.scope.ts b/packages/core/src/routes/role.scope.ts index c716cbba8..5c69655af 100644 --- a/packages/core/src/routes/role.scope.ts +++ b/packages/core/src/routes/role.scope.ts @@ -95,7 +95,7 @@ export default function roleScopeRoutes( } = ctx.guard; await (EnvSet.values.isDevFeaturesEnabled - ? quota.scopesGuardKey('roles', id) + ? quota.guardEntityScopesUsage('roles', id) : quota.guardKey('scopesPerRoleLimit', id)); await validateRoleScopeAssignment(scopeIds, id); diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index 5f30519af..cd1b18eee 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -152,7 +152,7 @@ export default function roleRoutes( // 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`. await (EnvSet.values.isDevFeaturesEnabled - ? quota.newGuardKey( + ? quota.guardTenantUsageByKey( roleBody.type === RoleType.MachineToMachine ? 'machineToMachineRolesLimit' : // In new pricing model, we rename `rolesLimit` to `userRolesLimit`, which is easier to be distinguished from `machineToMachineRolesLimit`. diff --git a/packages/core/src/routes/sign-in-experience/custom-ui-assets/index.ts b/packages/core/src/routes/sign-in-experience/custom-ui-assets/index.ts index f11b48054..505f7e73d 100644 --- a/packages/core/src/routes/sign-in-experience/custom-ui-assets/index.ts +++ b/packages/core/src/routes/sign-in-experience/custom-ui-assets/index.ts @@ -8,7 +8,7 @@ import { object, z } from 'zod'; import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.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 assertThat from '#src/utils/assert-that.js'; import { getConsoleLogFromContext } from '#src/utils/console.js'; @@ -35,7 +35,11 @@ export default function customUiAssetsRoutes( router.post( '/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({ files: object({ file: uploadFileGuard.array().min(1).max(1), diff --git a/packages/core/src/routes/sign-in-experience/index.ts b/packages/core/src/routes/sign-in-experience/index.ts index 46c9d32c7..b698da24a 100644 --- a/packages/core/src/routes/sign-in-experience/index.ts +++ b/packages/core/src/routes/sign-in-experience/index.ts @@ -19,7 +19,7 @@ export default function signInExperiencesRoutes( const { deleteConnectorById } = queries.connectors; const { signInExperiences: { validateLanguageInfo }, - quota: { guardKey, newGuardKey }, + quota: { guardKey, guardTenantUsageByKey }, } = libraries; const { getLogtoConnectors } = connectors; @@ -92,7 +92,7 @@ export default function signInExperiencesRoutes( if (mfa) { if (mfa.factors.length > 0) { await (EnvSet.values.isDevFeaturesEnabled - ? newGuardKey('mfaEnabled') + ? guardTenantUsageByKey('mfaEnabled') : guardKey('mfaEnabled')); } validateMfa(mfa); diff --git a/packages/core/src/routes/subject-token.ts b/packages/core/src/routes/subject-token.ts index 2a6d18992..1873c68fc 100644 --- a/packages/core/src/routes/subject-token.ts +++ b/packages/core/src/routes/subject-token.ts @@ -4,8 +4,9 @@ import { addSeconds } from 'date-fns'; import { object, string } from 'zod'; 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 { newKoaQuotaGuard } 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'; @@ -25,7 +26,9 @@ export default function subjectTokenRoutes( router.post( '/subject-tokens', - newKoaQuotaGuard({ key: 'subjectTokenEnabled', quota }), + EnvSet.values.isDevFeaturesEnabled + ? newKoaQuotaGuard({ key: 'subjectTokenEnabled', quota }) + : koaQuotaGuard({ key: 'subjectTokenEnabled', quota }), koaGuard({ body: object({ userId: string(), diff --git a/packages/core/src/test-utils/quota.ts b/packages/core/src/test-utils/quota.ts index 7413eddbb..d995f17b9 100644 --- a/packages/core/src/test-utils/quota.ts +++ b/packages/core/src/test-utils/quota.ts @@ -5,7 +5,7 @@ const { jest } = import.meta; export const createMockQuotaLibrary = (): QuotaLibrary => { return { guardKey: jest.fn(), - newGuardKey: jest.fn(), - scopesGuardKey: jest.fn(), + guardTenantUsageByKey: jest.fn(), + guardEntityScopesUsage: jest.fn(), }; }; diff --git a/packages/core/src/utils/subscription/index.ts b/packages/core/src/utils/subscription/index.ts index b39868884..c6e701b62 100644 --- a/packages/core/src/utils/subscription/index.ts +++ b/packages/core/src/utils/subscription/index.ts @@ -2,14 +2,28 @@ import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js' import assertThat from '../assert-that.js'; -import { type SubscriptionQuota, type SubscriptionUsage, type SubscriptionPlan } from './types.js'; +import { + type SubscriptionQuota, + type SubscriptionUsage, + type SubscriptionPlan, + type Subscription, +} from './types.js'; + +export const getTenantSubscription = async ( + cloudConnection: CloudConnectionLibrary +): Promise => { + const client = await cloudConnection.getClient(); + const subscription = await client.get('/api/tenants/my/subscription'); + + return subscription; +}; export const getTenantSubscriptionPlan = async ( cloudConnection: CloudConnectionLibrary ): Promise => { const client = await cloudConnection.getClient(); const [subscription, plans] = await Promise.all([ - client.get('/api/tenants/my/subscription'), + getTenantSubscription(cloudConnection), client.get('/api/subscription-plans'), ]); const plan = plans.find(({ id }) => id === subscription.planId); diff --git a/packages/core/src/utils/subscription/types.ts b/packages/core/src/utils/subscription/types.ts index 1bbaf386e..e1ae1b6d1 100644 --- a/packages/core/src/utils/subscription/types.ts +++ b/packages/core/src/utils/subscription/types.ts @@ -9,6 +9,8 @@ type RouteResponseType[number]; +export type Subscription = RouteResponseType; + // Since `standardConnectorsLimit` will be removed in the upcoming pricing V2, no need to guard it. // `tokenLimit` is not guarded in backend. export type FeatureQuota = Omit<