Governance w dbt — co naprawdę blokuje, a co tylko opisuje

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 dbtCo dbt faktycznie egzekwujeCo się zwykle zakładaGdzie to naprawdę żyje
group + accessktóre modele mogą się do siebie odwołać przez ref()kto może czytać albo budować modelIAM w GCP, uprawnienia w orkiestracji
owner grupynic — to tekst w dbt docskto odpowiada i zatwierdza zmianyCODEOWNERS + branch protection w gicie
severity: warnnic — test się wykonuje, build leci dalej„miękki”, ale działający testerror albo routing alertów
source freshness error_afternic w dbt build — to osobna komendapipeline nie ruszy na starych danychjawny krok w CI/orkiestracji
zielony dbt parsepoprawność 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:

  1. not_null na user_id daje ostrzeżenie. Build leci dalej.
  2. dim_orders buduje się z LEFT JOIN, więc days_since_first_order dostaje NULL dla tych zamówień.
  3. not_null na days_since_first_order — też warn — daje drugie ostrzeżenie. Build leci dalej.
  4. 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ę:

  • error na testach kluczy joinów i agregacji — user_id, created_at, wszystko, na czym stoi logika dalszych modeli. Złamany klucz ma zatrzymać build.
  • warn tylko 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-failures do tabeli plus alert. Bez routingu warn to test bez odbiorcy.
  • Freshness jako jawny krok — w orkiestracji najpierw dbt source freshness, sprawdzenie exit code, dopiero potem dbt 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:

  1. accepted_values na department w stagingu produktów pada — ale na warn, czyli wracamy do poprzedniej sekcji.
  2. dim_orders przebudowuje się z nową kolumną total_sold_kidsswear. Podwójne „s”, bo sklejanie .lower() z swear działało tylko dlatego, że men i women przypadkiem dają sensowne słowo.
  3. Nowa kolumna nie ma opisu w YAML-u, więc nie ma go też w metadanych BigQuery.
  4. 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:

PytanieWarstwaMechanizm
Kto może zmienić model?gitCODEOWNERS + branch protection
Kto może zbudować model?GCPIAM konta serwisowego, osobne konta dla dev i prod
Kto może czytać tabelę?BigQueryIAM na datasecie, grants w dbt
Czy złamany test zatrzyma pipeline?dbt + orkiestracjaseverity: error, routing alertów dla warn
Czy źródło jest świeże?orkiestracjadbt source freshness jako osobny, blokujący krok
Czy mart zmienił schemat?dbt + CIcontract: 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.

Powiązane wpisy