Манитон Docs
Reference

Auth Service - Реализация

Детальная реализация Auth Service

Auth Service - Реализация

Обзор

Auth Service — микросервис на NestJS для управления пользователями, KYC, лимитами и санкциями. Использует Connect RPC для gRPC API и Kafka для событий.

Структура проекта

apps/services/auth/src/
├── app/
│   ├── app.controller.ts
│   └── app.service.ts
├── app.module.ts
├── config/
│   └── config.module.ts
├── domains/
│   ├── authz/              # Авторизация
│   │   ├── application/
│   │   ├── domain/
│   │   └── presentation/
│   ├── gateway/            # Connect RPC Gateway
│   │   └── identity-grpc.gateway.ts
│   └── identity/           # Домен пользователя
│       ├── application/
│       │   └── use-cases/
│       ├── domain/
│       └── presentation/
├── infrastructure/
│   ├── database/
│   ├── kafka/
│   └── logging/
├── main.ts
└── shared/

Модули

AppModule

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DrizzleModule } from '@maniton/nestjs-common/database';
import { KafkaModule } from '@maniton/nestjs-common/kafka';
import { LoggingModule } from '@maniton/nestjs-common/logging';
import { IdentityModule } from './domains/identity/identity.module';
import { AuthZModule } from './domains/authz/authz.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: ['.env.local', '.env'],
    }),
    DrizzleModule.forRoot({
      url: process.env.DATABASE_URL,
    }),
    KafkaModule.forRoot({
      brokers: process.env.KAFKA_BROKERS?.split(',') || ['localhost:9092'],
      clientId: 'auth-service',
    }),
    LoggingModule.forRoot({
      level: process.env.LOG_LEVEL || 'info',
    }),
    IdentityModule,
    AuthZModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

Use Cases

GetUserProfileUseCase

Получение профиля пользователя.

// domains/identity/application/use-cases/get-user-profile.use-case.ts
import { Injectable } from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository';
import { UserProfile } from '@maniton/contracts/gen/ts/maniton/identity/v1/identity_pb';

@Injectable()
export class GetUserProfileUseCase {
  constructor(private readonly userRepository: UserRepository) {}

  async execute(userId: string): Promise<UserProfile> {
    const user = await this.userRepository.findById(userId);

    if (!user) {
      throw new Error('User not found');
    }

    return {
      userId: user.id,
      email: user.email,
      kycStatus: user.kycStatus,
      isAdmin: user.isAdmin,
      createdAt: user.createdAt,
      updatedAt: user.updatedAt,
    };
  }
}

SubmitKycUseCase

Отправка KYC заявки.

// domains/identity/application/use-cases/submit-kyc.use-case.ts
import { Injectable } from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository';
import { KycRepository } from '../../domain/repositories/kyc.repository';
import { SanctionsService } from '../../domain/services/sanctions.service';
import { KycStatus } from '@maniton/contracts/gen/ts/maniton/common/v1/common_pb';

@Injectable()
export class SubmitKycUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly kycRepository: KycRepository,
    private readonly sanctionsService: SanctionsService,
  ) {}

  async execute(userId: string, payload: any): Promise<void> {
    const user = await this.userRepository.findById(userId);

    if (!user) {
      throw new Error('User not found');
    }

    if (user.kycStatus !== KycStatus.NONE) {
      throw new Error('KYC already submitted');
    }

    // Проверка санкций
    const sanctionsCheck = await this.sanctionsService.checkSanctions(userId, payload);

    if (!sanctionsCheck.allowed) {
      throw new Error('Sanctions check failed');
    }

    // Создание KYC заявки
    await this.kycRepository.create({
      userId,
      status: KycStatus.PENDING,
      payload,
      submittedAt: new Date(),
    });

    // Обновление статуса пользователя
    await this.userRepository.update(userId, {
      kycStatus: KycStatus.PENDING,
    });
  }
}

CheckLimitsUseCase

Проверка лимитов пользователя.

// domains/identity/application/use-cases/check-limits.use-case.ts
import { Injectable } from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository';
import { LimitRepository } from '../../domain/repositories/limit.repository';
import { OperationType, LimitType } from '@maniton/contracts/gen/ts/maniton/common/v1/common_pb';

@Injectable()
export class CheckLimitsUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly limitRepository: LimitRepository,
  ) {}

  async execute(
    userId: string,
    limitType: LimitType,
    amount: { amount: string; currencyCode: string },
    operationType: OperationType,
  ): Promise<{ allowed: boolean; reason?: string }> {
    const user = await this.userRepository.findById(userId);

    if (!user) {
      throw new Error('User not found');
    }

    // Проверка KYC статуса
    if (user.kycStatus < KycStatus.STANDARD) {
      return { allowed: false, reason: 'KYC level too low' };
    }

    // Получение лимитов
    const limits = await this.limitRepository.findByUserIdAndType(userId, limitType);

    if (!limits) {
      return { allowed: false, reason: 'No limits configured' };
    }

    // Проверка лимитов
    const amountValue = Number(amount.amount);

    if (amountValue > limits.dailyLimit) {
      return { allowed: false, reason: 'Daily limit exceeded' };
    }

    if (amountValue > limits.monthlyLimit) {
      return { allowed: false, reason: 'Monthly limit exceeded' };
    }

    return { allowed: true };
  }
}

Connect RPC Gateway

IdentityGrpcGateway

// domains/identity/presentation/identity-grpc.gateway.ts
import { ConnectGateway, ConnectRouter } from '@maniton/nestjs-common/connect';
import { IdentityService } from '@maniton/contracts/gen/ts/maniton/identity/v1/identity_pb';
import type { IdentityUseCases } from './identity-grpc.gateway';

export class IdentityGrpcGateway extends ConnectGateway {
  protected readonly options = {
    requestPathPrefix: '/auth-service',
  };

  protected registerRoutes(router: ConnectRouter): void {
    router.service(IdentityService, {
      getUserProfile: (req) => this.useCases.getUserProfile.execute(req.userId),
      listUsers: (req) => this.useCases.listUsers.execute(req),
      submitKyc: (req) => this.useCases.submitKyc.execute(req.userId, req.payload),
      reviewKyc: (req) => this.useCases.reviewKyc.execute(req.kycId, req.approved),
      blockUser: (req) => this.useCases.blockUser.execute(req.userId, req.reason),
      unblockUser: (req) => this.useCases.unblockUser.execute(req.userId),
      getUserLimits: (req) => this.useCases.getUserLimits.execute(req.userId),
      checkLimits: (req) => this.useCases.checkLimits.execute(
        req.userId,
        req.limitType,
        req.amount,
        req.operationType
      )),
      checkSanctions: (req) => this.useCases.checkSanctions.execute(req.userId, req.payload),
      toggleAdminRole: (req) => this.useCases.toggleAdminRole.execute(req.userId),
      setKycLevel: (req) => this.useCases.setKycLevel.execute(req.userId, req.kycStatus),
    });
  }
}

Репозитории

UserRepository

// domains/identity/domain/repositories/user.repository.ts
import { Injectable } from '@nestjs/common';
import { DrizzleService } from '@maniton/nestjs-common/database';
import { users } from '@maniton/contracts/gen/drizzle/schema';
import { eq } from 'drizzle-orm';

@Injectable()
export class UserRepository {
  constructor(private readonly drizzle: DrizzleService) {}

  async findById(id: string) {
    const [user] = await this.drizzle.db.select().from(users).where(eq(users.id, id)).limit(1);

    return user;
  }

  async findByEmail(email: string) {
    const [user] = await this.drizzle.db
      .select()
      .from(users)
      .where(eq(users.email, email))
      .limit(1);

    return user;
  }

  async create(data: typeof users.$inferInsert) {
    const [user] = await this.drizzle.db.insert(users).values(data).returning();

    return user;
  }

  async update(id: string, data: Partial<typeof users.$inferInsert>) {
    const [user] = await this.drizzle.db
      .update(users)
      .set(data)
      .where(eq(users.id, id))
      .returning();

    return user;
  }

  async list(pagination?: { limit?: number; offset?: number }) {
    return this.drizzle.db.select().from(users).limit(pagination?.limit).offset(pagination?.offset);
  }
}

Сервисы

SanctionsService

// domains/identity/domain/services/sanctions.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class SanctionsService {
  constructor(private readonly config: ConfigService) {}

  async checkSanctions(
    userId: string,
    payload: any,
  ): Promise<{ allowed: boolean; reason?: string }> {
    // Интеграция с внешним сервисом проверки санкций
    // Требуется подключение к Rosfin, OFAC, EU Sanctions List
    // Временно возвращаем разрешено для разработки

    const apiKey = this.config.get('SANCTIONS_API_KEY');

    if (!apiKey) {
      // В development режиме пропускаем все проверки
      return { allowed: true };
    }

    // Проверка по внешнему API
    const response = await fetch('https://sanctions-api.example.com/check', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${apiKey}`,
      },
      body: JSON.stringify({
        userId,
        ...payload,
      }),
    });

    const result = await response.json();

    return {
      allowed: result.allowed,
      reason: result.reason,
    };
  }
}

События Kafka

Публикация событий

// infrastructure/kafka/event-publisher.service.ts
import { Injectable } from '@nestjs/common';
import { ProducerService } from '@maniton/nestjs-common/kafka';
import { create, toBinary } from '@bufbuild/protobuf';
import {
  CloudEventSchema,
  EventEnvelopeSchema,
} from '@maniton/contracts/gen/ts/maniton/events/v1/envelope_pb';
import { anyPack } from '@maniton/protobuf/wkt';

@Injectable()
export class EventPublisherService {
  constructor(private readonly producer: ProducerService) {}

  async publish(topic: string, eventType: string, payload: any, context: any) {
    const cloudEvent = create(CloudEventSchema, {
      id: crypto.randomUUID(),
      source: 'auth-service',
      specVersion: '1.0',
      type: eventType,
      dataContentType: 'application/protobuf',
      data: {
        case: 'protoData',
        value: anyPack(eventType, payload),
      },
    });

    const envelope = create(EventEnvelopeSchema, {
      event: cloudEvent,
      context,
      topic,
      schemaSubject: eventType,
    });

    const bytes = toBinary(EventEnvelopeSchema, envelope);

    await this.producer.send(topic, bytes);
  }
}

Обработка событий

// infrastructure/kafka/event-handler.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConsumerService } from '@maniton/nestjs-common/kafka';
import { fromBinary, anyIs, anyUnpack } from '@bufbuild/protobuf';
import { EventEnvelopeSchema } from '@maniton/contracts/gen/ts/maniton/events/v1/envelope_pb';

@Injectable()
export class EventHandlerService implements OnModuleInit {
  constructor(private readonly consumer: ConsumerService) {}

  async onModuleInit() {
    await this.consumer.subscribe({
      topic: 'maniton.identity.events.v1',
      groupId: 'auth-service',
      onMessage: async ({ value }) => {
        const envelope = fromBinary(EventEnvelopeSchema, value);
        const any =
          envelope.event?.data?.case === 'protoData' ? envelope.event.data.value : undefined;

        if (any) {
          await this.handleEvent(envelope.event.type, any);
        }
      },
    });
  }

  private async handleEvent(type: string, any: any) {
    switch (type) {
      case 'maniton.identity.v1.UserCreatedEvent':
        await this.handleUserCreated(anyUnpack(any));
        break;
      case 'maniton.identity.v1.UserBlockedEvent':
        await this.handleUserBlocked(anyUnpack(any));
        break;
      case 'maniton.identity.v1.UserUnblockedEvent':
        await this.handleUserUnblocked(anyUnpack(any));
        break;
    }
  }

  private async handleUserCreated(event: any) {
    // Обработка события
  }

  private async handleUserBlocked(event: any) {
    // Обработка события
  }

  private async handleUserUnblocked(event: any) {
    // Обработка события
  }
}

Конфигурация

ConfigModule

// config/config.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule } from '@nestjs/config';

@Module({
  imports: [
    NestConfigModule.forRoot({
      isGlobal: true,
      validationSchema: {
        DATABASE_URL: {
          type: 'string',
          required: true,
        },
        KAFKA_BROKERS: {
          type: 'string',
          required: true,
        },
        LOG_LEVEL: {
          type: 'string',
          default: 'info',
        },
        SANCTIONS_API_KEY: {
          type: 'string',
          required: false,
        },
      },
    }),
  ],
  exports: [NestConfigModule],
})
export class ConfigModule {}

Тестирование

Unit Tests

// domains/identity/application/use-cases/get-user-profile.use-case.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { GetUserProfileUseCase } from './get-user-profile.use-case';
import { UserRepository } from '../../domain/repositories/user.repository';

describe('GetUserProfileUseCase', () => {
  let useCase: GetUserProfileUseCase;
  let userRepository: jest.Mocked<UserRepository>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        GetUserProfileUseCase,
        {
          provide: UserRepository,
          useValue: {
            findById: jest.fn(),
          },
        },
      ],
    }).compile();

    useCase = module.get(GetUserProfileUseCase);
    userRepository = module.get(UserRepository);
  });

  it('should return user profile', async () => {
    const user = {
      id: 'user-123',
      email: 'test@example.com',
      kycStatus: 'NONE',
      isAdmin: false,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    userRepository.findById.mockResolvedValue(user);

    const result = await useCase.execute('user-123');

    expect(result).toEqual({
      userId: user.id,
      email: user.email,
      kycStatus: user.kycStatus,
      isAdmin: user.isAdmin,
      createdAt: user.createdAt,
      updatedAt: user.updatedAt,
    });
  });

  it('should throw error if user not found', async () => {
    userRepository.findById.mockResolvedValue(null);

    await expect(useCase.execute('user-123')).rejects.toThrow('User not found');
  });
});

Мониторинг

Метрики

// infrastructure/metrics/metrics.service.ts
import { Injectable } from '@nestjs/common';
import { Counter, Histogram, Gauge } from 'prom-client';

@Injectable()
export class MetricsService {
  private readonly operationsTotal = new Counter({
    name: 'auth_operations_total',
    help: 'Total number of auth operations',
    labelNames: ['operation', 'status'],
  });

  private readonly operationDuration = new Histogram({
    name: 'auth_operation_duration_seconds',
    help: 'Time spent on auth operations',
    labelNames: ['operation'],
    buckets: [0.1, 0.5, 1, 2, 5, 10, 30, 60],
  });

  private readonly activeUsers = new Gauge({
    name: 'auth_active_users',
    help: 'Number of active users',
  });

  incrementOperation(operation: string, status: string) {
    this.operationsTotal.inc({ operation, status });
  }

  recordOperationDuration(operation: string, duration: number) {
    this.operationDuration.observe({ operation }, duration);
  }

  setActiveUsers(count: number) {
    this.activeUsers.set(count);
  }
}

Логи

// infrastructure/logging/logger.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class LoggerService {
  private readonly context = 'AuthService';

  info(message: string, meta?: Record<string, any>) {
    console.log(`[${this.context}] [INFO] ${message}`, meta);
  }

  error(message: string, error?: Error, meta?: Record<string, any>) {
    console.error(`[${this.context}] [ERROR] ${message}`, error, meta);
  }

  warn(message: string, meta?: Record<string, any>) {
    console.warn(`[${this.context}] [WARN] ${message}`, meta);
  }

  debug(message: string, meta?: Record<string, any>) {
    console.debug(`[${this.context}] [DEBUG] ${message}`, meta);
  }
}

Troubleshooting

Проблема: Gateway не отвечает

Решение:

  1. Проверьте, что requestPathPrefix соответствует Ingress path
  2. Проверьте, что enableHttp: false в конфигурации
  3. Проверьте логи на "Initializing Connect RPC middleware at /prefix"

Проблема: Kafka события не публикуются

Решение:

  1. Проверьте, что Kafka брокеры доступны
  2. Проверьте, что topic существует
  3. Проверьте логи EventPublisherService

Проблема: База данных не подключается

Решение:

  1. Проверьте DATABASE_URL в переменных окружения
  2. Проверьте, что PostgreSQL запущен
  3. Проверьте логи DrizzleModule

Дополнительные ресурсы

On this page