Saltar al contenido principal

Deploy sin claves: chau a las service accounts @appspot en Jenkins

· 8 min de lectura
Franco R.
El chico de software @ sils.tech

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 @appspot de prod es Owner del proyecto.
  • Las claves estaban en todos lados: managed files de Jenkins, el secret.json que quedaba en el workspace y que el COPY . . 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​

RolPara qué
roles/run.admingcloud run deploy, incluido --allow-unauthenticated
roles/run.sourceDevelopergcloud run deploy --source / --function
roles/cloudfunctions.developergcloud functions deploy
roles/cloudbuild.builds.editor + storage.objectAdmin sobre gs://<proyecto>_cloudbuildgcloud 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.configEditorgcloud endpoints services deploy
roles/serviceusage.serviceUsageConsumer, roles/viewerconsumo de APIs y streaming de logs de Cloud Build
roles/iam.serviceAccountUser solo sobre la SA @appspot y la compute@developerdeployar 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​

  1. Agent docker { image 'google/cloud-sdk:latest' } en los stages de deploy.
  2. gcloud config unset account + gcloud config set auth/impersonate_service_account <deployer>. Nunca activate-service-account ni managed files con claves.
  3. El env file de Jenkins se copia con extensión .yaml si se usa con --env-vars-file.
  4. El código usa credenciales por defecto (new Storage(), new CloudTasksClient(), etc.): sin keyFilename y sin GOOGLE_APPLICATION_CREDENTIALS en el env.
  5. 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 staging y después en master. Los repos inactivos (Functions con nodejs10/nodejs14 que no se deployan desde 2022–2023) quedaron sin migrar: si se reactivan, se migran con sa-least-privilege/03-migrar-jenkinsfiles.py --apply <repo>.
  • apis: dejó de leer secret.json. Storage, BigQuery y Datastore usan credenciales por defecto, y el servicio se deploya con --service-account=<proyecto>@appspot para mantener los mismos permisos que tenía con la clave.
  • Conectores Loopback (@silstech/loopback-connector-google-bigquery 0.0.14 y @silstech/loopback-connector-google-cloud-datastore 2.22.1): keyFilename pasó a ser opcional.
  • n9m-center: se sacó GOOGLE_APPLICATION_CREDENTIALS=secret.json del env. La VM usa su SA para Cloud Tasks y Pub/Sub.
  • calculador-icm: si FIX_POSITION_URL no está definida, no crea tareas hacia jobsFixPosition (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-infra con scope cloud-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:

ArchivoTamañoFecha
europa.zip91,7 GB28/03/2026
full.txt.13,7 GB20/05/2026
full.txt285 MB21/05/2026
eu.txt57 MB29/03/2026
aaa.tar1,7 MB29/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 @appspot deshabilitadas 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 solo calculador-icm tiene 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 poner PermitRootLogin no. Agregar un docker image prune periódico para que el disco no se vuelva a llenar.
  • Borrar del bucket los archivos de /root si en 30 días nadie los reclama.
  • Fase 2: SAs de runtime propias por servicio o grupo, para sacarle Owner/Editor a @appspot y compute@developer.