Un template CloudFormation e un fișier text. E stocat de CloudFormation, e în cdk.out pe discul fiecărui developer, e în logurile de CI de la ultimul synth, iar dacă cineva comite cdk.out din greșeală, e în git pentru totdeauna. Orice apare în template ca valoare literală e, practic, public în interiorul organizației. Asta include parola de bază de date pe care ai setat-o cu environment: { DB_PASSWORD: '...' } pentru că era mai rapid decât s-o faci cum trebuie.
Rulăm trei conturi dintr-un singur cod CDK cu vreo douăzeci și cinci de secrete per cont, și niciunul n-a fost vreodată într-un template. Ăsta e setul de reguli care face asta adevărat, cele două servicii AWS implicate, când îl folosești pe care, și pattern-urile prin care o valoare ajunge într-un container, într-o Lambda sau într-un build fără ca un om s-o lipească vreodată undeva.
Regula
Template-ul poartă referințe, niciodată valori. O referință e un ARN, un nume sau o cale de parametru. Valoarea trăiește într-unul din două locuri, Secrets Manager sau SSM Parameter Store, și e luată la runtime de lucrul care are nevoie de ea, folosind o permisiune IAM acordată în același template. Dacă poți scoate un secret cu grep din cdk.out, e o scurgere, iar CDK face verificarea ușoară:
npx cdk synth --quiet && grep -rniE 'password|secret|token|api[_-]?key' cdk.out/*.template.json | grep -v 'arn:aws:\|Ref\|Fn::' || echo clean
Rulăm asta în CI. A picat de două ori, ambele pe o intrare environment: bine intenționată dintr-o Lambda.
Cele două servicii, și când îl folosești pe care
| Secrets Manager | SSM Parameter Store (SecureString) | |
|---|---|---|
| Preț | $0,40 per secret pe lună + $0,05 per 10.000 de apeluri | Gratuit la nivelul standard (4 KB, 10.000 de parametri); $0,05 per parametru avansat |
| Rotație | Încorporată, cu funcții Lambda de rotație pentru RDS, Aurora, Redshift și custom | Niciuna; rotești scriind o versiune nouă |
| Generare | Poate genera singur valoarea (generateSecretString) |
Nu |
| Între conturi | Politica de resursă permite altui cont să citească | Nu direct |
| Integrare nativă | App Runner runtimeEnvironmentSecrets, ECS secrets, extensia Lambda, RDS Proxy |
ECS secrets, extensia Lambda, CodeBuild parameter-store |
| Versionare | Etichete de staging (AWSCURRENT / AWSPREVIOUS) | Versiuni numerotate |
Regula la care am ajuns: Secrets Manager pentru orice se rotește, e generat sau e citit din alt cont. Parameter Store pentru tot restul. În practică, asta pune credențialele bazei de date, cheia de semnare pentru web push și token-urile terțe pe care le rotim după un program în Secrets Manager, iar lista lungă de secrete de tip configurație (feature flag-uri cu valori sensibile, un token de preview partajat, URL-uri de API per mediu cu chei încorporate) în Parameter Store.
Diferența de cost contează mai mult decât pare. La $0,40 per secret, douăzeci și cinci de secrete în trei conturi înseamnă $30 pe lună, adică 4 % din factura noastră. Două mutări au tăiat-o la jumătate: gruparea valorilor înrudite într-un singur secret JSON în loc de un secret per valoare, și mutarea celor de tip configurație în Parameter Store, care e gratuit.
Pattern-ul 1: secretul pe care îl creează CloudFormation și pe care nu-l vede nimeni
Cel mai bun secret e unul pe care niciun om nu l-a citit vreodată. Parola bazei de date e cazul canonic: CDK îi cere lui Secrets Manager s-o genereze, lui Aurora i se spune s-o folosească, iar valoarea există doar în Secrets Manager și în baza de date.
const dbSecret = new secretsmanager.Secret(this, 'DbSecret', {
secretName: `/platform/${env}/db`,
generateSecretString: {
secretStringTemplate: JSON.stringify({ username: 'app' }),
generateStringKey: 'password',
excludeCharacters: '"@/\\\'',
passwordLength: 40,
},
});
const cluster = new rds.DatabaseCluster(this, 'Db', {
credentials: rds.Credentials.fromSecret(dbSecret),
// ...
});
Template-ul conține resursa secretului, cu instrucțiuni de generare a unei valori. Nu conține valoarea. CloudFormation o creează, i-o pasează lui RDS printr-o referință dinamică ({{resolve:secretsmanager:...}}) rezolvată în interiorul CloudFormation și care nu apare niciodată în template-ul stocat, și asta e ultima dată când ceva din afara Secrets Manager și Aurora o atinge. Rotația, când o pornești, funcționează la fel; o tratăm separat.
Pattern-ul 2: secretul pe care un om îl introduce o dată, prin referință
Cheile de API terțe vin din dashboard-ul unui vendor și un om trebuie să le pună undeva. Undeva-ul ăla e CLI-ul, o dată per cont, și niciodată codul:
aws secretsmanager create-secret --name /platform/prod/payments \
--secret-string '{"apiKey":"...","webhookSecret":"..."}' --profile prod
Partea de CDK îl referențiază după nume și acordă drept de citire:
const payments = secretsmanager.Secret.fromSecretNameV2(this, 'Payments', `/platform/${env}/payments`);
payments.grantRead(apiService.instanceRole);
Template-ul conține numele. Nu valoarea, nici măcar un placeholder. Dacă secretul nu există într-un cont, serviciul nu pornește, cu o eroare clară, ceea ce e comportamentul corect pentru „cineva a uitat să configureze contul nou”. Ținem un secrets.md în repo care listează fiecare nume de secret și ce formă are JSON-ul lui, ca persoana care configurează un cont nou să aibă un checklist, iar codul să aibă o singură sursă de adevăr pentru chei.
Pattern-ul 3: cum ajunge valoarea în proces
Trei consumatori, trei mecanisme, niciunul nu implică template-ul.
App Runner are runtimeEnvironmentSecrets: un map de la numele variabilei de mediu la ARN-ul secretului plus cheia JSON. Serviciul ia valoarea la pornirea instanței și o injectează ca variabilă de mediu obișnuită în container. Rolul instanței are nevoie de secretsmanager:GetSecretValue exact pe acele ARN-uri, ceea ce îi dă grantRead.
runtimeEnvironmentSecrets: {
DB_PASSWORD: apprunner.Secret.fromSecretsManager(dbSecret, 'password'),
PAYMENTS_API_KEY: apprunner.Secret.fromSecretsManager(payments, 'apiKey'),
PREVIEW_TOKEN: apprunner.Secret.fromSsmParameter(previewToken),
},
Lambda n-are injecție echivalentă, așa că ia valoarea la cold start. Extensia AWS Parameters and Secrets pentru Lambda rulează ca layer, servește un endpoint HTTP local, cache-uiește pentru un TTL configurabil și înseamnă că codul funcției face un apel pe localhost în loc de un apel de SDK. Zece linii în init-ul handler-ului, iar o rotație se propagă în interiorul TTL-ului de cache fără redeploy.
CodeBuild citește Parameter Store și Secrets Manager direct în blocurile env.secrets-manager și env.parameter-store din buildspec, deci un build poate avea token-ul de registry fără ca token-ul să fie în definiția proiectului. Rolul de build primește grant-ul. Valoarea e mascată în logul de build, lucru pe care am învățat să-l verificăm de două ori după ce una a apărut oricum printr-un echo.
Cele trei moduri în care merge prost oricum
SecretValue.unsafePlainText. CDK te obligă să tastezi cuvântul „unsafe” ca să pui un secret literal într-un template, și oamenii tot o fac, de obicei într-un stack de test care devine mai târziu unul real. Grep-ul nostru din CI îl prinde. Interzice-l și în linter.
Secrete în cdk.context.json. Valorile de context sunt comise în git prin design, deci sunt locul greșit pentru orice sensibil. Am văzut o cheie de API ajungând acolo prin --context apiKey=... din linia de comandă. Contextul e pentru ID-uri de cont, lookup-uri de VPC și toggle-uri de funcții, nimic altceva.
Citirea unui secret în aplicația CDK în sine. secretsmanager.Secret.fromSecretNameV2(...).secretValue.unsafeUnwrap() la synth rezolvă valoarea pe mașina developerului și o scrie în template. E aceeași scurgere cu mai mulți pași. Singura folosire corectă a lui secretValue e pasarea către un construct care știe s-o transforme într-o referință dinamică, adică exact ce fac Credentials.fromSecret și runtimeEnvironmentSecrets.
Cum arată pe trei conturi
Același cod, trei conturi, trei seturi de valori, zero valori în repository:
| Secret | Trăiește în | Creat de | Citit de |
|---|---|---|---|
| Credențialele bazei de date | Secrets Manager | CloudFormation (generat) | App Runner, Lambda de migrare, RDS Proxy |
| Chei de API terțe (3) | Secrets Manager, câte un JSON fiecare | Un om, o dată per cont, prin CLI | App Runner, Lambda-uri de webhook |
| Cheia de semnare pentru web push | Secrets Manager | Un om, o dată | Lambda de notificări |
| Token de preview, feature flag-uri cu valori, URL-uri interne | Parameter Store | Un om, o dată, sau CDK pentru cele nesensibile | App Runner, Lambda-uri |
| Token de registry pentru build-uri | Secrets Manager | CloudFormation (generat) | CodeBuild |
Nimic din tabelul ăsta nu e într-un template, într-un fișier .env din git, într-un secret GitHub sau în istoricul de shell al unui laptop, iar rolul de deploy nu poate citi nimic din el, pentru că n-are nevoie: CloudFormation și serviciile consumatoare fac citirea, cu propriile roluri.
Dacă template-urile sau fișierele tale .env au valori în ele și ai vrea să nu mai aibă, am mai făcut migrarea asta; e cam o zi per cont.