Files
sfera-new/docs/business-processes/SUPPLY_DATA_SECURITY_RULES.md
Veronika Smirnova 12fd8ddf61 feat(supplier-orders): добавить параметры поставки в таблицу заявок
- Добавлены колонки Объём и Грузовые места между Цена товаров и Статус
- Реализованы инпуты для ввода volume и packagesCount в статусе PENDING для роли WHOLESALE
- Добавлена мутация UPDATE_SUPPLY_PARAMETERS с проверками безопасности
- Скрыта строка Поставщик для роли WHOLESALE (поставщик знает свои данные)
- Исправлено выравнивание таблицы при скрытии уровня поставщика
- Реорганизованы документы: legacy-rules/, docs/, docs-and-reports/

ВНИМАНИЕ: Компонент multilevel-supplies-table.tsx (1697 строк) нарушает правило модульной архитектуры (>800 строк требует рефакторинга)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 18:47:23 +03:00

670 lines
23 KiB
Markdown
Raw Permalink 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.

# ПРАВИЛА БЕЗОПАСНОСТИ ДАННЫХ В ПОСТАВКАХ SFERA
## 🎯 ОБЗОР
Система безопасности данных в поставках обеспечивает **коммерческую конфиденциальность** и **изоляцию данных** между участниками цепочки поставок: SELLER, WHOLESALE, FULFILLMENT, LOGIST.
### КЛЮЧЕВЫЕ ПРИНЦИПЫ:
1. **Принцип минимальных привилегий** - каждый участник видит только необходимые данные
2. **Коммерческая тайна** - защита закупочных цен и производственных секретов
3. **Изоляция данных** - участники не видят данные друг друга
4. **Аудит доступа** - логирование всех обращений к чувствительным данным
## 🔐 МАТРИЦА ДОСТУПА К ДАННЫМ
### СТРУКТУРА ДАННЫХ ПОСТАВКИ:
```typescript
interface SupplyOrder {
// Базовая информация (видна всем участникам)
id: string
status: SupplyOrderStatus
deliveryDate: Date
totalItems: number
// Коммерческая информация (ограниченный доступ)
productPrice: Decimal // Закупочная цена у поставщика
fulfillmentServicePrice: Decimal // Стоимость услуг ФФ
logisticsPrice: Decimal // Стоимость доставки
totalAmount: Decimal // Общая сумма
// Производственная информация (ограниченный доступ)
recipe: {
services: Service[] // Услуги ФФ
fulfillmentConsumables: Supply[] // Расходники ФФ
sellerConsumables: Supply[] // Расходники селлера
}
// Параметры поставки (опциональные)
packagesCount?: number // Количество грузовых мест
volume?: number // Объем груза в м³
readyDate?: Date // Дата готовности к отгрузке
notes?: string // Комментарии
}
```
### ТАБЛИЦА ДОСТУПА:
| Данные | SELLER | WHOLESALE | FULFILLMENT | LOGIST |
| ---------------------------------- | ------ | --------- | ----------- | ------ |
| **Базовая информация** | ✅ | ✅ | ✅ | ✅ |
| **productPrice** (закупочная цена) | ✅ | ✅ | ❌ | ❌ |
| **fulfillmentServicePrice** | ✅ | ❌ | ✅ | ❌ |
| **logisticsPrice** | ✅ | ❌ | ✅ | ✅ |
| **totalAmount для SELLER** | ✅ | ❌ | ❌ | ❌ |
| **totalAmount для FULFILLMENT** | ❌ | ❌ | ✅ | ❌ |
| **recipe (рецептура)** | ✅ | ❌ | ✅ | ❌ |
| **packagesCount, volume** | ✅ | ✅ | ✅ | ✅ |
| **Контакты других участников** | ❌ | ❌ | ❌ | ❌ |
## 📊 РАСЧЕТ СТОИМОСТЕЙ ПО РОЛЯМ
### ДЛЯ SELLER (полная стоимость):
```typescript
totalAmountForSeller =
productPrice + // Закупка у поставщика
fulfillmentServicePrice + // Услуги ФФ
logisticsPrice + // Доставка
fulfillmentConsumablesPrice + // Расходники ФФ
sellerConsumablesPrice // Свои расходники (price × quantity)
```
### ДЛЯ FULFILLMENT (без закупочных цен):
```typescript
totalAmountForFulfillment =
fulfillmentServicePrice + // Свои услуги
logisticsPrice + // Доставка (для планирования)
fulfillmentConsumablesPrice // Свои расходники
// НЕ ВИДИТ: productPrice, sellerConsumablesPrice
```
### ДЛЯ WHOLESALE (только свои товары):
```typescript
totalAmountForWholesale =
productPrice × quantity // Только стоимость своих товаров
// НЕ ВИДИТ: услуги ФФ, логистику, рецептуру
```
### ДЛЯ LOGIST (только доставка):
```typescript
totalAmountForLogist = logisticsPrice // Только стоимость доставки
// НЕ ВИДИТ: цены товаров, услуги, рецептуру
```
## 🛡️ РЕАЛИЗАЦИЯ БЕЗОПАСНОСТИ
### 1. ФИЛЬТРАЦИЯ НА УРОВНЕ RESOLVER
```typescript
// src/graphql/security/supply-data-filter.ts
export class SupplyDataFilter {
/**
* Фильтрует данные поставки в зависимости от роли пользователя
*/
static filterSupplyOrderByRole(order: SupplyOrder, userRole: OrganizationType, userId: string): FilteredSupplyOrder {
switch (userRole) {
case 'SELLER':
return this.filterForSeller(order, userId)
case 'WHOLESALE':
return this.filterForWholesale(order, userId)
case 'FULFILLMENT':
return this.filterForFulfillment(order, userId)
case 'LOGIST':
return this.filterForLogist(order, userId)
default:
throw new GraphQLError('Unauthorized organization type')
}
}
/**
* SELLER видит всю информацию по своим поставкам
*/
private static filterForSeller(order: SupplyOrder, userId: string): FilteredSupplyOrder {
// Проверка, что это поставка данного селлера
if (order.organizationId !== userId) {
throw new GraphQLError('Access denied to this supply order')
}
return {
...order,
// Селлер видит все данные своей поставки
}
}
/**
* WHOLESALE видит только свои товары без рецептуры
*/
private static filterForWholesale(order: SupplyOrder, userId: string): FilteredSupplyOrder {
// Фильтруем только позиции данного поставщика
const myItems = order.items.filter((item) => item.product.organizationId === userId)
if (myItems.length === 0) {
throw new GraphQLError('No items from your organization in this order')
}
return {
...order,
items: myItems.map((item) => ({
...item,
// Убираем рецептуру
recipe: null,
services: [],
fulfillmentConsumables: [],
sellerConsumables: [],
})),
// Скрываем общие суммы и услуги
totalAmount: null,
fulfillmentServicePrice: null,
logisticsPrice: null,
// Оставляем информацию об упаковке
packagesCount: order.packagesCount,
volume: order.volume,
}
}
/**
* FULFILLMENT видит рецептуру, но не видит закупочные цены
*/
private static filterForFulfillment(order: SupplyOrder, userId: string): FilteredSupplyOrder {
// Проверка, что поставка для данного ФФ
if (order.fulfillmentCenterId !== userId) {
throw new GraphQLError('Access denied to this supply order')
}
return {
...order,
items: order.items.map((item) => ({
...item,
// Скрываем закупочные цены
price: null,
productPrice: null,
// Оставляем рецептуру
recipe: item.recipe,
// Для расходников селлера показываем только ID и количество
sellerConsumables: item.sellerConsumables?.map((c) => ({
id: c.id,
name: c.name,
quantity: c.quantity,
// НЕ показываем цену
})),
})),
// Показываем только свою часть общей суммы
totalAmount: this.calculateFulfillmentTotal(order),
productPrice: null, // Скрыто
}
}
/**
* LOGIST видит только информацию о доставке
*/
private static filterForLogist(order: SupplyOrder, userId: string): FilteredSupplyOrder {
// Проверка, что логистика назначена на этот заказ
if (order.logisticsPartnerId !== userId) {
throw new GraphQLError('Access denied to this supply order')
}
return {
// Базовая информация
id: order.id,
status: order.status,
deliveryDate: order.deliveryDate,
// Информация о маршруте
routes: order.routes.map((route) => ({
from: route.from,
fromAddress: route.fromAddress,
to: route.to,
toAddress: route.toAddress,
// Только количество мест и объем
packagesCount: route.packagesCount,
volume: route.volume,
})),
// Только логистическая информация
logisticsPrice: order.logisticsPrice,
totalAmount: order.logisticsPrice, // Только своя сумма
// Скрываем все остальное
items: [],
recipe: null,
productPrice: null,
fulfillmentServicePrice: null,
}
}
/**
* Расчет суммы для фулфилмента
*/
private static calculateFulfillmentTotal(order: SupplyOrder): number {
return (
Number(order.fulfillmentServicePrice || 0) +
Number(order.logisticsPrice || 0) +
order.items.reduce((sum, item) => {
const consumablesPrice =
item.fulfillmentConsumables?.reduce((cSum, c) => cSum + c.pricePerUnit * c.quantity, 0) || 0
return sum + consumablesPrice
}, 0)
)
}
}
```
### 2. ИЗОЛЯЦИЯ ДАННЫХ МЕЖДУ УЧАСТНИКАМИ
```typescript
// src/graphql/security/participant-isolation.ts
export class ParticipantIsolation {
/**
* Проверяет, что селлеры не видят данные друг друга
*/
static async validateSellerIsolation(
prisma: PrismaClient,
currentUserId: string,
targetSellerId: string,
): Promise<boolean> {
// Селлер может видеть только свои данные
if (currentUserId !== targetSellerId) {
throw new GraphQLError('Access denied to other seller data')
}
return true
}
/**
* Проверяет доступ к данным через партнерство
*/
static async validatePartnerAccess(
prisma: PrismaClient,
organizationId: string,
partnerId: string,
): Promise<boolean> {
const partnership = await prisma.counterparty.findFirst({
where: {
OR: [
{
organizationId: organizationId,
counterpartyId: partnerId,
status: 'ACCEPTED',
},
{
organizationId: partnerId,
counterpartyId: organizationId,
status: 'ACCEPTED',
},
],
},
})
if (!partnership) {
throw new GraphQLError('No active partnership found')
}
return true
}
/**
* Группировка заказов для логистики с изоляцией селлеров
*/
static groupOrdersForLogistics(orders: SupplyOrder[]): GroupedLogisticsOrder[] {
// Группируем по маршрутам, скрывая информацию о селлерах
const grouped = orders.reduce(
(acc, order) => {
const routeKey = `${order.route.from}-${order.route.to}`
if (!acc[routeKey]) {
acc[routeKey] = {
route: {
from: order.route.from,
to: order.route.to,
},
orders: [],
totalPackages: 0,
totalVolume: 0,
}
}
// Добавляем заказ БЕЗ информации о селлере
acc[routeKey].orders.push({
id: order.id,
packagesCount: order.packagesCount || 0,
volume: order.volume || 0,
// НЕ добавляем: organizationId, sellerName и т.д.
})
acc[routeKey].totalPackages += order.packagesCount || 0
acc[routeKey].totalVolume += order.volume || 0
return acc
},
{} as Record<string, GroupedLogisticsOrder>,
)
return Object.values(grouped)
}
}
```
### 3. КОНТРОЛЬ ДОСТУПА К РЕЦЕПТУРЕ
```typescript
// src/graphql/security/recipe-access-control.ts
export class RecipeAccessControl {
/**
* Фильтрует рецептуру в зависимости от роли
*/
static filterRecipeByRole(
recipe: ProductRecipe,
userRole: OrganizationType,
userOrgId: string,
fulfillmentId?: string,
): FilteredRecipe | null {
switch (userRole) {
case 'SELLER':
// Селлер видит полную рецептуру
return recipe
case 'FULFILLMENT':
// ФФ видит рецептуру только если это его заказ
if (fulfillmentId === userOrgId) {
return {
services: recipe.services,
fulfillmentConsumables: recipe.fulfillmentConsumables.map((c) => ({
...c,
// Показываем pricePerUnit для расчета, НЕ закупочную цену
price: undefined,
pricePerUnit: c.pricePerUnit,
})),
sellerConsumables: recipe.sellerConsumables.map((c) => ({
id: c.id,
name: c.name,
quantity: c.quantity,
// НЕ показываем цены расходников селлера
})),
}
}
return null
case 'WHOLESALE':
case 'LOGIST':
// Поставщик и логистика НЕ видят рецептуру
return null
default:
return null
}
}
/**
* Проверяет доступ к услугам фулфилмента
*/
static async validateServiceAccess(
prisma: PrismaClient,
serviceIds: string[],
fulfillmentId: string,
): Promise<boolean> {
const services = await prisma.service.findMany({
where: {
id: { in: serviceIds },
organizationId: fulfillmentId,
},
})
if (services.length !== serviceIds.length) {
throw new GraphQLError('Some services do not belong to this fulfillment center')
}
return true
}
}
```
### 4. АУДИТ ДОСТУПА К КОММЕРЧЕСКИМ ДАННЫМ
```typescript
// src/graphql/security/commercial-data-audit.ts
export class CommercialDataAudit {
/**
* Логирует доступ к коммерческим данным
*/
static async logAccess(params: {
userId: string
organizationType: OrganizationType
accessType: 'VIEW_PRICE' | 'VIEW_RECIPE' | 'VIEW_CONTACTS'
resourceType: 'SUPPLY_ORDER' | 'PRODUCT' | 'SERVICE'
resourceId: string
metadata?: Record<string, any>
}): Promise<void> {
const { userId, organizationType, accessType, resourceType, resourceId, metadata } = params
// Критические типы доступа требующие особого внимания
const criticalAccess = [
'VIEW_PRICE', // Просмотр коммерческих цен
'VIEW_RECIPE', // Просмотр производственных секретов
]
if (criticalAccess.includes(accessType)) {
console.warn(
`🔐 CRITICAL DATA ACCESS: User ${userId} (${organizationType}) accessed ${accessType} for ${resourceType} ${resourceId}`,
)
}
// Сохраняем в базу данных
await prisma.auditLog.create({
data: {
userId,
organizationType,
action: `DATA_ACCESS:${accessType}`,
resourceType,
resourceId,
metadata: metadata || {},
ipAddress: metadata?.ipAddress,
userAgent: metadata?.userAgent,
timestamp: new Date(),
},
})
// Проверка на подозрительную активность
await this.checkSuspiciousActivity(userId, accessType)
}
/**
* Проверка подозрительной активности
*/
private static async checkSuspiciousActivity(userId: string, accessType: string): Promise<void> {
// Считаем количество обращений за последний час
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
const accessCount = await prisma.auditLog.count({
where: {
userId,
action: { contains: accessType },
timestamp: { gte: oneHourAgo },
},
})
// Пороги для разных типов доступа
const thresholds = {
VIEW_PRICE: 100, // Максимум 100 просмотров цен в час
VIEW_RECIPE: 50, // Максимум 50 просмотров рецептур в час
VIEW_CONTACTS: 200, // Максимум 200 просмотров контактов в час
}
if (accessCount > thresholds[accessType]) {
// Отправляем алерт администраторам
await this.sendSecurityAlert({
userId,
type: 'EXCESSIVE_DATA_ACCESS',
message: `User ${userId} exceeded ${accessType} threshold: ${accessCount} accesses in 1 hour`,
severity: 'HIGH',
})
}
}
/**
* Отправка алертов безопасности
*/
private static async sendSecurityAlert(alert: {
userId: string
type: string
message: string
severity: 'LOW' | 'MEDIUM' | 'HIGH'
}): Promise<void> {
console.error(`🚨 SECURITY ALERT [${alert.severity}]: ${alert.message}`)
// TODO: Интеграция с системой алертов (email, SMS, Slack)
// await notificationService.sendAlert(alert)
}
}
```
## 🔒 ПРАКТИЧЕСКИЕ ПРИМЕРЫ
### ПРИМЕР 1: Селлер создает поставку товаров
```typescript
// Селлер видит полную информацию
{
"id": "supply-001",
"status": "PENDING",
"items": [{
"product": { "name": "Товар A", "price": 1000 }, // ✅ Видит закупочную цену
"quantity": 10,
"recipe": { // ✅ Видит рецептуру
"services": ["Упаковка", "Маркировка"],
"fulfillmentConsumables": ["Пленка", "Скотч"],
"sellerConsumables": ["Этикетка бренда"]
}
}],
"totalAmount": 15000, // ✅ Видит полную сумму
"productPrice": 10000,
"fulfillmentServicePrice": 3000,
"logisticsPrice": 2000
}
```
### ПРИМЕР 2: Поставщик видит тот же заказ
```typescript
// Поставщик видит только свою часть
{
"id": "supply-001",
"status": "PENDING",
"deliveryDate": "2024-01-15",
"items": [{
"product": { "name": "Товар A", "price": 1000 }, // ✅ Видит свою цену
"quantity": 10
// ❌ НЕ видит recipe
}],
"packagesCount": 2, // ✅ Видит параметры поставки
"volume": 0.5,
// ❌ НЕ видит totalAmount, услуги ФФ, логистику
}
```
### ПРИМЕР 3: Фулфилмент видит тот же заказ
```typescript
// Фулфилмент видит рецептуру без закупочных цен
{
"id": "supply-001",
"status": "PENDING",
"items": [{
"product": { "name": "Товар A" }, // ❌ НЕ видит закупочную цену
"quantity": 10,
"recipe": { // ✅ Видит рецептуру
"services": ["Упаковка", "Маркировка"],
"fulfillmentConsumables": [{
"name": "Пленка",
"pricePerUnit": 50 // ✅ Видит свою цену расходника
}],
"sellerConsumables": [{
"name": "Этикетка бренда",
"quantity": 10
// ❌ НЕ видит цену расходников селлера
}]
}
}],
"totalAmount": 5000, // ✅ Только сумма услуг ФФ + логистика + расходники ФФ
"fulfillmentServicePrice": 3000,
"logisticsPrice": 2000
}
```
### ПРИМЕР 4: Логистика видит только доставку
```typescript
// Логистика видит минимум информации
{
"id": "supply-001",
"status": "LOGISTICS_CONFIRMED",
"routes": [{
"from": "Склад поставщика",
"fromAddress": "ул. Садовая, 1",
"to": "Фулфилмент центр",
"toAddress": "ул. Складская, 10",
"packagesCount": 2, // ✅ Видит количество мест
"volume": 0.5 // ✅ Видит объем
}],
"logisticsPrice": 2000, // ✅ Видит только свою стоимость
// ❌ НЕ видит товары, цены, рецептуру, участников
}
```
## ⚠️ КРИТИЧЕСКИЕ ПРАВИЛА БЕЗОПАСНОСТИ
### 1. НИКОГДА НЕ ПОКАЗЫВАТЬ:
- **Фулфилменту** - закупочные цены поставщика (`productPrice`)
- **Поставщику** - рецептуру и услуги фулфилмента
- **Логистике** - коммерческую информацию и рецептуру
- **Селлерам** - данные других селлеров
### 2. ВСЕГДА ПРОВЕРЯТЬ:
- Партнерские отношения перед доступом к данным
- Принадлежность заказа текущей организации
- Роль пользователя перед фильтрацией данных
- Подозрительную активность в логах
### 3. ОБЯЗАТЕЛЬНО ЛОГИРОВАТЬ:
- Все обращения к коммерческим данным
- Попытки несанкционированного доступа
- Массовые запросы данных
- Изменения критических полей
## 🛠️ IMPLEMENTATION CHECKLIST
- [ ] Реализовать `SupplyDataFilter` класс для фильтрации по ролям
- [ ] Добавить `ParticipantIsolation` для изоляции участников
- [ ] Внедрить `RecipeAccessControl` для контроля рецептур
- [ ] Настроить `CommercialDataAudit` для аудита
- [ ] Обновить GraphQL резолверы с новыми фильтрами
- [ ] Добавить тесты безопасности для каждой роли
- [ ] Настроить мониторинг и алерты
- [ ] Провести security review кода
## 📚 СВЯЗАННЫЕ ДОКУМЕНТЫ
- [SECURITY_PRACTICES.md](../infrastructure/SECURITY_PRACTICES.md) - Общие практики безопасности
- [SUPPLY_CHAIN_WORKFLOW.md](./SUPPLY_CHAIN_WORKFLOW.md) - Workflow поставок
- [GRAPHQL_SCHEMA_RULES.md](../api-layer/GRAPHQL_SCHEMA_RULES.md) - Правила GraphQL API
---
ата создания: 2025-08-22_
_Автор: Claude (Anthropic)_
_Критически важный документ для безопасности коммерческих данных_