
Implemented comprehensive data security infrastructure for SFERA platform: ## Security Classes Created: - `SupplyDataFilter`: Role-based data filtering for supply orders - `ParticipantIsolation`: Data isolation between competing organizations - `RecipeAccessControl`: Protection of production recipes and trade secrets - `CommercialDataAudit`: Audit logging and suspicious activity detection - `SecurityLogger`: Centralized security event logging system ## Infrastructure Components: - Feature flags system for gradual security rollout - Database migrations for audit logging (AuditLog, SecurityAlert models) - Secure resolver wrapper for automatic GraphQL security - TypeScript interfaces and type safety throughout ## Security Features: - Role-based access control (SELLER, WHOLESALE, FULFILLMENT, LOGIST) - Commercial data protection between competitors - Production recipe confidentiality - Audit trail for all data access - Real-time security monitoring and alerts - Rate limiting and suspicious activity detection ## Implementation Notes: - All console logging replaced with centralized security logger - Comprehensive TypeScript typing with no explicit 'any' types - Modular architecture following SFERA coding standards - Feature flag controlled rollout for safe deployment This completes Phase 1 of the security implementation plan. Next phases will integrate these classes into existing GraphQL resolvers. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
27 KiB
27 KiB
ПРАВИЛА GRAPHQL СХЕМЫ СИСТЕМЫ SFERA
🎯 ОБЩИЕ ПРИНЦИПЫ СХЕМЫ
1. ТИПОБЕЗОПАСНОСТЬ
- Строгая типизация: Все поля должны иметь четко определенный тип
- Обязательные поля: Использование
!
для критичных данных - Nullable поля: Явное указание опциональности без
!
2. КОНСИСТЕНТНОСТЬ ИМЕНОВАНИЯ
// ✅ Правильное именование
type Organization {
id: ID! // Всегда ID! для идентификаторов
name: String // Nullable для опциональных данных
createdAt: DateTime! // Обязательные временные метки
}
// ❌ Неправильное именование
type organization { ... } // Должно быть PascalCase
type User {
user_id: String // Должно быть camelCase: userId
}
📋 ОСНОВНЫЕ ENUMS СИСТЕМЫ
OrganizationType (Типы организаций)
enum OrganizationType {
FULFILLMENT # Фулфилмент-центры
SELLER # Селлеры (продавцы)
LOGIST # Логистические компании
WHOLESALE # Поставщики (оптовики)
}
Правила использования:
- ✅ Обязательное поле в модели Organization
- ✅ Определяет доступные функции в UI
- ✅ Используется для фильтрации в поиске контрагентов
- ❌ Нельзя изменить тип существующей организации
SupplyOrderStatus (Статусы поставок)
enum SupplyOrderStatus {
PENDING # Ожидает одобрения поставщика
SUPPLIER_APPROVED # Поставщик одобрил, ждет логистику
LOGISTICS_CONFIRMED # Логистика подтвердила, ждет отправки
SHIPPED # Отправлено поставщиком
DELIVERED # Доставлено и принято
CANCELLED # Отменено любым участником
# Legacy статусы (обратная совместимость):
CONFIRMED # → SUPPLIER_APPROVED
IN_TRANSIT # → SHIPPED
}
Правила переходов:
- ✅ Только последовательные переходы
- ✅ Любой статус → CANCELLED
- ❌ Возврат к предыдущим статусам
- ❌ Пропуск промежуточных статусов
CounterpartyRequestStatus (Статусы заявок на партнерство)
enum CounterpartyRequestStatus {
PENDING # Отправлена, ждет ответа
ACCEPTED # Принята - партнерство активно
REJECTED # Отклонена
CANCELLED # Отменена отправителем
}
MarketplaceType (Поддерживаемые маркетплейсы)
enum MarketplaceType {
WILDBERRIES # WB API интеграция
OZON # Ozon API интеграция
}
🔍 ПРАВИЛА QUERY ОПЕРАЦИЙ
1. ПОИСК И ФИЛЬТРАЦИЯ
# ✅ Правильная структура поиска
searchOrganizations(
type: OrganizationType # Фильтр по типу организации
search: String # Текстовый поиск по имени/ИНН
): [Organization!]!
# ✅ Пагинация для больших списков
messages(
counterpartyId: ID!
limit: Int # Лимит записей
offset: Int # Смещение
): [Message!]!
Реальная реализация поиска организаций:
// Из src/graphql/resolvers.ts
searchOrganizations: async (_: unknown, args: { type?: string; search?: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
// Получаем текущую организацию пользователя
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
// Строим фильтры поиска
const where: Prisma.OrganizationWhereInput = {
id: { not: currentUser.organization.id }, // Исключаем себя из результатов
}
// Фильтр по типу организации
if (args.type) {
where.type = args.type as OrganizationType
}
// Текстовый поиск по имени/ИНН
if (args.search) {
where.OR = [
{ name: { contains: args.search, mode: 'insensitive' } },
{ fullName: { contains: args.search, mode: 'insensitive' } },
{ inn: { contains: args.search, mode: 'insensitive' } },
]
}
return await prisma.organization.findMany({
where,
take: 20, // Лимит результатов для производительности
orderBy: { createdAt: 'desc' },
})
}
2. ПРАВА ДОСТУПА В QUERIES
# ✅ Доступ к своим данным
mySupplies: [Supply!]! # Только мои расходники
myServices: [Service!]! # Только мои услуги
myCounterparties: [Organization!]! # Только мои контрагенты
# ✅ Доступ к данным контрагентов (с проверкой партнерства)
counterpartyServices(organizationId: ID!): [Service!]!
organizationProducts(organizationId: ID!): [Product!]!
# ✅ Публичные данные (без ограничений)
allProducts: [Product!]!
categories: [Category!]!
3. АГРЕГИРОВАННЫЕ ДАННЫЕ
# ✅ Счетчики для dashboard
type PendingSuppliesCount {
incomingSupplierOrders: Int! # Для поставщиков
logisticsOrders: Int! # Для логистики
ourSupplyOrders: Int! # Для фулфилмента
sellerSupplyOrders: Int! # Заказы от селлеров
}
# ✅ Иерархические данные
type WarehouseDataResponse {
partners: [WarehousePartner!]! # 3-уровневая структура
}
Реальная реализация счетчиков (из pendingSuppliesCount):
// Динамические счетчики в зависимости от типа организации
pendingSuppliesCount: async (_: unknown, __: unknown, context: Context) => {
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
})
// 🔔 ВХОДЯЩИЕ ЗАКАЗЫ ДЛЯ ПОСТАВЩИКОВ (WHOLESALE)
const incomingSupplierOrders = await prisma.supplyOrder.count({
where: {
partnerId: currentUser.organization.id, // Мы - поставщик
status: 'PENDING', // Ожидает подтверждения от поставщика
},
})
// 🚚 ЛОГИСТИЧЕСКИЕ ЗАЯВКИ ДЛЯ ЛОГИСТИКИ (LOGIST)
const logisticsOrders = await prisma.supplyOrder.count({
where: {
logisticsPartnerId: currentUser.organization.id, // Мы - назначенная логистика
status: {
in: [
'CONFIRMED', // Legacy: подтверждено ФФ
'SUPPLIER_APPROVED', // Подтверждено поставщиком
'LOGISTICS_CONFIRMED', // Подтверждено логистикой - нужно забрать товар
],
},
},
})
// 🏭 ЗАКАЗЫ ДЛЯ ФУЛФИЛМЕНТА
const ourSupplyOrders = await prisma.supplyOrder.count({
where: {
organizationId: currentUser.organization.id, // Наши собственные заказы
status: { notIn: ['DELIVERED', 'CANCELLED'] },
},
})
const sellerSupplyOrders = await prisma.supplyOrder.count({
where: {
fulfillmentCenterId: currentUser.organization.id, // Мы - получатели
status: { notIn: ['DELIVERED', 'CANCELLED'] },
},
})
// Определяем приоритетный счетчик по типу организации
let pendingSupplyOrders = 0
if (currentUser.organization.type === 'FULFILLMENT') {
pendingSupplyOrders = ourSupplyOrders + sellerSupplyOrders
} else if (currentUser.organization.type === 'WHOLESALE') {
pendingSupplyOrders = incomingSupplierOrders
} else if (currentUser.organization.type === 'LOGIST') {
pendingSupplyOrders = logisticsOrders
}
return {
incomingSupplierOrders,
logisticsOrders,
ourSupplyOrders,
sellerSupplyOrders,
pendingSupplyOrders, // Главный счетчик для UI
}
}
🔄 ПРАВИЛА MUTATION ОПЕРАЦИЙ
1. СТРУКТУРА INPUT ТИПОВ
# ✅ Консистентное именование Input типов
input CreateEmployeeInput {
name: String!
position: String!
salary: Float
# Обязательные поля с !, опциональные без
}
input UpdateEmployeeInput {
name: String # В Update все поля опциональны
position: String
salary: Float
}
2. RESPONSE ТИПЫ ДЛЯ МУТАЦИЙ
# ✅ Стандартная структура Response
type EmployeeResponse {
success: Boolean!
message: String
employee: Employee # Данные при успехе
errors: [String!] # Ошибки валидации
}
3. ПРАВИЛА АВТОРИЗАЦИИ В МУТАЦИЯХ
# ✅ Мутации требующие авторизации
createSupplyOrder(input: SupplyOrderInput!): SupplyOrderResponse!
# ✅ Мутации для конкретных ролей
updateProductInWarehouse(...): Product! # Только FULFILLMENT
approveSupplyOrder(...): SupplyOrder! # Только WHOLESALE
# ✅ Административные мутации
adminLogin(username: String!, password: String!): AdminAuthResponse!
Реальная реализация createSupplyOrder с валидацией:
// Из src/graphql/resolvers.ts
createSupplyOrder: async (
_: unknown,
args: {
input: {
partnerId: string
deliveryDate: string
fulfillmentCenterId?: string // ID фулфилмент-центра для доставки
logisticsPartnerId?: string // ID логистической компании
items: Array<{
productId: string
quantity: number
recipe?: {
services?: string[]
fulfillmentConsumables?: string[]
sellerConsumables?: string[]
marketplaceCardId?: string
}
}>
}
},
context: Context,
) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
// Проверка прав доступа по типу организации
const allowedTypes = ['FULFILLMENT', 'SELLER', 'LOGIST']
if (!allowedTypes.includes(currentUser.organization.type)) {
throw new GraphQLError('Заказы поставок недоступны для данного типа организации')
}
// Проверка существования поставщика
const partner = await prisma.organization.findUnique({
where: { id: args.input.partnerId },
})
if (!partner || partner.type !== 'WHOLESALE') {
throw new GraphQLError('Поставщик не найден или некорректный тип')
}
// Вычисляем общую стоимость и количество товаров
let totalAmount = 0
let totalItems = 0
for (const item of args.input.items) {
const product = await prisma.product.findUnique({
where: { id: item.productId },
})
if (!product) {
throw new GraphQLError(`Товар с ID ${item.productId} не найден`)
}
totalAmount += Number(product.price) * item.quantity
totalItems += item.quantity
}
// Создаем заказ поставки
const supplyOrder = await prisma.supplyOrder.create({
data: {
organizationId: currentUser.organization.id,
partnerId: args.input.partnerId,
fulfillmentCenterId: args.input.fulfillmentCenterId,
logisticsPartnerId: args.input.logisticsPartnerId,
deliveryDate: new Date(args.input.deliveryDate),
totalAmount: totalAmount,
totalItems: totalItems,
status: 'PENDING', // Начальный статус
// Создаем позиции заказа
items: {
create: args.input.items.map((item) => ({
productId: item.productId,
quantity: item.quantity,
price: product.price,
totalPrice: Number(product.price) * item.quantity,
// Сохраняем рецептуру если есть
services: item.recipe?.services || [],
fulfillmentConsumables: item.recipe?.fulfillmentConsumables || [],
sellerConsumables: item.recipe?.sellerConsumables || [],
marketplaceCardId: item.recipe?.marketplaceCardId,
})),
},
},
include: {
partner: true,
items: true,
},
})
return {
success: true,
message: 'Заказ поставки успешно создан',
order: supplyOrder,
}
}
📊 ПРАВИЛА ТИПОВ ДАННЫХ
1. ОСНОВНЫЕ СКАЛЯРЫ
scalar DateTime # ISO 8601 формат
scalar JSON # Гибкие данные (phones, emails, etc.)
# ✅ Использование
type Organization {
createdAt: DateTime! # Всегда обязательно
phones: JSON # Массив телефонов
validationData: JSON # Данные валидации API
}
Реальная реализация custom скаляров:
// DateTime скаляр для работы с датами (из src/graphql/resolvers.ts)
const DateTimeScalar = new GraphQLScalarType({
name: 'DateTime',
description: 'DateTime custom scalar type',
// Сериализация: Date → ISO string (для клиента)
serialize(value: unknown) {
if (value instanceof Date) {
return value.toISOString() // 2025-08-21T15:30:00.000Z
}
return value
},
// Парсинг: ISO string → Date (от клиента)
parseValue(value: unknown) {
if (typeof value === 'string') {
return new Date(value) // Парсим ISO строку в Date
}
throw new GraphQLError('Invalid DateTime format')
},
// Парсинг литералов в запросах
parseLiteral(ast) {
if (ast.kind === Kind.STRING) {
return new Date(ast.value)
}
throw new GraphQLError('Invalid DateTime literal')
},
})
// JSON скаляр для гибких данных
const JSONScalar = new GraphQLScalarType({
name: 'JSON',
description: 'JSON custom scalar type',
serialize(value: unknown) {
return value // JSON как есть
},
parseValue(value: unknown) {
return value // Принимаем любые JSON данные
},
parseLiteral(ast) {
switch (ast.kind) {
case Kind.STRING:
case Kind.BOOLEAN:
return ast.value
case Kind.INT:
case Kind.FLOAT:
return Number(ast.value)
case Kind.OBJECT:
// Рекурсивный парсинг объектов
const obj: Record<string, unknown> = {}
ast.fields.forEach((field) => {
obj[field.name.value] = this.parseLiteral(field.value)
})
return obj
case Kind.LIST:
return ast.values.map((value) => this.parseLiteral(value))
case Kind.NULL:
return null
default:
throw new GraphQLError(`Unexpected kind: ${ast.kind}`)
}
},
})
2. СТРУКТУРА ОСНОВНЫХ ТИПОВ
User (Пользователь)
type User {
id: ID!
phone: String! # Уникальный идентификатор
avatar: String # Аватар (опционально)
managerName: String # Имя менеджера
organization: Organization # Связь с организацией
createdAt: DateTime!
updatedAt: DateTime!
}
Organization (Организация)
type Organization {
# Обязательные поля
id: ID!
inn: String!
type: OrganizationType!
# Реквизиты (могут быть пустыми)
name: String
fullName: String
address: String
# Связанные данные
users: [User!]!
apiKeys: [ApiKey!]!
services: [Service!]!
supplies: [Supply!]!
# Партнерство
isCounterparty: Boolean
hasOutgoingRequest: Boolean
hasIncomingRequest: Boolean
# Реферальная система
referralCode: String
referralPoints: Int!
# Marketplace данные
market: String # Физический рынок (для WHOLESALE)
# Временные метки
createdAt: DateTime!
updatedAt: DateTime!
}
🏪 СПЕЦИФИЧНЫЕ ПРАВИЛА ДЛЯ ПОСТАВЩИКОВ (WHOLESALE)
ЗАПРОСЫ ПОСТАВЩИКОВ:
# Получение товаров поставщика
query GetMyProducts {
myProducts {
id
name
article
price
quantity
organization {
id
name
market # Физический рынок поставщика
}
}
}
# Получение входящих заказов поставщика
query GetSupplierOrders($status: SupplyOrderStatus) {
supplyOrders(where: { partnerId: $myOrgId, status: $status }) {
id
status
totalAmount
deliveryDate
organization {
name
inn
} # Заказчик
fulfillmentCenter {
name
address
} # Получатель
items {
id
quantity
price
totalPrice
product {
id
name
article
}
}
}
}
# Получение партнеров поставщика
query GetMyCounterparties($type: OrganizationType) {
myCounterparties(type: $type) {
id
name
type
market
fullName
inn
isCounterparty
hasOutgoingRequest
hasIncomingRequest
}
}
МУТАЦИИ ПОСТАВЩИКОВ:
# Одобрение заказа поставщиком с опциональными полями упаковки
mutation SupplierApproveOrder(
$orderId: ID!
$packagesCount: Int
$volume: Float
$readyDate: DateTime
$notes: String
) {
supplierApproveOrder(
id: $orderId
packagesCount: $packagesCount # Опционально: для логистических расчетов
volume: $volume # Опционально: для планирования логистики
readyDate: $readyDate # Опционально: дата готовности к отгрузке
notes: $notes # Опционально: комментарии
) {
success
message
order {
id
status # PENDING → SUPPLIER_APPROVED
organization {
id
name
}
totalAmount
packagesCount # null если не указано
volume # null если не указано
readyDate # null если не указано
notes # null если не указано
}
}
}
# Отклонение заказа поставщиком
mutation SupplierRejectOrder($orderId: ID!, $reason: String) {
supplierRejectOrder(id: $orderId, reason: $reason) {
success
message
order {
id
status # PENDING → CANCELLED
}
}
}
# Отгрузка товара поставщиком
mutation SupplierShipOrder($orderId: ID!) {
supplierShipOrder(id: $orderId) {
success
message
order {
id
status # LOGISTICS_CONFIRMED → SHIPPED
organization {
id
name
}
logisticsPartner {
id
name
}
}
}
}
# Создание товара поставщиком
mutation CreateProduct($input: ProductInput!) {
createProduct(input: $input) {
success
message
product {
id
article
name
price
organization {
id
name
}
}
}
}
ПРАВИЛА АВТОРИЗАЦИИ ПОСТАВЩИКОВ:
// Resolver-level security для поставщиков
const wholesaleResolvers = {
// Проверка что пользователь - поставщик
validateWholesaleAccess: (context) => {
if (context.user.organization.type !== 'WHOLESALE') {
throw new GraphQLError('Access denied: Wholesale access required')
}
},
// Фильтрация заказов для поставщика
getSupplierOrders: async (parent, args, context) => {
// Поставщик видит только заказы где он является поставщиком
return await prisma.supplyOrder.findMany({
where: {
partnerId: context.user.organization.id, // Мы - поставщик
...args.where,
},
})
},
// Проверка доступа к товарам
validateProductAccess: async (productId, context) => {
const product = await prisma.product.findFirst({
where: {
id: productId,
organizationId: context.user.organizationId, // Только свои товары
},
})
if (!product) {
throw new GraphQLError('Product not found or access denied')
}
return product
},
}
КРИТИЧЕСКИЕ ПРАВИЛА ПАРТНЕРСТВА:
// ✅ ПРАВИЛЬНО: Поставщики берутся ТОЛЬКО из партнеров
const getWholesalePartners = `
query GetMyCounterparties {
myCounterparties(type: WHOLESALE) {
id, name, fullName, inn, market
isCounterparty # Должно быть true
}
}
`;
// ❌ НЕПРАВИЛЬНО: Прямой запрос всех поставщиков
const wrongSupplierQuery = `
query GetAllSuppliers {
organizations(type: WHOLESALE) { # Неправильно - нет проверки партнерства
id, name
}
}
`;
// Правильная фильтрация в резолвере:
const correctPartnershipFilter = `
// Показываем только организации-партнеры
const counterparties = await prisma.counterparty.findMany({
where: {
initiatorId: currentUser.organization.id,
status: 'ACCEPTED',
partner: { type: 'WHOLESALE' }
},
include: { partner: true }
})
`;
# Временные метки (обязательно)
createdAt: DateTime!
updatedAt: DateTime!
}
🔐 ПРАВИЛА БЕЗОПАСНОСТИ В СХЕМЕ
1. КОНТРОЛЬ ДОСТУПА К ДАННЫМ
# ✅ Поля требующие проверки принадлежности
type Organization {
apiKeys: [ApiKey!]! # Только владелец
users: [User!]! # Только владелец
# Публичная информация
name: String
type: OrganizationType!
}
2. ВАЛИДАЦИЯ ВХОДНЫХ ДАННЫХ
# ✅ Обязательные поля для критических операций
input SellerRegistrationInput {
phone: String! # Обязательно
wbApiKey: String # Опционально при регистрации
referralCode: String # Опционально
}
input FulfillmentRegistrationInput {
phone: String! # Обязательно
inn: String! # Обязательно для бизнеса
type: OrganizationType! # Обязательно
}
3. ЗАЩИТА ОТ РАСКРЫТИЯ ИНФОРМАЦИИ
# ✅ API ключи не возвращаются полностью
type ApiKey {
id: ID!
marketplace: MarketplaceType!
isActive: Boolean!
# apiKey: String - НЕ ВОЗВРАЩАЕТСЯ в queries
}
🎯 ПРАВИЛА РАСШИРЕНИЯ СХЕМЫ
1. ДОБАВЛЕНИЕ НОВЫХ ТИПОВ ОРГАНИЗАЦИЙ
# При добавлении нового типа в OrganizationType:
enum OrganizationType {
FULFILLMENT
SELLER
LOGIST
WHOLESALE
# NEW_TYPE # Добавлять в конец для совместимости
}
# Обязательно добавить:
# 1. Соответствующие queries для нового типа
# 2. Мutations регистрации
# 3. Права доступа в resolvers
# 4. UI компоненты
2. НОВЫЕ СТАТУСЫ В WORKFLOW
# При изменении workflow:
enum SupplyOrderStatus {
# Существующие статусы НЕ УДАЛЯТЬ
# Новые добавлять в конец
# Обновлять правила переходов в resolvers
}
3. ВЕРСИОНИРОВАНИЕ API
# ✅ Добавление новых полей (обратно совместимо)
type Organization {
# Существующие поля
name: String
# Новые поля (nullable для совместимости)
newField: String
}
# ❌ Изменение существующих полей (ломает совместимость)
type Organization {
# Было: name: String
# Стало: name: String! - ЛОМАЕТ старые клиенты
}
📈 ПРАВИЛА ПРОИЗВОДИТЕЛЬНОСТИ
1. ИЗБЕГАТЬ N+1 ПРОБЛЕМ
# ✅ Использовать включения в одном запросе
type Organization {
users: [User!]! # Загружается через include в Prisma
services: [Service!]! # Загружается через include
}
# ❌ Отдельные запросы для связанных данных
query {
organizations {
id
}
}
# Затем отдельно для каждой организации запрос users
2. ОГРАНИЧЕНИЯ НА МАССОВЫЕ ОПЕРАЦИИ
# ✅ Лимиты по умолчанию
messages(
counterpartyId: ID!
limit: Int = 50 # Разумный лимит по умолчанию
offset: Int = 0
): [Message!]!
# ✅ Максимальные лимиты в resolvers
# limit: Math.min(args.limit || 50, 1000)
Извлечено из анализа: GraphQL typedefs, resolvers, patterns
Дата создания: 2025-08-21
Основано на коде: src/graphql/typedefs.ts, src/graphql/resolvers.ts