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

23 KiB
Raw Permalink Blame History

ПРАВИЛА БЕЗОПАСНОСТИ ДАННЫХ В ПОСТАВКАХ SFERA

🎯 ОБЗОР

Система безопасности данных в поставках обеспечивает коммерческую конфиденциальность и изоляцию данных между участниками цепочки поставок: SELLER, WHOLESALE, FULFILLMENT, LOGIST.

КЛЮЧЕВЫЕ ПРИНЦИПЫ:

  1. Принцип минимальных привилегий - каждый участник видит только необходимые данные
  2. Коммерческая тайна - защита закупочных цен и производственных секретов
  3. Изоляция данных - участники не видят данные друг друга
  4. Аудит доступа - логирование всех обращений к чувствительным данным

🔐 МАТРИЦА ДОСТУПА К ДАННЫМ

СТРУКТУРА ДАННЫХ ПОСТАВКИ:

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 (полная стоимость):

totalAmountForSeller =
  productPrice + // Закупка у поставщика
  fulfillmentServicePrice + // Услуги ФФ
  logisticsPrice + // Доставка
  fulfillmentConsumablesPrice + // Расходники ФФ
  sellerConsumablesPrice // Свои расходники (price × quantity)

ДЛЯ FULFILLMENT (без закупочных цен):

totalAmountForFulfillment =
  fulfillmentServicePrice + // Свои услуги
  logisticsPrice + // Доставка (для планирования)
  fulfillmentConsumablesPrice // Свои расходники
// НЕ ВИДИТ: productPrice, sellerConsumablesPrice

ДЛЯ WHOLESALE (только свои товары):

totalAmountForWholesale =
  productPrice × quantity           // Только стоимость своих товаров
  // НЕ ВИДИТ: услуги ФФ, логистику, рецептуру

ДЛЯ LOGIST (только доставка):

totalAmountForLogist = logisticsPrice // Только стоимость доставки
// НЕ ВИДИТ: цены товаров, услуги, рецептуру

🛡️ РЕАЛИЗАЦИЯ БЕЗОПАСНОСТИ

1. ФИЛЬТРАЦИЯ НА УРОВНЕ RESOLVER

// 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. ИЗОЛЯЦИЯ ДАННЫХ МЕЖДУ УЧАСТНИКАМИ

// 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. КОНТРОЛЬ ДОСТУПА К РЕЦЕПТУРЕ

// 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. АУДИТ ДОСТУПА К КОММЕРЧЕСКИМ ДАННЫМ

// 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: Селлер создает поставку товаров

// Селлер видит полную информацию
{
  "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: Поставщик видит тот же заказ

// Поставщик видит только свою часть
{
  "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: Фулфилмент видит тот же заказ

// Фулфилмент видит рецептуру без закупочных цен
{
  "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: Логистика видит только доставку

// Логистика видит минимум информации
{
  "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 кода

📚 СВЯЗАННЫЕ ДОКУМЕНТЫ


Дата создания: 2025-08-22
Автор: Claude (Anthropic)
Критически важный документ для безопасности коммерческих данных