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:
commit
b322b9a037
22 changed files with 339 additions and 41 deletions
|
@ -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<number> => {
|
||||
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,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 };
|
||||
};
|
||||
|
|
|
@ -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']);
|
||||
|
||||
|
|
|
@ -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<UsageGuardConfig, 'key'> & {
|
||||
key: keyof SubscriptionQuota;
|
||||
};
|
||||
|
||||
/** @deprecated */
|
||||
export default function koaQuotaGuard<StateT, ContextT, ResponseBodyT>({
|
||||
key,
|
||||
quota,
|
||||
|
@ -25,3 +31,17 @@ export default function koaQuotaGuard<StateT, ContextT, ResponseBodyT>({
|
|||
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();
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
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.guardTenantUsageByKey('machineToMachineLimit')
|
||||
: quota.guardKey('machineToMachineLimit')),
|
||||
rest.isThirdParty &&
|
||||
(isDevFeaturesEnabled
|
||||
? quota.guardTenantUsageByKey('thirdPartyApplicationsLimit')
|
||||
: quota.guardKey('thirdPartyApplicationsLimit')),
|
||||
isDevFeaturesEnabled
|
||||
? quota.guardTenantUsageByKey('applicationsLimit')
|
||||
: quota.guardKey('applicationsLimit'),
|
||||
]);
|
||||
|
||||
assertThat(
|
||||
|
@ -349,3 +364,4 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
|
|||
|
||||
applicationCustomDataRoutes(router, tenant);
|
||||
}
|
||||
/* eslint-enable max-lines */
|
||||
|
|
|
@ -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.guardTenantUsageByKey('socialConnectorsLimit')
|
||||
: quota.guardKey('socialConnectorsLimit'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
|
||||
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,
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
|
||||
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(),
|
||||
|
|
|
@ -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<T extends ManagementApiRo
|
|||
response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard),
|
||||
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) => {
|
||||
const { isCloud, isIntegrationTest } = EnvSet.values;
|
||||
if (tenantId === adminTenantId && isCloud && !isIntegrationTest) {
|
||||
|
@ -112,7 +114,9 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
|
|||
response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard),
|
||||
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) => {
|
||||
const { isIntegrationTest } = EnvSet.values;
|
||||
|
||||
|
@ -215,7 +219,9 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
|
|||
response: jsonObjectGuard,
|
||||
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) => {
|
||||
const { body } = ctx.guard;
|
||||
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
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'],
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
]: RouterInitArgs<T>
|
||||
) {
|
||||
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'],
|
||||
});
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
] = 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 },
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
body,
|
||||
} = 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');
|
||||
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
|
||||
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.
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
body: { scopeIds },
|
||||
} = ctx.guard;
|
||||
|
||||
await quota.guardKey('scopesPerRoleLimit', id);
|
||||
await (EnvSet.values.isDevFeaturesEnabled
|
||||
? quota.guardEntityScopesUsage('roles', id)
|
||||
: quota.guardKey('scopesPerRoleLimit', id));
|
||||
|
||||
await validateRoleScopeAssignment(scopeIds, id);
|
||||
await insertRolesScopes(
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
// `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.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(
|
||||
!(await findRoleByRoleName(roleBody.name)),
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
|
||||
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),
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
const { deleteConnectorById } = queries.connectors;
|
||||
const {
|
||||
signInExperiences: { validateLanguageInfo },
|
||||
quota: { guardKey },
|
||||
quota: { guardKey, guardTenantUsageByKey },
|
||||
} = libraries;
|
||||
const { getLogtoConnectors } = connectors;
|
||||
|
||||
|
@ -55,6 +56,7 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
|
|||
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<T extends ManagementApiRouter>(
|
|||
|
||||
if (mfa) {
|
||||
if (mfa.factors.length > 0) {
|
||||
await guardKey('mfaEnabled');
|
||||
await (EnvSet.values.isDevFeaturesEnabled
|
||||
? guardTenantUsageByKey('mfaEnabled')
|
||||
: guardKey('mfaEnabled'));
|
||||
}
|
||||
validateMfa(mfa);
|
||||
}
|
||||
|
|
|
@ -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<T extends ManagementApiRout
|
|||
/* Create a new single sign on connector */
|
||||
router.post(
|
||||
pathname,
|
||||
koaQuotaGuard({ key: 'ssoEnabled', quota }),
|
||||
EnvSet.values.isDevFeaturesEnabled
|
||||
? newKoaQuotaGuard({ key: 'enterpriseSsoLimit', quota })
|
||||
: koaQuotaGuard({ key: 'ssoEnabled', quota }),
|
||||
koaGuard({
|
||||
body: ssoConnectorCreateGuard,
|
||||
response: SsoConnectors.guard,
|
||||
|
|
|
@ -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 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';
|
||||
|
||||
|
@ -25,7 +26,9 @@ export default function subjectTokenRoutes<T extends ManagementApiRouter>(
|
|||
|
||||
router.post(
|
||||
'/subject-tokens',
|
||||
koaQuotaGuard({ key: 'subjectTokenEnabled', quota }),
|
||||
EnvSet.values.isDevFeaturesEnabled
|
||||
? newKoaQuotaGuard({ key: 'subjectTokenEnabled', quota })
|
||||
: koaQuotaGuard({ key: 'subjectTokenEnabled', quota }),
|
||||
koaGuard({
|
||||
body: object({
|
||||
userId: string(),
|
||||
|
|
|
@ -5,5 +5,7 @@ const { jest } = import.meta;
|
|||
export const createMockQuotaLibrary = (): QuotaLibrary => {
|
||||
return {
|
||||
guardKey: jest.fn(),
|
||||
guardTenantUsageByKey: jest.fn(),
|
||||
guardEntityScopesUsage: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -2,14 +2,28 @@ 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,
|
||||
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 (
|
||||
cloudConnection: CloudConnectionLibrary
|
||||
): Promise<SubscriptionPlan> => {
|
||||
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);
|
||||
|
@ -18,3 +32,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<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;
|
||||
};
|
||||
|
|
|
@ -9,9 +9,31 @@ type RouteResponseType<T extends { search?: unknown; body?: unknown; response?:
|
|||
|
||||
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.
|
||||
// `tokenLimit` is not guarded in backend.
|
||||
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<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']
|
||||
>;
|
||||
|
|
Loading…
Reference in a new issue