Процесс: как изолировать FHIR-данные между tenants и не допустить утечки PHI между ними. Главный принцип — PHI целиком в FHIR-хранилище, ноль в Cloud SQL (phi-in-fhir-not-sql). Вокруг него — пять слоёв изоляции, чтобы один пропущенный фильтр не приводил к утечке.

Страница состоит из трёх частей: дизайн (как решили в session 871a7608), реальность (что в коде сейчас), отклонения (drift между ними и его последствия).

Дизайн (session 871a7608, Feb 13-16)

5 слоёв, каждый защищает независимо ([Н10] в digest):

API Request
   │
   ▼
[1] Auth middleware            ← orgId из WorkOS session            → [[workos]]
   │
   ▼
[2] Prisma $extends            ← SET app.current_tenant
   │                             ↓
   │                           PostgreSQL Row-Level Security policy
   │
   ▼
[3] FhirClientRegistry         ← orgId → cached HealthcareFhirClient
   │                             URL содержит datasetName              → [[google-healthcare-api]]
   │
   ▼
[4] GCS path prefix            ← {orgId}/...
   │                             отдельный namespace для объектов
   │
   ▼
[5] Inngest events             ← organizationId в payload + per-tenant concurrency
                                 resolveTenantContext() в начале каждого step  → [[inngest]]
                                 ↓
                            Healthcare API
                            (физическая изоляция dataset на уровне URL)

Идея — независимая защита: один пропущенный WHERE в коде недостаточен. Физическая изоляция FHIR-store — самый сильный слой; RLS должен срабатывать даже если кто-то напишет prisma.$queryRaw мимо extension ([Р6] в digest: “RLS — fallback даже при raw SQL”).

Реальность (verified 2026-04-25)

Verified против bloodgpt-for-business:

  • Слой [1] Auth middleware — работает, orgId резолвится из WorkOS session.
  • Слой [2] PostgreSQL RLS — есть, но только для non-owner ролей. Миграция 20260217000001_fix_rls_bypass (17 февраля) явно отмечает: “RLS currently only applies to non-owner roles. To fully activate, create a restricted app_user role and use FORCE ROW LEVEL SECURITY”. Application работает под owner-ролью → RLS обходится.
  • Слой [3] FhirClientRegistry — есть, но имеет fallback. resolveTenantContext возвращает {organizationId, fhirClient, isTenantScoped} (3 поля). Если у org не выставлен healthcareDatasetName — возвращается getDefaultFhirClient() с isTenantScoped: false. Для legacy / неполностью-provisioned тенантов physical isolation не работает.
  • Слой [4] GCS path prefix — есть, через getTenantKey() в storage-пакете.
  • Слой [5] InngestorganizationId в events + per-tenant concurrency: key: "event.data.organizationId", limit: 10 (recognize.orchestrator.ts:39). resolveTenantContext() запускается в начале каждого step.

Расположение реализации: apps/analysis-worker/src/inngest/functions/utils/tenant-context.ts, packages/database/tenant-prisma.ts, packages/database/prisma/migrations/20260217000001_fix_rls_bypass/.

Отклонения (drift) и последствия

Историческая рамка. Продукт начинался без мультитенантности — один FHIR-клиент, application работало под owner-ролью PostgreSQL. Когда мультитенантность добавлялась (Phase 1-3 в session 871a7608), её внедряли поверх существующего, не ломая старый код и старые данные. Поэтому drift в дизайне ниже — это не баги и не сознательные развилки, а следствие incremental migration: каждый слой включали “best-effort”, чтобы legacy-сценарии продолжали работать.

ЧтоДизайнРеальностьАрхеология
RLS scope”fallback даже при raw SQL” — подразумевает FORCE-like sealТолько non-owner; owner-connection обходитApplication исторически работало под owner-ролью. FORCE ROW LEVEL SECURITY сразу сломал бы существующие запросы. Compromise — оставить non-owner, чтобы поднять до FORCE отдельным шагом после ввода restricted app_user роли (план в комменте миграции 20260217000001_fix_rls_bypass)
Tenant-scoping FHIR”FhirClientRegistry: orgId → cached HealthcareFhirClient” — все orgs scopedFallback на getDefaultFhirClient() если org не provisionedНа момент включения мультитенантности были orgs без provisioned healthcareDatasetName. Hard-fail сломал бы их. Soft-fallback позволяет постепенно мигрировать каждую org на свой dataset, не затрагивая остальные

Последствия:

  • Защита от запросов мимо application (DB-консоль, отдельный service-аккаунт без owner-роли) — есть.
  • Защита от багов в коде приложения через RLS — нет (owner обходит). Изоляция держится только на явных organizationId-фильтрах в Prisma и в событиях.
  • Для тенантов без healthcareDatasetName физическая изоляция FHIR не работает — данные идут в общий dataset через default client.
  • Между декларацией “5 независимых слоёв” и “часть слоёв обходится по умолчанию” — разрыв, который должен закрываться отдельным assurance-тестом (см. [О2] ниже): попытаться прочитать данные чужого тенанта и зафиксировать, какие слои реально режут запрос.

Дизайн не отменяется — это roadmap. Реальность сейчас — промежуточная точка миграции. Поднять RLS до FORCE и сделать tenant-scoping обязательным — отдельные шаги, привязанные к завершению provisioning всех тенантов и введению restricted app_user роли.

Что где живёт — PHI vs non-PHI

ДанныеГдеПочему
Patient (name, birthDate, gender)FHIRphi (прямые identifiers)
Observation (value, unit, range, note, interpretation)FHIRPHI
DiagnosticReport, Composition, CarePlanFHIRPHI (содержит интерпретации)
Organization (tenant config), ApiKeyCloud SQLnon-PHI
BloodTest (testId, fhirReportId, status, timestamps)Cloud SQLnon-PHI metadata
Patient (portal auth — email, password hash)Cloud SQLnon-PHI auth
Batch / TestOrder / LabTestCatalogCloud SQLbusiness logic
GCS path / gcsKey (путь к PDF, не содержимое)Cloud SQLnon-PHI pointer

Точная граница “что считаем PHI у себя” — в phi. Сейчас draft, есть открытые вопросы.

Ключевые решения

Не зафиксированные decisions (упоминались в session 871a7608, ingest pending):

  • TBD decisions/postgres-rls-multi-tenant — RLS policy + Prisma $extends для tenant-isolation в SQL слое. Защищает metadata даже если application-level filter забыт. Не покрывает prisma.$queryRaw — там нужен manual tenant guard.
  • TBD decisions/per-tenant-fhir-client — factory + registry pattern. HealthcareFhirClient extends FhirClient (override request() для refresh OAuth per-call). FhirClientRegistry кэширует per-org client. Внутренняя breaking change — private методы базы переведены на protected.
  • TBD decisions/per-tenant-concurrency — Inngest function-level concurrency key: "event.data.organizationId". Один tenant с burst-нагрузкой не блокирует pipeline для остальных. Operational защита (не только performance).

FHIR store как shared state между Inngest steps

Связано с ai-enrichment-separate-step и phi-in-fhir-not-sql:

До миграции (legacy):
  Crosscheck → Normalize → LOINC → Ranges
       ↓ all read/write BloodTest.rawRecognitionResult JSON в Cloud SQL
  Save FHIR (last) → создаёт Observations

После:
  fhir-resource-creation       → POST Observations → returns observationIds[]
  interpretation-analysis      → reads Observations from FHIR by ID → AI generates
  enrichFhirObservations       → PUT Observations с note[] + interpretation
  saveFhirAiResources          → создаёт Composition + CarePlan

Orchestrator передаёт IDs (маленькие) между steps. FHIR store = single source of truth вместо JSON blob в SQL. Это позволяет cleanup BloodTest.rawRecognitionResult поля (см. phi-in-fhir-not-sql следствия).

Следствия для BloodGPT

(Черновик — не согласовано с Ильдаром.)

  • Физическая изоляция FHIR-store — частичная. У тенантов с выставленным healthcareDatasetName данные лежат в отдельном dataset под отдельным URL. У остальных — fallback через default-клиент в общий dataset. Это значит, что для не-provisioned тенантов physical isolation не работает — их PHI смешано в одном хранилище с другими fallback-тенантами. До закрытия этого ([О5] ниже) позиционирование “у каждого тенанта свой dataset” не верно — оно применимо только к provisioned-части.
  • Готовый FHIR-обмен с партнёрами через Patient/$everything — позиционирование: партнёр говорит на FHIR, мы тоже отдаём FHIR без custom-формата. На практике используется с дополнительной подгрузкой Composition и CarePlan (см. fhir-modeling-ai-content) — Google Healthcare API не гарантирует их в одном bundle.
  • Проверка возможностей вендора до архитектурных решений — обязательна. Pivot с Device на Organization случился потому, что поддержка Device в GCP Healthcare API не была проверена заранее. Урок: формальная корректность по FHIR не равна поддержке у конкретного вендора.
  • Per-tenant concurrency в Inngest — операционная защита, а не оптимизация производительности. Один тенант с проблемной интеграцией или резким всплеском нагрузки не должен ронять обработку у остальных. Это часть изоляции, не отдельная фича.
  • 5-слойная архитектура — пока best-effort, не force. На сегодня RLS обходится owner-ролью, FHIR-клиент имеет fallback на default. Дизайн рассчитан на независимые слои; реальность — часть слоёв в неполной строгости (см. раздел “Отклонения” выше).

Открытые вопросы

[О1] Data migration с Cloud SQL → Healthcare API — fall-back на случай реальных prod данных

Скрипт миграции не написан. На момент 871a7608 был в плане как Phase 5, но на практике не понадобился — продакшн-данных в Cloud SQL не было (см. phi-in-fhir-not-sql следствия). Если когда-то появится legacy tenant с историей — потребуется реализация: чтение BloodTest.rawRecognitionResult + ParameterAnalysis + TestOverview → собрать FHIR Bundle → POST в per-org store. Idempotent, batch-aware, rollback-ready.

[О2] Матрица проверки возможностей вендоров

Pivot Device → Organization обнаружился только в тесте, близком к проду. Нужна систематическая матрица: какие FHIR-конструкции (extensions, conditional create, search modifiers, transaction bundles, custom operations) поддерживаются в каждом целевом backend’е (HAPI / GCP Healthcare API / Azure FHIR / Medplum). Сейчас решения принимаются на уровне спецификации, без проверки у вендора — это и привело к pivot’у.

Сюда же — assurance-тест на defense-in-depth: реально проверить, что попытка чтения чужого тенанта режется на каждом из слоёв (или хотя бы зафиксировать какие слои пропускают, см. drift выше).

[О3] HIPAA compliance checklist

Не закрыто:

  • Cloud Audit Logs (Data Access logs) на Healthcare API
  • VPC Service Controls — periphery defense вокруг Healthcare API endpoint
  • Cleanup PHI из Inngest events (organizationId есть, но проверка что медданные не утекают в payload не делалась)
  • OAuth2 scope minimization (request только нужные permissions)
  • Audit trail для AI actions (связано с TBD decisions/ai-content-audit-marking)
  • BAA verification для всех связанных GCP services

[О4] Чтение через FHIR — как именно

Принцип “FHIR как источник истины для чтения” зафиксирован в phi-in-fhir-not-sql. На практике GET-эндпоинты в b2b-api переехали частично — apps/b2b-api/src/utils/fhir-response-builder.ts есть, но не все эндпоинты через него. Открытое — как правильно это делать массово:

  • Стратегия пагинации (FHIR _count + Bundle.link.next или offset) — какой подход для какого эндпоинта.
  • Общие TypeScript-типы между builder’ами (запись) и response-builder (чтение) — чтобы не разъезжалось.
  • Что делать с эндпоинтами, которым нужны данные одновременно из FHIR и из Cloud SQL (например, BloodTest.status + содержимое из Composition).
  • Тесты, которые ловят регрессию “GET вернул то же самое из Cloud SQL вместо FHIR”.

[О5] Аудит provisioning + удаление default-fallback (+ backfill Organization/bloodgpt)

Здесь два параллельных gap’а incremental migration. Оба про “новые orgs покрыты, existing — нет”.

[О5a] Default FHIR client для не-provisioned тенантовlive-leak risk. Их PHI лежит в общем dataset → physical isolation для них фактически выключена.

[О5b] Existing prod datasets без Organization/bloodgpt — verified в session c9560637. Provisioning function создаёт Organization/bloodgpt только для новых datasets. Existing prod orgs на момент Feb 17 2026 не имели его → runtime error reference target(s) not found: Organization/bloodgpt при попытке записать AI-content. Manual fix через gcloud + curl сделан только для двух prod datasets:

  • org-cmlpaxq2g0001nv1511l4t6v9
  • org-cmlpbcp5l0000pl14apu115cw

Backfill для остальных prod datasets не выполнен системно — остальные tenants словят error при следующем enrichment-шаге. Plan B (idempotent Organization/bloodgpt PUT-entry в начале каждого fhir-resource-creation transaction bundle) обсуждался Claude в c9560637, не закоммичен.

Шаги (общие):

  1. Аудит: сколько Organization в проде без healthcareDatasetName. Если 0 — fallback можно сразу выключать. Если >0 — backfill сначала.
  2. Backfill provisioning: для каждой такой org прогнать Healthcare dataset provisioning (Inngest function уже есть, см. session 871a7608 [Сделано]). Перенести их данные из default dataset в свой (если данные там успели накопиться).
  3. Hard-fail вместо fallback: заменить ветку getDefaultFhirClient() в resolveTenantContext на throw. Default-клиент оставить только для явных internal scripts с opt-in flag, не для production-request путей.
  4. Provisioning bundle включает Organization/bloodgpt идемпотентно (PUT-entry в начале каждого transaction bundle при fhir-resource-creation). Тогда missing-Organization runtime errors не возможны.

После этого реальность догонит дизайн ([Слой 3]: “FhirClientRegistry: orgId → cached HealthcareFhirClient” для всех orgs), и одна из двух drift-точек закроется.

[С1] Задержка FHIR для read-heavy путей

Латентность чтения FHIR обсуждалась в phi-in-fhir-not-sql как несущественная на фоне LLM (секунды). Но для frontend-эндпоинтов dashboard’ов, где LLM не задействован, это может стать заметно. Варианты:

  • Кэш FHIR-ответов — сложности с compliance (PHI в кэше).
  • Read-replica или денормализованная проекция — лишний слой синхронизации.
  • Гибрид (вариант C из phi-in-fhir-not-sql) — рассматривался и отклонён как doubled complexity, но может вернуться в обсуждение, если read-heavy сценарии всплывут.

Не решено. Сначала нужны измерения на реальной нагрузке, а не теоретический выбор.

Связано

Источники

Источники: 1 2 3 4 5.

Сноски

  1. Реализация (feature/multi-tenant-healthcare-api, коммит c55ba78), accessed 2026-05-17, https://github.com/Realai-plus/bloodgpt-for-business/tree/main.

  2. PostgreSQL RLS, accessed 2026-05-17, https://www.postgresql.org/docs/current/ddl-rowsecurity.html.

  3. Prisma Client extensions, accessed 2026-05-17, https://www.prisma.io/docs/orm/prisma-client/client-extensions.

  4. Inngest concurrency, accessed 2026-05-17, https://www.inngest.com/docs/functions/concurrency.

  5. Google Cloud Healthcare API, accessed 2026-05-17, https://cloud.google.com/healthcare-api/docs.