Files
sfera-new/docs/API_KEYS_IMPLEMENTATION_PLAN.md
Veronika Smirnova b6935428ab docs: добавить планы по API ключам и отладке статистики
- API_KEYS_IMPLEMENTATION_PLAN.md - план реализации системы API ключей
- API_KEYS_SECURITY_PLAN.md - план безопасности API ключей
- API_KEYS_SIMPLE_PLAN.md - упрощенный план API ключей
- DEBUG_SELLER_STATISTICS.md - отладка статистики селлеров
- FIX_API_KEYS_SAVING.md - исправление сохранения API ключей

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 21:31:27 +03:00

17 KiB
Raw Blame History

🔐 ДЕТАЛЬНЫЙ ПЛАН РЕАЛИЗАЦИИ СИСТЕМЫ API КЛЮЧЕЙ

📋 ТРЕБОВАНИЯ

  1. Множественные ключи - организация может иметь несколько API ключей для разных приложений/магазинов
  2. Шифрование - все ключи хранятся зашифрованными
  3. Функциональность - ключи должны работать без задержек на расшифровку
  4. Безопасный UI - никогда не показывать реальные ключи в интерфейсе
  5. Аудит - логирование всех операций с ключами

🏗️ АРХИТЕКТУРА

1. НОВАЯ СХЕМА БД

// Основная таблица API ключей
model ApiKey {
  id               String          @id @default(cuid())
  name             String          // Название ключа (например: "Основной магазин WB")
  marketplace      MarketplaceType
  encryptedKey     String          // Зашифрованный ключ
  encryptionIv     String          // Вектор инициализации
  encryptionTag    String          // Тег аутентификации
  keyFingerprint   String          // Последние 4 символа для идентификации
  isActive         Boolean         @default(true)
  isPrimary        Boolean         @default(false) // Основной ключ для маркетплейса

  // Метаданные
  clientId         String?         // Для Ozon
  validationData   Json?           // Результаты последней валидации
  lastValidatedAt  DateTime?
  lastUsedAt       DateTime?
  usageCount       Int             @default(0)

  // Связи
  organizationId   String
  organization     Organization    @relation(fields: [organizationId], references: [id])
  createdById      String
  createdBy        User           @relation("ApiKeyCreator", fields: [createdById], references: [id])

  // Аудит
  auditLogs        ApiKeyAudit[]

  // Временные метки
  createdAt        DateTime        @default(now())
  updatedAt        DateTime        @updatedAt
  expiresAt        DateTime?       // Опциональный срок действия

  @@index([organizationId, marketplace])
  @@index([keyFingerprint])
  @@map("api_keys")
}

// Аудит операций с ключами
model ApiKeyAudit {
  id          String       @id @default(cuid())
  action      ApiKeyAction
  performedAt DateTime     @default(now())
  performedBy String
  user        User         @relation(fields: [performedBy], references: [id])
  apiKeyId    String
  apiKey      ApiKey       @relation(fields: [apiKeyId], references: [id])

  // Контекст операции
  ipAddress   String?
  userAgent   String?
  metadata    Json?        // Дополнительные данные

  @@index([apiKeyId])
  @@index([performedBy])
  @@map("api_key_audit")
}

enum ApiKeyAction {
  CREATED
  UPDATED
  VALIDATED
  ACTIVATED
  DEACTIVATED
  DELETED
  USED_FOR_SYNC
  VALIDATION_FAILED
}

2. СЕРВИС ШИФРОВАНИЯ

// src/services/encryption-service.ts
import crypto from 'crypto'
import { LRUCache } from 'lru-cache'

export class EncryptionService {
  private algorithm = 'aes-256-gcm'
  private keyDerivationSalt: Buffer

  // Кэш для расшифрованных ключей (время жизни: 5 минут)
  private decryptedCache = new LRUCache<string, string>({
    max: 100,
    ttl: 1000 * 60 * 5, // 5 минут
    dispose: (value) => {
      // Очистка из памяти
      if (value) {
        crypto.randomFillSync(Buffer.from(value))
      }
    },
  })

  constructor() {
    const masterKey = process.env.ENCRYPTION_MASTER_KEY
    if (!masterKey) {
      throw new Error('ENCRYPTION_MASTER_KEY not set')
    }

    // Генерация ключа шифрования из мастер-ключа
    this.keyDerivationSalt = Buffer.from(process.env.ENCRYPTION_SALT || 'sfera-api-keys-salt')
  }

  private getDerivedKey(): Buffer {
    const masterKey = process.env.ENCRYPTION_MASTER_KEY!
    return crypto.pbkdf2Sync(masterKey, this.keyDerivationSalt, 10000, 32, 'sha256')
  }

  encrypt(plainText: string): EncryptedData {
    const key = this.getDerivedKey()
    const iv = crypto.randomBytes(16)
    const cipher = crypto.createCipheriv(this.algorithm, key, iv)

    let encrypted = cipher.update(plainText, 'utf8', 'hex')
    encrypted += cipher.final('hex')

    return {
      encrypted,
      iv: iv.toString('hex'),
      tag: cipher.getAuthTag().toString('hex'),
    }
  }

  decrypt(encryptedData: EncryptedData): string {
    // Проверяем кэш
    const cacheKey = `${encryptedData.encrypted}-${encryptedData.iv}`
    const cached = this.decryptedCache.get(cacheKey)
    if (cached) {
      return cached
    }

    const key = this.getDerivedKey()
    const decipher = crypto.createDecipheriv(this.algorithm, key, Buffer.from(encryptedData.iv, 'hex'))

    decipher.setAuthTag(Buffer.from(encryptedData.tag, 'hex'))

    let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8')
    decrypted += decipher.final('utf8')

    // Сохраняем в кэш
    this.decryptedCache.set(cacheKey, decrypted)

    return decrypted
  }

  // Получение отпечатка ключа (последние 4 символа)
  getFingerprint(apiKey: string): string {
    return apiKey.slice(-4)
  }

  // Генерация маски для UI
  generateMask(fingerprint: string): string {
    return `••••••••••••${fingerprint}`
  }
}

interface EncryptedData {
  encrypted: string
  iv: string
  tag: string
}

3. ОБНОВЛЕННЫЙ СЕРВИС API КЛЮЧЕЙ

// src/services/api-key-service.ts
export class ApiKeyService {
  constructor(
    private prisma: PrismaClient,
    private encryption: EncryptionService,
    private marketplace: MarketplaceService,
  ) {}

  async createApiKey(data: CreateApiKeyInput, userId: string): Promise<ApiKey> {
    // 1. Валидация ключа
    const validation = await this.marketplace.validateApiKey(data.marketplace, data.apiKey, data.clientId)

    if (!validation.isValid) {
      throw new Error(`Недействительный API ключ: ${validation.error}`)
    }

    // 2. Шифрование
    const encrypted = this.encryption.encrypt(data.apiKey)
    const fingerprint = this.encryption.getFingerprint(data.apiKey)

    // 3. Сохранение
    return this.prisma.$transaction(async (tx) => {
      // Если это первый ключ для маркетплейса - делаем его основным
      const existingCount = await tx.apiKey.count({
        where: {
          organizationId: data.organizationId,
          marketplace: data.marketplace,
          isActive: true,
        },
      })

      const apiKey = await tx.apiKey.create({
        data: {
          name: data.name,
          marketplace: data.marketplace,
          encryptedKey: encrypted.encrypted,
          encryptionIv: encrypted.iv,
          encryptionTag: encrypted.tag,
          keyFingerprint: fingerprint,
          clientId: data.clientId,
          isPrimary: existingCount === 0,
          organizationId: data.organizationId,
          createdById: userId,
          validationData: validation.data,
          lastValidatedAt: new Date(),
        },
      })

      // 4. Аудит
      await tx.apiKeyAudit.create({
        data: {
          action: 'CREATED',
          apiKeyId: apiKey.id,
          performedBy: userId,
          metadata: {
            name: data.name,
            marketplace: data.marketplace,
          },
        },
      })

      return apiKey
    })
  }

  async getDecryptedKey(apiKeyId: string, userId: string): Promise<string> {
    const apiKey = await this.prisma.apiKey.findUnique({
      where: { id: apiKeyId },
    })

    if (!apiKey) {
      throw new Error('API ключ не найден')
    }

    // Аудит использования
    await this.prisma.apiKeyAudit.create({
      data: {
        action: 'USED_FOR_SYNC',
        apiKeyId: apiKey.id,
        performedBy: userId,
      },
    })

    // Обновляем статистику
    await this.prisma.apiKey.update({
      where: { id: apiKeyId },
      data: {
        lastUsedAt: new Date(),
        usageCount: { increment: 1 },
      },
    })

    // Расшифровка
    return this.encryption.decrypt({
      encrypted: apiKey.encryptedKey,
      iv: apiKey.encryptionIv,
      tag: apiKey.encryptionTag,
    })
  }

  async rotateKey(apiKeyId: string, newApiKey: string, userId: string): Promise<void> {
    // Валидация нового ключа
    const apiKey = await this.prisma.apiKey.findUnique({
      where: { id: apiKeyId },
    })

    if (!apiKey) throw new Error('API ключ не найден')

    const validation = await this.marketplace.validateApiKey(apiKey.marketplace, newApiKey, apiKey.clientId)

    if (!validation.isValid) {
      throw new Error('Новый ключ недействителен')
    }

    // Шифрование нового ключа
    const encrypted = this.encryption.encrypt(newApiKey)
    const fingerprint = this.encryption.getFingerprint(newApiKey)

    // Обновление
    await this.prisma.$transaction(async (tx) => {
      await tx.apiKey.update({
        where: { id: apiKeyId },
        data: {
          encryptedKey: encrypted.encrypted,
          encryptionIv: encrypted.iv,
          encryptionTag: encrypted.tag,
          keyFingerprint: fingerprint,
          lastValidatedAt: new Date(),
        },
      })

      await tx.apiKeyAudit.create({
        data: {
          action: 'UPDATED',
          apiKeyId: apiKeyId,
          performedBy: userId,
          metadata: { reason: 'key_rotation' },
        },
      })
    })

    // Очистка кэша
    this.encryption.clearCache()
  }
}

4. ОБНОВЛЕННЫЙ UI КОМПОНЕНТ

// src/components/settings/ApiKeysTab.tsx
export function ApiKeysTab() {
  const [apiKeys, setApiKeys] = useState<ApiKeyDisplay[]>([])
  const [showAddModal, setShowAddModal] = useState(false)
  const [editingKey, setEditingKey] = useState<string | null>(null)

  return (
    <div className="space-y-6">
      {/* Заголовок с кнопкой добавления */}
      <div className="flex justify-between items-center">
        <h3 className="text-lg font-semibold text-white">API Ключи</h3>
        <Button onClick={() => setShowAddModal(true)}>
          <Plus className="h-4 w-4 mr-2" />
          Добавить ключ
        </Button>
      </div>

      {/* Список ключей */}
      <div className="space-y-4">
        {apiKeys.map((apiKey) => (
          <Card key={apiKey.id} className="glass-card p-4">
            <div className="flex items-center justify-between">
              <div className="flex-1">
                <div className="flex items-center gap-3">
                  <h4 className="font-medium text-white">{apiKey.name}</h4>
                  <Badge variant={apiKey.isPrimary ? 'default' : 'secondary'}>
                    {apiKey.isPrimary ? 'Основной' : 'Дополнительный'}
                  </Badge>
                  <Badge variant={apiKey.isActive ? 'success' : 'destructive'}>
                    {apiKey.isActive ? 'Активен' : 'Неактивен'}
                  </Badge>
                </div>

                <div className="mt-2 space-y-1">
                  <p className="text-sm text-white/60">
                    Маркетплейс: {apiKey.marketplace}
                  </p>
                  <p className="text-sm text-white/60">
                    Ключ: {apiKey.maskedKey}
                  </p>
                  {apiKey.lastUsedAt && (
                    <p className="text-sm text-white/60">
                      Использован: {formatDate(apiKey.lastUsedAt)}
                    </p>
                  )}
                </div>
              </div>

              <div className="flex items-center gap-2">
                {editingKey === apiKey.id ? (
                  <RotateKeyForm
                    apiKeyId={apiKey.id}
                    onSuccess={() => setEditingKey(null)}
                    onCancel={() => setEditingKey(null)}
                  />
                ) : (
                  <>
                    <Button
                      variant="ghost"
                      size="sm"
                      onClick={() => setEditingKey(apiKey.id)}
                    >
                      <RefreshCw className="h-4 w-4" />
                    </Button>
                    <Button
                      variant="ghost"
                      size="sm"
                      onClick={() => toggleKeyStatus(apiKey.id)}
                    >
                      {apiKey.isActive ?
                        <ToggleLeft className="h-4 w-4" /> :
                        <ToggleRight className="h-4 w-4" />
                      }
                    </Button>
                  </>
                )}
              </div>
            </div>
          </Card>
        ))}
      </div>

      {/* Модальное окно добавления */}
      {showAddModal && (
        <AddApiKeyModal
          onClose={() => setShowAddModal(false)}
          onSuccess={() => {
            setShowAddModal(false)
            refetchApiKeys()
          }}
        />
      )}
    </div>
  )
}

5. GRAPHQL СХЕМА

type ApiKey {
  id: ID!
  name: String!
  marketplace: MarketplaceType!
  maskedKey: String! # Только маска ••••••••1234
  isActive: Boolean!
  isPrimary: Boolean!
  lastUsedAt: DateTime
  lastValidatedAt: DateTime
  createdAt: DateTime!
  usageCount: Int!
}

input CreateApiKeyInput {
  name: String!
  marketplace: MarketplaceType!
  apiKey: String!
  clientId: String # Для Ozon
}

type Mutation {
  createApiKey(input: CreateApiKeyInput!): ApiKeyResponse!
  updateApiKey(id: ID!, newKey: String!): ApiKeyResponse!
  toggleApiKeyStatus(id: ID!): ApiKeyResponse!
  deleteApiKey(id: ID!): Boolean!
  setPrimaryApiKey(id: ID!): ApiKeyResponse!
}

type Query {
  myApiKeys(marketplace: MarketplaceType): [ApiKey!]!
  validateApiKey(marketplace: MarketplaceType!, apiKey: String!): ValidationResponse!
}

📋 ПОШАГОВЫЙ ПЛАН РЕАЛИЗАЦИИ

ЭТАП 1: Подготовка (1 день)

  1. Создать миграцию БД с новой схемой
  2. Настроить переменные окружения для шифрования
  3. Создать EncryptionService
  4. Написать тесты для шифрования

ЭТАП 2: Бэкенд (2-3 дня)

  1. Создать ApiKeyService
  2. Обновить GraphQL схему
  3. Создать резолверы для API ключей
  4. Добавить middleware для аудита
  5. Миграция существующих ключей

ЭТАП 3: Интеграция (2 дня)

  1. Обновить WildberriesService для работы с новой системой
  2. Обновить статистику селлеров
  3. Добавить автоматическую ротацию ключей
  4. Настроить мониторинг использования

ЭТАП 4: UI (2 дня)

  1. Создать компонент ApiKeysTab
  2. Добавить модалки для добавления/редактирования
  3. Интегрировать в user-settings
  4. Добавить уведомления

ЭТАП 5: Тестирование (1 день)

  1. E2E тесты
  2. Тесты безопасности
  3. Нагрузочное тестирование

🔒 МЕРЫ БЕЗОПАСНОСТИ

  1. Никогда не логировать расшифрованные ключи
  2. Автоматическая очистка кэша каждые 5 минут
  3. Принудительная ротация ключей каждые 90 дней
  4. Алерты при подозрительной активности
  5. Ограничение количества ключей на организацию

ОПТИМИЗАЦИИ

  1. LRU кэш для расшифрованных ключей (5 минут TTL)
  2. Индексы БД по organizationId и marketplace
  3. Batch загрузка ключей для статистики
  4. Фоновая валидация ключей раз в сутки