Процесс: как изолировать 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] Inngest —
organizationIdв 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 scoped | Fallback на 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) | FHIR | phi (прямые identifiers) |
| Observation (value, unit, range, note, interpretation) | FHIR | PHI |
| DiagnosticReport, Composition, CarePlan | FHIR | PHI (содержит интерпретации) |
| Organization (tenant config), ApiKey | Cloud SQL | non-PHI |
| BloodTest (testId, fhirReportId, status, timestamps) | Cloud SQL | non-PHI metadata |
| Patient (portal auth — email, password hash) | Cloud SQL | non-PHI auth |
| Batch / TestOrder / LabTestCatalog | Cloud SQL | business logic |
GCS path / gcsKey (путь к PDF, не содержимое) | Cloud SQL | non-PHI pointer |
Точная граница “что считаем PHI у себя” — в phi. Сейчас draft, есть открытые вопросы.
Ключевые решения
- phi-in-fhir-not-sql — где живут данные
- authorship-organization-not-device — кто проставлен как author для AI-генераций
Не зафиксированные 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(overriderequest()для refresh OAuth per-call).FhirClientRegistryкэширует per-org client. Внутренняя breaking change —privateметоды базы переведены наprotected. - TBD
decisions/per-tenant-concurrency— Inngest function-level concurrencykey: "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-cmlpaxq2g0001nv1511l4t6v9org-cmlpbcp5l0000pl14apu115cw
Backfill для остальных prod datasets не выполнен системно — остальные tenants словят error при следующем enrichment-шаге. Plan B (idempotent Organization/bloodgpt PUT-entry в начале каждого fhir-resource-creation transaction bundle) обсуждался Claude в c9560637, не закоммичен.
Шаги (общие):
- Аудит: сколько
Organizationв проде безhealthcareDatasetName. Если 0 — fallback можно сразу выключать. Если >0 — backfill сначала. - Backfill provisioning: для каждой такой org прогнать Healthcare dataset provisioning (Inngest function уже есть, см. session 871a7608 [Сделано]). Перенести их данные из default dataset в свой (если данные там успели накопиться).
- Hard-fail вместо fallback: заменить ветку
getDefaultFhirClient()вresolveTenantContextна throw. Default-клиент оставить только для явных internal scripts с opt-in flag, не для production-request путей. - 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 сценарии всплывут.
Не решено. Сначала нужны измерения на реальной нагрузке, а не теоретический выбор.
Связано
- phi-in-fhir-not-sql · authorship-organization-not-device · ai-enrichment-separate-step
- phi — границы “что считаем PHI”
- fhir-organization —
Organization/bloodgptAI-author + provisioning gap - fhir-modeling-ai-content — структура AI-output в FHIR (что попадает в store)
- google-healthcare-api — vendor-страница, OAuth + Workload Identity, BAA scope, Device-limit
- hapi-fhir — alternative backend
- inngest — orchestrator, per-tenant concurrency, resolveTenantContext
- workos — auth, orgId resolution
- custom-domains-saas — tenant routing на frontend layer
Источники
Сноски
-
Реализация (
feature/multi-tenant-healthcare-api, коммитc55ba78), accessed 2026-05-17, https://github.com/Realai-plus/bloodgpt-for-business/tree/main. ↩ -
PostgreSQL RLS, accessed 2026-05-17, https://www.postgresql.org/docs/current/ddl-rowsecurity.html. ↩
-
Prisma Client extensions, accessed 2026-05-17, https://www.prisma.io/docs/orm/prisma-client/client-extensions. ↩
-
Inngest concurrency, accessed 2026-05-17, https://www.inngest.com/docs/functions/concurrency. ↩
-
Google Cloud Healthcare API, accessed 2026-05-17, https://cloud.google.com/healthcare-api/docs. ↩