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

517 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 🔐 ДЕТАЛЬНЫЙ ПЛАН РЕАЛИЗАЦИИ СИСТЕМЫ API КЛЮЧЕЙ
## 📋 ТРЕБОВАНИЯ
1. **Множественные ключи** - организация может иметь несколько API ключей для разных приложений/магазинов
2. **Шифрование** - все ключи хранятся зашифрованными
3. **Функциональность** - ключи должны работать без задержек на расшифровку
4. **Безопасный UI** - никогда не показывать реальные ключи в интерфейсе
5. **Аудит** - логирование всех операций с ключами
## 🏗️ АРХИТЕКТУРА
### 1. НОВАЯ СХЕМА БД
```prisma
// Основная таблица 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. СЕРВИС ШИФРОВАНИЯ
```typescript
// 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 КЛЮЧЕЙ
```typescript
// 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 КОМПОНЕНТ
```typescript
// 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 СХЕМА
```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. **Фоновая валидация ключей раз в сутки**