# ПРАВИЛА БЕЗОПАСНОСТИ ДАННЫХ В ПОСТАВКАХ 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 { // Селлер может видеть только свои данные if (currentUserId !== targetSellerId) { throw new GraphQLError('Access denied to other seller data') } return true } /** * Проверяет доступ к данным через партнерство */ static async validatePartnerAccess( prisma: PrismaClient, organizationId: string, partnerId: string, ): Promise { 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, ) 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 { 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 }): Promise { 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 { // Считаем количество обращений за последний час 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 { 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)_ _Критически важный документ для безопасности коммерческих данных_