Co wiedziałem i co zbudowałem
Dwa seamy w Exeris Kernel dorobione po fakcie — body codec i identity provider — i dlaczego obie luki widać dopiero od strony konsumenta, nie kontraktu.
To ósmy artykuł z serii o Exeris Kernel. Pierwszych siedem dotyczyło decyzji, których jestem pewien: propagacja kontekstu, gdzie polimorfizm runtime przestaje się opłacać, gdzie StructuredTaskScope naprawdę się sprawdza, off-heap TLS, silnik Flow, poprawność kompensacji i wzorzec LoanedBuffer. Ten jest inny. Jest o dwóch kontraktach, które musiałem dorobić po tym, jak początkowy projekt został zamknięty — i o jednej rzeczy, która je łączy, a której nie widziałem, dopóki obie się nie wydarzyły.
Miałem jedną architektoniczną wizję Exeris Kernel i dwa przeoczenia w jej realizacji. Z zewnątrz wyglądają na niepowiązane — jedno jest w kliencie HTTP, drugie w podsystemie bezpieczeństwa — ale mają ten sam kształt. W obu przypadkach zbudowałem pojedynczą, konkretną instancję czegoś, czego architektura potrzebowała jako pluggable seam. I w obu przypadkach luka pozostawała niewidoczna, dopóki nie przeczytałem konsumującego kodu od początku do końca.
Ta ostatnia rzecz jest sednem całego artykułu, więc powiem ją od
razu: nie złapałem żadnego z tych przeoczeń na design review.
Złapałem je, będąc konsumentem własnego kernela. Dlaczego to ważne —
i dlaczego to nie jest tylko spowiedź — bo większość ludzi, którzy
projektują kontrakt, nigdy nie zajmuje pozycji, z której widać jego
luki. Wydają kontrakt, ktoś inny konsumuje go dwie warstwy niżej, a
luka wychodzi jako incydent na produkcji. Ja złapałem swoje wcześnie
i sam tylko dlatego, że nie pracuję wyłącznie nad kernelem. Buduję
też tooling, który generuje kod pod niego, i produkt, który na tym
działa. Siedzę po obu stronach granicy, którą zaprojektowałem. I
„wcześnie” jest dosłowne: lukę w codeku znalazłem w lokalnych
testach, czytając zarówno generator w exeris-tooling, jak i kod,
który on emituje. Tooling, który ten kod emituje, nie ma jeszcze
taga release’owego — wygenerowany output regeneruje się na żądanie,
nie ma nic opublikowanego, do czego aplikacja downstream mogłaby się
przypiąć. Więc ta wersja generatora, która hardcodowała konkretny
codec, nigdy nie miała konsumenta zależnego od siebie; zanim rewrite
konsumujący SPI wszedł, kontrakt, który konsumuje, był już w wydanym
kernelu. To nie szczęście. Stałem na krawędzi konsumenta, gdy
kontrakt miał jeszcze zero zależności — najtańszy możliwy moment na
znalezienie takiej luki.
Jest jeszcze trzecia, mniejsza wersja tego samego błędu — zagnieżdżona w naprawie pierwszego. Dojdę do niej; to część, którą uważam za najbardziej użyteczną, bo pokazuje, że ten ślepy punkt nie jest jednorazową wpadką, którą da się rozwiązać większą ostrożnością. On się powtarza — nawet wtedy, gdy aktywnie naprawiasz jego instancję.
HttpClient, który zbudowałem i nie mogłem użyć
Najpierw słowo o numerach wersji, bo są dwa tory i łatwo je pomylić. Numerowane wersje w tym artykule to release’y kernela — 0.5.0 i 0.8.0 niżej, 0.8.1 obecnie, wszystkie otagowane na GitHubie. Tooling, który konsumuje te seamy, to osobny tor: zaimplementowany przez 0.4, na 0.5.0-SNAPSHOT, celowo jeszcze bez taga release’owego. Więc kiedy mówię, że naprawa „weszła”, mam na myśli wydany kernel. Kiedy mówię, że rewrite generatora jest „sekwencjonowany”, mam na myśli tor toolingu, który celowo nie otagował release’u — więcej o tym „dlaczego” na końcu.
Punkt zapalny był konkretny. Budowałem warstwę SDK/tooling —
generator kodu w exeris-tooling, który emituje typowane, per-encja
klienty REST (WidgetClient.findById(id), WidgetClient.create(widget))
pod dowolny artefakt kernela, który wybierze POM aplikacji. Całe
zadanie generatora to być tier-neutralnym: emituje kod, który
kompiluje się pod tier Community (HTTP/1.1 + HTTP/2) albo Enterprise
(HTTP/3 + Panama JSON, gdy wejdzie), nie wiedząc i nie dbając o to,
który.
Nie mógł. Fasada klienta HTTP, którą wydałem — wtedy nazwana
CommunityWebClient — hardcodowała Jackson 3 jako codec JSON.
Marshalling ciała żył wewnątrz fasady: mapper.writeValueAsBytes na
wyjściu, mapper.readValue na wejściu. Konstruktor brał ObjectMapper
wprost. Wynikły z tego dwie konsekwencje, z których żadnej nie
uświadomiłem sobie, dopóki generator nie wepchnął mi ich przed oczy.
Pierwsza: codec nie był wymienny. Gdyby konsument chciał Protobuf,
CBOR albo przyszły Panama-native binding JSON, nie było seamu, w
który można by się wpiąć — format był wtopiony w fasadę. To była
świadoma decyzja, gdy ją podejmowałem. Jackson był już codekiem,
który linkowała reszta kernela; wystawienie ObjectMapper na
konstruktorze było ścieżką najmniejszego oporu, gdy budowałem fasadę
(ADR-026), i wmówiłem sobie, że kernel nie ma jeszcze konkretnej
potrzeby drugiego codeka. Lokalnie racjonalne. Problem w tym, że „nie
ma jeszcze konkretnej potrzeby” było zdaniem o kernelu w izolacji — a
konsument, który potrzebował seamu, czyli tier-neutralny generator,
jeszcze nie istniał, gdy podejmowałem tę decyzję.
Druga konsekwencja była gorsza, bo nie była nawet decyzją, którą
pamiętam, że podejmowałem. Fasada nazywała się CommunityWebClient,
a generator emitował tę nazwę do każdego pliku klienta, który
produkował:
public final class WidgetClient {
private final CommunityWebClient client; // tier identity, in user code
public WidgetClient(CommunityWebClient client) { ... }
public Optional<Widget> findById(UUID id) {
try { return Optional.ofNullable(client.get(...)); }
catch (CommunityWebClient.WebClientException ex) { ... } // tier identity again
}
}
Nazwa tier wyciekała do każdej klasy aplikacji, którą tooling wygenerował. To jest build-time’owy odpowiednik reguły, którą już spisałem i egzekwowałem wszędzie indziej w kernelu — The Wall (ADR-006): granica kernela widoczna dla aplikacji musi być implementation-blind. Egzekwowałem ją rygorystycznie w runtime. Nie zauważyłem, że stosuje się też w build-time — do symboli, które pojawiają się w generowanym źródle.
Wzorzec, który już zbudowałem — raz
Tu jest część, która zabolała. Strona serwerowa już miała SPI,
którego brakowało mi po stronie klienta. HttpResponseBodyEncoder
był tier-neutralnym seamem SPI od 0.5.0 (era ADR-009): kontrakt
enkodera, rekord kontekstu enkodowania, registry z fabryką empty(),
reużywalny off-heap nośnik HttpEncodedBody i Community’owy driver
Jacksona, który go implementował. Zaprojektowałem dokładnie właściwy
kształt — encoder + context + registry, z konkretnym codekiem jako
wymiennym driverem — a potem zbudowałem go w dokładnie jednym miejscu
i nie zauważyłem, że ten sam kształt jest potrzebny w trzech innych.
Przestrzeń projektowa codeków ciała to macierz 2×2: {request, response} × {encode, decode}. Jedna ćwiartka miała porządne SPI od
początku — server-side response-encode, wydane w 0.5.0 (ADR-009).
Pozostałe trzy były hardcodowane na Jacksona, wtapiane w różnych
momentach, w miarę jak budowała się każda powierzchnia: client-side
request-encode i response-decode weszły, gdy później budowałem fasadę
klienta (CommunityWebClient, ADR-026), a server-side request-decode
siedział w generowanych handlerach. Nie brakowało mi koncepcji SPI
codeka. Sam ją zaprojektowałem — zanim fasada, która jej potrzebowała,
w ogóle istniała — i potem nie użyłem jej ponownie, gdy tę fasadę
budowałem, bo wtedy ta jedna ćwiartka, którą wypełniłem, była jedyną
z konsumentem patrzącym jej w oczy.
| encode | decode | |
|---|---|---|
| request | client-side — ADR-034 (nowe) | server-side — ADR-036 (przeoczone) |
| response | server-side — ADR-009 (0.5.0) | client-side — ADR-034 (nowe) |
Naprawa (ADR-034)
ADR-034 zamknął połowę po stronie klienta. Sześć nowych typów SPI w
eu.exeris.kernel.spi.http, celowo lustrzanych względem istniejącego
serwerowego trypletu enkodera dla grep-symetrii: HttpRequestBodyEncoder,
HttpRequestEncodingContext, HttpRequestBodyEncoderRegistry po
stronie encode; HttpResponseBodyDecoder, HttpResponseDecodingContext,
HttpResponseBodyDecoderRegistry po stronie decode. Binding Jacksona
stał się dwoma Community’owymi driverami (CommunityJsonRequestBodyEncoder,
CommunityJsonResponseBodyDecoder) za tymi kontraktami. Nośnik
HttpEncodedBody z 0.5.0 został reużyty, a nie zduplikowany.
Koszt jest realny i warto go nazwać: każde ciało przechodzi teraz
przez rozwiązanie w registry i indirekcję drivera, co w oryginalnej
fasadzie było bezpośrednim wywołaniem mapper.writeValueAsBytes. Dla
przypadku jednego codeka to narzut, który płacę, żeby utrzymać seam
otwarty — świadomy trade, nie darmowa wygrana.
Sama fasada wyszła z tieru Community do Core i straciła nazwę tier:
CommunityWebClient stał się KernelWebClient w
eu.exeris.kernel.core.http.client. Jej konstruktor stracił parametr
ObjectMapper — Jackson zszedł do drivera codeka, a Core przestał
mieć jakiekolwiek zdanie o JSON-ie. Generator emituje teraz
KernelWebClient do kodu aplikacji; żadna tożsamość tier nie
wypływa. ADR-034 zastąpił ADR-026, który był wcześniejszym domem
fasady i traktował wyciek nazwy tier jako problem podmiany stringa do
naprawienia w lockstepie — zamiast jako strukturalny symptom, którym
faktycznie był.
Chcę być precyzyjny co do tego, co było decyzją, a co przeoczeniem — bo to nie to samo, a mylenie ich byłoby zbyt dla mnie łaskawe. Hardcodowanie Jacksona w fasadzie klienta było decyzją, której granice doszacowałem za nisko — do obrony w momencie podjęcia, błędną w momencie, gdy pojawił się konsument. Wyciek nazwy tier nie był decyzją w ogóle. To było coś, co złapałbym natychmiast, gdybym przeczytał output generatora jako kod aplikacji, a nie jako swój własny kod. Różnica między tymi dwoma to różnica między źle skalibrowanym osądem a niepatrzeniem — i tylko jedno z nich da się naprawić, myśląc mocniej na etapie projektu.
Przeoczenie wewnątrz naprawy (ADR-036)
To część, którą uważam za najbardziej użyteczną — i to dla niej w ogóle piszę ten artykuł.
ADR-034 dokończył trzy z czterech ćwiartek macierzy:
server-response-encode (już było od 0.5.0), client-request-encode
(nowe), client-response-decode (nowe). Zostawił czwartą — server-side
decode ciała żądania, czyli ciało z wire, które staje się typowanym
argumentem handlera, jak Widget dla POST /widgets. Ta ćwiartka
nie miała seamu SPI w ogóle. Był tam hardcodowany Jackson wewnątrz
generowanego kodu: toolingowy KernelHandlerGenerator emitował
statyczne pole Jacksona MAPPER i import JacksonException do
każdego kontrolera, który produkował. Dokładnie to samo build-time’owe
naruszenie The Wall, które właśnie naprawiłem po stronie klienta —
siedzące nietknięte po stronie serwera, w innym generatorze.
Wyszło dokładnie tak samo jak pierwotny problem — przez czytanie
konsumującego kodu od początku do końca. Pierwszy zestaw luk wyszedł,
gdy ponownie przeczytałem KernelClientGenerator. Ta wyszła, gdy
ponownie przeczytałem KernelHandlerGenerator. Ta sama czynność,
inny plik, ta sama klasa znaleziska.
Chcę to czysto oddzielić od decyzji, którą podjąłem świadomie w
ADR-034, bo uczciwa wersja tej historii zależy od tego rozróżnienia.
ADR-034 świadomie odłożył jedną rzecz: unifikację serwerowej ścieżki
encode — refaktor działającego JsonBodyEncoder z 0.5.0, żeby
subskrybował content-type tak, jak robi to nowy decoder klienta. To
był wybór, zapisany jako sprzątanie na v1.0, bo to refaktor seamu,
który już działa. Serwerowa ćwiartka request-decode była czymś
zupełnie innym: nie seamem, który postanowiłem zostawić w spokoju,
lecz seamem, który nigdy nie istniał — ukrytym w generowanym kodzie,
na który nie patrzyłem. Jedno było odłożone. Drugie przeoczone.
ADR-036 zamknął to przeoczone, lustrując triplet response-decodera
słowo w słowo w HttpRequestBodyDecoder / HttpRequestDecodingContext
/ HttpRequestBodyDecoderRegistry. Oba ADR-y weszły w kernelu 0.8.0 —
czteroćwiartkowe SPI codeka jest w wydanym kernelu dziś. Toolingowy
generator, który konsumuje nowy seam server-decode, został od tamtej
pory przepisany, żeby rozwiązywać przez registry zamiast wtapiać
konkretny symbol codeka; co to oznacza dla release’u toolingu —
wracam do tego na końcu.
To jest część, która ma znaczenie: pracowałem aktywnie dokładnie nad tym problemem — seamy codeków, build-time’owa higiena The Wall, macierz — i wciąż zostawiłem jedną ćwiartkę hardcodowaną, bo ta ćwiartka żyła w konsumencie, którego jeszcze nie przeczytałem ponownie. To nie nieostrożność. Patrzyłem na trzy z czterech miejsc, w których żył wzorzec; czwarte było gdzieś, gdzie nie skierowałem uwagi. Sekcja zamykająca jest o tym, dlaczego to strukturalne, a nie porażka dyscypliny.
Walidator, który zbudowałem, i seam, którego nie
Drugie przeoczenie jest w podsystemie bezpieczeństwa i to ono nauczyło mnie najwięcej — bo gdy pierwszy raz opisałem je sobie, opisałem je źle. Moje początkowe ujęcie brzmiało: „zapomniałem zbudować wsparcie dla identity providerów”. Nie to się stało, a prawdziwa wersja jest bardziej użyteczna.
Nie zapomniałem o tożsamości. Kernel ma walidację tokenów od 0.5.0.
SecurityProvider.authenticate(LoanedBuffer rawToken) zwraca
AuthenticationResult niosący PrincipalContext (principal id,
tenant id, role, scope’y) i StorageContext (decyzję o routingu
multi-tenant). Ten seam jest dziś konsumowany na krawędzi HTTP. Za
nim siedzi CommunityJwksValidator — pipeline Nimbus JOSE, który
robi prawdziwą robotę: kid → key → signature → issuer → audience →
expiry, fail-closed na każdym kroku zgodnie z ADR-012. Walidacja
tożsamości nie jest nieobecna. Działa i działała.
To, co zbudowałem, to pojedynczy, statyczny walidator tylko-RSA
wtopiony w jedną implementację SecurityProvider. Zestaw kluczy to
niemutowalna Map<kid, RSAPublicKey> wstrzyknięta przy konstrukcji —
bez rotacji, bez pobierania JWKS, bez EC, i co kluczowe, bez sposobu,
by ugościć drugiego identity providera bez dziedziczenia albo
duplikowania cross-cutting logiki, która otacza walidację. Pipeline
walidacji to już jakieś osiemdziesiąt procent walidatora
resource-server OIDC. Po prostu wtopiłem te osiemdziesiąt procent w
jedną konkretną klasę, zamiast wystawić je jako kontrakt.
Nie zauważyłem, że to ten sam błąd co codec w HttpClient, dopóki nie zapisałem walidatora w ten sam sposób. Inny podsystem, identyczny kształt: tam zbudowałem jedną ćwiartkę codeka i zostawiłem resztę konkretną; tu zbudowałem jeden walidator i nie zostawiłem seamu na drugi. W obu przypadkach to, co wydałem, działa idealnie dla jednego przypadku, który obsługuje. W obu przypadkach architektura potrzebowała pluggable kontraktu, a ja dałem jej konkretną instancję.
Gdzie to wyszło
Luka w HttpClient wyszła, gdy czytałem generatory toolingu. Ta wyszła, gdy spróbowałem postawić prawdziwy produkt na kernelu.
Deployment B2B SaaS nie uwierzytelnia się przeciw jednemu identity
providerowi. Pracowników wystawia przez jednego (powiedzmy Okta),
użytkowników B2C przez drugiego (Auth0), a ruch service-to-service
przez trzeciego (coś wewnętrznego). Dispatch dzieje się przez
inspekcję issuera tokena przed walidacją. Moja architektura z jednym
SecurityProvider nie miała na to miejsca. Jedynym sposobem
doczepienia drugiego providera był composite na poziomie aplikacji,
który musiałby reimplementować fail-closed, deterministic-deny
inwarianty, które ADR-012 celowo trzyma wewnątrz kernela. Innymi
słowy: żeby użyć kernela do tego, do czego zbudowałem kernel,
aplikacja musiałaby sięgnąć dookoła kernela i reimplementować jego
najbardziej safety-critical kontrakt. To jest ta luka. To nie brak
walidacji. To brakujący seam — a ten seam to dokładnie ta część,
której prawdziwy konsument potrzebuje najpierw.
Konsumentem, który to ujawnił, był BudgetHQ — produkt, który buduję
na kernelu. BudgetHQ jest B2C i B2B naraz: indywidualni użytkownicy z
jednej strony, biznesowe workspace’y z drugiej. Ścieżka B2C
uwierzytelnia się bez problemu przeciw jednemu providerowi — i
dokładnie dlatego luka pozostawała ukryta: walidator jednego
providera obsługuje typowy przypadek bez narzekania. Wyszła w
momencie, gdy zacząłem planować multi-provider authentication dla
biznesowych workspace’ów: workspace federujący własnych pracowników
przez własnego identity providera, podczas gdy użytkownicy B2C dalej
uwierzytelniają się przez domyślnego. Ścieżka od Authorization: Bearer … do wypełnionego PrincipalContext była — dla tego
przypadku multi-provider — po prostu nieobecna jako ścieżka wspierana
przez kernel. Roadmapa nazywa to bez owijania: największy pojedynczy
blocker „wydania B2B SaaS” dla ekosystemu. Wiem to tylko dlatego, że
jestem też tym, kto próbuje wydać ten biznesowy workspace.
Decyzja (RFC, jeszcze nie kod)
Będę szczery co do statusu tego — bo różni się od przypadku HttpClient, a różnica ma znaczenie. Przeoczenie HttpClient jest naprawione i wydane — ADR-034 i ADR-036 weszły w kernelu 0.8.0, a seam jest w kernelu działającym dziś. Toolingowy generator, który go konsumuje, został od tamtej pory przepisany, żeby pasować; jedyny element wciąż w locie to release toolingu niosący ten rewrite, na torze, który nie jest jeszcze otagowany. Przeoczenie IDP jest na zupełnie innym etapie: zdiagnozowane i zdecydowane, ale jeszcze nie zbudowane. Decyzja przeszła przez RFC, a nie prosto do ADR — bo w odróżnieniu od naprawy codeka niosła naprawdę otwarte wybory strategiczne, i chcę pokazać to rozróżnienie, a nie je zamaskować.
Decyzja: wprowadzić dedykowane SPI IdentityProvider, do którego
SecurityProvider deleguje. SecurityProvider zostaje punktem
wejścia, ale staje się cienkim dispatcherem — wybiera jednego
IdentityProvider tanim peekiem na issuer/format, deleguje walidację,
a potem stosuje cross-cutting troski z ADR-012 (isolation-claim →
StorageContext, semantyka fail-closed) jednolicie do dowolnego
dispatchowanego providera. Walidacja staje się per-provider;
orkiestracja zostaje centralna. Pierwszym referencyjnym driverem jest
OIDC + JWKS, bo istniejący CommunityJwksValidator jest już w
większości na miejscu — ekstrakcja to głównie refaktor, czyli
najmniej ryzykowny sposób wydania pierwszego drivera. Registry czyta
się tak samo jak registry codeków z ADR-034 (of(List<…>),
uporządkowane priorytetem) — celowo, żeby identity provider, który
nie wspierałby pluggable wariantów, był wyjątkiem, a nie normą.
Ten dispatch też nie jest darmowy: każde żądanie płaci teraz peek przed walidacją, żeby wybrać providera, zanim ruszy jakakolwiek kryptografia. Tanie obok weryfikacji podpisu, ale to drugi krok na krawędzi auth, którego projekt z jednym walidatorem nie miał.
W tym projekcie czai się ostry failure mode — ten typ rzeczy, który zamienia security seam w security hole. Gdyby dispatcher ponawiał próbę na następnym providerze po nieudanej walidacji wybranego providera, token pasujący do issuera A, ale oblewający podpis A, mógłby zostać „uratowany” przez providera B — federacyjny fail-open. Kontrakt zamyka to normatywnie: selekcja poprzedza walidację i jest od niej oddzielona; gdy provider zostanie wybrany, jego porażka jest terminalna, bez fall-through. Ten inwariant to obowiązkowa asercja TCK, nie komentarz w kodzie. Seam, którego za pierwszym razem nie zbudowałem, okazuje się mieć własność poprawnościową, której w ogóle bym nie wyspecyfikował, gdyby nie zmuszono mnie do zaprojektowania wersji pluggable.
To właśnie kupuje złapanie luki na poziomie seamu zamiast na produkcji: produkcyjna wersja tej lekcji to incydent fail-open, odkryty przez kogoś, kto nie jest tobą, w systemie, którego nie da się już tanio zmienić. Wersja z czasu projektowania to asercja TCK w RFC. Ta sama lekcja, dramatycznie inny koszt.
Propagacja tożsamości na zewnątrz — jak sparsowana tożsamość
podróżuje do następnego serwisu w łańcuchu wywołań — to powiązane
pytanie z własnym seamem (HttpClientRequestEnricher z ADR-032, który
propaguje sparsowane nagłówki X-Tenant-Id / X-Principal-Id,
zamiast przekazywać surowe tokeny bearer). Ta część już istnieje i
jest celowo trzymana wąsko: kernel trzyma sparsowaną tożsamość, nie
surowy credential, więc nie może wyciec tokena, którego nigdy nie
zatrzymał. Seam wejściowy — SPI IdentityProvider — to ten, który
RFC rozstrzyga, a v0.10 implementuje.
Dwa przeoczenia, jeden ślepy punkt
Z zewnątrz te dwa wyglądają jak różne kategorie błędu. Jedno to codec wtopiony w fasadę; drugie to walidator wtopiony w jednego providera. Inne podsystemy, inne konsekwencje, inne naprawy. Ale mechanicznie to ten sam błąd — i oblały tak samo.
Błąd jest ten sam: zbudowałem konkretną instancję tam, gdzie
architektura potrzebowała pluggable seamu — Jackson wtopiony w
klienta, OIDC wtopiony w jeden SecurityProvider. Obie działały, dla
dokładnie tego jednego przypadku, który miał konsumenta patrzącego mu
w oczy, gdy pisałem kod; kształt, którego potrzebowałbym dla drugiego
przypadku, był już albo gdzieś zaprojektowany (serwerowe SPI
enkodera), albo w osiemdziesięciu procentach obecny (walidator JWKS) —
więc to nigdy nie była niewiedza o właściwym projekcie. Failure mode
też jest ten sam: obie luki były niewidoczne od wewnątrz kernela i
oczywiste od strony konsumenta. Defekt nigdy nie był w kodzie, który
czytałem — był w relacji między tym kodem a konsumentem o warstwę czy
dwie dalej, a relacji nie zobaczysz, gapiąc się mocniej w jeden jej
koniec.
To, co faktycznie złapało obie luki, to zajęcie pozycji konsumenta przeze mnie samego. Lukę w codeku znalazłem, bo piszę tooling, który generuje kod pod kernel. Lukę w walidatorze znalazłem, bo buduję produkt, który działa na kernelu. W obu przypadkach przeszedłem ze strony autora granicy na stronę konsumenta, przeczytałem kontrakt stamtąd — i luka była natychmiast oczywista. Nie dlatego, że zmądrzałem w trakcie przejścia — dlatego, że luka jest widoczna tylko z tamtej strony.
To uogólnia się poza mną — i nie w pochlebnym kierunku. W większości zespołów osoba, która projektuje kontrakt, jest strukturalnie odcięta od zajęcia pozycji jego konsumenta. Wydaje kontrakt; inny zespół konsumuje go dwie warstwy niżej; luka wychodzi miesiące później jako incydent na produkcji, którego nikt nie naprawi tanio, bo kontrakt już stwardniał i zebrał zależności. Gaszenie pożaru nie jest oznaką, że ktoś był nieostrożny. To domyślny wynik podziału pracy, w którym projektant nigdy nie stoi tam, gdzie luka jest widoczna. Złapałem swoje na seamie projektowym zamiast na produkcji z jednego strukturalnego powodu, który nie ma nic wspólnego z dyscypliną: tak się składa, że siedzę po obu stronach granicy, którą zaprojektowałem.
Nie sądzę, żeby wniosek brzmiał „zawsze bądź własnym konsumentem” — to często niemożliwe, a postawione tak szeroko jest tym rodzajem rady, która jest prawdziwa i bezużyteczna. Wąska, wykonalna wersja brzmi tak: luki kontraktu żyją na jego krawędzi konsumenta, więc ktoś musi przeczytać kontrakt od strony konsumenta, zanim stwardnieje. Jeśli nie może to być projektant, musi to być prawdziwy konsument wciągnięty wcześnie — nie recenzent oceniający kontrakt na jego własnych warunkach, lecz ktoś zmuszony faktycznie budować przeciw niemu. Najtańszy moment na znalezienie tych luk jest przed tym, jak kontrakt ma zależności. Gdy już je ma, ta sama naprawa kosztuje skoordynowaną zmianę SPI plus migrację każdego konsumenta, który utwardził się względem starego kształtu — czyli stan, w którym większość zespołów odkrywa lukę.
Rekursja z sekcji o HttpClient jest dowodem, że to nie problem siły woli. Zostawiłem czwartą ćwiartkę codeka hardcodowaną podczas aktywnego naprawiania pozostałych trzech. Byłem tak wyczulony na ten dokładnie failure mode, jak tylko będę kiedykolwiek — i wciąż mi umknął, bo czwarta ćwiartka żyła w konsumencie, którego jeszcze nie przeczytałem ponownie. Nie pokonasz tego, próbując mocniej. Pokonasz to, organizując sobie przeczytanie każdego konsumenta od początku do końca, zanim kontrakt wyjdzie — albo godząc się, że te, których nie przeczytasz, są tam, gdzie czeka następna luka.
Czego to nie rozwiązuje
Ta heurystyka — przeczytaj kod konsumenta, zanim kontrakt stwardnieje — ma granicę, co do której chcę być szczery.
Zakłada, że istnieje konsument do przeczytania. Gdy projektujesz kontrakt naprawdę przed jakimkolwiek konsumentem, nie ma jeszcze nic do przeczytania z drugiej strony, a „bądź własnym konsumentem” degeneruje się w zgadywanie. Metoda też nie jest darmowa: wciągnięcie prawdziwego konsumenta wcześnie, zanim kontrakt się ustabilizuje, spowalnia projekt i sprzęga dwa ruchome cele. Ja wchłonąłem ten koszt tanio, bo konsument i kontrakt są oba moje. Zespół, który płaci go świadomie, robi prawdziwy trade, nie zbiera darmowej wygranej.
I nie domyka historii tożsamości. Seam IdentityProvider jest tylko
wejściowy — token do PrincipalContext. Wyjściowa tożsamość
service-to-service poza sparsowanymi nagłówkami (token exchange,
on-behalf-of, client-credentials) jest zarezerwowana w RFC jako
przyszły seam OutboundCredentialProvider, nie zbudowana. Mesh
zero-trust, który potrzebuje, żeby kernel wybił downstreamowy
credential, to dokładnie przypadek, który ten projekt nazywa, a potem
odkłada.
Co dalej
Konkretnie, po stronie kernela:
SPI codeka HttpClient jest wydane. ADR-034 (client-side request
encode + response decode, fasada KernelWebClient) i ADR-036
(server-side request decode, domykające macierz 2×2) weszły oba w
kernelu 0.8.0; czteroćwiartkowy seam jest w kernelu działającym dziś,
0.8.1. Naprawa nie była sprzątaniem — była warunkiem wstępnym.
Tier-neutralnego generatora nie dało się napisać, póki codec był
wtopiony w fasadę, więc seamy, które kernel wydał w 0.8.0, to dokładnie
to, co odblokowało generator, który ich potrzebował. Naprawa kontraktu
w wydanym kernelu oczyściła następną warstwę w górę. Nie naprawiłem
codeka w próżni — naprawiłem go, bo wymagała tego rzecz, którą
budowałem dalej.
I następna warstwa poszła za tym. Generatory, które konsumują te
seamy — KernelHandlerGenerator, KernelClientGenerator — zostały od
tamtej pory przepisane, żeby ich używać: handler rozwiązuje ciało
żądania przez registry decoderów zamiast przez wstawione wywołanie
Jacksona, a klient emituje tier-neutralny KernelWebClient, co domyka
też oryginalny wyciek nazwy tier, którym ten artykuł się otworzył. Ten
rewrite jest zmergowany na głównej linii toolingu. To, co wciąż jest
pending, jest węższe niż rewrite — to sam release toolingu: tooling
siedzi na 0.5.0-SNAPSHOT bez wyciętego taga, celowy hold, którego
następny tag czeka na milestone Capabilities/SKU — tak samo, jak
kernel otagował 0.8.0 dopiero, gdy jego seamy były gotowe. Pętla jest
domknięta w źródle — seam kernela wydany, konsumujące generatory
przepisane — a jedyne, co wciąż jest sekwencjonowane, to release,
który to wysyła.
SPI IdentityProvider jest zdecydowane i zarezerwowane — kierunek
jest zablokowany przez RFC (dedykowane SPI, SecurityProvider jako
dispatcher, OIDC-first jako driver referencyjny, fail-closed
terminalna selekcja). ADR, który blokuje detal, i implementacja —
oba wchodzą w v0.10. Najpierw wchodzi zależność nośna: rotacja kluczy
JWKS z oknem nakładania, w v0.9, którą driver OIDC potem konsumuje.
Żadna z tych napraw nie jest interesującą częścią. Interesującą częścią jest pytanie, które przepuszczam teraz przez każdy kontrakt, zanim nazwę jego projekt zamkniętym: kto to konsumuje — i czy przeczytałem jego kod, nie swój, od początku do końca? To słabe pytanie. Nie brzmi jak architektura. Ale łapie tę konkretną klasę luki, na którą mocne myślenie architektoniczne jest strukturalnie ślepe — i po dwóch instancjach tego samego ślepego punktu ufam słabemu pytaniu bardziej niż własnej pewności, że projekt jest kompletny.
Omawiane tu SPI codeka żyje w exeris-kernel-spi pod eu.exeris.kernel.spi.http; fasada KernelWebClient jest w exeris-kernel-core. Seam bezpieczeństwa (SecurityProvider, PrincipalContext, StorageContext) jest w exeris-kernel-spi pod eu.exeris.kernel.spi.security. Zapisy decyzji — ADR-034, ADR-036, ADR-032 i RFC o IdentityProvider — są w drzewie docs kernela:
🔗 exeris-systems/exeris-kernel