Deploy sin claves: chau a las service accounts @appspot en Jenkins
Hasta octubre de 2026, todos los pipelines de Jenkins de plataforma y colectores se
autenticaban contra GCP con claves JSON de las service accounts por defecto de App Engine
(sils-producto-stage@appspot y sils-producto@appspot). Esas claves tenían años, daban
Editor en stage y Owner en prod, y terminaban copiadas en workspaces, imágenes Docker
y máquinas locales.
Las reemplazamos por impersonación de dos service accounts de deploy con permisos mínimos, sin ninguna clave. Este post cuenta qué cambió, cómo se deploya ahora y los problemas que aparecieron en el camino.
Por qué
- Una clave filtrada = control total de prod. La SA
@appspotde prod es Owner del proyecto. - Las claves estaban en todos lados: managed files de Jenkins, el
secret.jsonque quedaba en el workspace y que elCOPY . .del Dockerfile metía dentro de las imágenes publicadas en GCR, y copias locales en varias máquinas. - Rotarlas era caro y riesgoso: nadie sabía con certeza qué dependía de cada clave.
Cómo funciona ahora
VM Jenkins (sils-dev-no-spot, proyecto sils-keeper-infra)
└─ identidad: jenkins@sils-keeper-infra (scope cloud-platform, sin claves)
│
│ impersona (roles/iam.serviceAccountTokenCreator)
├──────────────► jenkins-deployer@sils-producto-stage ──► deploys en stage
└──────────────► jenkins-deployer@sils-producto ──► deploys en prod
Servicios en runtime (Cloud Run, Functions, VMs de los MIGs)
└─ usan la SA asignada al recurso (Application Default Credentials), sin archivos de clave
- Jenkins no tiene ninguna clave. La VM obtiene credenciales del metadata server de GCE, que Google rota solo.
- Un deployer por ambiente. Jenkins de stage no puede tocar prod salvo que el pipeline impersone explícitamente al deployer de prod.
- Los deployers solo pueden deployar: no leen ni escriben datos de negocio.
Permisos de los deployers
| Rol | Para qué |
|---|---|
roles/run.admin | gcloud run deploy, incluido --allow-unauthenticated |
roles/run.sourceDeveloper | gcloud run deploy --source / --function |
roles/cloudfunctions.developer | gcloud functions deploy |
roles/cloudbuild.builds.editor + storage.objectAdmin sobre gs://<proyecto>_cloudbuild | gcloud builds submit |
roles/compute.instanceAdmin.v1 + rol custom jenkinsDeployerMigExtras (compute.healthChecks.use) | instance templates y rolling updates de MIGs con autohealing |
roles/vpcaccess.user | --vpc-connector |
roles/servicemanagement.configEditor | gcloud endpoints services deploy |
roles/serviceusage.serviceUsageConsumer, roles/viewer | consumo de APIs y streaming de logs de Cloud Build |
roles/iam.serviceAccountUser solo sobre la SA @appspot y la compute@developer | deployar servicios que corren con esas identidades |
El script que crea todo esto es idempotente y está en sa-least-privilege/01-crear-deployers.sh.
Cómo se ve un Jenkinsfile
Antes:
configFileProvider([
configFile(fileId:'3002a838-…', targetLocation: 'secret.json', variable: 'GCP_SERVICE_ACCOUNT'),
configFile(fileId:'…', targetLocation: 'test.env', variable: 'ENV_FILE')
]) {
sh "gcloud auth activate-service-account --key-file ${env.GCP_SERVICE_ACCOUNT}"
sh "gcloud config set project ${GCP_PROJECT}"
sh "gcloud run deploy … --env-vars-file ${env.ENV_FILE}"
}
Ahora:
configFileProvider([
configFile(fileId:'…', targetLocation: 'test.yaml', variable: 'ENV_FILE')
]) {
sh "gcloud config unset account"
sh "gcloud config set auth/impersonate_service_account jenkins-deployer@sils-producto-stage.iam.gserviceaccount.com"
sh "gcloud config set project ${GCP_PROJECT}"
sh "gcloud run deploy … --env-vars-file ${env.ENV_FILE}"
}
Para prod se usa jenkins-deployer@sils-producto.iam.gserviceaccount.com. En los logs del
build aparece este warning, que es esperado y sirve para auditar con qué identidad se deployó:
WARNING: This command is using service account impersonation. All API calls will be executed as [jenkins-deployer@…].
Checklist para un repo nuevo
- Agent
docker { image 'google/cloud-sdk:latest' }en los stages de deploy. gcloud config unset account+gcloud config set auth/impersonate_service_account <deployer>. Nuncaactivate-service-accountni managed files con claves.- El env file de Jenkins se copia con extensión
.yamlsi se usa con--env-vars-file. - El código usa credenciales por defecto (
new Storage(),new CloudTasksClient(), etc.): sinkeyFilenamey sinGOOGLE_APPLICATION_CREDENTIALSen el env. - Si el servicio necesita permisos, se dan a la SA con la que corre, no a una clave.
Cambios en los proyectos
- 62 Jenkinsfiles analizados. Se migraron los 44 repos con deploys en 2026, primero en
stagingy después enmaster. Los repos inactivos (Functions connodejs10/nodejs14que no se deployan desde 2022–2023) quedaron sin migrar: si se reactivan, se migran consa-least-privilege/03-migrar-jenkinsfiles.py --apply <repo>. apis: dejó de leersecret.json. Storage, BigQuery y Datastore usan credenciales por defecto, y el servicio se deploya con--service-account=<proyecto>@appspotpara mantener los mismos permisos que tenía con la clave.- Conectores Loopback (
@silstech/loopback-connector-google-bigquery0.0.14 y@silstech/loopback-connector-google-cloud-datastore2.22.1):keyFilenamepasó a ser opcional. n9m-center: se sacóGOOGLE_APPLICATION_CREDENTIALS=secret.jsondel env. La VM usa su SA para Cloud Tasks y Pub/Sub.calculador-icm: siFIX_POSITION_URLno está definida, no crea tareas haciajobsFixPosition(desactivada por ahora).dispositivo-2-sils: se eliminaron MIGs, templates e imágenes, que tenían la clave adentro.- VM de Jenkins: pasó a usar la SA
jenkins@sils-keeper-infracon scopecloud-platform. - Claves
@appspot: deshabilitadas el 07/10/2026 en ambos proyectos, después de verificar en los audit logs que ningún pipeline las seguía usando.
Problemas que aparecieron (y cómo se resolvieron)
Failed to impersonate … Permission 'iam.serviceAccounts.getAccessToken' denied y
"authenticated as …@appspot". Todos los Jenkinsfiles tienen HOME = '.', así que la config
de gcloud vive en el workspace y sobrevive entre builds. Un build viejo había dejado activa
la cuenta @appspot como core/account, y gcloud intentaba impersonar desde esa cuenta en vez
de la VM. Solución: gcloud config unset account antes del impersonate.
Required 'compute.healthChecks.use' permission en el rolling update de MIGs con
autohealing (n9m-center, sils-traccar, n9m-media-go). compute.instanceAdmin.v1 no lo
incluye y los roles predefinidos que sí lo tienen son enormes, así que se creó el rol custom
jenkinsDeployerMigExtras.
Servicios que leían la clave en runtime. El COPY . . del Dockerfile metía en la imagen
el secret.json que Jenkins dejaba en el workspace. Cuando el archivo dejó de existir, fallaron
los clientes con GOOGLE_APPLICATION_CREDENTIALS=secret.json en el env (n9m-center) o con
keyFilename en el código (apis, jobsFixPosition). En n9m-center el código tiene fallback
a HTTP directo, así que no se perdieron posiciones.
Invalid .env file …, expected map-like data. Las versiones nuevas de gcloud (586+)
interpretan cualquier archivo que termine en .env como dotenv, y nuestros env files son YAML
(CLAVE: valor). Solución: copiar el managed file como test.yaml. No hace falta tocar el
contenido.
Sonar marca duplicación en new code porque el Jenkinsfile repite el bloque de deploy en
stage y master. Se excluye con sonar.cpd.exclusions=Jenkinsfile en sonar-project.properties,
o globalmente en SonarQube con **/Jenkinsfile en Duplication Exclusions.
Unrecoverable indexing failures en Sonar: el disco de la VM de Jenkins/Sonar estaba al
97% y Elasticsearch pone los índices en solo lectura al pasar el 95%. Se liberó espacio
borrando imágenes Docker sin usar (docker image prune -a).
Limpieza de archivos sospechosos en la VM de Jenkins
Mientras investigábamos el disco lleno, encontramos en /root de la VM sils-dev-no-spot
archivos que no tienen relación con Jenkins, Sonar ni Nexus, y que no pudimos asociar a nadie
del equipo:
| Archivo | Tamaño | Fecha |
|---|---|---|
europa.zip | 91,7 GB | 28/03/2026 |
full.txt.1 | 3,7 GB | 20/05/2026 |
full.txt | 285 MB | 21/05/2026 |
eu.txt | 57 MB | 29/03/2026 |
aaa.tar | 1,7 MB | 29/03/2026 |
Por los nombres, los tamaños y que el historial de root no registra cómo se crearon, los
tratamos como sospechosos. Antes de borrarlos los subimos al bucket
gs://sils-dev-root-archive/sils-dev-root-20261007/ (proyecto sils-keeper-infra, clase
Archive). Verificamos que los tamaños coincidieran byte a byte y después los borramos de la VM.
El permiso de escritura de la VM sobre el bucket fue temporal (condición IAM con vencimiento).
Entre esta limpieza y la de imágenes Docker, el disco de la VM bajó del 97% al 54%.
Si alguien reconoce esos archivos, los puede recuperar del bucket. Si nadie los reclama, se borran también de ahí.
Además, /root/.ssh/authorized_keys tiene dos claves que todavía no pudimos asociar a nadie
del equipo (root@046b969dcfd6 y root@sda). En los últimos 30 días nadie entró como root por SSH: el
equipo administra la VM con su usuario y sudo.
Pendiente
- Borrar definitivamente las claves
@appspotdeshabilitadas y vaciar sus managed files en Jenkins. - Migrar o dar de baja los repos inactivos que todavía referencian las claves.
- Pasar el resto de los Jenkinsfiles a
targetLocation: 'test.yaml': por ahora solocalculador-icmtiene el cambio, y los demás van a fallar en su próximo deploy con gcloud 586+. - Evaluar qué hacer con las claves
firebase-adminsdk, que siguen siendo archivos JSON. - VM de Jenkins: confirmar de quién son las claves de
/root/.ssh/authorized_keys, borrarlas y ponerPermitRootLogin no. Agregar undocker image pruneperiódico para que el disco no se vuelva a llenar. - Borrar del bucket los archivos de
/rootsi en 30 días nadie los reclama. - Fase 2: SAs de runtime propias por servicio o grupo, para sacarle Owner/Editor a
@appspotycompute@developer.