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>
This commit is contained in:
516
docs/API_KEYS_IMPLEMENTATION_PLAN.md
Normal file
516
docs/API_KEYS_IMPLEMENTATION_PLAN.md
Normal file
@ -0,0 +1,516 @@
|
||||
# 🔐 ДЕТАЛЬНЫЙ ПЛАН РЕАЛИЗАЦИИ СИСТЕМЫ 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. **Фоновая валидация ключей раз в сутки**
|
Reference in New Issue
Block a user