# Das Datenbankpasswort ohne Ausfall rotieren: das, was Sie schon lange aufschieben

Alle sind sich einig, dass das Datenbankpasswort rotieren sollte. Bei fast niemandem tut es das, und der Grund ist nicht Faulheit. Es ist, dass beim ersten Versuch etwas kaputtgeht: ein Connection Pool, der das alte Passwort hält, eine Lambda, die es beim Kaltstart gecacht hat, ein Hintergrundjob, der es aus einer beim Deploy gesetzten Umgebungsvariable gelesen hat. Also wird die Rotation zurückgerollt, ein Ticket geöffnet, und das Passwort bleibt zwei Jahre gleich.

Unseres rotiert alle dreißig Tage, automatisch, und die Anwendung merkt nichts. Das hier ist der Mechanismus, der größtenteils von AWS stammt, und die drei Änderungen an der Anwendung, die ihn sicher gemacht haben, die von uns stammen.

## Warum die naive Rotation Dinge zerstört

Rotation mit einem einzelnen Datenbanknutzer läuft so: neues Passwort erzeugen, `ALTER USER app PASSWORD 'new'`, Secret aktualisieren. Zwischen dem `ALTER` und dem Moment, in dem jeder Konsument das Secret neu gelesen hat, wird jede *neue* Verbindung mit dem alten Passwort abgelehnt. Bestehende Verbindungen überleben, weil Postgres beim Verbindungsaufbau authentifiziert, aber ein Pool, der in diesem Fenster eine neue Verbindung öffnet, scheitert, und ein Dienst, der das Secret nur beim Start liest, scheitert bei jeder Neuverbindung, bis er neu gestartet wird.

Das Fenster kann Sekunden dauern, wenn alles prompt neu liest. Es kann Stunden dauern, wenn etwas cacht. In der Praxis ist es „bis zum nächsten Deploy“, weil dann die Umgebungsvariablen aufgefrischt werden, und das ist der Ausfall.

## Alternierende Nutzer: die Rotation, die nie ein genutztes Passwort ungültig macht

Die Multi-User-Rotationsstrategie des Secrets Manager verwendet zwei Datenbanknutzer, `app` und `app_clone`, mit identischen Rechten. Zu jedem Zeitpunkt ist einer der *aktuelle* Nutzer im Secret und der andere ruht. Die Rotation ändert das Passwort des *ruhenden* Nutzers, testet es und schwenkt dann das Secret auf ihn um. Der bisher aktuelle Nutzer, dessen Passwort jeder Konsument halten könnte, bleibt bis zur nächsten Rotation dreißig Tage später unangetastet, und bis dahin hat jeder Konsument das Secret viele Male neu gelesen.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Zeitstrahl der Rotation mit alternierenden Nutzern. Tag 0: das Secret zeigt auf Nutzer app mit Passwort P1; app_clone hat Passwort P0, ruhend. Tag 30, Rotation: die Rotations-Lambda setzt das Passwort von app_clone auf P2, testet eine Verbindung, dann schwenkt das Secret auf app_clone. Konsumenten mit app P1 arbeiten weiter; neue Verbindungen nutzen app_clone P2. Tag 60: die Rotation setzt das Passwort von app auf P3 und schwenkt zurück. Zu keinem Zeitpunkt wird ein Passwort ungültig, das ein Konsument halten könnte.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Alternierende Nutzer · das genutzte Passwort ist nie das geänderte</text>
<line x1="60" y1="120" x2="860" y2="120" stroke="#2a3150" stroke-width="2"/>
<text x="60" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">Tag 0</text><text x="460" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">Tag 30 · Rotation</text><text x="860" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">Tag 60 · Rotation</text>
<rect x="60" y="60" width="400" height="22" rx="4" fill="#4fffb0" opacity="0.7"/><text x="260" y="75" text-anchor="middle" fill="#0d1120" font-size="11" font-weight="700">Secret → app · P1 · jeder Konsument nutzt das</text>
<rect x="60" y="88" width="400" height="22" rx="4" fill="#2a3150"/><text x="260" y="103" text-anchor="middle" fill="#9aa3c7" font-size="11">app_clone · P0 · ruhend</text>
<rect x="460" y="60" width="400" height="22" rx="4" fill="#2a3150"/><text x="660" y="75" text-anchor="middle" fill="#9aa3c7" font-size="11">app · P1 · weiter gültig, ruhend, keine Verbindung bricht</text>
<rect x="460" y="88" width="400" height="22" rx="4" fill="#4fffb0" opacity="0.7"/><text x="660" y="103" text-anchor="middle" fill="#0d1120" font-size="11" font-weight="700">Secret → app_clone · P2 · neue Verbindungen nutzen das</text>
<line x1="460" y1="50" x2="460" y2="125" stroke="#ffd166" stroke-width="2" stroke-dasharray="5,3"/>
<text x="460" y="176" text-anchor="middle" fill="#ffd166" font-size="11">1 · Passwort von app_clone auf P2 setzen   2 · Verbindung testen   3 · Secret umschwenken</text>
<text x="450" y="206" text-anchor="middle" fill="#9aa3c7">Ein Konsument mit P1 arbeitet noch 30 Tage weiter. Bis dahin hat er das Secret viele Male neu gelesen.</text>
<text x="450" y="228" text-anchor="middle" fill="#9aa3c7">Tag 60 macht dasselbe umgekehrt: app bekommt P3, das Secret schwenkt zurück.</text>
</g>
</svg>
</div>

In CDK, auf einem Cluster, dessen Zugangsdaten aus einem generierten Secret stammen, sind es ein paar Zeilen:

```ts
cluster.addRotationMultiUser('Rotation', {
  secret: appUserSecret,                 // das Secret des Nutzers 'app', mit gesetztem masterarn
  automaticallyAfter: Duration.days(30),
  vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
});
```

CDK deployt AWS' Rotations-Lambda aus dem Serverless Application Repository ins VPC, verbindet sie mit dem Secret und plant sie ein. Die Lambda muss sowohl die Datenbank erreichen (sie liegt im VPC) als auch den Secrets Manager (ein [Interface-Endpunkt oder ein NAT](/de/blog/nat-gateway-the-most-expensive-line-you-do-not-see); wir nutzen den Endpunkt). Der Nutzer `app_clone` wird von der Lambda bei der ersten Rotation angelegt, falls er nicht existiert, mit denselben Rechten wie `app`, und das ist der eine Punkt, den man von Hand prüfen sollte: bekommt `app` später durch eine Migration weitere Rechte, braucht `app_clone` sie ebenfalls. Wir lösen das, indem wir Rechte einer *Rolle* erteilen, in der beide Nutzer Mitglied sind, sodass Grants einmal gemacht werden.

## Die drei Anwendungsänderungen

Der Rotationsmechanismus ist per Konstruktion sicher. Die Anwendung muss das *neue* Secret trotzdem irgendwann lesen, und wie sie das tut, entscheidet, ob die Rotation unsichtbar ist oder ein Ausfall in Zeitlupe.

**1. Das Secret beim Verbinden lesen, nicht beim Start.** Die Verbindungsfabrik des Pools ruft bei jedem Öffnen einer Verbindung den Secrets Manager auf (über einen Cache mit fünf Minuten TTL). Ein langlebiger Dienst übernimmt ein neues Secret innerhalb von fünf Minuten nach dem Umschwenken, ohne Neustart. App Runners `runtimeEnvironmentSecrets` injiziert den Wert beim Instanzstart, was für einen Dienst, der wöchentlich neu deployt wird, in Ordnung ist und für einen, der einen Monat läuft, falsch. Speziell für die Datenbank-Zugangsdaten lesen wir deshalb im Code aus dem Secrets Manager statt aus der Umgebung.

**2. Bei einem Authentifizierungsfehler einmal neu holen und wiederholen.** Doppelt genäht: Scheitert ein Verbindungsversuch mit `28P01` (ungültiges Passwort), wird der Cache invalidiert, das Secret neu geholt und einmal wiederholt. Das deckt den Fall ab, dass die Cache-TTL genau im Moment des Umschwenkens noch nicht abgelaufen ist. Es sind zehn Zeilen, und sie haben in Produktion genau so oft gegriffen, wie wir rotiert haben: etwa einmal im Monat, still, in den Logs.

```ts
async function connect(): Promise<Client> {
  try {
    return await open(await creds.get());
  } catch (e) {
    if (isAuthError(e)) { creds.invalidate(); return open(await creds.get()); }
    throw e;
  }
}
```

**3. Für die Lambdas RDS Proxy die Arbeit machen lassen.** Die Funktionen im VPC verbinden sich über RDS Proxy, und der Proxy authentifiziert sich bei der Datenbank mit dem Secret selbst: er beobachtet den Secrets Manager und übernimmt Rotationen eigenständig. Die Funktionen authentifizieren sich beim *Proxy* mit IAM, sodass sie nie ein Datenbankpasswort halten. Rotation ist für sie per Design ein Nicht-Ereignis. Es behebt außerdem das Verbindungssturm-Problem, das Lambdas mit Postgres haben, was [der Grund ist, warum wir den Proxy ohnehin empfehlen würden](/de/blog/aurora-serverless-v2-review).

<div class="article-figure">
<svg viewBox="0 0 900 220" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Zwei Konsumentenpfade. App-Runner-Dienst: der Pool liest das Secret bei jeder neuen Verbindung über einen 5-Minuten-Cache aus dem Secrets Manager und invalidiert bei einem Auth-Fehler den Cache und holt einmal neu. Lambda-Funktionen: sie authentifizieren sich per IAM beim RDS Proxy und halten kein Passwort; der Proxy liest das Secret selbst und folgt Rotationen. Beide Pfade erreichen Aurora. Die Rotations-Lambda ändert alle 30 Tage das Passwort des ruhenden Nutzers.">
<defs><marker id="arrR" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="15" y="30" width="200" height="70" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="115" y="54" text-anchor="middle" fill="#f1f3ff" font-weight="700">App-Runner-Dienst</text><text x="115" y="72" text-anchor="middle" fill="#9aa3c7" font-size="11">Secret beim Verbinden · 5-Min-Cache</text><text x="115" y="90" text-anchor="middle" fill="#9aa3c7" font-size="11">Auth-Fehler → einmal neu holen</text>
<rect x="15" y="130" width="200" height="70" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="115" y="154" text-anchor="middle" fill="#f1f3ff" font-weight="700">Lambda-Funktionen</text><text x="115" y="172" text-anchor="middle" fill="#9aa3c7" font-size="11">IAM-Auth beim Proxy</text><text x="115" y="190" text-anchor="middle" fill="#9aa3c7" font-size="11">halten nie ein Passwort</text>
<rect x="330" y="30" width="200" height="70" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="430" y="54" text-anchor="middle" fill="#4fffb0" font-weight="700">Secrets Manager</text><text x="430" y="72" text-anchor="middle" fill="#9aa3c7" font-size="11">app / app_clone · AWSCURRENT</text><text x="430" y="90" text-anchor="middle" fill="#9aa3c7" font-size="11">Rotations-Lambda alle 30 Tage</text>
<rect x="330" y="130" width="200" height="70" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="430" y="154" text-anchor="middle" fill="#f1f3ff" font-weight="700">RDS Proxy</text><text x="430" y="172" text-anchor="middle" fill="#9aa3c7" font-size="11">liest das Secret selbst</text><text x="430" y="190" text-anchor="middle" fill="#9aa3c7" font-size="11">folgt Rotationen · poolt Verbindungen</text>
<rect x="680" y="80" width="200" height="70" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="780" y="104" text-anchor="middle" fill="#f1f3ff" font-weight="700">Aurora</text><text x="780" y="122" text-anchor="middle" fill="#9aa3c7" font-size="11">Nutzer app und app_clone</text><text x="780" y="140" text-anchor="middle" fill="#9aa3c7" font-size="11">gleiche Rechte über eine Rolle</text>
<line x1="217" y1="65" x2="328" y2="65" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrR)"/>
<line x1="217" y1="165" x2="328" y2="165" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrR)"/>
<line x1="430" y1="102" x2="430" y2="128" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrR)"/>
<path d="M217,80 C300,115 560,115 678,110" fill="none" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrR)"/>
<line x1="532" y1="165" x2="678" y2="125" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrR)"/>
</g>
</svg>
</div>

## Die erste Rotation, in Staging, mit Stoppuhr

Wir haben sie nicht zuerst in Produktion eingeschaltet. In Staging haben wir manuell eine Rotation ausgelöst (`aws secretsmanager rotate-secret`), das Log der Lambda beobachtet und währenddessen einen Lastgenerator gegen die API laufen lassen. Ergebnisse des ersten Versuchs:

| Moment | Was passierte |
|---|---|
| T+0 s | Rotation startet; Lambda setzt Passwort von `app_clone`, testet es, schwenkt das Secret um |
| T+4 s | Umschwenken abgeschlossen. Jede bestehende Verbindung weiter in Ordnung (sie halten `app`) |
| T+0 bis T+300 s | Neue Verbindungen aus dem App-Runner-Pool nutzen noch die gecachten `app`-Zugangsdaten. Weiter in Ordnung, weil `app` noch gültig ist |
| T+300 s | Cache läuft ab; die nächste neue Verbindung holt `app_clone`. Funktioniert |
| T+5 Min bis T+30 Tage | Beide Nutzer gültig. Kein Konsument kann überrascht werden |

Null Fehler im Lastgenerator. Das Einzige, was scheiterte, war ein Cron-Job in einem anderen Konto, der das Passwort in einer Umgebungsvariable hatte: er lief dreißig Tage weiter (sein Nutzer war noch gültig) und brach dann bei der *zweiten* Rotation, was genau der verzögerte Fehler ist, den die alternierende Strategie für Konsumenten erzeugt, die nicht neu lesen. Genau dafür macht man es zuerst in Staging. Der Cron-Job liest jetzt das Secret.

## Die Checkliste

- Multi-User-Rotation, nie Single-User. Der zusätzliche Datenbanknutzer ist kostenlos und macht den ganzen Unterschied.
- Beide Nutzer bekommen Rechte über eine gemeinsame Rolle, sodass eine Migration, die der Rolle Rechte erteilt, beide abdeckt.
- Die Rotations-Lambda lebt im VPC und braucht einen Pfad zum Secrets Manager: Interface-Endpunkt oder NAT.
- Jeder Konsument liest das Secret beim Verbinden über einen Cache mit kurzer TTL oder geht per IAM über RDS Proxy.
- Bei `28P01` einmal neu holen.
- Erste Rotation in Staging, manuell ausgelöst, unter Last, mit offenem Log.
- Dann jede Umgebungsvariable, `.env`, jeden Cron und jedes Skript nach dem alten Passwort greppen. Was es noch hat, bricht in dreißig bis sechzig Tagen. Besser jetzt finden.

Dreißig-Tage-Rotation, sechs Monate, sechs Rotationen, null Vorfälle. Das Passwort, das niemand gelesen hat, hat sich sechsmal geändert, und niemand hat es gemerkt, und genau so sollte sich ein Secret anfühlen.

Wenn Ihr Datenbankpasswort einen Geburtstag hat, [bringen wir es an einem Tag zum Rotieren](/contact), zuerst in Staging.
