Plan nie całkiem tylko do odczytu
Nasz potok CI logował się do chmury parą długowiecznych kluczy dostępu, które od lat leżały we współdzielonym magazynie sekretów. To był ten rodzaj poświadczenia, który działa wiecznie, i właśnie dlatego chcieliśmy się go pozbyć. Cel był czysty i modny: zamienić stałe klucze na krótkotrwałą tożsamość federacyjną — system CI przedstawia token OIDC, chmura sprawdza go wobec polityki zaufania, a na czas joba przyjmowana jest rola. Żadnych kluczy do rotowania, żadnych kluczy do wycieku, żadnej archeologii „kto zostawił to w zmiennych grupy”.
Trzeba było trzech osobnych porażek potoku, żeby się nauczyć, że plan, który planuje infrastrukturę, wcale nie jest tylko do odczytu — i jeszcze jednej, żeby się nauczyć, że „sukces”, który świętowaliśmy, nigdy naprawdę się nie wydarzył.
Haczyk: wymień klucze, utrzymaj potoki na zielono
Całość obejmowała trzy środowiska chmurowe („coś jak dev”, „coś jak test”, „coś jak prod”), każde na własnym koncie, każde z własnym state. Projekt był podręcznikowy: dwie role na środowisko.
┌──────────────────────────────┐ ┌──────────────────────────────┐ │ rola ZAPISU │ │ rola „plan” TYLKO DO ODCZYTU│ │ zaufanie: tylko main + │ │ zaufanie: dowolna gałąź │ │ gałąź release │ │ funkcji │ │ uprawnienia: wszystko │ │ uprawnienia: zarządzana │ │ │ │ polityka „read-only” AWS │ │ używana przez: prawdziwe │ │ używana przez: podglądy MR │ └──────────────────────────────┘ └──────────────────────────────┘
Pomysł: merge request z gałęzi dowolnego programisty powinien móc uruchomić nieszkodliwy podgląd planu infrastruktury, ale prawdziwego apply powinny dotykać tylko chronione refy. Rola planu dostała więc zarządzaną politykę „read-only”, a rola apply pełny dostęp. Czysty podział. Minimalne uprawnienia, zrobione porządnie.
Potem wypchnęliśmy gałąź i patrzyliśmy na potok.
Zwrot pierwszy: „tylko do odczytu” nie umie czytać
Pierwszy plan MR padł na pierwszym zasobie, który próbował odświeżyć:
Error: AccessDenied ... not authorized to perform: secretsmanager:GetSecretValue on <a secret>
Nie literówka, nie problem z polityką zaufania. Rola została przyjęta — to działało. Rzecz w tym, że polityka „read-only” jawnie zabrania czytania wartości sekretów. „Tylko do odczytu” znaczy tam „możesz listować, opisywać i oglądać metadane” — ale czytanie faktycznej wartości sekretu to osobna akcja, którą zarządzana polityka „wszystko poza zapisem” celowo wyklucza. Jak przy staniu na kredowym obrysie: „wszystkie odczyty” okazały się nie znaczyć „wszystkich odczytów”.
Oto część, przez którą przez chwilę goniliśmy nie to, co trzeba: nasz siostrzany projekt — ten sam zespół, ten sam wzorzec federacji — przeszedł dokładnie tę migrację tygodnie wcześniej, a jego rola planu nie potrzebowała niczego takiego. Jego rola tylko do odczytu działała na samej zarządzanej polityce.
Różnicą nie była rola. Był nią state. State siostrzanego projektu nie zawierał zasobów z wartościami sekretów, więc jego plan nigdy nie dotykał wartości i nigdy nie trafiał na tę granicę. Nasz state zawierał ich dziesiątki (główne poświadczenia baz danych, tokeny usług, sekrety poszczególnych usług). Plan odświeża bieżącą wartość każdego z nich, a odświeżenie wartości wymaga dokładnie tego jednego uprawnienia, którego „read-only” odmawia.
Lekcja pierwsza: „tylko do odczytu” to twierdzenie o polityce, a nie o tym, czego faktycznie potrzebuje twój state. Plan, który odświeża wartości sekretów, nie jest tylko do odczytu, jakkolwiek nazywa się rola.
Zwrot drugi: jeden zaszyfrowany sekret
Daliśmy roli planu ograniczoną instrukcję „czytaj wartości tych sekretów”. Środowiska dev i test zmieniły się na zielone. Produkcja została czerwona — na jednym sekrecie.
Jeden sekret. Produkcja przeszła wszystko poza jedną wartością. Dlaczego ta była inna?
Bo była zaszyfrowana kluczem zarządzanym przez klienta. Zarządzana polityka „read-only” w chmurze odmawia też Decrypt, a ten jeden sekret był zamknięty za kluczem, którego właścicielem był zespół. Odczytanie go wymagało dwóch uprawnień, a nie jednego: odczytania wartości i jej odszyfrowania. Daliśmy pierwsze, zapomnieliśmy o drugim.
Poprawką było uprawnienie Decrypt, które na papierze wyglądało niepokojąco — Resource: "*" — ale w praktyce było bezpieczne, bo przypięte warunkiem ViaService do jedynej usługi chmurowej, która zgodnie z prawem wywołuje odszyfrowanie, i nadal ograniczone do sekretów, które już dopuściliśmy. Wygląda szeroko, działa wąsko.
Lekcja druga: jeden uparty sekret to znak, że odszyfrowywane jest coś jeszcze. Wartość, która nie wraca, to zwykle problem z kluczem, a nie z sekretem.
Zwrot trzeci: klaster pyta „kim jesteś?”
Potem ten sam plan, już umiejący czytać sekrety... padł na zasobach Kubernetesa z krótkim Unauthorized. Nie błąd uprawnień — błąd tożsamości. Rola miała porządny dostęp IAM: mogła listować klastry, czytać wszystko w AWS. Ale API Kubernetesa po prostu jej nie rozpoznawało.
IAM w chmurze i uwierzytelnianie w klastrze to osobne osie. Ważne poświadczenia chmurowe doprowadzają cię do drzwi wejściowych klastra; nie otwierają drzwi. Trzeba jawnie zarejestrować podmiot w klastrze („access entry”) i powiedzieć, co może robić w środku. Nasza rola planu miała dostęp do chmury i zerową obecność w klastrze, więc każde wywołanie w stylu kubectl w trakcie planu wracało jako Unauthorized.
Daliśmy jej rolę klastra tylko do odczytu — taką, która naprawdę jest tylko do odczytu, ale obejmuje Secrets, bo narzędzia konfiguracyjne czytają stan wydań z Secrets podczas planowania, a standardowa rola „view” celowo je ukrywa.
Lekcja trzecia: poprawne poświadczenia chmurowe i poprawny dostęp do klastra to dwa osobne uprawnienia. „Uwierzytelnienie zadziałało” i „mam autoryzację do rzeczy, której dotykam” to różne zdania.
Zwrot czwarty (ten podstępny): „sukces”, który się nie wydarzył
Gdzieś po drodze mieliśmy uruchomienie, które przeszło. Całe zielone. „Poprawka działa!” Tyle że nie — kiedy uruchomiliśmy je ponownie, ten sam job padł.
Znikający sukces był nieaktualną powtórką. Kiedy klikasz „re-run” na zakończonym jobie CI, system nie uruchamia twojego najnowszego kodu — odtwarza dokładnie ten commit, z którego job powstał, czyli u nas starą rewizję sprzed poprawki. Zielone uruchomienie było tak naprawdę innym jobem (ze starszego potoku, działającym na pozostałej ścieżce poświadczeń), a my porównywaliśmy stary sukces z nową porażką i nazywaliśmy to regresją.
old job (old commit, old creds) -> green <- the "success" we trusted new job (fixed commit, OIDC) -> red <- the real signal
Wypchnięcie prawdziwego commita dało czysty potok na bieżącej rewizji i mętny obraz się wyklarował.
Lekcja czwarta: ponowne uruchomienie to nie świeże uruchomienie. Jeśli zaliczonego joba nie da się odtworzyć kodem, który byś wypuścił, to nie twój kod przeszedł.
Rozwiązanie
Wdrożyliśmy zmianę: dwie ograniczone role na środowisko (zapis na chronionych refach, tylko do odczytu z dostępem do sekretów na gałęziach funkcji), jawne access entries w klastrze, żeby obie role były rozpoznawane, i istniejący wcześniej provider federacji przejęty importem zamiast tworzenia od nowa. Plany wszystkich trzech środowisk przechodzą pod krótkotrwałą tożsamością, a długowieczne klucze — pierwotny powód całego ćwiczenia — czekają na usunięcie.
Wskazówki dla czytelnika
- To, że nie możesz zapisywać, nie znaczy, że możesz czytać. Zarządzane polityki „read-only” po cichu zabraniają czytania wartości sekretów. Tylko twój state decyduje, czy to ugryzie.
- Granicą jest odświeżanie wartości. Plan, który ponownie czyta zapisane sekrety, potrzebuje uprawnienia do ich odczytu. Rola planu „tylko do odczytu”, która potyka się o sekret, działa zgodnie z projektem.
- Jeden zawodzący sekret zwykle oznacza klucz. Jeśli jedna wartość nie chce wrócić przy
Decrypt, jest zaszyfrowana czymś, czego rola nie może odszyfrować — sprawdź zawężanie w styluViaService, żeby szeroko wyglądające uprawnienie zostało wąskie. - Dostęp do chmury ≠ dostęp do klastra. Rejestruj role CI jako jawne podmioty klastra z naprawdę tylko-do-odczytu polityką, inaczej każdy odczyt w klastrze w trakcie planu będzie
Unauthorized. - Re-run odtwarza stary commit. Żeby przetestować najnowszą zmianę, wypchnij ją i uruchom tę — nie ufaj powtórce joba utworzonego przed twoją poprawką.