Governance w dbt — co naprawdę blokuje, a co tylko opisuje
- dbt
- BigQuery
- Data Governance
W projekcie dbt, nad którym ostatnio pracowałem, są dwa komentarze, które sobie przeczą. Pierwszy, w dbt_project.yml, nad +group: sales: „Group to mechanizm kontroli dostępu”. Drugi, w dim_orders.yml, kilka plików dalej: grupa i access „nie mają wpływu na uprawnienia w samej bazie danych”.
Rację ma drugi. Ale pierwszy brzmi tak naturalnie, że przeszedłby każde code review — i to jest powód, dla którego piszę ten tekst.
To mały projekt analityczny na publicznym datasecie thelook_ecommerce w BigQuery — staging, warstwa intermediate, jeden mart dim_orders. Nie produkcja, nie klient. Przejrzałem jego konfigurację linijka po linijce i z kilkunastu poprawek, które wyszły, trzy układają się w jeden wzór: w dbt większość zabezpieczeń to metadane. Opisują intencję. Egzekwowanie — CI, orkiestrację, IAM, gita — dobudowujesz sam.
Governance w dbt — co jest bramką, a co etykietą
Zanim wejdę w kod, zestawienie. Lewa kolumna to to, co wpisujesz w YAML. Prawa — gdzie żyje to, co ludzie zwykle zakładają, że ta linijka załatwia.
| Konfiguracja w dbt | Co dbt faktycznie egzekwuje | Co się zwykle zakłada | Gdzie to naprawdę żyje |
|---|---|---|---|
group + access | które modele mogą się do siebie odwołać przez ref() | kto może czytać albo budować model | IAM w GCP, uprawnienia w orkiestracji |
owner grupy | nic — to tekst w dbt docs | kto odpowiada i zatwierdza zmiany | CODEOWNERS + branch protection w gicie |
severity: warn | nic — test się wykonuje, build leci dalej | „miękki”, ale działający test | error albo routing alertów |
source freshness error_after | nic w dbt build — to osobna komenda | pipeline nie ruszy na starych danych | jawny krok w CI/orkiestracji |
zielony dbt parse | poprawność grafu i YAML | „projekt się kompiluje” | dbt build na żywym połączeniu, kontrakt |
Z tej tabeli dbt blokuje coś naprawdę w jednym wierszu na pięć. Reszta to deklaracje.
group, access, owner — granica ref(), nie kontrola dostępu
group i access robią dokładnie jedną rzecz: pilnują, które modele mogą się do siebie odwołać. private — tylko modele z tej samej grupy. protected — grupa i projekt. public — każdy projekt dbt, także w układzie wieloprojektowym. Złamanie reguły wywala się, zanim dbt wyśle cokolwiek do BigQuery. To prawdziwa bramka — tylko wąska.
Nic z tego nie mówi, kto może odpalić dbt run. To decyduje tożsamość z profiles.yml — konto serwisowe albo OAuth — i jej role w GCP. Nic nie mówi też, kto może przeczytać dim_orders w konsoli BigQuery albo podpiąć ją do Looker Studio. To IAM na datasecie. Jedyna konfiguracja dbt, która dotyka uprawnień w bazie, to grants — i nie ma ona związku z group ani access.
W dbt_project.yml wygląda to tak:
models:
ecommerce_analytics:
marts:
+group: sales
I w dim_orders.yml:
groups:
- name: sales
owner:
email: ... # placeholder, nigdzie niewalidowany
models:
- name: dim_orders
config:
access: public
Trzy rzeczy, które z tego wynikają, a których nie widać na pierwszy rzut oka.
Po pierwsze, każdy nowy model wrzucony do models/marts/ po cichu dziedziczy group: sales. Nikt z „sales” go nie widział, nikt nie zatwierdził. To zwykłe dziedziczenie configu, nie proces.
Po drugie, folder ustawia group, ale nie access. Nowy mart dostanie domyślne protected, a nie public jak dim_orders. Łatwo założyć odwrotnie — i zdziwić się przy pierwszym ref() z innego projektu.
Po trzecie, owner to czysty tekst. dbt wymaga tylko, żeby było tam name albo email. Nie sprawdza, czy adres istnieje, czy ta osoba ma jakikolwiek związek z tym, kto uruchamia model, ani z tym, kto może zmienić jego kod. W tym projekcie stoi tam placeholder i build nie ma z tym najmniejszego problemu.
„Kto może zmienić ten model” to w ogóle nie jest pytanie do dbt. Odpowiada na nie git: plik CODEOWNERS mapujący models/marts/ na właściciela plus branch protection, która wymaga jego akceptacji przy PR-ze. Dopiero wtedy owner w YAML-u opisuje coś, co ktoś faktycznie pilnuje.
To ten sam podział ról, który opisywałem przy data governance w analityce internetowej: Owner decyduje, Steward pilnuje reguł, Custodian trzyma IAM i dostępy. dbt obsługuje kawałek pracy Stewarda. Custodiana w nim nie ma.
severity: warn — test, który niczego nie zatrzymuje
Drugi wzór siedzi w jednej linijce na dole dbt_project.yml:
tests:
ecommerce_analytics:
+severity: warn
Z komentarzem, który brzmi rozsądnie: problem jakości danych ma być widoczny, ale pipeline ma polecieć dalej. Wyjątek z error jest jeden — klucz główny order_id w stg_ecommerce__orders.
Tylko że „widoczny” znaczy tu: żółta linijka w logu, który nikt nie czyta. severity: warn to nie miękki test. Test się wykonuje, znajduje błędne wiersze, zapisuje ostrzeżenie — a dbt build idzie dalej i buduje modele zależne, jakby nic się nie stało. Exit code zero. Orkiestrator widzi sukces.
Konkretnie w tym projekcie: user_id i created_at w stagingu zamówień mają not_null na domyślnym warn. user_id to klucz joina w dim_orders do modelu z datą pierwszego zamówienia klienta. Gdy w źródle pojawią się zamówienia bez user_id:
not_nullnauser_iddaje ostrzeżenie. Build leci dalej.dim_ordersbuduje się zLEFT JOIN, więcdays_since_first_orderdostajeNULLdla tych zamówień.not_nullnadays_since_first_order— teżwarn— daje drugie ostrzeżenie. Build leci dalej.- Tabela ląduje w BigQuery. Dashboard pokazuje dziurę w metryce retencji.
Dwa testy złapały problem. Żaden go nie zatrzymał. W YAML-u martu stoi przy tym komentarz, że dim_orders „ufa, że staging już zweryfikował dane”. Ufa istnieniu testu, nie temu, że test cokolwiek blokuje.
Ten sam mechanizm dotyczy source freshness. Źródło events ma w src_ecommerce.yml ustawione error_after na 24 godziny — brzmi jak twarda reguła. Ale dbt source freshness to osobna komenda, a dbt build składa się z seedów, snapshotów, modeli i testów. Świeżości źródeł nie sprawdza. Jeśli nic w orkiestracji nie spina wyniku freshness z dalszą częścią pipeline’u, error_after nie zatrzyma niczego.
Co z tym robię:
errorna testach kluczy joinów i agregacji —user_id,created_at, wszystko, na czym stoi logika dalszych modeli. Złamany klucz ma zatrzymać build.warntylko tam, gdzie ktoś go zobaczy — reguły biznesowe i kosmetyczne (statusy, kolejność timestampów) mogą zostać ostrzeżeniem, pod warunkiem że ostrzeżenie gdzieś trafia:--store-failuresdo tabeli plus alert. Bez routinguwarnto test bez odbiorcy.- Freshness jako jawny krok — w orkiestracji najpierw
dbt source freshness, sprawdzenie exit code, dopiero potemdbt build. W Airflow to dwa taski z zależnością, w CI dwa kroki joba.
Odwrócenie defaultu też wchodzi w grę: globalnie error, warn punktowo z uzasadnieniem. Wtedy zapomniany wyjątek kończy się głośnym błędem, a nie cichym przepuszczeniem.
Zielony dbt parse, który nie widział SQL-a
Trzeci wzór jest najmniej oczywisty, bo dotyczy samej bramki. dbt parse jest szybki, nie potrzebuje połączenia z bazą — kusi, żeby postawić go w CI jako test „czy projekt się kompiluje”. W tym projekcie przechodził czysto, zero ostrzeżeń.
Tyle że dbt parse w CI sprawdza graf i YAML, nie SQL. A w przypadku dim_orders sprawdza model, który nie ma połowy kolumn.
dim_orders generuje kolumny sprzedaży per dział pętlą Jinja:
{%- set departments = dbt_utils.get_column_values(
table=ref('int_ecommerce__order_items_products'),
column='product_department') -%}
{% for department in departments %}
SUM(IF(product_department = '{{ department }}',
item_sale_price, 0))
AS total_sold_{{ department.lower() }}swear
{{- "," if not loop.last }}
{%- endfor %}
get_column_values odpytuje bazę, żeby poznać listę działów — i dopiero z niej wiadomo, ile kolumn powstanie. Podczas parsowania dbt nie wykonuje zapytań: zmienna execute ma wartość false, a makro w dbt_utils ma na tę okoliczność jawny warunek na starcie i zwraca pustą listę. Pętla wykonuje się zero razy. Model renderuje się bez total_sold_menswear i total_sold_womenswear. Parse kończy się sukcesem.
To nie jest teoretyczny przypadek. We wcześniejszej wersji tego modelu druga pętla — ta w końcowym SELECT — odwoływała się do zmiennej, której nigdzie nie zdefiniowano. Jinja wyrenderowała ją jako pusty ciąg, kolumny total_sold_* zniknęły z martu, a nic nie zgłosiło błędu. Błąd wyszedł przy przeglądzie kodu, nie z żadnej bramki.
Gorzej robi się, gdy źródło się zmieni. Załóżmy, że w danych pojawia się nowy dział, Kids:
accepted_valuesnadepartmentw stagingu produktów pada — ale nawarn, czyli wracamy do poprzedniej sekcji.dim_ordersprzebudowuje się z nową kolumnątotal_sold_kidsswear. Podwójne „s”, bo sklejanie.lower()zsweardziałało tylko dlatego, żemeniwomenprzypadkiem dają sensowne słowo.- Nowa kolumna nie ma opisu w YAML-u, więc nie ma go też w metadanych BigQuery.
- Nie ma na niej żadnego testu. Jedyna nowa miara biznesowa w marcie jest jedyną nietestowaną.
I jeszcze jeden szczegół: get_column_values domyślnie sortuje wartości po liczności (count(*) desc). Kolejność kolumn total_sold_* zależy więc od tego, który dział ma więcej wierszy. Gdy proporcje się odwrócą, kolumny zamienią się miejscami przy najbliższym przebiegu — bez zmiany w kodzie. Dla raportu na SELECT * to zmiana kontraktu.
A dim_orders ma access: public. Deklaruje się jako stabilny punkt dla innych projektów i narzędzi BI — i nic tej deklaracji nie pilnuje.
Naprawa to model contract w dbt:
models:
- name: dim_orders
config:
access: public
contract:
enforced: true
columns:
- name: order_id
data_type: int64
# ... każda kolumna z data_type
Z contract: enforced: true dbt przed budową porównuje kolumny i typy z YAML-a z faktycznym wynikiem modelu i przerywa build przy rozjeździe. Nowy dział w danych przestaje być cichą zmianą schematu — staje się czerwonym buildem, który ktoś musi świadomie obsłużyć. Koszt: data_type przy każdej kolumnie i lista działów wpisana w YAML. W modelu z dynamicznymi kolumnami ten koszt jest dokładnie tym, czego chcesz — wymusza decyzję.
Druga połowa naprawy to CI, które faktycznie rozmawia z bazą: dbt build na osobnym datasecie, najlepiej tylko na zmienionych modelach i ich potomkach (--select state:modified+ z porównaniem do manifestu z produkcji). dbt parse może zostać jako szybki pierwszy krok. Nie może być ostatnim.
Gdzie dobudować egzekwowanie
Wszystkie trzy przypadki mają wspólną cechę: konfiguracja wygląda na zabezpieczenie, build jest zielony, a dane i tak wychodzą złe albo niepilnowane. dbt świetnie opisuje intencję — kto jest właścicielem, co jest publiczne, jaki test obowiązuje. Egzekwowanie leży w warstwach, których dbt nie posiada:
| Pytanie | Warstwa | Mechanizm |
|---|---|---|
| Kto może zmienić model? | git | CODEOWNERS + branch protection |
| Kto może zbudować model? | GCP | IAM konta serwisowego, osobne konta dla dev i prod |
| Kto może czytać tabelę? | BigQuery | IAM na datasecie, grants w dbt |
| Czy złamany test zatrzyma pipeline? | dbt + orkiestracja | severity: error, routing alertów dla warn |
| Czy źródło jest świeże? | orkiestracja | dbt source freshness jako osobny, blokujący krok |
| Czy mart zmienił schemat? | dbt + CI | contract: enforced, dbt build w CI zamiast samego dbt parse |
Przy każdej linijce YAML-a, która brzmi jak zabezpieczenie, zadaję jedno pytanie: co się stanie, gdy ta reguła zostanie złamana? Jeśli odpowiedź brzmi „pojawi się wpis w logu” albo „zmieni się opis w dbt docs” — to nie jest zabezpieczenie. To dokumentacja. Przydatna, ale trzeba ją tak nazywać.
Jeśli pracujesz z dbt i BigQuery przy pomocy agenta, ten podział ma jeszcze jedną konsekwencję: agent czyta YAML tak samo jak człowiek i tak samo łatwo uzna access: public za kontrolę dostępu. O tym, jak dawać agentowi realny kontekst projektu, pisałem przy Google MCP Toolbox dla dbt i BigQuery.