Secrets und der Vault¶
Ein Modul, und bewusst kein zweiter Mechanismus
Jeder Provider-Key, jedes Bot-Token eines Channels, alle MCP-Zugangsdaten und
jeder API-Key eines Drittanbieters in dieser Plattform läuft durch
app/core/vault.py. Einen zweiten Weg hinzuzufügen, Zugangsdaten im
Ruhezustand zu halten, ist genau der Defekt, den zwei Migrationen entfernt
haben.
Envelope-Verschlüsselung¶
Jedes Secret wird mit einem eigenen zufälligen Datenschlüssel versiegelt. Dieser Datenschlüssel wird mit einem Schlüssel versiegelt, der aus dem Masterschlüssel und dem Scope, dem das Secret gehört, abgeleitet ist — einer Organisation oder dem Mitglied, zu dem eine persönliche Verbindung gehört.
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>"]
Daraus folgen zwei Eigenschaften, und beide sind der Grund für diese Form:
Ein Chiffrat kann nicht zwischen Eigentümern verschoben werden
Selbst mit vollem Datenbankzugriff lässt sich eine Zeile, die von
Organisation A nach Organisation B kopiert wurde, nicht entpacken. Die
Mandantentrennung ist hier kryptografisch und keine WHERE-Klausel, die
jemand vergessen könnte.
Der Masterschlüssel ist rotierbar. Er verschlüsselt nie direkt eine Nutzlast,
nur Datenschlüssel, sodass eine Rotation pro Secret einen kleinen Blob neu
umhüllt, statt jeden Wert neu zu verschlüsseln. Jeder Envelope verzeichnet die
key_version, die ihn versiegelt hat, und genau das macht eine gestufte Rotation
überhaupt erst möglich.
Der Vault entscheidet nichts darüber, wer ein Secret lesen darf — das ist die Berechtigungsebene. Er garantiert nur, dass ein Secret im Ruhezustand ohne den Masterschlüssel unlesbar und außerhalb des Scopes, für den es versiegelt wurde, unbrauchbar ist.
Wie daraus ein einziger Mechanismus wurde¶
Der Satz ganz oben brauchte zwei Runden, um wahr zu werden, und die Geschichte ist eine Minute wert, weil sie die Form des Fehlers zeigt.
Früher hielten drei Mechanismen Secrets im Ruhezustand, und nur einer band ein
Chiffrat an seinen Eigentümer. Provider-Keys liefen durch den Vault, Bot-Token
von Channels durch einen einzigen deploymentweiten Fernet-Key, MCP-Token durch
einen weiteren. Ein Slack-Token ließ sich aus der Zeile einer Organisation in die
einer anderen kopieren, und es entschlüsselte. Eine Migration entfernte diese
beiden, bevor die Kette zu 0001_baseline zusammengequetscht wurde.
Ein vierter überlebte das und überdauerte den Satz darüber um einige Monate.
app/core/crypto.py hielt einen deploymentweiten Fernet-Key über den
Zugangsdatenfeldern von sync_sources.config — das JSON des Google-Service-Accounts
und das AWS-Schlüsselpaar, mit dem sich ein RAG-Sync-Connector authentifiziert.
Es war in seinem eigenen Docstring ehrlich über sich, und es war trotzdem ein zweiter Mechanismus, sodass ein Leser, der "es gibt keinen zweiten Mechanismus" glaubte, sich bei einer Tabelle irrte.
Am Leben hielt es ein Reihenfolgeproblem, keine Meinungsverschiedenheit: ein
Envelope wird aus der ID seines Eigentümers abgeleitet, und
sync_sources.organization_id war nullable, weil die CLI Zeilen ohne eine
solche anlegte.
#707 gab rag-source-add
eine Organisation, 0042_sync_source_secret_id ließ die Spalte das sagen, und
#937 löschte das Modul.
Eine Sync-Source referenziert jetzt ein Secret im Vault über seine ID, so wie
ModelProfile.secret_id und CapabilityBindingSpec.secret_id es tun, und ihr
config hält nur noch das, was ein Connector braucht, um die Dokumente zu
finden.
Zwei Folgen jenseits der Kryptografie, und es sind die, die ein Betreiber merkt: Zugangsdaten werden einmal hinterlegt und von jeder Source wiederverwendet, die sie braucht, statt pro Source eingefügt und an ebenso vielen Stellen rotiert zu werden; und sie erscheinen wie alles andere auf der Vault-Seite, sodass "hält diese Organisation Google-Zugangsdaten" eine Antwort hat.
Arten¶
Ein Secret ist nicht immer eine Zeichenkette, und alle Zugangsdaten in ein einziges Feld "API key" zu pressen ergibt ein Formular, das jemand korrekt ausfüllt und am Ende doch Zugangsdaten hat, die beim ersten Run scheitern. Also hat ein Secret eine Art, und die Art entscheidet, welche Felder existieren.
| Art | Felder |
|---|---|
api_key |
Ein undurchsichtiges Token |
azure_openai |
Key, Endpunkt, festgelegte API-Version |
aws_credentials |
Access Key ID, Secret Access Key, Region, optionales Session-Token |
gcp_service_account |
Das JSON des Service-Accounts, beim Hineingeben validiert |
github_oauth_app |
Die öffentliche Client-ID einer GitHub OAuth App und deren Secret |
none |
Kein Secret — die Markierung für einen Endpunkt, der keine Zugangsdaten braucht |
github_oauth_app wird von der Plattform ausgegeben und nicht von einer Person
ausgewählt — der GitHub-Verbindungsablauf liest es serverseitig, um den
Token-Austausch durchzuführen — also muss es für die Organisation sichtbar
sein, und es darf genau eines geben: die privaten Zugangsdaten eines Mitglieds
werden nie stillschweigend für die Verbindung der ganzen Organisation verwendet,
und bei zwei gespeicherten org-sichtbaren Apps wird die Verbindung abgelehnt
(unter Nennung beider), statt an denjenigen Namen gebunden zu werden, der zuerst
sortiert.
aws_credentials ist der klarste Fall dafür, dass es Arten überhaupt gibt: die
Access Key ID ist nicht geheim und der Secret Access Key ist es, und ein einzelnes
Feld kann das nicht ausdrücken. gcp_service_account wird beim Einfügen
validiert, weil ein fehlerhaftes JSON sonst Stunden später als
Authentifizierungsfehler auffällt, ohne dass irgendetwas auf das Einfügen
zurückweist, das ihn verursacht hat.
none ist das, was Sie für Ollama auf localhost hinterlegen. Es ist eine Art und
keine leere Zeichenkette, damit der Resolver über eine vollständige Menge
verzweigen kann — und weil der Vault es ablehnt, einen leeren Wert zu versiegeln.
Nur die Laufzeit kann none halten; niemand kann eines speichern, und das hält
"ein Secret ohne Wert" aus dem API-Schema heraus.
Jedes Feld, das authentifiziert — ein API-Schlüssel, ein Secret Access Key, ein Client Secret — muss mindestens acht Zeichen lang sein. Die Liste zeigt als Hinweis die letzten vier Zeichen der Zugangsdaten, ein kürzerer Wert würde also durch seinen eigenen Hinweis vollständig veröffentlicht; die Untergrenze fängt außerdem eine abgeschnittene Einfügung ab, solange das Formular noch offen ist.
Wo sie verwendet werden¶
Model-Provider. Benannt von einem Model-Profil. Ausgaben werden dem Secret zugerechnet, auf das der Run aufgelöst hat, und so bekommt "welcher Key kostet am meisten" eine Antwort.
Capabilities. Eine Capability erklärt, dass sie Zugangsdaten einer
bestimmten Art braucht — nie eine Instanz. Der Code sagt "ich brauche einen API
Key"; die secret_id eines Bindings sagt, welchen. Siehe
den Capability-Katalog.
MCP-Verbindungen. Bearer-Token und OAuth-Nutzlasten, versiegelt an die Organisation oder an das Mitglied. Siehe MCP.
Channel-Bots. Alle Zugangsdaten auf der Zeile, versiegelt an die Organisation
des Bots unter einer gemeinsamen key_version: das Bot-Token, das Signing Secret
und das App-Token einer Slack-App sowie das gemeinsame Secret, gegen das ein
eingehender Webhook authentifiziert wird — Telegrams
X-Telegram-Bot-Api-Secret-Token, das Token eines ausgehenden
Mattermost-Webhooks. Siehe Channels.
Event-Trigger. Das Secret, gegen das der eingehende Webhook eines
Event-Triggers verifiziert wird - GitHubs HMAC-Key oder das Signing Secret, das
ein Mail- oder API-Relay sendet - versiegelt an die Organisation und inline auf
der Trigger-Zeile gespeichert, mitsamt der key_version, die es versiegelt hat,
in derselben Form wie das Signing Secret eines Channel-Bots. Es wird nie im
Klartext zurückgegeben oder geloggt; die Verifikation entsiegelt es, vergleicht in
konstanter Zeit, und eine Zustellung, die scheitert, ist ein 403. Siehe
Konzepte.
Embeds. Ein jwt-Widget verifiziert Besucher-Token gegen ein
HS256-Signing-Secret, das das Backend des Kunden hält. Es ist an die Organisation
des Agents versiegelt und verzeichnet seine key_version wie jede andere
versiegelte Zeile, sodass eine Rotation des Masterschlüssels es per rewrap
umhüllen kann und das Widget weiter verifiziert — während ein Embed, das seine
Version nicht verzeichnet hätte, nach einer Rotation nie wieder zu öffnen wäre.
Eine Zeile mit mehreren Chiffratspalten — die vier eines Channel-Bots, die eine
eines Embeds — versiegelt sie über vault.seal_fields, das jedes Feld unter einer
Version versiegelt und diese Version zum Speichern zurückgibt: der eine Weg, eine
solche Zeile zu schreiben, sodass "keine Versionsspalte" und "ein Feld auf v1
zurücksetzen" gar nicht erst von Hand schreibbar sind.
Dienste von Drittanbietern. Ein kleiner Katalog von Diensten, für die eine Organisation ihren eigenen Key mitbringen darf:
| Dienst | Verwendet von |
|---|---|
| Tavily | web_research |
| Brave Search | web_research |
| Exa | web_research |
| Logfire | Observability pro Agent — Traces in ein eigenes Projekt |
| LlamaParse | PDF-Parsing, abgerechnet auf den eigenen Key der Organisation |
| mem0 | memory_mem0 — die ganze Capability, die die semantischen Erinnerungen eines Agents in einem mem0-Dienst hält (Cloud oder selbst gehostet) statt hier. In diesem Deployment wird nichts gespeichert, also rechnet mem0 sein eigenes Embedding außerhalb ab, und Erinnerungen in die Cloud von mem0 zu senden ist eine Entscheidung über Datenresidenz, die der Builder benennt. Eine selbst gehostete base_url muss https sein und auf der Erlaubnisliste MEM0_ALLOWED_HOSTS stehen, sodass der Vault-Key nie an einen vom Agent kontrollierten Origin gesendet wird. Für diese Erinnerungen gibt es keine Betreiberkonsole: mem0 hat seinen eigenen Speicher, seine eigene Auflistung und sein eigenes Löschen. |
Was nie passiert¶
Vier Garantien, festgehalten durch Tests statt durch Konvention
Kein Klartext in einer Antwort, in einer Logzeile, in einem Audit-Eintrag oder in einem exportierten Spec - und eine Capability erfährt nie, woher ihre Zugangsdaten kamen.
- Keine API-Antwort gibt einen Klartext zurück. Es gibt keinen Endpunkt dafür. Der Service, dem die Secrets einer Organisation gehören, hat zwei Leser, die einen liefern, und keiner von beiden reicht ihn an einen Aufrufer weiter: der des Runners, während er einen Agent baut, und der des Model-Katalogs, der ein Bearer-Token für eine einzige ausgehende Anfrage an einen Provider ausgibt und die zurückgekommenen Modellnamen liefert. Nichts außerhalb dieses Service öffnet ein Secret — die Route für die Modellauflistung tat das früher, und das war der Schichtungsdefekt.
- Keine Logzeile und kein Audit-Eintrag enthält einen. Jedes Feld, das ein
Secret trägt, ist ein Pydantic-
SecretStr, sodass die Dataclasses mit Zugangsdaten sich in einer Repr selbst maskieren — und das ist der Weg, auf dem ein Klartext-Key normalerweise entkommt. - Kein Spec trägt einen. Ein exportiertes Agent-Spec referenziert Secrets über IDs. Genau das macht es sicher, es in das Git-Repository eines Kunden zu committen.
- Eine Capability erfährt nie, woher ihre Zugangsdaten kamen, und das Model sieht sie überhaupt nicht.
Diese vier sind durch Tests festgehalten, nicht durch Konvention.
Zugriff¶
| Berechtigung | Gewährt |
|---|---|
secrets:view |
Sehen, dass ein Secret existiert, welche Art es hat, wie es beschriftet ist |
secrets:edit |
Anlegen, rotieren, löschen |
mcp:manage |
MCP-Verbindungen der Organisation und deren Zugangsdaten |
connections:manage |
Organisationsweite Zugangsdaten: Verbindungen zu Model-Providern und Integrationen von Sync-Sources |
Die Reichweite unterscheidet sich nach Rolle — ein Owner bearbeitet jedes Secret der Organisation, ein Member nur seine eigenen. Ein Secret kann außerdem über einen Resource Grant an ein bestimmtes Mitglied oder einen bestimmten Agent freigegeben werden, was den Zugriff auf genau diese eine Zeile erweitert, ohne jemanden zu befördern. Siehe Berechtigungen.
Betrieb¶
Der Masterschlüssel ist VAULT_MASTER_KEY. Er fällt auf SECRET_KEY zurück,
damit ein frischer Checkout ohne zusätzliche Einrichtung läuft, und die
Konfiguration lehnt einen nicht gesetzten Schlüssel überall außer in
local/development ab — Staging ist ein vollwertiges Deployment und hält
regelmäßig echte Provider-Keys, also bekommt es dieselbe Ablehnung wie
Produktion.
Jeden konfigurierten Schlüssel zu verlieren heißt, alle gespeicherten Zugangsdaten sind weg
Es gibt keinen Wiederherstellungsweg und keine Escrow-Kopie: jedes Secret muss von Hand neu eingegeben werden. Sichern Sie den Schlüssel an einem Ort, an dem das Datenbank-Backup nicht liegt.
Rotieren ist eine gestufte Operation, und VAULT_MASTER_KEYS ist die gestufte
Form: eine JSON-Abbildung jeder noch genutzten Version. Die höchste Version
versiegelt neue Secrets; die älteren halten bestehende Zeilen lesbar, bis sie neu
umhüllt sind. key_version auf jeder versiegelten Zeile verzeichnet, welche
Version sie umhüllt hat, und nach einer Version zu fragen, für die kein Schlüssel
konfiguriert ist, scheitert unter Nennung des fehlenden Eintrags statt als
allgemeiner Entschlüsselungsfehler.
# 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.
Entfernen Sie den alten Schlüssel nicht, bevor vault-rotate null Fehlschläge meldet
Eine Zeile, die scheitert, wird benannt und bleibt, wie sie war, und der Befehl endet mit einem Exit-Code ungleich null - Version 1 bei einer unvollständigen Rotation zu entfernen macht diese Zeilen unlesbar.
vault-rotate läuft über jede Tabelle, die Envelopes hält, und verschiebt die
Chiffrate einer Zeile gemeinsam mit deren Versionsspalte, oder gar nicht. Eine
Zeile, die keinen Envelope hält, aber eine Version nennt — eine Verbindung, deren
Zugangsdaten geleert wurden —, bekommt diese Angabe ebenfalls auf die aktuelle
Version verschoben, sodass das nächste dort hinein versiegelte Secret auf einem
Schlüssel landet, den es noch gibt. Nur der umhüllte Datenschlüssel wird neu
versiegelt — die Nutzlasten bleiben unberührt, und das macht die Rotation
günstig.
make platform-bootstrap BOOTSTRAP_API_KEY=sk-... hinterlegt den ersten
Provider-Key für Sie. Siehe Konfiguration für die Umgebung
und die Produktionscheckliste, bevor Sie
mit einem generierten Standardwert live gehen.
Zusammenfassung¶
- Ein Modul,
app/core/vault.py. Es gibt keinen zweiten Mechanismus, und einen hinzuzufügen ist der Defekt, den zwei Migrationen entfernt haben. - Ein Secret wird mit einem eigenen Datenschlüssel versiegelt, umhüllt von einem Schlüssel, der aus dem Masterschlüssel und dem besitzenden Scope abgeleitet ist — ein Chiffrat kann sich also nicht zwischen Eigentümern bewegen.
- Ein Secret hat eine Art, weil
aws_credentialsvier Felder sind und eines davon nicht geheim ist. - Vier Garantien, durch Tests festgehalten: kein Klartext in einer Antwort, einem Log, einem Audit-Eintrag oder einem exportierten Spec.
- Die Rotation ist gestuft — beide Versionen konfigurieren,
vault-rotate --dry-run, rotieren, dann den alten Schlüssel entfernen, sobald null Fehlschläge gemeldet werden.