mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
feat(core): apply all quota guard (#4187)
This commit is contained in:
parent
d5885160cc
commit
34e907d1ec
21 changed files with 267 additions and 109 deletions
|
@ -2,6 +2,7 @@ import { createMockUtils } from '@logto/shared/esm';
|
|||
|
||||
import { mockFreePlan } from '#src/__mocks__/subscription.js';
|
||||
import { createMockCloudConnectionLibrary } from '#src/test-utils/cloud-connection.js';
|
||||
import { createMockConnectorLibrary } from '#src/test-utils/connectors.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
|
@ -14,6 +15,7 @@ const { getTenantSubscriptionPlan } = await mockEsmWithActual(
|
|||
);
|
||||
|
||||
const cloudConnection = createMockCloudConnectionLibrary();
|
||||
const connectors = createMockConnectorLibrary();
|
||||
|
||||
const { MockQueries } = await import('#src/test-utils/tenant.js');
|
||||
const { createQuotaLibrary } = await import('./quota.js');
|
||||
|
@ -28,16 +30,16 @@ describe('guardKey()', () => {
|
|||
getTenantSubscriptionPlan.mockClear();
|
||||
});
|
||||
|
||||
const { guardKey } = createQuotaLibrary(queries, cloudConnection);
|
||||
const { guardKey } = createQuotaLibrary(queries, cloudConnection, connectors);
|
||||
|
||||
it('should pass when limit is not exeeded', async () => {
|
||||
countNonM2mApplications.mockResolvedValueOnce(0);
|
||||
countNonM2mApplications.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
await expect(guardKey('applicationsLimit')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when limit is exeeded', async () => {
|
||||
countNonM2mApplications.mockResolvedValueOnce(mockFreePlan.quota.applicationsLimit);
|
||||
countNonM2mApplications.mockResolvedValueOnce({ count: mockFreePlan.quota.applicationsLimit });
|
||||
|
||||
await expect(guardKey('applicationsLimit')).rejects.toThrow();
|
||||
});
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { ConnectorType } from '@logto/connector-kit';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
|
@ -6,36 +8,74 @@ import { getTenantSubscriptionPlan } from '#src/utils/subscription/index.js';
|
|||
import { type FeatureQuota } from '#src/utils/subscription/types.js';
|
||||
|
||||
import { type CloudConnectionLibrary } from './cloud-connection.js';
|
||||
import { type ConnectorLibrary } from './connector.js';
|
||||
|
||||
export type QuotaLibrary = ReturnType<typeof createQuotaLibrary>;
|
||||
|
||||
export const createQuotaLibrary = (queries: Queries, cloudConnection: CloudConnectionLibrary) => {
|
||||
const notNumber = (): never => {
|
||||
throw new Error('Only support usage query for numberic quota');
|
||||
};
|
||||
|
||||
export const createQuotaLibrary = (
|
||||
queries: Queries,
|
||||
cloudConnection: CloudConnectionLibrary,
|
||||
connectorLibraray: ConnectorLibrary
|
||||
) => {
|
||||
const {
|
||||
applications: { countNonM2mApplications, countM2mApplications },
|
||||
resources: { findTotalNumberOfResources },
|
||||
hooks: { getTotalNumberOfHooks },
|
||||
roles: { countRoles },
|
||||
scopes: { countScopesByResourceId },
|
||||
rolesScopes: { countRolesScopesByRoleId },
|
||||
} = queries;
|
||||
|
||||
const getTenantUsage = async (key: keyof FeatureQuota): Promise<number> => {
|
||||
if (key === 'applicationsLimit') {
|
||||
return countNonM2mApplications();
|
||||
}
|
||||
const { getLogtoConnectors } = connectorLibraray;
|
||||
|
||||
if (key === 'machineToMachineLimit') {
|
||||
return countM2mApplications();
|
||||
}
|
||||
|
||||
if (key === 'resourcesLimit') {
|
||||
const tenantUsageQueries: Record<
|
||||
keyof FeatureQuota,
|
||||
(queryKey?: string) => Promise<{ count: number }>
|
||||
> = {
|
||||
applicationsLimit: countNonM2mApplications,
|
||||
hooksLimit: getTotalNumberOfHooks,
|
||||
machineToMachineLimit: countM2mApplications,
|
||||
resourcesLimit: async () => {
|
||||
const { count } = await findTotalNumberOfResources();
|
||||
// Ignore the default management API resource
|
||||
return count - 1;
|
||||
}
|
||||
|
||||
// TODO: add other keys
|
||||
|
||||
throw new Error('Unsupported subscription quota key');
|
||||
return { count: count - 1 };
|
||||
},
|
||||
rolesLimit: async () => countRoles(),
|
||||
scopesPerResourceLimit: async (queryKey) => {
|
||||
assertThat(queryKey, new TypeError('queryKey for scopesPerResourceLimit is required'));
|
||||
return countScopesByResourceId(queryKey);
|
||||
},
|
||||
scopesPerRoleLimit: async (queryKey) => {
|
||||
assertThat(queryKey, new TypeError('queryKey for scopesPerRoleLimit is required'));
|
||||
return countRolesScopesByRoleId(queryKey);
|
||||
},
|
||||
socialConnectorsLimit: async () => {
|
||||
const connectors = await getLogtoConnectors();
|
||||
const count = connectors.filter(({ type }) => type === ConnectorType.Social).length;
|
||||
return { count };
|
||||
},
|
||||
standardConnectorsLimit: async () => {
|
||||
const connectors = await getLogtoConnectors();
|
||||
const count = connectors.filter(({ metadata: { isStandard } }) => isStandard).length;
|
||||
return { count };
|
||||
},
|
||||
customDomainEnabled: notNumber,
|
||||
omniSignInEnabled: notNumber, // No limit for now
|
||||
builtInEmailConnectorEnabled: notNumber, // No limit for now
|
||||
};
|
||||
|
||||
const guardKey = async (key: keyof FeatureQuota) => {
|
||||
const getTenantUsage = async (key: keyof FeatureQuota, queryKey?: string): Promise<number> => {
|
||||
const query = tenantUsageQueries[key];
|
||||
const { count } = await query(queryKey);
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
const guardKey = async (key: keyof FeatureQuota, queryKey?: string) => {
|
||||
const { isCloud, isIntegrationTest, isProduction } = EnvSet.values;
|
||||
|
||||
// Cloud only feature, skip in non-cloud production environments
|
||||
|
@ -56,6 +96,10 @@ export const createQuotaLibrary = (queries: Queries, cloudConnection: CloudConne
|
|||
const plan = await getTenantSubscriptionPlan(cloudConnection);
|
||||
const limit = plan.quota[key];
|
||||
|
||||
if (limit === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof limit === 'boolean') {
|
||||
assertThat(
|
||||
limit,
|
||||
|
@ -68,7 +112,7 @@ export const createQuotaLibrary = (queries: Queries, cloudConnection: CloudConne
|
|||
})
|
||||
);
|
||||
} else if (typeof limit === 'number') {
|
||||
const tenantUsage = await getTenantUsage(key);
|
||||
const tenantUsage = await getTenantUsage(key, queryKey);
|
||||
|
||||
assertThat(
|
||||
tenantUsage < limit,
|
||||
|
|
|
@ -35,7 +35,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
|||
where ${fields.type} != ${ApplicationType.MachineToMachine}
|
||||
`);
|
||||
|
||||
return Number(count);
|
||||
return { count: Number(count) };
|
||||
};
|
||||
const countM2mApplications = async () => {
|
||||
const { count } = await pool.one<{ count: string }>(sql`
|
||||
|
@ -44,7 +44,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
|||
where ${fields.type} = ${ApplicationType.MachineToMachine}
|
||||
`);
|
||||
|
||||
return Number(count);
|
||||
return { count: Number(count) };
|
||||
};
|
||||
|
||||
const deleteApplicationById = async (id: string) => {
|
||||
|
|
|
@ -18,6 +18,16 @@ export const createRolesScopesQueries = (pool: CommonQueryMethods) => {
|
|||
)}
|
||||
`);
|
||||
|
||||
const countRolesScopesByRoleId = async (roleId: string) => {
|
||||
const { count } = await pool.one<{ count: string }>(sql`
|
||||
select count(*)
|
||||
from ${table}
|
||||
where ${fields.roleId}=${roleId}
|
||||
`);
|
||||
|
||||
return { count: Number(count) };
|
||||
};
|
||||
|
||||
const findRolesScopesByRoleId = async (roleId: string) =>
|
||||
pool.any<RolesScope>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`,`)}
|
||||
|
@ -45,5 +55,11 @@ export const createRolesScopesQueries = (pool: CommonQueryMethods) => {
|
|||
}
|
||||
};
|
||||
|
||||
return { insertRolesScopes, findRolesScopesByRoleId, findRolesScopesByRoleIds, deleteRolesScope };
|
||||
return {
|
||||
insertRolesScopes,
|
||||
findRolesScopesByRoleId,
|
||||
findRolesScopesByRoleIds,
|
||||
deleteRolesScope,
|
||||
countRolesScopesByRoleId,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -30,8 +30,8 @@ export const createRolesQueries = (pool: CommonQueryMethods) => {
|
|||
const countRoles = async (
|
||||
search: Search = defaultUserSearch,
|
||||
{ excludeRoleIds = [], roleIds }: { excludeRoleIds?: string[]; roleIds?: string[] } = {}
|
||||
) =>
|
||||
pool.one<{ count: number }>(sql`
|
||||
) => {
|
||||
const { count } = await pool.one<{ count: string }>(sql`
|
||||
select count(*)
|
||||
from ${table}
|
||||
where (not starts_with(${fields.name}, ${internalRolePrefix}))
|
||||
|
@ -47,6 +47,9 @@ export const createRolesQueries = (pool: CommonQueryMethods) => {
|
|||
${buildRoleConditions(search)}
|
||||
`);
|
||||
|
||||
return { count: Number(count) };
|
||||
};
|
||||
|
||||
const findRoles = async (
|
||||
search: Search,
|
||||
limit?: number,
|
||||
|
|
|
@ -15,7 +15,11 @@ import { buildConditionsFromSearch } from '#src/utils/search.js';
|
|||
const { table, fields } = convertToIdentifiers(Scopes, true);
|
||||
const resources = convertToIdentifiers(Resources, true);
|
||||
|
||||
const buildResourceConditions = (search: Search) => {
|
||||
const buildResourceConditions = (search?: Search) => {
|
||||
if (!search) {
|
||||
return sql``;
|
||||
}
|
||||
|
||||
const hasSearch = search.matches.length > 0;
|
||||
const searchFields = [Scopes.fields.id, Scopes.fields.name, Scopes.fields.description];
|
||||
|
||||
|
@ -56,14 +60,17 @@ export const createScopeQueries = (pool: CommonQueryMethods) => {
|
|||
${conditionalSql(offset, (value) => sql`offset ${value}`)}
|
||||
`);
|
||||
|
||||
const countScopesByResourceId = async (resourceId: string, search: Search) =>
|
||||
pool.one<{ count: number }>(sql`
|
||||
const countScopesByResourceId = async (resourceId: string, search?: Search) => {
|
||||
const { count } = await pool.one<{ count: string }>(sql`
|
||||
select count(*)
|
||||
from ${table}
|
||||
where ${fields.resourceId}=${resourceId}
|
||||
${buildResourceConditions(search)}
|
||||
`);
|
||||
|
||||
return { count: Number(count) };
|
||||
};
|
||||
|
||||
const countScopesByScopeIds = async (scopeIds: string[], search: Search) =>
|
||||
pool.one<{ count: number }>(sql`
|
||||
select count(*)
|
||||
|
|
57
packages/core/src/routes/connector/factory.ts
Normal file
57
packages/core/src/routes/connector/factory.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { connectorFactoryResponseGuard } from '@logto/schemas';
|
||||
import { string, object } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { loadConnectorFactories, transpileConnectorFactory } from '#src/utils/connectors/index.js';
|
||||
|
||||
import type { AuthedRouter, RouterInitArgs } from '../types.js';
|
||||
|
||||
export default function connectorFactoryRoutes<T extends AuthedRouter>(
|
||||
...[router]: RouterInitArgs<T>
|
||||
) {
|
||||
router.get(
|
||||
'/connector-factories',
|
||||
koaGuard({
|
||||
response: connectorFactoryResponseGuard.array(),
|
||||
status: [200],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const connectorFactories = await loadConnectorFactories();
|
||||
ctx.body = connectorFactories.map((connectorFactory) =>
|
||||
transpileConnectorFactory(connectorFactory)
|
||||
);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/connector-factories/:id',
|
||||
koaGuard({
|
||||
params: object({ id: string().min(1) }),
|
||||
response: connectorFactoryResponseGuard,
|
||||
status: [200, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { id },
|
||||
} = ctx.guard;
|
||||
const connectorFactories = await loadConnectorFactories();
|
||||
|
||||
const connectorFactory = connectorFactories.find((factory) => factory.metadata.id === id);
|
||||
assertThat(
|
||||
connectorFactory,
|
||||
new RequestError({
|
||||
code: 'entity.not_found',
|
||||
status: 404,
|
||||
})
|
||||
);
|
||||
|
||||
ctx.body = transpileConnectorFactory(connectorFactory);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '#src/__mocks__/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import type { LogtoConnector } from '#src/utils/connectors/types.js';
|
||||
|
@ -65,7 +66,9 @@ const tenantContext = new MockTenant(
|
|||
return connector;
|
||||
},
|
||||
},
|
||||
{}
|
||||
{
|
||||
quota: createMockQuotaLibrary(),
|
||||
}
|
||||
);
|
||||
|
||||
const connectorDataRoutes = await pickDefault(import('./index.js'));
|
||||
|
|
|
@ -1,35 +1,36 @@
|
|||
import { buildRawConnector } from '@logto/cli/lib/connector/index.js';
|
||||
import { type ConnectorFactory, buildRawConnector } from '@logto/cli/lib/connector/index.js';
|
||||
import { demoConnectorIds, validateConfig } from '@logto/connector-kit';
|
||||
import {
|
||||
connectorFactoryResponseGuard,
|
||||
Connectors,
|
||||
ConnectorType,
|
||||
connectorResponseGuard,
|
||||
type JsonObject,
|
||||
} from '@logto/schemas';
|
||||
import { Connectors, ConnectorType, connectorResponseGuard, type JsonObject } from '@logto/schemas';
|
||||
import { buildIdGenerator } from '@logto/shared';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import cleanDeep from 'clean-deep';
|
||||
import { string, object } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { type QuotaLibrary } from '#src/libraries/quota.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { buildExtraInfo } from '#src/utils/connectors/extra-information.js';
|
||||
import {
|
||||
loadConnectorFactories,
|
||||
transpileConnectorFactory,
|
||||
transpileLogtoConnector,
|
||||
} from '#src/utils/connectors/index.js';
|
||||
import { loadConnectorFactories, transpileLogtoConnector } from '#src/utils/connectors/index.js';
|
||||
import { checkSocialConnectorTargetAndPlatformUniqueness } from '#src/utils/connectors/platform.js';
|
||||
|
||||
import type { AuthedRouter, RouterInitArgs } from '../types.js';
|
||||
|
||||
import connectorAuthorizationUriRoutes from './authorization-uri.js';
|
||||
import connectorConfigTestingRoutes from './config-testing.js';
|
||||
import connectorFactoryRoutes from './factory.js';
|
||||
|
||||
const generateConnectorId = buildIdGenerator(12);
|
||||
|
||||
const guardConnectorsQuota = async (factory: ConnectorFactory, quota: QuotaLibrary) => {
|
||||
if (factory.metadata.isStandard) {
|
||||
await quota.guardKey('standardConnectorsLimit');
|
||||
}
|
||||
if (factory.type === ConnectorType.Social) {
|
||||
await quota.guardKey('socialConnectorsLimit');
|
||||
}
|
||||
};
|
||||
|
||||
export default function connectorRoutes<T extends AuthedRouter>(
|
||||
...[router, tenant]: RouterInitArgs<T>
|
||||
) {
|
||||
|
@ -43,6 +44,7 @@ export default function connectorRoutes<T extends AuthedRouter>(
|
|||
} = tenant.queries.connectors;
|
||||
const { getLogtoConnectorById, getLogtoConnectors } = tenant.connectors;
|
||||
const {
|
||||
quota,
|
||||
signInExperiences: { removeUnavailableSocialConnectorTargets },
|
||||
} = tenant.libraries;
|
||||
|
||||
|
@ -78,6 +80,8 @@ export default function connectorRoutes<T extends AuthedRouter>(
|
|||
});
|
||||
}
|
||||
|
||||
await guardConnectorsQuota(connectorFactory, quota);
|
||||
|
||||
assertThat(
|
||||
connectorFactory.metadata.isStandard !== true || Boolean(metadata?.target),
|
||||
'connector.should_specify_target'
|
||||
|
@ -221,50 +225,6 @@ export default function connectorRoutes<T extends AuthedRouter>(
|
|||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/connector-factories',
|
||||
koaGuard({
|
||||
response: connectorFactoryResponseGuard.array(),
|
||||
status: [200],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const connectorFactories = await loadConnectorFactories();
|
||||
ctx.body = connectorFactories.map((connectorFactory) =>
|
||||
transpileConnectorFactory(connectorFactory)
|
||||
);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/connector-factories/:id',
|
||||
koaGuard({
|
||||
params: object({ id: string().min(1) }),
|
||||
response: connectorFactoryResponseGuard,
|
||||
status: [200, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { id },
|
||||
} = ctx.guard;
|
||||
const connectorFactories = await loadConnectorFactories();
|
||||
|
||||
const connectorFactory = connectorFactories.find((factory) => factory.metadata.id === id);
|
||||
assertThat(
|
||||
connectorFactory,
|
||||
new RequestError({
|
||||
code: 'entity.not_found',
|
||||
status: 404,
|
||||
})
|
||||
);
|
||||
|
||||
ctx.body = transpileConnectorFactory(connectorFactory);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/connectors/:id',
|
||||
koaGuard({
|
||||
|
@ -360,4 +320,5 @@ export default function connectorRoutes<T extends AuthedRouter>(
|
|||
|
||||
connectorConfigTestingRoutes(router, tenant);
|
||||
connectorAuthorizationUriRoutes(router, tenant);
|
||||
connectorFactoryRoutes(router, tenant);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { type Domain } from '@logto/schemas';
|
|||
import { pickDefault } from '@logto/shared/esm';
|
||||
|
||||
import { mockDomain, mockDomainResponse } from '#src/__mocks__/domain.js';
|
||||
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
|
@ -33,6 +34,7 @@ const mockLibraries = {
|
|||
addDomain,
|
||||
deleteDomain,
|
||||
},
|
||||
quota: createMockQuotaLibrary(),
|
||||
};
|
||||
|
||||
const tenantContext = new MockTenant(undefined, { domains }, undefined, mockLibraries);
|
||||
|
|
|
@ -4,6 +4,7 @@ import { z } from 'zod';
|
|||
|
||||
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 assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type { AuthedRouter, RouterInitArgs } from './types.js';
|
||||
|
@ -15,6 +16,7 @@ export default function domainRoutes<T extends AuthedRouter>(
|
|||
domains: { findAllDomains, findDomainById },
|
||||
} = queries;
|
||||
const {
|
||||
quota,
|
||||
domains: { syncDomainStatus, addDomain, deleteDomain },
|
||||
} = libraries;
|
||||
|
||||
|
@ -54,6 +56,7 @@ export default function domainRoutes<T extends AuthedRouter>(
|
|||
|
||||
router.post(
|
||||
'/domains',
|
||||
koaQuotaGuard({ key: 'customDomainEnabled', quota }),
|
||||
koaGuard({
|
||||
body: Domains.createGuard.pick({ domain: true }),
|
||||
response: domainResponseGuard,
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
mockNanoIdForHook,
|
||||
mockTenantIdForHook,
|
||||
} from '#src/__mocks__/hook.js';
|
||||
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
|
@ -85,6 +86,7 @@ const mockLibraries = {
|
|||
attachExecutionStatsToHook,
|
||||
testHook,
|
||||
},
|
||||
quota: createMockQuotaLibrary(),
|
||||
};
|
||||
|
||||
const tenantContext = new MockTenant(undefined, mockQueries, undefined, mockLibraries);
|
||||
|
|
|
@ -7,6 +7,7 @@ import { z } from 'zod';
|
|||
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 assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type { AuthedRouter, RouterInitArgs } from './types.js';
|
||||
|
@ -32,6 +33,7 @@ export default function hookRoutes<T extends AuthedRouter>(
|
|||
|
||||
const {
|
||||
hooks: { attachExecutionStatsToHook, testHook },
|
||||
quota,
|
||||
} = libraries;
|
||||
|
||||
router.get(
|
||||
|
@ -128,6 +130,7 @@ export default function hookRoutes<T extends AuthedRouter>(
|
|||
|
||||
router.post(
|
||||
'/hooks',
|
||||
koaQuotaGuard({ key: 'hooksLimit', quota }),
|
||||
koaGuard({
|
||||
body: Hooks.createGuard.omit({ id: true, signingKey: true }).extend({
|
||||
events: nonemptyUniqueHookEventsGuard.optional(),
|
||||
|
|
|
@ -16,7 +16,13 @@ const resourceId = buildIdGenerator(21);
|
|||
const scopeId = resourceId;
|
||||
|
||||
export default function resourceRoutes<T extends AuthedRouter>(
|
||||
...[router, { queries, libraries }]: RouterInitArgs<T>
|
||||
...[
|
||||
router,
|
||||
{
|
||||
queries,
|
||||
libraries: { quota, resources },
|
||||
},
|
||||
]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
resources: {
|
||||
|
@ -38,7 +44,7 @@ export default function resourceRoutes<T extends AuthedRouter>(
|
|||
updateScopeById,
|
||||
},
|
||||
} = queries;
|
||||
const { attachScopesToResources } = libraries.resources;
|
||||
const { attachScopesToResources } = resources;
|
||||
|
||||
router.get(
|
||||
'/resources',
|
||||
|
@ -77,7 +83,7 @@ export default function resourceRoutes<T extends AuthedRouter>(
|
|||
|
||||
router.post(
|
||||
'/resources',
|
||||
koaQuotaGuard({ key: 'resourcesLimit', quota: libraries.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.
|
||||
|
@ -242,6 +248,8 @@ export default function resourceRoutes<T extends AuthedRouter>(
|
|||
body,
|
||||
} = ctx.guard;
|
||||
|
||||
await quota.guardKey('scopesPerResourceLimit', resourceId);
|
||||
|
||||
assertThat(!/\s/.test(body.name), 'scope.name_with_space');
|
||||
|
||||
assertThat(
|
||||
|
|
|
@ -3,6 +3,7 @@ import { pickDefault } from '@logto/shared/esm';
|
|||
|
||||
import { mockRole, mockScope, mockResource, mockScopeWithResource } from '#src/__mocks__/index.js';
|
||||
import { mockId, mockStandardId } from '#src/test-utils/nanoid.js';
|
||||
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
|
@ -51,13 +52,20 @@ const users = {
|
|||
|
||||
const roleRoutes = await pickDefault(import('./role.scope.js'));
|
||||
|
||||
const tenantContext = new MockTenant(undefined, {
|
||||
users,
|
||||
rolesScopes,
|
||||
resources,
|
||||
scopes,
|
||||
roles,
|
||||
});
|
||||
const tenantContext = new MockTenant(
|
||||
undefined,
|
||||
{
|
||||
users,
|
||||
rolesScopes,
|
||||
resources,
|
||||
scopes,
|
||||
roles,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
quota: createMockQuotaLibrary(),
|
||||
}
|
||||
);
|
||||
|
||||
describe('role scope routes', () => {
|
||||
const roleRequester = createRequester({ authedRoutes: roleRoutes, tenantContext });
|
||||
|
|
|
@ -13,7 +13,13 @@ import { parseSearchParamsForSearch } from '#src/utils/search.js';
|
|||
import type { AuthedRouter, RouterInitArgs } from './types.js';
|
||||
|
||||
export default function roleScopeRoutes<T extends AuthedRouter>(
|
||||
...[router, { queries }]: RouterInitArgs<T>
|
||||
...[
|
||||
router,
|
||||
{
|
||||
queries,
|
||||
libraries: { quota },
|
||||
},
|
||||
]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
resources: { findResourcesByIds },
|
||||
|
@ -106,6 +112,9 @@ export default function roleScopeRoutes<T extends AuthedRouter>(
|
|||
} = ctx.guard;
|
||||
|
||||
await findRoleById(id);
|
||||
|
||||
await quota.guardKey('scopesPerRoleLimit', id);
|
||||
|
||||
const rolesScopes = await findRolesScopesByRoleId(id);
|
||||
|
||||
for (const scopeId of scopeIds) {
|
||||
|
|
|
@ -3,6 +3,7 @@ import { pickDefault } from '@logto/shared/esm';
|
|||
|
||||
import { mockRole, mockScope, mockUser, mockResource } from '#src/__mocks__/index.js';
|
||||
import { mockId, mockStandardId } from '#src/test-utils/nanoid.js';
|
||||
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
|
@ -71,14 +72,19 @@ const {
|
|||
|
||||
const roleRoutes = await pickDefault(import('./role.js'));
|
||||
|
||||
const tenantContext = new MockTenant(undefined, {
|
||||
usersRoles,
|
||||
users,
|
||||
rolesScopes,
|
||||
resources,
|
||||
scopes,
|
||||
roles,
|
||||
});
|
||||
const tenantContext = new MockTenant(
|
||||
undefined,
|
||||
{
|
||||
usersRoles,
|
||||
users,
|
||||
rolesScopes,
|
||||
resources,
|
||||
scopes,
|
||||
roles,
|
||||
},
|
||||
undefined,
|
||||
{ quota: createMockQuotaLibrary() }
|
||||
);
|
||||
|
||||
describe('role routes', () => {
|
||||
const roleRequester = createRequester({ authedRoutes: roleRoutes, tenantContext });
|
||||
|
|
|
@ -7,6 +7,7 @@ import { object, string, z, number } from 'zod';
|
|||
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 koaRoleRlsErrorHandler from '#src/middleware/koa-role-rls-error-handler.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { parseSearchParamsForSearch } from '#src/utils/search.js';
|
||||
|
@ -14,7 +15,13 @@ import { parseSearchParamsForSearch } from '#src/utils/search.js';
|
|||
import type { AuthedRouter, RouterInitArgs } from './types.js';
|
||||
|
||||
export default function roleRoutes<T extends AuthedRouter>(
|
||||
...[router, { queries }]: RouterInitArgs<T>
|
||||
...[
|
||||
router,
|
||||
{
|
||||
queries,
|
||||
libraries: { quota },
|
||||
},
|
||||
]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
rolesScopes: { insertRolesScopes },
|
||||
|
@ -112,6 +119,7 @@ export default function roleRoutes<T extends AuthedRouter>(
|
|||
|
||||
router.post(
|
||||
'/roles',
|
||||
koaQuotaGuard({ key: 'rolesLimit', quota }),
|
||||
koaGuard({
|
||||
body: Roles.createGuard
|
||||
.omit({ id: true })
|
||||
|
|
|
@ -25,7 +25,7 @@ export default class Libraries {
|
|||
applications = createApplicationLibrary(this.queries);
|
||||
verificationStatuses = createVerificationStatusLibrary(this.queries);
|
||||
domains = createDomainLibrary(this.queries);
|
||||
quota = createQuotaLibrary(this.queries, this.cloudConnection);
|
||||
quota = createQuotaLibrary(this.queries, this.cloudConnection, this.connectors);
|
||||
|
||||
constructor(
|
||||
public readonly tenantId: string,
|
||||
|
|
13
packages/core/src/test-utils/connectors.ts
Normal file
13
packages/core/src/test-utils/connectors.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { type ConnectorLibrary } from '#src/libraries/connector.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
export const createMockConnectorLibrary = (): ConnectorLibrary => {
|
||||
return {
|
||||
getCloudConnectionData: jest.fn(),
|
||||
getConnectorConfig: jest.fn(),
|
||||
getLogtoConnectors: jest.fn(),
|
||||
getLogtoConnectorsWellKnown: jest.fn(),
|
||||
getLogtoConnectorById: jest.fn(),
|
||||
};
|
||||
};
|
|
@ -9,4 +9,7 @@ type RouteResponseType<T extends { search?: unknown; body?: unknown; response?:
|
|||
|
||||
export type SubscriptionPlan = RouteResponseType<GetRoutes['/api/subscription-plans']>[number];
|
||||
|
||||
export type FeatureQuota = Omit<SubscriptionPlan['quota'], 'tenantLimit' | 'mauLimit'>;
|
||||
export type FeatureQuota = Omit<
|
||||
SubscriptionPlan['quota'],
|
||||
'tenantLimit' | 'mauLimit' | 'auditLogsRetentionDays'
|
||||
>;
|
||||
|
|
Loading…
Add table
Reference in a new issue