Перейти к содержимому

28. Границы соответствия требованиям

Большинство архитектурных вопросов целиком живут внутри команды — какие сервисы существуют, что они вызывают, кто ими владеет. Соответствие требованиям — это другое. Соответствие — это про границы: какие данные пересекают какие линии, какие сервисы попадают в область действия PCI, какие касаются PII, какие могут дотянуться до публичного интернета. Эти границы существуют независимо от того, моделируете вы их или нет. Вопрос в том, знает ли о них модель.

Эта глава показывает, как использовать аспекты, проекции и правила валидации совместно, чтобы сделать границы соответствия первоклассными в архитектуре. Результат: модель, которая выявляет нарушения на этапе разбора, автоматически порождает проекции соответствия и превращает разговор с аудиторами в тривиальную задачу.

Небольшая система, чья история соответствия живёт на трёх различных плоскостях — и важнейшее решение моделирования в этой главе состоит в том, чтобы держать их раздельно:

  • Сетевое размещение — в каком сегменте модуль физически находится (dmz, internal, vpc) и какие сегменты могут достучаться до каких.
  • Чувствительность данных — какой класс данных трогает модуль (public, internal, pii, pci, secret).
  • Регуляторная область — какие режимы применяются (pci, gdpr, …).

Распространённый первый подход сваливает всё это в один аспект security.zone со значениями вроде DMZ, Internal и PCI, смешанными вместе (как и делают предыдущие разобранные главы, ради краткости). Эта путаница — ровно тот запашок, который должно ловить ревью соответствия: DMZ — это сетевое размещение, PCIрегуляторная область, а трактовка их как одной оси означает, что вы не можете спросить «что в VPC?» отдельно от «что в области действия PCI?». Каждая плоскость — своя, отдельная; каждая получает свой ключ аспекта.

От модели мы хотим четырёх вещей:

  1. Автоматические проекции соответствия — «покажи всё, что в области PCI», «покажи всё, что касается PII», «покажи, что в регулируемом VPC».
  2. Материализованную сетевую плоскость — сегменты как настоящие модули, с процессом, который декларирует, какой сегмент может достучаться до какого. Сеть — часть модели, на своей плоскости, связанная с бизнес-плоскостью через аспекты.
  3. Контроль границ — никакой модуль из dmz не достучится до internal, кроме как через шлюз; ничто за пределами VPC не достучится до модуля pci напрямую. Обеспечивается политикой, а не бдительностью ревьюера.
  4. Ясность при онбординге — новый инженер, читающий модель, знает для каждого сервиса его сегмент, его класс данных и его режимы.

Три оси аспектов, по одной на плоскость, все каскадирующие:

АспектЗначенияПлоскость
network-zonedmz, internal, vpcСегментация сети (материализуемая — см. ниже)
data.classificationpublic, internal, pii, pci, secretЧувствительность данных
compliance.regimepci, 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: Проектируем метамодель → — практический разбор для авторов типов. Постройте предметный словарь поверх стандартной библиотеки.