Przejdź do treści

Sekrety i vault

Jeden moduł i celowo żadnego drugiego mechanizmu

Każdy klucz providera, każdy token bota kanału, każde poświadczenie MCP i każdy klucz API strony trzeciej na tej platformie przechodzi przez app/core/vault.py. Dodanie drugiego sposobu przechowywania poświadczenia w spoczynku to defekt, który usunęły dwie migracje.

Szyfrowanie kopertowe

Każdy sekret jest pieczętowany własnym losowym kluczem danych. Ten klucz danych jest pieczętowany kluczem wyprowadzonym z klucza głównego oraz ze scope'u, który jest właścicielem sekretu — organizacji albo członka, do którego należy osobiste połączenie.

flowchart LR
    M["VAULT_MASTER_KEY<br/><i>version n</i>"] --> K
    S["the owning scope<br/><i>org id, or member id</i>"] --> K
    K["derived key"] -->|wraps| D["a random data key<br/><i>one per secret</i>"]
    D -->|seals| C["the ciphertext<br/><i>+ key_version</i>"]

Wynikają z tego dwie własności i obie są powodem takiego kształtu:

Szyfrogramu nie da się przenieść między właścicielami

Nawet przy pełnym dostępie do bazy danych wiersz skopiowany z organizacji A do organizacji B nie da się odpieczętować. Izolacja tenantów jest tu kryptograficzna, a nie klauzulą WHERE, o której ktoś może zapomnieć.

Klucz główny da się rotować. Nigdy nie szyfruje ładunku bezpośrednio, tylko klucze danych, więc rotacja opakowuje na nowo jeden mały blob na sekret, zamiast przeszyfrowywać każdą wartość. Każda koperta zapisuje key_version, którym została zapieczętowana, i to właśnie czyni etapową rotację w ogóle możliwą.

Vault nie decyduje o tym, kto może przeczytać sekret — od tego jest warstwa uprawnień. Gwarantuje wyłącznie, że sekret w spoczynku jest nieczytelny bez klucza głównego i bezużyteczny poza scope'em, dla którego został zapieczętowany.

Jak stało się to jednym mechanizmem

To zdanie na górze potrzebowało dwóch rund, żeby stać się prawdą, a ta historia jest warta minuty, bo pokazuje kształt tego błędu.

Kiedyś sekrety w spoczynku trzymały trzy mechanizmy i tylko jeden wiązał szyfrogram z jego właścicielem. Klucze providerów szły przez vault, tokeny botów kanałów przez pojedynczy klucz Fernet obowiązujący dla całego wdrożenia, a tokeny MCP przez jeszcze inny. Token Slacka dało się skopiować z wiersza jednej organizacji do wiersza innej i dawał się odszyfrować. Jedna migracja usunęła te dwa, zanim łańcuch został spłaszczony do 0001_baseline.

Czwarty to przeżył i przeżył też zdanie nad sobą o dobrych kilka miesięcy. app/core/crypto.py trzymał jeden klucz Fernet obowiązujący dla całego wdrożenia nad polami poświadczeń w sync_sources.config — JSON-em konta serwisowego Google i parą kluczy AWS, którymi uwierzytelnia się konektor synchronizacji RAG.

Był wobec siebie uczciwy we własnym docstringu i mimo to był drugim mechanizmem, więc czytelnik, który wierzył, że „nie ma drugiego mechanizmu", nie miał racji co do jednej tabeli.

Przy życiu trzymał go problem kolejności, a nie różnica zdań: koperta jest wyprowadzana z identyfikatora właściciela, a sync_sources.organization_id była nullowalna, bo CLI tworzyło wiersze bez niej. #707 dało rag-source-add organizację, 0042_sync_source_secret_id sprawiło, że kolumna to odzwierciedla, a #937 usunęło moduł.

Źródło synchronizacji odwołuje się teraz do sekretu w vault po id, tak jak robią to ModelProfile.secret_id i CapabilityBindingSpec.secret_id, a jego config trzyma tylko to, czego konektor potrzebuje, żeby znaleźć dokumenty.

Dwie konsekwencje poza samą kryptografią, i to je zauważa operator: poświadczenie dodaje się raz i używa go każde źródło, które go potrzebuje, zamiast wklejać je osobno przy każdym źródle i rotować w tylu miejscach; oraz pojawia się na stronie Vault jak wszystko inne, więc pytanie „czy ta organizacja ma poświadczenie Google" ma odpowiedź.

Rodzaje

Sekret nie zawsze jest łańcuchem znaków, a wtłaczanie każdego poświadczenia w jedno pole „API key" daje formularz, który ktoś wypełnia poprawnie i i tak kończy z poświadczeniem, które zawodzi przy pierwszym runie. Sekret ma więc rodzaj, a rodzaj decyduje o tym, jakie pola istnieją.

Rodzaj Pola
api_key Jeden nieprzezroczysty token
azure_openai Klucz, endpoint, przypięta wersja API
aws_credentials Access key id, secret access key, region, opcjonalny token sesji
gcp_service_account JSON konta serwisowego, walidowany przy wprowadzaniu
github_oauth_app Publiczny client id aplikacji GitHub OAuth App i jej sekret
none Nie jest sekretem — znacznik endpointu, który nie potrzebuje poświadczenia

github_oauth_app jest zużywany przez platformę, a nie wybierany przez człowieka — proces łączenia z GitHubem czyta go po stronie serwera, żeby przeprowadzić wymianę tokenów — musi więc być widoczny dla organizacji i musi być dokładnie jeden: prywatne poświadczenie członka nigdy nie zostaje po cichu użyte dla połączenia całej organizacji, a przy dwóch zapisanych aplikacjach widocznych dla organizacji łączenie zostaje odrzucone (z nazwaniem obu), zamiast zostać przypisane do tej, której nazwa sortuje się pierwsza.

aws_credentials to najczytelniejszy argument za tym, żeby rodzaje w ogóle istniały: access key id nie jest tajny, a secret access key jest, i jedno pole nie potrafi tego wyrazić. gcp_service_account jest walidowany w momencie wklejenia, bo tryb awarii źle sformowanego JSON-a to błąd uwierzytelnienia wiele godzin później, przy którym nic nie wskazuje z powrotem na wklejenie, które go spowodowało.

none to jest to, co zapisujesz dla Ollamy na localhoście. Jest rodzajem, a nie pustym łańcuchem znaków, żeby resolver mógł przełączać się po pełnym zbiorze — i dlatego, że vault odmawia zapieczętowania pustej wartości. Tylko runtime może trzymać none; nikt nie może takiego zapisać, i to właśnie trzyma „sekret bez wartości" poza schematem API.

Każde pole, które uwierzytelnia — klucz API, secret access key, client secret — musi mieć co najmniej osiem znaków. Lista pokazuje jako podpowiedź cztery ostatnie znaki poświadczenia, więc krótsza wartość zostałaby opublikowana w całości przez własną podpowiedź; ta dolna granica wyłapuje też ucięte wklejenie, póki formularz jest jeszcze otwarty.

Gdzie są używane

Providerzy modeli. Nazywani przez profil modelu. Wydatek jest przypisywany do sekretu, do którego rozwiązał się dany run, i tak właśnie pytanie „który klucz kosztuje najwięcej" dostaje odpowiedź.

Capabilities. Capability deklaruje, że potrzebuje poświadczenia danego rodzaju — nigdy konkretnej instancji. Kod mówi „potrzebuję klucza API"; secret_id w bindingu mówi, którego. Zobacz katalog capabilities.

Połączenia MCP. Tokeny bearer i ładunki OAuth, pieczętowane dla organizacji albo dla członka. Zobacz MCP.

Boty kanałów. Każde poświadczenie w wierszu, zapieczętowane dla organizacji bota jednym wspólnym key_version: token bota, signing secret i app token aplikacji Slacka oraz wspólny sekret, wobec którego uwierzytelniany jest przychodzący webhook — X-Telegram-Bot-Api-Secret-Token Telegrama, token wychodzącego webhooka Mattermosta. Zobacz Kanały.

Wyzwalacze zdarzeń. Sekret, wobec którego weryfikowany jest przychodzący webhook wyzwalacza zdarzeń — klucz HMAC GitHuba albo signing secret wysyłany przez przekaźnik pocztowy lub API — zapieczętowany dla organizacji i zapisany bezpośrednio w wierszu wyzwalacza razem z key_version, który go zapieczętował, w tym samym kształcie co signing secret bota kanału. Nigdy nie jest zwracany ani logowany jawnie; weryfikacja odpieczętowuje go, porównuje w stałym czasie, a dostarczenie, które się nie powiedzie, to 403. Zobacz Pojęcia.

Embedy. Widget jwt weryfikuje tokeny odwiedzających wobec signing secretu HS256, który trzyma backend klienta. Jest zapieczętowany dla organizacji agenta i zapisuje swój key_version jak każdy inny zapieczętowany wiersz, więc rotacja klucza głównego może go przepakować przez rewrap, a widget dalej weryfikuje — podczas gdy embed, który nie zapisałby swojej wersji, nie dałby się już nigdy otworzyć po rotacji.

Wiersz z kilkoma kolumnami szyfrogramów — cztery u bota kanału, jedna u embeda — pieczętuje je przez vault.seal_fields, które pieczętuje każde pole jedną wersją i zwraca tę wersję do zapisania: to jedyny sposób zapisania takiego wiersza, więc „brak kolumny z wersją" i „zresetuj jedno pole do v1" nie dają się napisać ręcznie.

Usługi stron trzecich. Niewielki katalog usług, do których organizacja może przynieść własny klucz:

Usługa Używana przez
Tavily web_research
Brave Search web_research
Exa web_research
Logfire Obserwowalność per agent — ślady do osobnego projektu
LlamaParse Parsowanie PDF-ów, rozliczane na własny klucz organizacji
mem0 memory_mem0 — cała capability, która trzyma semantyczne wspomnienia agenta w usłudze mem0 (chmurowej albo self-hosted), a nie tutaj. Nic nie jest zapisywane w tym wdrożeniu, więc mem0 rozlicza własny embedding poza pasmem, a wysyłanie wspomnień do chmury mem0 jest decyzją o rezydencji danych, którą podejmuje Builder. Self-hostowany base_url musi być https i musi być na liście dozwolonych MEM0_ALLOWED_HOSTS, więc klucz z vault nigdy nie trafia do originu kontrolowanego przez agenta. Dla tych wspomnień nie ma konsoli operatora: mem0 ma własny magazyn, własne listowanie i własne usuwanie.

Co nigdy się nie zdarza

Cztery gwarancje, przypięte testami, a nie konwencją

Żadnego tekstu jawnego w odpowiedzi, w linii logu, we wpisie audytu ani w wyeksportowanym specu — a capability nigdy nie dowiaduje się, skąd wzięło się jej poświadczenie.

  • Żadna odpowiedź API nie zwraca tekstu jawnego. Nie ma na to endpointu. Serwis, który jest właścicielem sekretów organizacji, ma dwóch czytelników, którzy go wydobywają, i żaden nie oddaje go wołającemu: czytelnik runnera, w trakcie budowania agenta, oraz czytelnik katalogu modeli, który zużywa token bearer na jedno wychodzące żądanie do providera i zwraca nazwy modeli, które wróciły. Nic poza tym serwisem nie otwiera sekretu — kiedyś robił to route listujący modele i to był właśnie defekt warstwowania.
  • Żadna linia logu ani wpis audytu nie zawiera tekstu jawnego. Każde pole niosące sekret jest Pydanticowym SecretStr, więc dataclassy niosące poświadczenia maskują się same w reprze — a to jest droga, którą jawny klucz zwykle ucieka.
  • Żaden spec go nie niesie. Wyeksportowany spec agenta odwołuje się do sekretów po id. To właśnie sprawia, że można go bezpiecznie zacommitować do repozytorium git klienta.
  • Capability nigdy nie dowiaduje się, skąd wzięło się jej poświadczenie, a model nie widzi go w ogóle.

Te cztery są przypięte testami, a nie konwencją.

Dostęp

Uprawnienie Daje
secrets:view Zobaczenie, że sekret istnieje, jego rodzaju i etykiety
secrets:edit Tworzenie, rotowanie, usuwanie
mcp:manage Połączenia MCP organizacji i ich poświadczenia
connections:manage Poświadczenia całej organizacji: połączenia do providerów modeli i integracje źródeł synchronizacji

Zakresy różnią się w zależności od roli — Owner edytuje dowolny sekret w organizacji, Member edytuje tylko własne. Sekret można też udostępnić konkretnemu członkowi albo agentowi przez resource grant, który poszerza dostęp do tego jednego wiersza, nie awansując nikogo. Zobacz Uprawnienia.

Operacje

Kluczem głównym jest VAULT_MASTER_KEY. Spada z powrotem na SECRET_KEY, żeby świeży checkout działał bez dodatkowej konfiguracji, a konfiguracja odrzuca nieustawiony klucz wszędzie poza local/development — staging jest pełnoprawnym wdrożeniem i rutynowo trzyma prawdziwe klucze providerów, więc dostaje tę samą odmowę co produkcja.

Utrata każdego skonfigurowanego klucza oznacza, że każde zapisane poświadczenie przepadło

Nie ma ścieżki odzyskiwania ani kopii w depozycie: każdy sekret trzeba wprowadzić ręcznie od nowa. Zrób kopię zapasową klucza w miejscu, w którym nie leży kopia zapasowa bazy danych.

Rotacja jest operacją etapową, a VAULT_MASTER_KEYS jest formą etapową: mapą JSON każdej wersji, która wciąż jest w użyciu. Najwyższa wersja pieczętuje nowe sekrety; starsze utrzymują istniejące wiersze czytelnymi, dopóki nie zostaną przepakowane. key_version w każdym zapieczętowanym wierszu zapisuje, która wersja go opakowała, a poproszenie o wersję bez skonfigurowanego klucza kończy się błędem nazywającym brakujący wpis, a nie ogólnym błędem odszyfrowania.

# 1. Configure both keys — the old one as the version that sealed today's rows,
#    the new one above it — and unset the single VAULT_MASTER_KEY.
#    VAULT_MASTER_KEYS={"1": "<old>", "2": "<new>"}
# 2. Prove every stored envelope opens before anything moves:
uv run agenticos cmd vault-rotate --dry-run
# 3. Re-wrap every sealed row to the new version:
uv run agenticos cmd vault-rotate
# 4. Once it reports zero failures, drop version 1 from VAULT_MASTER_KEYS.

Nie usuwaj starego klucza, dopóki vault-rotate nie zgłosi zera niepowodzeń

Wiersz, który zawiedzie, zostaje nazwany i zostawiony taki, jaki był, a komenda kończy się kodem niezerowym — usunięcie wersji 1 przy częściowej rotacji czyni te wiersze nieczytelnymi.

vault-rotate przechodzi po każdej tabeli trzymającej koperty i przesuwa szyfrogramy wiersza razem z jego kolumną wersji albo nie przesuwa ich wcale. Wiersz, który nie trzyma żadnej koperty, ale nazywa wersję — połączenie, którego poświadczenia wyczyszczono — ma to wskazanie przesunięte na bieżącą wersję również, więc następny zapieczętowany w nim sekret ląduje na kluczu, który wciąż istnieje. Przepieczętowywany jest tylko opakowany klucz danych — ładunki pozostają nietknięte i to właśnie czyni rotację tanią.

uv run agenticos cmd doctor    # reports whether a vault key is configured at all

make platform-bootstrap BOOTSTRAP_API_KEY=sk-... zapisuje za ciebie pierwszy klucz providera. Zobacz Konfigurację, żeby poznać zmienne środowiskowe, oraz listę kontrolną produkcji, zanim wejdziesz na żywo z wygenerowaną wartością domyślną.

Podsumowanie

  • Jeden moduł, app/core/vault.py. Nie ma drugiego mechanizmu, a dodanie go to defekt, który usunęły dwie migracje.
  • Sekret jest pieczętowany własnym kluczem danych, opakowanym kluczem wyprowadzonym z klucza głównego i ze scope'u właściciela — więc szyfrogram nie może przenieść się między właścicielami.
  • Sekret ma rodzaj, bo aws_credentials to cztery pola, z których jedno nie jest tajne.
  • Cztery gwarancje, przypięte testami: żadnego tekstu jawnego w odpowiedzi, w logu, we wpisie audytu ani w wyeksportowanym specu.
  • Rotacja jest etapowa — skonfiguruj obie wersje, vault-rotate --dry-run, zrotuj, a potem usuń stary klucz, gdy komenda zgłosi zero niepowodzeń.