0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-03 22:15:32 -05:00

feat(cloud): limit control for send email (#3418)

This commit is contained in:
wangsijie 2023-03-15 15:55:21 +08:00 committed by GitHub
parent 63938d6d4b
commit dc175580b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 101 additions and 5 deletions

View file

@ -1,6 +1,8 @@
import { buildRawConnector, defaultConnectorMethods } from '@logto/cli/lib/connector/index.js';
import type { AllConnector, EmailConnector, SendMessagePayload } from '@logto/connector-kit';
import { ConnectorType, validateConfig } from '@logto/connector-kit';
import { generateStandardId } from '@logto/core-kit';
import type { ServiceLogType } from '@logto/schemas';
import { adminTenantId } from '@logto/schemas';
import { trySafe } from '@logto/shared';
import { RequestError } from '@withtyped/server';
@ -9,6 +11,8 @@ import type { Queries } from '#src/queries/index.js';
import type { LogtoConnector } from '#src/utils/connector/index.js';
import { loadConnectorFactories } from '#src/utils/connector/index.js';
export const serviceCountLimitForTenant = 100;
export class ServicesLibrary {
constructor(public readonly queries: Queries) {}
@ -96,4 +100,18 @@ export class ServicesLibrary {
return sendMessage(data);
}
async addLog(tenantId: string, type: ServiceLogType) {
return this.queries.serviceLogs.insertLog({
id: generateStandardId(),
type,
tenantId,
});
}
async getTenantBalanceForType(tenantId: string, type: ServiceLogType) {
const usedCount = await this.queries.serviceLogs.countTenantLogs(tenantId, type);
return serviceCountLimitForTenant - usedCount;
}
}

View file

@ -5,6 +5,7 @@ import { parseDsn } from '#src/utils/postgres.js';
import { createApplicationsQueries } from './application.js';
import { createConnectorsQuery } from './connector.js';
import { createServiceLogsQueries } from './service-logs.js';
import { createTenantsQueries } from './tenants.js';
import { createUsersQueries } from './users.js';
@ -16,4 +17,5 @@ export class Queries {
public readonly users = createUsersQueries(this.client);
public readonly applications = createApplicationsQueries(this.client);
public readonly connectors = createConnectorsQuery(this.client);
public readonly serviceLogs = createServiceLogsQueries(this.client);
}

View file

@ -0,0 +1,29 @@
import type { CreateServiceLog, ServiceLogType } from '@logto/schemas';
import type { PostgreSql } from '@withtyped/postgres';
import { sql } from '@withtyped/postgres';
import type { Queryable } from '@withtyped/server';
import { insertInto } from '#src/utils/query.js';
export type ServiceLogsQueries = ReturnType<typeof createServiceLogsQueries>;
export const createServiceLogsQueries = (client: Queryable<PostgreSql>) => {
const insertLog = async (data: Omit<CreateServiceLog, 'payload'>) =>
client.query(insertInto(data, 'service_logs'));
const countTenantLogs = async (tenantId: string, type: ServiceLogType) => {
const { rows } = await client.query<{ count: number }>(sql`
select count(id) as count
from service_logs
where tenantId = ${tenantId}
and type = ${type}
`);
return rows[0]?.count ?? 0;
};
return {
insertLog,
countTenantLogs,
};
};

View file

@ -1,4 +1,4 @@
import { CloudScope } from '@logto/schemas';
import { CloudScope, ServiceLogType } from '@logto/schemas';
import { buildRequestAuthContext, createHttpContext } from '#src/test-utils/context.js';
import { noop } from '#src/test-utils/function.js';
@ -28,6 +28,33 @@ describe('POST /api/services/send-email', () => {
).rejects.toMatchObject({ status: 403 });
});
it('should throw 403 when tenant id not found', async () => {
await expect(
router.routes()(
buildRequestAuthContext('POST /services/send-email', {
body: { data: mockSendMessagePayload },
})([CloudScope.SendEmail]),
noop,
createHttpContext()
)
).rejects.toMatchObject({ status: 403 });
});
it('should throw 403 when insufficient funds', async () => {
library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId');
library.getTenantBalanceForType.mockResolvedValueOnce(0);
await expect(
router.routes()(
buildRequestAuthContext('POST /services/send-email', {
body: { data: mockSendMessagePayload },
})([CloudScope.SendEmail]),
noop,
createHttpContext()
)
).rejects.toMatchObject({ status: 403 });
});
it('should return 201', async () => {
library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId');
@ -38,6 +65,7 @@ describe('POST /api/services/send-email', () => {
async ({ status }) => {
expect(status).toBe(201);
expect(library.sendEmail).toBeCalledWith(mockSendMessagePayload);
expect(library.addLog).toBeCalledWith('tenantId', ServiceLogType.SendEmail);
},
createHttpContext()
);

View file

@ -1,5 +1,5 @@
import { sendMessagePayloadGuard } from '@logto/connector-kit';
import { CloudScope } from '@logto/schemas';
import { CloudScope, ServiceLogType } from '@logto/schemas';
import { createRouter, RequestError } from '@withtyped/server';
import { z } from 'zod';
@ -17,10 +17,18 @@ export const servicesRoutes = (library: ServicesLibrary) =>
const tenantId = await library.getTenantIdFromApplicationId(context.auth.id);
// TODO limitation control
console.log(tenantId);
if (!tenantId) {
throw new RequestError('Unable to find tenant id.', 403);
}
const balance = await library.getTenantBalanceForType(tenantId, ServiceLogType.SendEmail);
if (!balance) {
throw new RequestError('Service usage limit reached.', 403);
}
await library.sendEmail(context.guarded.body.data);
await library.addLog(tenantId, ServiceLogType.SendEmail);
return next({ ...context, status: 201 });
}

View file

@ -1,4 +1,4 @@
import type { TenantInfo } from '@logto/schemas';
import type { ServiceLogType, TenantInfo } from '@logto/schemas';
import type { ServicesLibrary } from '#src/libraries/services.js';
import type { TenantsLibrary } from '#src/libraries/tenants.js';
@ -25,4 +25,10 @@ export class MockServicesLibrary implements ServicesLibrary {
public sendEmail = jest.fn();
public getAdminTenantLogtoConnectors = jest.fn();
public addLog = jest.fn();
public getTenantBalanceForType = jest
.fn<Promise<number>, [string, ServiceLogType]>()
.mockResolvedValue(100);
}

View file

@ -14,3 +14,4 @@ export * from './system.js';
export * from './tenant.js';
export * from './user-assets.js';
export * from './hook.js';
export * from './service-log.js';

View file

@ -0,0 +1,4 @@
export enum ServiceLogType {
SendEmail = 'sendEmail',
SendSms = 'sendSms',
}