28. Границы соответствия требованиям
Большинство архитектурных вопросов целиком живут внутри команды — какие сервисы существуют, что они вызывают, кто ими владеет. Соответствие требованиям — это другое. Соответствие — это про границы: какие данные пересекают какие линии, какие сервисы попадают в область действия PCI, какие касаются PII, какие могут дотянуться до публичного интернета. Эти границы существуют независимо от того, моделируете вы их или нет. Вопрос в том, знает ли о них модель.
Эта глава показывает, как использовать аспекты, проекции и правила валидации совместно, чтобы сделать границы соответствия первоклассными в архитектуре. Результат: модель, которая выявляет нарушения на этапе разбора, автоматически порождает проекции соответствия и превращает разговор с аудиторами в тривиальную задачу.
Сценарий
Заголовок раздела «Сценарий»Небольшая система, чья история соответствия живёт на трёх различных плоскостях — и важнейшее решение моделирования в этой главе состоит в том, чтобы держать их раздельно:
- Сетевое размещение — в каком сегменте модуль физически находится (
dmz,internal,vpc) и какие сегменты могут достучаться до каких. - Чувствительность данных — какой класс данных трогает модуль (
public,internal,pii,pci,secret). - Регуляторная область — какие режимы применяются (
pci,gdpr, …).
Распространённый первый подход сваливает всё это в один аспект security.zone со значениями вроде DMZ, Internal и PCI, смешанными вместе (как и делают предыдущие разобранные главы, ради краткости). Эта путаница — ровно тот запашок, который должно ловить ревью соответствия: DMZ — это сетевое размещение, PCI — регуляторная область, а трактовка их как одной оси означает, что вы не можете спросить «что в VPC?» отдельно от «что в области действия PCI?». Каждая плоскость — своя, отдельная; каждая получает свой ключ аспекта.
От модели мы хотим четырёх вещей:
- Автоматические проекции соответствия — «покажи всё, что в области PCI», «покажи всё, что касается PII», «покажи, что в регулируемом VPC».
- Материализованную сетевую плоскость — сегменты как настоящие модули, с процессом, который декларирует, какой сегмент может достучаться до какого. Сеть — часть модели, на своей плоскости, связанная с бизнес-плоскостью через аспекты.
- Контроль границ — никакой модуль из
dmzне достучится доinternal, кроме как через шлюз; ничто за пределами VPC не достучится до модуляpciнапрямую. Обеспечивается политикой, а не бдительностью ревьюера. - Ясность при онбординге — новый инженер, читающий модель, знает для каждого сервиса его сегмент, его класс данных и его режимы.
Аспекты
Заголовок раздела «Аспекты»Три оси аспектов, по одной на плоскость, все каскадирующие:
| Аспект | Значения | Плоскость |
|---|---|---|
network-zone | dmz, internal, vpc | Сегментация сети (материализуемая — см. ниже) |
data.classification | public, internal, pii, pci, secret | Чувствительность данных |
compliance.regime | pci, gdpr, ccpa, sox, hipaa | Регуляторная область |
Эти оси — соглашения, а не встроенные конструкции языка. Вы объявляете их в типах своего проекта (или опираетесь на значения по умолчанию из стандартной библиотеки), затем каждый модуль выставляет их подходящим образом. Ключ в том, чтобы держать одну плоскость на ключ: network-zone отвечает на «где это работает», data.classification — на «насколько чувствительны его данные», compliance.regime — на «какие правила им управляют». Смешивать их в один ключ — это антипаттерн.
Множественное членство — это значение-список. Модуль, попадающий в область действия и GDPR, и PCI, несёт оба на одном аспекте:
aspect { compliance.regime: ["gdpr", "pci"] // member of both regime aspects}Аспект со значением-списком делает модуль членом каждой классификации из списка (согласно правилам аспектов в §4.4 спецификации) — не нужно по логическому ключу на каждый режим.
platform.arch (выборочно, чтобы показать разметку аспектами):
frontend StoreFront { aspect team: "Frontend"
aspect { domain: "Commerce" network-zone: "dmz" data.classification: "public" }
"Customer-facing storefront. Public-internet exposed."}
gateway WebGateway { aspect team: "Platform"
aspect { domain: "Platform" network-zone: "dmz" data.classification: "internal" }
"Edge gateway. Terminates TLS, applies WAF rules, forwards into the internal segment. The single sanctioned crossing from dmz to internal."
rest_create forward { "Reverse-proxy inbound API traffic to internal services." }}
service Orders { aspect team: "Commerce"
aspect { domain: "Commerce" network-zone: "internal" data.classification: "pii" compliance.regime: ["gdpr"] }
"Order processing. Stores customer name/address/email — PII."
rest_create createOrder { "Place an order. Reached only via the gateway." }}
service Payments { aspect team: "Payments"
aspect { domain: "Payments" network-zone: "vpc" data.classification: "pci" compliance.regime: ["pci"] }
"Card payment processing. Card data NEVER leaves this module unencrypted."}
database PaymentVault { aspect team: "Payments"
data.encryption.algorithm: "aes-256-gcm" // a property → field, not an aspect
aspect { domain: "Payments" network-zone: "vpc" data.classification: "pci" compliance.regime: ["pci"] }
"Encrypted vault for tokenized card data."}Стоит заметить две вещи. WebGateway — это gateway, а не обобщённый service — это высокоуровневый равноправный элемент, единственное место, где трафику dmz→internal разрешено пересечь границу, и именно там живёт периметровая политика (аутентификация, WAF, лимиты частоты). data.encryption.algorithm — это поле, а не аспект — алгоритм является структурированным свойством хранилища, а не межплоскостной связью. Аспекты нужны для членства в плоскости; конкретные значения вроде алгоритма шифрования или URL ранбука место в полях.
Сдвиг мышления. Аспекты соответствия — это не опциональные метаданные. Они задают поведенческие ограничения.
data.classification: pci— не ярлык для галочки, это контракт, что данные и вызовы этого модуля будут трактоваться по правилам PCI. Если вы навешиваете аспекты только на те модули, которые случайно подпадают под соответствие, вы получите ложноотрицательные результаты (модульinternalпротащит вызов вpciмимо ревью). Расставьте аспекты везде, включая сервисыpublic. Модель либо полна, либо бесполезна.
Сетевая плоскость, материализованная
Заголовок раздела «Сетевая плоскость, материализованная»Пока что network-zone: "dmz" — это чистая классификация: аспект со строковым значением, которая группирует все dmz-модули вместе на невидимой плоскости. Это уже полезно (можно сфокусировать на ней проекцию), но сеть — это реальная вещь с реальными правилами. Вы можете материализовать плоскость: превратить каждый сегмент в настоящий модуль, написать процесс, декларирующий разрешённые потоки, и связать с ним бизнес-модули.
network.arch:
// A type that earns its keep: every segment must declare its CIDR and link// to its firewall console, and ships the ingress interface that flows target.export type module network_segment { required cidr required ext.firewall.console.url
rest_create ingress { "Traffic admitted into this segment." }
"A network segment. Cross-segment reachability is declared by the NetworkPolicy process — a flow with no step is denied by default."}
network_segment Dmz { aspect team: "Platform" cidr: "10.0.0.0/24" ext.firewall.console.url: "https://fw.acme.com/dmz" "Public-facing segment. Terminates inbound internet traffic."}
network_segment Internal { aspect team: "Platform" cidr: "10.0.1.0/24" ext.firewall.console.url: "https://fw.acme.com/internal" "Private segment. No direct internet ingress."}
network_segment Vpc { aspect team: "Platform" cidr: "10.0.2.0/24" ext.firewall.console.url: "https://fw.acme.com/vpc" "Isolated VPC for regulated (PCI) workloads."}
// The policy spans all three segments, so it lives at the root — the// smallest scope that contains every segment it touches.process NetworkPolicy { Internet > Dmz.ingress "443 only, WAF-filtered" Dmz > Internal.ingress "north-south, mTLS" Internal > Vpc.ingress "PCI workloads only, mTLS"
// No `Dmz > Vpc.ingress` step — the DMZ may NOT reach the VPC // directly. The absence of a step IS the policy: only declared // flows are permitted.}network_segment — это настоящий тип, а не пустая обёртка: он заставляет каждый сегмент указывать CIDR и ссылку на консоль файрвола и поставляет интерфейс ingress, на который нацеливается процесс-политика. NetworkPolicy декларирует топологию сегментов — три перехода, никакого ярлыка из dmz в vpc. Он находится в корне, потому что охватывает все три сегмента; процесс живёт в наименьшей области, содержащей всё, чего он касается.
Теперь свяжем бизнес-плоскость с сетевой. Строковая классификация network-zone становится голой ссылкой (членством) на настоящий модуль-сегмент:
service Payments { aspect { domain: "Payments" network-zone: Vpc // bare value → reference to the Vpc segment module data.classification: "pci" compliance.regime: ["pci"] } // ...}network-zone: "vpc" (в кавычках) — это слабая классификация: «живёт в зоне vpc». network-zone: Vpc (голое) материализует это в жёсткое членство со смоделированным сегментом. Аспект — это связь между плоскостями: бизнес-модуль остаётся на плоскости сервисов, сегмент живёт на сетевой плоскости, а аспект — это нить, их соединяющая. Это общий паттерн для любой плоскости развёртывания — так же, как база данных связывается со своей СУБД, а СУБД — со своим хостом.
Правило. Материализуйте плоскость, когда у неё есть собственные правила. Сеть, которую вы только размечаете, — это документация; сеть, которую вы моделируете — сегменты, интерфейсы ingress, процесс разрешённых потоков — это то, что модель может проверить. Повышайте аспект до настоящих модулей в тот момент, когда «какая зона может достучаться до какой» становится вопросом, на который вам нужен ответ.
Процесс, где шлюз — это граница
Заголовок раздела «Процесс, где шлюз — это граница»Сетевая политика управляет достижимостью сегмент-к-сегменту. Бизнес-процессы едут поверх неё — и они должны честно показывать пересечения:
process #h8r4ja PlaceOrder { Customer > WebGateway.forward "TLS-terminated at the edge, WAF-filtered" WebGateway > Orders.createOrder "dmz → internal, the one sanctioned crossing" Orders > Payments.authorize "internal → vpc"}WebGateway появляется в пути вызова, потому что он находится в пути вызова. Соблазн — написать Customer > Orders.createOrder и трактовать шлюз как обвязку — но это ровно то упущение, которого нужно избегать.
Правило. Никогда не опускайте шлюз в процессе. Шлюз — это высокоуровневый равноправный элемент, а не инкапсулированная деталь (в отличие от собственной вложенной базы данных сервиса, которую можно пропустить). Выбросив его, вы скрываете пересечение dmz→internal и политику, обеспечиваемую там —
Customer > Orders.createOrderподразумевал бы, что публичный интернет достукивается до внутреннего сервиса напрямую, нарушение границы, которое модель должна выявлять, а не скрывать. Опущенный шлюз — это то, как нарушения политики становятся невидимыми.
Проекции
Заголовок раздела «Проекции»views.arch:
view PCIScope { "Every module in PCI scope. Used in quarterly compliance review." show @@compliance.regime:"pci" group by @@team}
view PIIScope { "Every module touching PII. Used in data-mapping exercises." show @@data.classification:"pii" group by @@team}
view PublicSurface { "Internet-exposed segment. Useful for pen-test scoping." show @@network-zone:"dmz"}
view RegulatedVpc { "Everything in the regulated VPC, plus the segment itself." show @@network-zone:"vpc" group by @@team}
view CrossZoneFlow { "All processes; highlights edges that cross network zones (look for any dmz→vpc shortcut)." show process style @@network-zone:"dmz" > @@network-zone:"vpc" { color: crimson }}Сфокусированные проекции тривиальны, как только проставлены аспекты — каждая ось это отдельная плоскость, поэтому каждая проекция нарезает отдельный аспект. CrossZoneFlow — та же модель, где запрещённые рёбра dmz→vpc помечены красным клаузой style; поскольку сетевая плоскость материализована, неожиданное ребро dmz→vpc выглядит как настоящее пересечение, а не как скрытое.
Правила валидации
Заголовок раздела «Правила валидации»Здесь метамодель отрабатывает свой хлеб. Валидатор стандартной библиотеки выполняет небольшой набор встроенных проверок (обязательные пустые слоты, разрешение вызываемых). Кастомные проверки живут в типах вашего проекта — required слоты, специально нацеленные на факты соответствия.
types.arch:
// Every service in PCI scope must declare a runbook and a contact.export type service pci_service { required aspect team required ext.runbook_url required ext.compliance.contact required aspect data.classification // instance must pick a value (pii, pci, secret, ...)
aspect { compliance.regime: ["pci"] // a compliance fact, by construction }
"A service in PCI scope. Compliance facts are enforced here; network placement (network-zone) is a separate plane, set per instance."}
// Every service touching PII must declare a retention policy.export type service pii_service { required aspect team required data.retention_days required data.deletion_endpoint
aspect { data.classification: "pii" }}Заметьте, что pci_service кодирует только плоскость соответствия — режим, ранбук, контакт, класс данных. Он намеренно не фиксирует network-zone: где сервис работает — это другая плоскость, задаваемая независимо на экземпляре. Вложив размещение в тип соответствия, вы воссоздали бы ту самую путаницу, ради устранения которой и существует эта глава.
Теперь вместо service Payments пишем:
pci_service Payments { aspect team: "Payments" ext.runbook_url: "https://wiki.acme.com/pci-runbook" ext.compliance.contact: "compliance@acme.com" aspect { data.classification: "pci" // fulfills the required blank network-zone: Vpc // network placement, set independently }
rest_create authorize rest_create capture rest_create refund}Если разработчик добавляет pci_service без одного из обязательных полей, валидация падает. Факты соответствия больше не «надо не забыть это указать» — они принудительно проверяются на этапе разбора.
Обеспечивайте границы политикой, а не соглашением
Заголовок раздела «Обеспечивайте границы политикой, а не соглашением»Граница, которая имеет значение, должна обеспечиваться моделью, а не ревьюером, который вспомнил посмотреть. Встроенная валидация ловит структурные ошибки (вызывающий — модуль, вызываемый — интерфейс) и пробелы в required-слотах. Сквозные правила границ — «никакой модуль dmz не достучится до internal, кроме как через шлюз», «ничто за пределами VPC не достучится до модуля pci напрямую» — это работа политик.
Политики (проверки на основе запросов, §10 спецификации) объявляются рядом с моделью и обеспечиваются archlang check — пересечение роняет сборку с указанием имени политики. Правило forbid отмечает каждое ребро, которое сопоставляет его селектор; оговорка except выделяет санкционированные пересечения:
policy NoDmzToInternalExceptGateway { "DMZ traffic must transit the WebGateway to reach the internal segment." severity: error forbid @@network-zone:"dmz" > @@network-zone:"internal" except WebGateway > @@network-zone:"internal" "WebGateway is the one sanctioned dmz→internal crossing"}
policy NoOutsideToPci { "Only VPC-resident callers may reach a PCI-scoped service." forbid (not @@network-zone:"vpc") > @@compliance.regime:"pci"}Разделение объявлено, archlang check его обеспечивает, и пересечение роняет сборку с указанием имени политики. Не довольствуйтесь соглашением. Для процедурной проверки, которую декларативный язык политик не выражает, вы всё ещё можете написать скрипт над разрешённой моделью на @archlang/engine (глава 24):
// scripts/check-zones.ts — a procedural check over the resolved model.import { loadPackage, ioNode } from "@archlang/lsp";import { buildGraph } from "@archlang/engine";
// loadPackage resolves + cascades the package (see Chapter 24 for the API).const loaded = await loadPackage(ioNode(), new URL(".", import.meta.url).href);const graph = buildGraph(loaded.resolved.model);
function zone(moduleId: string | undefined): string | undefined { if (!moduleId) return undefined; return graph.modulesById.get(moduleId)?.aspects.get("network-zone");}function isGateway(moduleId: string | undefined): boolean { if (!moduleId) return false; return graph.modulesById.get(moduleId)?.type === "gateway";}
const violations: string[] = [];for (const edge of graph.edges) { const fromZone = zone(edge.fromModuleId); const toZone = zone(edge.toModuleId);
if (fromZone === "dmz" && toZone === "internal" && !isGateway(edge.fromModuleId)) { violations.push(`${edge.fromName} → ${edge.toName} (dmz→internal, not via gateway)`); } if (toZone === "vpc" && fromZone !== "vpc") { violations.push(`${edge.fromName} → ${edge.toName} (outside→vpc)`); }}
if (violations.length > 0) { console.error("Zone violations:\n" + violations.join("\n")); process.exit(1);}Запускайте в CI; нарушения роняют сборку. Предпочитайте блок policy всякий раз, когда правило выразимо декларативно — оно путешествует вместе с моделью, и archlang check его запускает. Тянитесь к скрипту только для проверки, которую язык политик выразить не может.
Правило. Подкрепляйте важные границы политиками, а не одним лишь соглашением. Граница, обеспечиваемая ревьюером, в одном усталом дне от того, чтобы быть пробитой; граница, обеспечиваемая политикой, роняет сборку.
Что видят команды управления
Заголовок раздела «Что видят команды управления»После того как аспекты и типы расставлены:
- Квартальное ревью PCI — откройте проекцию
PCIScope, сделайте снимок экрана, подшейте. Проекция точная и актуальная, потому что является функцией от исходника. - Карта данных PII — откройте
PIIScope. Каждый сервис, касающийся PII, в списке; по каждому видны хранение и удаление. - Инвентаризация DPIA — скрипт по разрешённой модели: перечислите все модули, чей список
compliance.regimeсодержитgdpr. - Сетевой аудит — откройте материализованную сетевую плоскость и процесс
NetworkPolicy: каждый разрешённый поток сегмент-к-сегменту — это один объявленный шаг, и регулятор может прочитать топологию напрямую. - Нарушение границы — разработчик добавляет
serviceбез аспектов соответствия и шаг процесса в PCI-сервис. Политика роняет сборку. Разработчик либо обосновывает вызов (и тогда тот проходит ревью), либо убирает его.
Ничто из этого не требует поддерживать параллельный документ соответствия. Документ — это и есть модель.
Решения, с которыми вы столкнётесь
Заголовок раздела «Решения, с которыми вы столкнётесь»Покрытие аспектов. Либо каждый модуль несёт network-zone и data.classification, либо у вас есть невидимые пробелы. Достигайте полноты, делая аспекты required в своих типах. Цена: разовый проход с обновлением существующих экземпляров. Выгода: железобетонное покрытие.
Каскад против явного указания. Контейнер system PaymentsDomain { ... } с aspect { network-zone: Vpc } каскадирует на каждый вложенный сервис. Чище, чем повторять аспект пять раз. Используйте каскад для общего размещения; задавайте явный аспект только там, где отдельный модуль является исключением.
Аспект или материализация. Строки network-zone: "vpc" достаточно, когда вам нужны только группировка и проекции. Повысьте её до материализованной плоскости (настоящие модули-сегменты + процесс NetworkPolicy + аспекты с голыми ссылками — членство), когда правила достижимости становятся тем, что нужно обеспечивать или аудировать. Эти два подхода сосуществуют: начните с аспекта, материализуйте, когда вопросы усложнятся.
Где живут правила. Локальные для проекта типы (pci_service, pii_service, network_segment) кодируют словарь. В большой организации поднимите общие из них в выделенный изолированный пакет типов и делайте use их повсюду — запасной выход для словаря, разделяемого между командами (глава 29).
Что НЕ моделировать. Реализацию хранения данных (cron-задача, выполняющая удаление). Места назначения журналов аудита. Всё, что является выбором реализации, должно отражаться в полях («какой именно»), а не в структуре модели («существует ли») — ровно поэтому алгоритм шифрования это поле на хранилище, а не аспект.
- Одна плоскость на ключ аспекта:
network-zone(размещение),data.classification(чувствительность),compliance.regime(регуляторная область). Свалить их в одну осьsecurity.zone— это запашок. - Множественное членство — это значение-список (
compliance.regime: ["gdpr", "pci"]), а не ключ-на-значение. - Каскадируйте эти аспекты — задайте один раз, охватите всё, что внутри.
- Материализуйте сетевую плоскость, когда у достижимости есть правила: настоящие модули
network_segment, процессNetworkPolicy, чьи отсутствующие шаги и есть запрещённые потоки, и аспекты с голыми ссылками (членство), соединяющие бизнес-плоскость с ней. - Никогда не опускайте шлюз в процессе — это высокоуровневый равноправный элемент, где живут граница и её политика.
- Свойства (алгоритм шифрования, URL ранбука) — это поля; членство в плоскости — это аспекты.
- Типы (
pci_service,pii_service,network_segment) делают обязательные факты проверяемыми на этапе разбора. - Обеспечивайте границы политиками, запускаемыми
archlang check; спускайтесь к CI-скрипту на@archlang/engineтолько для процедурных проверок, никогда не одно лишь соглашение. - Модель становится документом соответствия.
Что дальше
Заголовок раздела «Что дальше»Глава 29: Проектируем метамодель → — практический разбор для авторов типов. Постройте предметный словарь поверх стандартной библиотеки.