Un isolamento che non puoi dimostrare è solo un presupposto
I bug d'ambiente che fanno male non lanciano eccezioni.
Una variabile che dovrebbe puntare a staging punta a produzione, e ogni richiesta va a buon fine. Un service account creato per lo sviluppo riceve un ruolo sul progetto di produzione, concesso di fretta per sbloccare qualcuno, e non si rompe niente. Un nuovo secret viene aggiunto a tre ambienti su quattro. Ognuna di queste cose è invisibile fino al momento in cui qualcosa scrive dove non dovrebbe, e a quel punto è un incidente con una causa radice cortissima e molto imbarazzante.
Quasi tutti i team con cui ho lavorato documentano il proprio isolamento tra ambienti. C'è un diagramma con tre riquadri, una pagina wiki con una convenzione sui nomi, magari una checklist nel processo di rilascio. Quello che quasi nessuno ha è un controllo che fallisce quando l'isolamento smette di essere vero.
È la stessa distinzione a cui questa serie continua a tornare. Un confine che descrivi soltanto è un desiderio. Un confine che fai rispettare è architettura. E un confine imposto che non testi mai è un presupposto che per oggi, per caso, sta ancora reggendo.
Ecco la scala che uso, quattro livelli, ciascuno con il codice per farlo. Su una piattaforma recente ho costruito bene i primi due. Gli altri due sono quelli che aggiungerei adesso, e dirò esplicitamente quale è quale, perché la distanza tra i due gruppi è la parte utile.
Livello 1: rifiutati di avviarti con la configurazione sbagliata
Il livello più economico, e quello che intercetta di più. Ogni deployable, frontend e backend, valida la propria configurazione all'avvio contro uno schema. Una chiave mancante, un valore della forma sbagliata, un URL che non è un URL: il processo non parte, e dice esattamente perché.
Uso zod per questo, perché lo schema fa anche da documentazione. Ogni proprietà porta una descrizione, e la descrizione finisce nel messaggio d'errore, così chi legge il fallimento alle due di notte scopre a cosa serve la chiave, non solo che manca.
// src/env.schema.ts
import { z } from 'zod'
export const EnvObject = z.object({
APP_ENV: z
.enum(['dev', 'staging', 'prod'])
.describe('The environment this process believes it is running in'),
GCP_PROJECT_ID: z
.string()
.regex(/^acme-app-(dev|staging|prod)$/)
.describe('The Google Cloud project hosting this deployable'),
DATABASE_URL: z
.string()
.url()
.describe('Postgres connection string, injected from Secret Manager'),
PAYMENTS_API_KEY: z
.string()
.min(20)
.describe('Payments provider key, injected from Secret Manager'),
LOG_LEVEL: z
.enum(['debug', 'info', 'warn', 'error'])
.default('info')
.describe('Minimum log level'),
})
// The process must agree with its own project about where it is.
export const EnvSchema = EnvObject.refine(
env => env.GCP_PROJECT_ID.endsWith(`-${env.APP_ENV}`),
{
path: ['GCP_PROJECT_ID'],
message: 'does not match APP_ENV',
}
)
// Keys that live in Secret Manager. By convention the secret id equals the key.
export const SECRET_KEYS = ['DATABASE_URL', 'PAYMENTS_API_KEY'] as const
export const REQUIRED_KEYS = Object.entries(EnvObject.shape)
.filter(([, schema]) => !schema.isOptional())
.map(([key]) => key)
// src/env.ts
import { EnvObject, EnvSchema } from './env.schema'
export function loadEnv(source: NodeJS.ProcessEnv = process.env) {
const result = EnvSchema.safeParse(source)
if (result.success) return result.data
const shape = EnvObject.shape as Record<string, { description?: string }>
const lines = result.error.issues.map(issue => {
const key = issue.path.join('.')
const description = shape[key]?.description
return ` ${key}: ${issue.message}${description ? ` (${description})` : ''}`
})
console.error(`Refusing to start, invalid configuration:\n${lines.join('\n')}`)
process.exit(1)
}
Il controllo incrociato in fondo allo schema è la parte che si è guadagnata il posto in un pezzo sull'isolamento invece che sulla validazione. Il processo sa in quale ambiente crede di essere, e si rifiuta di girare se il suo stesso progetto non è d'accordo. Una build di sviluppo deployata per sbaglio con la configurazione di produzione non serve traffico in silenzio. Muore alla prima riga.
Su una piattaforma che non instrada traffico verso una revisione che non riesce ad avviarsi, come succede su Cloud Run e su qualunque cosa con una readiness probe, questo è già un gate di deploy, e non è costato niente in più.
Il suo unico limite è il tempo. Fallisce all'avvio, cioè dopo il deploy. Il Livello 4 sposta lo stesso controllo davanti al deploy, usando lo stesso schema, quindi tieni a mente questo punto.
Livello 2: rendi il riferimento incrociato impossibile da esprimere
Il passo successivo ovvio è un lint che scansiona la configurazione di sviluppo alla ricerca di qualunque cosa somigli alla produzione: un hostname di produzione, un id di progetto di produzione, un nome di secret con prod dentro. Funziona, ed è un rilevatore, il che significa che intercetta solo i pattern che qualcuno ha pensato di scrivere.
Quello che ho fatto io, invece, è stato eliminare la possibilità. Ogni ambiente è un progetto Google Cloud a sé, provisionato dallo stesso modulo Terraform con un file di variabili diverso. I suoi secret, i suoi service account, la sua infrastruttura, niente di condiviso.
# environments/prod/main.tf
module "platform" {
source = "../../modules/platform"
project_id = "acme-app-prod"
environment = "prod"
secret_ids = ["DATABASE_URL", "PAYMENTS_API_KEY"]
}
# modules/platform/identity.tf
resource "google_service_account" "runtime" {
project = var.project_id
account_id = "runtime"
display_name = "Runtime identity (${var.environment})"
}
resource "google_secret_manager_secret" "this" {
for_each = toset(var.secret_ids)
project = var.project_id
secret_id = each.value
replication {
auto {}
}
}
resource "google_secret_manager_secret_iam_member" "runtime_access" {
for_each = google_secret_manager_secret.this
project = var.project_id
secret_id = each.value.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.runtime.email}"
}
L'effetto è che non c'è niente che un lint possa trovare. Un servizio di sviluppo risolve DATABASE_URL nel progetto di sviluppo perché è l'unico progetto che conosce. Per raggiungere un secret di produzione dovrebbe nominare esplicitamente un altro progetto, e anche allora la sua identità non ha alcun permesso lì.
È la mossa che farei per prima su qualunque piattaforma nuova, e vale la pena dire perché batte il lint. Un rilevatore deve anticipare ogni modo in cui l'errore può essere scritto. Una struttura in cui l'errore non si può scrivere non deve anticipare niente.
Quello che la separazione non ti dà
Qui mi sono fermato, e non avrei dovuto.
I progetti separati sono uno stato, e gli stati derivano. La struttura rende l'errore impossibile da esprimere per sbaglio. Non impedisce a qualcuno di esprimerlo apposta. Una correzione urgente, uno script di migrazione che deve leggere dati di produzione da un runner di sviluppo, un collega che concede al service account di sviluppo un ruolo sul progetto di produzione "solo per oggi". Un binding, aggiunto dalla console, fuori da Terraform, e l'isolamento è finito senza che un solo test diventi rosso.
È il divario tra i livelli 2 e 3. Tutto quello che c'è fin qui rende l'isolamento il comportamento predefinito. Niente, fin qui, ti dice quando il predefinito è stato scavalcato.
Livello 3: dimostra che l'altro ambiente dice no
La prova più forte che due ambienti sono isolati è provare ad attraversare il confine e vederlo rifiutare. Quindi il test è negativo: impersona l'identità di runtime di sviluppo e prova a leggere un secret di produzione. Il test passa soltanto con un errore di permesso.
#!/usr/bin/env bash
# scripts/check-cross-env-denied.sh
# Passes only if the dev runtime identity is DENIED access to a prod secret.
set -uo pipefail
DEV_SA="runtime@acme-app-dev.iam.gserviceaccount.com"
PROD_PROJECT="acme-app-prod"
PROBE_SECRET="DATABASE_URL"
ERR="$(mktemp)"
if gcloud secrets versions access latest \
--secret="$PROBE_SECRET" \
--project="$PROD_PROJECT" \
--impersonate-service-account="$DEV_SA" >/dev/null 2>"$ERR"; then
echo "FAIL: $DEV_SA can read $PROBE_SECRET in $PROD_PROJECT" >&2
exit 1
fi
if ! grep -q "PERMISSION_DENIED" "$ERR"; then
echo "INCONCLUSIVE: expected PERMISSION_DENIED, got:" >&2
cat "$ERR" >&2
exit 1
fi
echo "OK: cross-environment read denied"
Il secondo if è quello importante. Un test negativo che passa per la ragione sbagliata è peggio di nessun test, perché ti consegna una sicurezza che non hai. Se il comando fallisce perché l'identità di CI non ha il permesso di impersonare l'account di sviluppo, o per un errore di rete, o perché qualcuno ha rinominato il secret, non hai dimostrato l'isolamento. Hai dimostrato che qualcosa è andato storto. Quindi qualsiasi cosa diversa da un diniego esplicito di permesso fa fallire il controllo. All'identità di CI serve roles/iam.serviceAccountTokenCreator sul service account di sviluppo, altrimenti l'impersonazione non funziona proprio.
Il test comportamentale copre un secret e un'identità. Il complemento strutturale verifica che nessun service account di un altro progetto sia legato da nessuna parte nella policy IAM del progetto di produzione.
#!/usr/bin/env bash
# scripts/check-no-foreign-iam.sh
# Fails if service accounts from other projects are bound in prod.
set -euo pipefail
PROD="acme-app-prod"
members="$(gcloud projects get-iam-policy "$PROD" --format=json \
| jq -r '.bindings[].members[]')"
# User-managed service accounts look like name@PROJECT.iam.gserviceaccount.com.
# Google-managed service agents live under gcp-sa-* and are expected.
foreign="$(printf '%s\n' "$members" \
| grep -E '^serviceAccount:.+@.+\.iam\.gserviceaccount\.com$' \
| grep -vE "@(${PROD}|gcp-sa-[a-z0-9-]+)\.iam\.gserviceaccount\.com$" \
|| true)"
if [[ -n "$foreign" ]]; then
echo "FAIL: service accounts from other projects are bound in $PROD:" >&2
echo "$foreign" >&2
exit 1
fi
echo "OK: no foreign service accounts bound in $PROD"
Nota set -euo pipefail in cima, e il || true esattamente dove un risultato vuoto è legittimo. Senza di loro, una chiamata gcloud fallita produce una lista di membri vuota, il grep non trova niente di esterno, e il controllo segnala successo. Il controllo stesso deve fallire chiuso, altrimenti è solo un altro posto in cui l'isolamento può smettere di essere vero senza che nessuno se ne accorga.
Entrambi gli script verificano. Se vuoi che sia la piattaforma a imporre il confine anche quando un binding sbagliato passa, lo strumento nativo su Google Cloud è VPC Service Controls: un perimetro attorno al progetto di produzione che blocca l'accesso ai suoi dati dall'esterno, qualunque cosa dica IAM.
resource "google_access_context_manager_service_perimeter" "prod" {
parent = "accessPolicies/${var.access_policy_id}"
name = "accessPolicies/${var.access_policy_id}/servicePerimeters/prod"
title = "prod"
status {
resources = ["projects/${var.prod_project_number}"]
restricted_services = [
"secretmanager.googleapis.com",
"storage.googleapis.com",
"sqladmin.googleapis.com",
]
}
}
Richiede un'organizzazione e una access policy, e ha una curva di apprendimento, quindi non è da lì che comincerei. Per una piattaforma che custodisce dati regolati è dove finirei, perché sposta la garanzia sotto l'identità invece di fidarsi che l'identità sia configurata correttamente.
Livello 4: blocca la promozione
Torniamo al limite del livello 1. Lo schema fallisce all'avvio, dopo il deploy. Lo stesso schema può fallire prima.
Lo schema sa già quali chiavi sono richieste e quali vivono in Secret Manager. Quindi, prima di promuovere una build in un ambiente, uno script verifica che ogni chiave richiesta sia dichiarata sul servizio di destinazione e che ogni chiave appoggiata a un secret abbia una versione abilitata nel progetto di destinazione. Se manca qualcosa, la promozione si ferma, con un elenco, prima che venga deployato qualsiasi cosa.
// scripts/check-promotion.ts
// Usage: npx tsx scripts/check-promotion.ts <project> <region> <service>
import { execFileSync } from 'node:child_process'
import { REQUIRED_KEYS, SECRET_KEYS } from '../src/env.schema'
const [project, region, service] = process.argv.slice(2)
if (!project || !region || !service) {
console.error('Usage: check-promotion <project> <region> <service>')
process.exit(2)
}
const gcloud = (...args: string[]) =>
execFileSync('gcloud', [...args, `--project=${project}`], {
encoding: 'utf8',
}).trim()
// Keys actually declared on the target Cloud Run service.
const spec = JSON.parse(
gcloud('run', 'services', 'describe', service, `--region=${region}`, '--format=json')
)
const declared = new Set<string>(
(spec.spec.template.spec.containers[0].env ?? []).map(
(e: { name: string }) => e.name
)
)
const hasEnabledVersion = (secret: string) => {
try {
return (
gcloud(
'secrets', 'versions', 'list', secret,
'--filter=state=ENABLED', '--limit=1', '--format=value(name)'
) !== ''
)
} catch {
return false // missing secret or no access: treat as unresolvable
}
}
const problems = [
...REQUIRED_KEYS.filter(key => !declared.has(key)).map(
key => `${key}: not declared on ${service}`
),
...SECRET_KEYS.filter(key => !hasEnabledVersion(key)).map(
key => `${key}: no enabled version in Secret Manager`
),
]
if (problems.length > 0) {
console.error(`Promotion to ${project} blocked:\n ${problems.join('\n ')}`)
process.exit(1)
}
console.log(`OK: every required key resolves in ${project}`)
Ogni percorso d'errore finisce in una promozione bloccata. Un secret che non esiste, un'identità di CI che non può elencare le versioni, un servizio che non può essere descritto: nessuno di questi viene letto come un successo.
Collegato alla pipeline, il job di promozione esegue tutti e tre i controlli prima del passo di deploy, così un fallimento in qualunque punto ferma il rilascio.
# .github/workflows/deploy.yml (excerpt)
promote-prod:
needs: [build, test]
runs-on: ubuntu-latest
environment: prod
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.WIF_PROVIDER }}
service_account: ${{ vars.CI_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
- run: npm ci
- run: npx tsx scripts/check-promotion.ts acme-app-prod europe-west1 api
- run: ./scripts/check-no-foreign-iam.sh
- run: ./scripts/check-cross-env-denied.sh
- run: ./scripts/deploy.sh prod
Lo schema è ora l'unica fonte di verità in tre posti: rifiuta di avviare un processo mal configurato, blocca una promozione verso un ambiente che non può soddisfarlo, e documenta ogni chiave per chiunque legga il fallimento.
L'ordine in cui lo farei
Se dovessi partire domani con una piattaforma nuova, non salirei la scala un piolo alla volta.
Prima la separazione strutturale, perché elimina un'intera categoria di errore invece di rilevarne le istanze. Lo schema all'avvio lo stesso giorno, perché è un pomeriggio di lavoro e trasforma ogni errore di configurazione da silenzioso a rumoroso. Poi le due cose che ho saltato l'ultima volta: il test negativo e il controllo dei binding esterni, in CI e su schedule, perché la separazione regge solo finché qualcuno non la scavalca, e lo scavalcamento è sempre urgente e sempre ragionevole. Il gate di promozione per ultimo, quando c'è più di un ambiente che vale la pena proteggere.
E ogni controllo fallisce chiuso, compresi i controlli stessi. Un test di isolamento che segnala successo quando non è riuscito a girare è la cosa più pericolosa nella pipeline, perché è quella di cui tutti si fidano.
Quindi la domanda che farei sul tuo setup è breve. Se qualcuno concedesse alla tua identità di sviluppo un ruolo sulla produzione questo pomeriggio, quali dei tuoi controlli diventerebbero rossi, e quando?