Cerrábamos la lección anterior con una observación incómoda: hemos dado a los componentes de Rutas Norte su configuración, sus credenciales y sus recursos, pero no les hemos dado identidad. Ahora mismo, los pods de tienda-web, api-reservas, postgres-reservas, redis-cache y worker-notificaciones están usando todos la misma ServiceAccount default de su namespace, con un token de acceso a la API de Kubernetes montado dentro que ninguno de ellos necesita y que es lo primero que buscaría un atacante que consiguiera ejecutar código en uno de esos contenedores. Esta lección lo arregla: entenderás qué es una ServiceAccount y en qué se diferencia de un usuario, cómo funcionan los tokens proyectados modernos, por qué automountServiceAccountToken: false debe ser tu opción por defecto, y hablarás con la API desde dentro de un pod con curl para ver con tus ojos tanto una respuesta autorizada como un 403 Forbidden bien merecido.

Contenido

  1. La identidad de una carga de trabajo
  2. Usuarios frente a ServiceAccounts
  3. La ServiceAccount default y por qué no debe usarse
  4. Crear ServiceAccounts dedicadas para Rutas Norte
  5. El token proyectado: cómo ha cambiado
  6. Qué hay en /var/run/secrets/kubernetes.io/serviceaccount/
  7. automountServiceAccountToken: false
  8. Hablar con la API desde dentro de un pod
  9. El 403 Forbidden y qué significa
  10. imagePullSecrets en la ServiceAccount
  11. La ServiceAccount dice quién eres, RBAC dice qué puedes hacer
  12. Federación de identidad con las nubes

  1. La identidad de una carga de trabajo

Toda petición que llega al kube-apiserver pasa por tres fases que ya conoces del módulo 1:

flowchart LR
    A["Peticion"] --> B["AUTENTICACION<br/>Quien eres?"]
    B --> C["AUTORIZACION (RBAC)<br/>Puedes hacer esto?"]
    C --> D["ADMISION<br/>LimitRanger, ResourceQuota..."]
    D --> E["etcd"]
    B -.->|"no identificado"| F["401 Unauthorized"]
    C -.->|"identificado pero sin permiso"| G["403 Forbidden"]

Cuando ejecutas kubectl get pods, la primera fase se resuelve con el certificado o el token de tu kubeconfig. Pero, ¿y cuando quien pregunta es un pod?

Los casos en que un pod necesita hablar con la API son más comunes de lo que parece:

Quién Para qué
Un controlador de Ingress Leer objetos Ingress y Service para configurar su enrutamiento
Prometheus Descubrir qué pods hay que consultar (07-03)
cert-manager Crear y actualizar Secrets de tipo TLS (04-05)
Un operador Reconciliar sus recursos personalizados (06-07)
Argo CD Aplicar manifiestos desde Git (10-05)
Una aplicación tuya Leer un ConfigMap en caliente, consultar sus propias réplicas, coordinar una elección de líder

Y en Rutas Norte hay un caso concreto que llegará en el módulo 6: informes-ocupacion, la tarea nocturna, tendrá que consultar la API para saber cuántas réplicas de api-reservas hubo activas cada hora y correlacionarlo con la ocupación. Necesita identidad y necesita permisos.

Pero la razón principal para estudiar esto no son los pods que hablan con la API: son los cinco que no lo hacen y que, sin embargo, llevan encima una credencial válida sin necesitarla. Eso es superficie de ataque gratuita.

  1. Usuarios frente a ServiceAccounts

Kubernetes distingue dos tipos de identidad, y la diferencia es de fondo:

Usuarios (users) ServiceAccounts
Para quién Personas: administradores, desarrolladores Procesos: pods, controladores
¿Es un objeto de Kubernetes? No. No existe kind: User : kind: ServiceAccount
Quién los gestiona Un sistema externo: certificados, OIDC, LDAP, IAM de la nube Kubernetes, con kubectl create sa
kubectl get Imposible kubectl get serviceaccounts
Ámbito Todo el clúster Con namespace
Nombre en RBAC [email protected] system:serviceaccount:<ns>:<nombre>
Cómo se autentican Certificado de cliente, token OIDC Token JWT firmado por el clúster
Ciclo de vida Externo Ligado al namespace

El primer punto sorprende siempre: Kubernetes no tiene usuarios. No hay una base de datos de usuarios, ni un comando para crear uno. Cuando tu kubeconfig usa un certificado de cliente, Kubernetes lee el campo CN (Common Name) del certificado y lo toma como nombre de usuario, confiando en que la autoridad certificadora del clúster solo firma certificados legítimos. Con OIDC, delega en el proveedor de identidad. La gestión de personas es, deliberadamente, un problema de fuera.

Las ServiceAccounts, en cambio, son objetos de primera clase:

kubectl get serviceaccounts -n rutas-norte-pro
NAME      SECRETS   AGE
default   0         14d

Solo hay una, la default, y todos nuestros pods la están usando. Fíjate en SECRETS 0: es la señal de que estamos en Kubernetes moderno, donde las ServiceAccounts ya no llevan un Secret con un token permanente asociado. Volveremos a ello en el apartado 5.

El nombre completo de una ServiceAccount en el sistema de autorización tiene esta forma:

system:serviceaccount:rutas-norte-pro:api-reservas
     |         |              |             |
   prefijo   tipo         namespace       nombre

Ese es el identificador exacto que usarás en los RoleBindings de RBAC, y también el que aparece en los registros de auditoría. Además, toda ServiceAccount pertenece automáticamente al grupo system:serviceaccounts y al grupo de su namespace, system:serviceaccounts:rutas-norte-pro, lo que permite conceder permisos a todas las de un entorno de una vez (algo que casi nunca es buena idea, pero conviene saber que existe).

  1. La ServiceAccount default y por qué no debe usarse

Cada namespace obtiene una ServiceAccount llamada default en el momento de crearse. Si un pod no especifica ninguna, se le asigna esa.

kubectl get pod api-reservas-7f4b8c9d6-2xkqp -n rutas-norte-pro \
  -o jsonpath='{.spec.serviceAccountName}'; echo
default

Cinco razones para no usarla:

Razón 1: es compartida. Todos los pods del namespace tienen la misma identidad. Si mañana informes-ocupacion necesita permiso para leer Deployments y se lo concedes a default, se lo estás concediendo también a tienda-web, a redis-cache y a postgres-reservas. Es lo contrario del mínimo privilegio.

Razón 2: impide la trazabilidad. En el registro de auditoría todas las peticiones aparecen como system:serviceaccount:rutas-norte-pro:default. Ante un acceso sospechoso, no hay forma de saber qué componente lo hizo.

Razón 3: impide revocar de forma selectiva. Si worker-notificaciones se ve comprometido, quieres retirarle los permisos de inmediato. Con la default, retirárselos significa retirárselos a todo el namespace.

Razón 4: monta un token que casi nadie necesita. Por defecto, automountServiceAccountToken es true, así que todos nuestros pods llevan dentro un token válido de la API. Compruébalo:

kubectl exec -n rutas-norte-pro deploy/tienda-web -- ls /var/run/secrets/kubernetes.io/serviceaccount/
ca.crt
namespace
token

Un servidor nginx que solo sirve ficheros estáticos tiene ahí una credencial de acceso a la API del clúster. Si alguien encuentra una vulnerabilidad de lectura arbitraria de ficheros en esa aplicación (algo nada exótico), lo primero que hará es leer ese token.

Razón 5: la tentación de darle permisos. Es tan cómodo que, en un apuro, alguien acaba enlazando la default a un ClusterRole potente para "que funcione ya". Y eso no se revierte nunca.

La regla es simple:

Una ServiceAccount por componente, y automountServiceAccountToken: false en todas salvo en las que de verdad hablen con la API.

  1. Crear ServiceAccounts dedicadas para Rutas Norte

Empecemos por el objeto. Es de los más simples de Kubernetes:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
  labels:
    app: api-reservas
    app.kubernetes.io/name: api-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
automountServiceAccountToken: false     # api-reservas NO habla con la API

O de forma imperativa, para generar el YAML:

kubectl create serviceaccount api-reservas -n rutas-norte-pro --dry-run=client -o yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  creationTimestamp: null
  name: api-reservas
  namespace: rutas-norte-pro

El plan completo para Rutas Norte, con la decisión razonada para cada componente:

Componente ServiceAccount ¿Habla con la API? automountServiceAccountToken
tienda-web tienda-web No false
api-reservas api-reservas No false
postgres-reservas postgres-reservas No false
redis-cache redis-cache No false
worker-notificaciones worker-notificaciones No false
informes-ocupacion informes-ocupacion (módulo 6) true

Cinco de seis no necesitan token. Ese es el resultado normal: la inmensa mayoría de las aplicaciones de negocio no hablan con la API de Kubernetes. Y aun así, la configuración por defecto se lo monta a todas.

¿Y por qué crear una ServiceAccount para los que no la usan, si no van a hablar con la API? Por tres motivos:

  1. Trazabilidad: si algún día uno de ellos hace una petición, sabrás cuál.
  2. imagePullSecrets: la ServiceAccount es el sitio correcto para declararlos (apartado 10).
  3. Preparación: cuando mañana api-reservas necesite un permiso concreto, la identidad ya existe y no hay que tocar el Deployment.

Los seis objetos, en k8s/base/serviceaccounts.yaml:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: tienda-web
  namespace: rutas-norte-pro
  labels: {app: tienda-web, app.kubernetes.io/part-of: rutas-norte, entorno: pro}
automountServiceAccountToken: false
imagePullSecrets:
  - name: registry-rutasnorte
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
  labels: {app: api-reservas, app.kubernetes.io/part-of: rutas-norte, entorno: pro}
automountServiceAccountToken: false
imagePullSecrets:
  - name: registry-rutasnorte
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: postgres-reservas
  namespace: rutas-norte-pro
  labels: {app: postgres-reservas, app.kubernetes.io/part-of: rutas-norte, entorno: pro}
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: redis-cache
  namespace: rutas-norte-pro
  labels: {app: redis-cache, app.kubernetes.io/part-of: rutas-norte, entorno: pro}
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: worker-notificaciones
  namespace: rutas-norte-pro
  labels: {app: worker-notificaciones, app.kubernetes.io/part-of: rutas-norte, entorno: pro}
automountServiceAccountToken: false
imagePullSecrets:
  - name: registry-rutasnorte
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: informes-ocupacion
  namespace: rutas-norte-pro
  labels: {app: informes-ocupacion, app.kubernetes.io/part-of: rutas-norte, entorno: pro}
# Esta SI necesita el token: consultara la API en el modulo 6.
automountServiceAccountToken: true
imagePullSecrets:
  - name: registry-rutasnorte

Y la asignación en el pod, con el campo serviceAccountName:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
spec:
  replicas: 4
  selector:
    matchLabels:
      app: api-reservas
      entorno: pro
  template:
    metadata:
      labels:
        app: api-reservas
        app.kubernetes.io/name: api-reservas
        app.kubernetes.io/part-of: rutas-norte
        entorno: pro
    spec:
      serviceAccountName: api-reservas       # <-- la identidad del pod
      automountServiceAccountToken: false    # cinturon y tirantes
      containers:
        - name: api
          image: registry.rutasnorte.example/api-reservas:2.5.0

Un detalle histórico que verás en manifiestos antiguos: existe también un campo serviceAccount (sin Name). Está obsoleto desde hace muchas versiones y solo se conserva por compatibilidad. Usa siempre serviceAccountName.

Aplicamos y verificamos:

kubectl apply -f k8s/base/serviceaccounts.yaml
kubectl rollout restart deploy -n rutas-norte-pro
kubectl get pods -n rutas-norte-pro \
  -o custom-columns='POD:.metadata.name,SA:.spec.serviceAccountName'
serviceaccount/tienda-web created
serviceaccount/api-reservas created
serviceaccount/postgres-reservas created
serviceaccount/redis-cache created
serviceaccount/worker-notificaciones created
serviceaccount/informes-ocupacion created

POD                                     SA
api-reservas-8b5c7d9e2-4mkqp            api-reservas
api-reservas-8b5c7d9e2-9wnzr            api-reservas
postgres-reservas-6e9g7c0d5-l3nqx       postgres-reservas
redis-cache-7d0e9g8c6-wo5ry             redis-cache
tienda-web-6c8d0g5e9-i8qol              tienda-web
worker-notificaciones-7g8e0d9c5-mn4qu   worker-notificaciones

Cada componente con su identidad. Y el token ha desaparecido de donde no hacía falta:

kubectl exec -n rutas-norte-pro deploy/tienda-web -- ls /var/run/secrets/kubernetes.io/serviceaccount/
ls: /var/run/secrets/kubernetes.io/serviceaccount/: No such file or directory
command terminated with exit code 1

Ese error es exactamente el resultado deseado. No hay credencial que robar.

  1. El token proyectado: cómo ha cambiado

Esta parte tiene historia y es importante conocerla, porque encontrarás manifiestos y tutoriales de la época anterior.

Antes de Kubernetes 1.24: tokens permanentes en Secrets

Al crear una ServiceAccount, un controlador generaba automáticamente un Secret de tipo kubernetes.io/service-account-token con un JWT dentro. Ese token:

  • No caducaba nunca.
  • No estaba ligado a ningún pod: quien lo copiara podía usarlo desde cualquier sitio, incluso desde fuera del clúster.
  • Se guardaba en etcd, así que estaba en las copias de seguridad.
  • No se podía revocar sin borrar el Secret y la ServiceAccount.

Era, en la práctica, una contraseña eterna en cada namespace. Si un token se filtraba en un log, en un volcado o en un repositorio, seguía siendo válido meses después.

Desde 1.24, y obligatorio desde 1.25: la API TokenRequest

El mecanismo actual es completamente distinto y mucho mejor:

sequenceDiagram
    participant K as kubelet
    participant A as apiserver (TokenRequest)
    participant P as Pod

    K->>A: TokenRequest para la SA api-reservas,<br/>audiencia y vida limitadas,<br/>ligado a ESTE pod
    A-->>K: JWT firmado (caduca en 1 hora)
    K->>P: lo escribe en tmpfs<br/>/var/run/secrets/.../token
    Note over K,P: A las 48 minutos (80% de la vida)
    K->>A: renovacion
    A-->>K: JWT nuevo
    K->>P: sustituye el fichero
    Note over P: La aplicacion debe RELEER el fichero

Las propiedades del token moderno:

Propiedad Token antiguo (Secret) Token proyectado (TokenRequest)
Caducidad Nunca 1 hora por defecto
Rotación Manual Automática, por el kubelet al 80 % de la vida
Ligado al pod No : contiene el UID del pod
Al borrar el pod Sigue siendo válido Se invalida
Audiencia (aud) Genérica Restringible a un destinatario
Guardado en etcd No
Dónde vive Secret + volumen Solo en tmpfs del pod

Veamos el contenido real de un token. Dentro de un pod que sí lo tenga montado:

kubectl exec -n rutas-norte-pro deploy/informes-ocupacion -- sh -c \
  'cut -d. -f2 /var/run/secrets/kubernetes.io/serviceaccount/token | base64 -d 2>/dev/null'
{
  "aud": ["https://kubernetes.default.svc.cluster.local"],
  "exp": 1785703921,
  "iat": 1785700321,
  "iss": "https://kubernetes.default.svc.cluster.local",
  "jti": "b41e9c02-7f3a-4d18-9a6e-2c85f0d31447",
  "kubernetes.io": {
    "namespace": "rutas-norte-pro",
    "node": {"name": "rutas-norte-m02", "uid": "d3a1..."},
    "pod": {"name": "informes-ocupacion-28912440-x7mzq", "uid": "7f2c..."},
    "serviceaccount": {"name": "informes-ocupacion", "uid": "1e8b..."}
  },
  "nbf": 1785700321,
  "sub": "system:serviceaccount:rutas-norte-pro:informes-ocupacion"
}

Es un JWT estándar. Lo relevante:

  • sub es el identificador que usará RBAC.
  • exp - iat = 3600 segundos: una hora de vida.
  • kubernetes.io.pod ata el token a un pod concreto. Si ese pod se borra, el apiserver rechaza el token aunque no haya caducado.
  • aud limita para quién es válido.

Un aviso práctico importante: si tu aplicación lee el token una vez al arrancar y lo guarda en memoria, dejará de funcionar en una hora. Las bibliotecas cliente oficiales (client-go, kubernetes de Python, etc.) releen el fichero automáticamente. Si hablas con la API con curl o con una biblioteca HTTP genérica, tienes que releer el fichero en cada petición. Es una fuente de errores 401 desconcertantes que aparecen exactamente una hora después del despliegue.

Se puede personalizar la proyección con un volumen explícito:

      volumes:
        - name: token-api
          projected:
            sources:
              - serviceAccountToken:
                  path: token
                  expirationSeconds: 3600          # minimo 600
                  audience: api.rutasnorte.example  # para un destinatario concreto

El campo audience es especialmente útil para un patrón avanzado: emitir un token que solo sirva ante un servicio externo concreto, de modo que si se filtra no valga para hablar con la API de Kubernetes.

Y si alguna vez necesitas un token permanente (integrar una herramienta externa que no puede rotar), hay que crearlo explícitamente:

apiVersion: v1
kind: Secret
metadata:
  name: informes-ocupacion-token-permanente
  namespace: rutas-norte-pro
  annotations:
    kubernetes.io/service-account.name: informes-ocupacion
type: kubernetes.io/service-account-token

Evítalo siempre que puedas. Vuelve a introducir todos los problemas del modelo antiguo. Para casos puntuales, la alternativa correcta es pedir un token de vida limitada:

kubectl create token informes-ocupacion -n rutas-norte-pro --duration=30m
eyJhbGciOiJSUzI1NiIsImtpZCI6Ilp3...

  1. Qué hay en /var/run/secrets/kubernetes.io/serviceaccount/

Cuando el token se monta, el directorio contiene tres ficheros:

kubectl exec -n rutas-norte-pro deploy/informes-ocupacion -- sh -c \
  'ls -la /var/run/secrets/kubernetes.io/serviceaccount/ && mount | grep serviceaccount'
total 0
drwxrwxrwt 3 root root 140 Aug  5 22:03 .
drwxr-xr-x 3 root root  60 Aug  5 22:03 ..
lrwxrwxrwx 1 root root  13 Aug  5 22:03 ca.crt -> ..data/ca.crt
lrwxrwxrwx 1 root root  16 Aug  5 22:03 namespace -> ..data/namespace
lrwxrwxrwx 1 root root  12 Aug  5 22:03 token -> ..data/token

tmpfs on /var/run/secrets/kubernetes.io/serviceaccount type tmpfs (ro,relatime)

Reconoces dos cosas de lecciones anteriores: la cadena de enlaces a ..data que permite la actualización atómica (aquí es lo que hace posible la rotación del token) y el tmpfs, es decir, memoria RAM.

Fichero Contenido Para qué sirve
token El JWT firmado Cabecera Authorization: Bearer <token>
ca.crt Certificado de la CA del clúster Verificar el certificado TLS del apiserver
namespace Nombre del namespace, en texto plano Que la aplicación sepa dónde vive sin la Downward API
kubectl exec -n rutas-norte-pro deploy/informes-ocupacion -- sh -c \
  'cat /var/run/secrets/kubernetes.io/serviceaccount/namespace; echo; \
   head -1 /var/run/secrets/kubernetes.io/serviceaccount/ca.crt'
rutas-norte-pro
-----BEGIN CERTIFICATE-----

El ca.crt es la pieza que suele olvidarse. Sin él, un cliente que hable con https://kubernetes.default.svc no puede validar el certificado del apiserver y hay que recurrir a --insecure, que es exactamente lo que no hay que hacer: sin validación, un atacante con acceso a la red del pod podría suplantar al apiserver y capturar el token.

Junto con las variables KUBERNETES_SERVICE_HOST y KUBERNETES_SERVICE_PORT que Kubernetes inyecta en todos los pods (las viste en la lección de variables de entorno), estos tres ficheros son todo lo necesario para hablar con la API. Es precisamente lo que las bibliotecas cliente llaman configuración dentro del clúster (InClusterConfig).

  1. automountServiceAccountToken: false

Este campo se puede poner en dos sitios, y la precedencia importa:

# En la ServiceAccount: afecta a TODOS los pods que la usen
apiVersion: v1
kind: ServiceAccount
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
automountServiceAccountToken: false
# En el pod: afecta solo a ESE pod y GANA sobre la ServiceAccount
spec:
  serviceAccountName: api-reservas
  automountServiceAccountToken: false
ServiceAccount Pod Resultado
(sin especificar) (sin especificar) Se monta (el valor por defecto)
false (sin especificar) No se monta
true (sin especificar) Se monta
false true Se monta: el pod gana
true false No se monta: el pod gana

El pod siempre tiene la última palabra. Eso permite el patrón recomendado: poner false en la ServiceAccount como red de seguridad, y true explícito solo en el pod concreto que de verdad lo necesita.

Por qué esto importa tanto, en términos de seguridad: si un atacante consigue ejecutar código en un contenedor —a través de una vulnerabilidad de la aplicación, una dependencia comprometida o una inyección—, lo primero que hace un guion de explotación automatizado es leer ese token y probar qué permisos tiene. Es un paso estándar en cualquier herramienta de escalada de privilegios en Kubernetes.

Comparemos la superficie de ataque:

Con token montado Sin token montado
Puede leer el token No existe
Puede consultar la API Sí, con los permisos de la SA No, ni siquiera se identifica
Puede enumerar el clúster Depende de RBAC No
Si RBAC está mal configurado Escalada de privilegios Nada que escalar

Sin token, incluso una configuración de RBAC descuidada queda neutralizada para ese pod: no hay credencial con la que ejercerla.

Comprobación del estado en todo el clúster, que es una auditoría que merece la pena hacer:

kubectl get pods -A -o json | jq -r '
  .items[] |
  select(.spec.automountServiceAccountToken != false) |
  "\(.metadata.namespace)/\(.metadata.name) -> \(.spec.serviceAccountName)"' | head -10
kube-system/coredns-7db6d8ff4d-2vqxn -> coredns
kube-system/kube-proxy-8xvmk -> kube-proxy
rutas-norte-pro/informes-ocupacion-28912440-x7mzq -> informes-ocupacion

Los del sistema lo necesitan legítimamente. De Rutas Norte, solo informes-ocupacion. Ese es el objetivo.

Un apunte de operación: si añades automountServiceAccountToken: false a una ServiceAccount, los pods que ya corren conservan su token. El montaje se decide al crear el pod. Hay que recrearlos:

kubectl rollout restart deploy -n rutas-norte-pro

  1. Hablar con la API desde dentro de un pod

Vamos a hacerlo a mano para entender qué hacen por dentro las bibliotecas cliente. Usaremos informes-ocupacion, el único componente de Rutas Norte que sí necesita hablar con la API.

Paso 1: un pod con la ServiceAccount y el token montado.

apiVersion: v1
kind: Pod
metadata:
  name: consultor-api
  namespace: rutas-norte-pro
  labels:
    app: informes-ocupacion
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
spec:
  serviceAccountName: informes-ocupacion
  automountServiceAccountToken: true
  restartPolicy: Never
  containers:
    - name: consultor
      image: curlimages/curl:8.10.1
      command: ["sleep", "3600"]
      resources:
        requests:
          cpu: 50m
          memory: 64Mi
        limits:
          cpu: 100m
          memory: 128Mi
kubectl apply -f consultor-api.yaml
kubectl exec -it consultor-api -n rutas-norte-pro -- sh

Paso 2: preparar las variables dentro del pod.

# Ya dentro del contenedor
SA=/var/run/secrets/kubernetes.io/serviceaccount
TOKEN=$(cat $SA/token)
NS=$(cat $SA/namespace)
APISERVER=https://kubernetes.default.svc

echo "Namespace: $NS"
echo "API:       $APISERVER"
echo "Token:     ${TOKEN:0:40}..."
Namespace: rutas-norte-pro
API:       https://kubernetes.default.svc
Token:     eyJhbGciOiJSUzI1NiIsImtpZCI6Ilp3RTFvSm...

kubernetes.default.svc es el Service del apiserver, que existe en el namespace default de todo clúster. Cualquier pod puede resolverlo por DNS, como viste en el módulo 2.

Paso 3: la primera petición, al endpoint de versión (que no requiere permisos).

curl -s --cacert $SA/ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/version
{
  "major": "1",
  "minor": "30",
  "gitVersion": "v1.30.4",
  "goVersion": "go1.22.5",
  "platform": "linux/amd64"
}

Ha funcionado. Los tres elementos de la petición:

  1. --cacert $SA/ca.crt: verifica que el servidor es realmente el apiserver del clúster. Nunca uses -k ni --insecure.
  2. Authorization: Bearer $TOKEN: la autenticación. Es el mecanismo estándar de JWT.
  3. La URL del Service interno, resuelta por DNS.

Paso 4: preguntar quién soy. Este endpoint es utilísimo para depurar:

curl -s --cacert $SA/ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -X POST $APISERVER/apis/authentication.k8s.io/v1/selfsubjectreviews \
  -d '{"apiVersion":"authentication.k8s.io/v1","kind":"SelfSubjectReview"}'
{
  "kind": "SelfSubjectReview",
  "apiVersion": "authentication.k8s.io/v1",
  "status": {
    "userInfo": {
      "username": "system:serviceaccount:rutas-norte-pro:informes-ocupacion",
      "uid": "1e8b4c73-9d02-4a5f-8e31-7b6c0f294a15",
      "groups": [
        "system:serviceaccounts",
        "system:serviceaccounts:rutas-norte-pro",
        "system:authenticated"
      ]
    }
  }
}

Ahí está la identidad completa: el username con el formato que anticipábamos y los tres grupos automáticos. Desde fuera, el equivalente es:

kubectl auth whoami --as=system:serviceaccount:rutas-norte-pro:informes-ocupacion

Paso 5: intentar algo que requiere permisos.

curl -s -o /dev/null -w "%{http_code}\n" --cacert $SA/ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/api/v1/namespaces/$NS/pods
403

Y aquí llega la parte instructiva.

  1. El 403 Forbidden y qué significa

Veamos el cuerpo completo de esa respuesta:

curl -s --cacert $SA/ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/api/v1/namespaces/$NS/pods | head -20
{
  "kind": "Status",
  "apiVersion": "v1",
  "metadata": {},
  "status": "Failure",
  "message": "pods is forbidden: User \"system:serviceaccount:rutas-norte-pro:informes-ocupacion\"
              cannot list resource \"pods\" in API group \"\" in the namespace \"rutas-norte-pro\"",
  "reason": "Forbidden",
  "details": {"kind": "pods"},
  "code": 403
}

Este mensaje es una pequeña lección en sí mismo. Descompongámoslo:

Parte Significado
User "system:serviceaccount:..." La autenticación funcionó: el apiserver sabe quién eres
cannot list El verbo denegado
resource "pods" El recurso
in API group "" El grupo de API: "" es el grupo core
in the namespace "..." El ámbito
"code": 403 Prohibido, no "no autenticado"

La distinción entre 401 y 403 es fundamental y hay que tenerla clara:

Código Significado Causa típica
401 Unauthorized "No sé quién eres" Token ausente, caducado, malformado o firmado por otro clúster
403 Forbidden "Sé quién eres, pero no puedes" Falta un Role o un RoleBinding

Si obtienes un 401, el problema está en el token: revisa que lo estés leyendo bien, que no haya caducado (recuerda la hora de vida) y que el pod tenga el volumen montado. Si obtienes un 403, el token es correcto y lo que falta son permisos.

Y ese 403 es exactamente lo que debe pasar ahora mismo. Nuestra ServiceAccount informes-ocupacion no tiene ningún permiso concedido, porque conceder permisos es trabajo de RBAC, y RBAC es el tema de la lección 08-01. Una ServiceAccount recién creada, sin RoleBindings, no puede hacer absolutamente nada más allá de los pocos endpoints públicos como /version.

Esto merece subrayarse porque es el diseño correcto:

Kubernetes deniega por defecto. Crear una identidad no concede ningún permiso. Todo lo que una ServiceAccount pueda hacer tiene que estar concedido explícitamente.

Podemos comprobar los permisos sin hacer la petición, con kubectl auth can-i:

kubectl auth can-i list pods -n rutas-norte-pro \
  --as=system:serviceaccount:rutas-norte-pro:informes-ocupacion
kubectl auth can-i get deployments -n rutas-norte-pro \
  --as=system:serviceaccount:rutas-norte-pro:informes-ocupacion
no
no

Y la lista completa de lo que sí puede:

kubectl auth can-i --list -n rutas-norte-pro \
  --as=system:serviceaccount:rutas-norte-pro:informes-ocupacion
Resources                                       Non-Resource URLs   Verbs
selfsubjectreviews.authentication.k8s.io        []                  [create]
selfsubjectaccessreviews.authorization.k8s.io   []                  [create]
selfsubjectrulesreviews.authorization.k8s.io    []                  [create]
                                                [/healthz]          [get]
                                                [/version]          [get]

Solo lo mínimo: preguntar quién es, preguntar qué puede hacer, y consultar la salud y la versión. Nada de leer pods, ni deployments, ni muchísimo menos los Secrets con la contraseña de postgres-reservas.

Como anticipo del módulo 8, así se vería la concesión mínima que informes-ocupacion necesitará, para que sepas hacia dónde vamos:

# ADELANTO de 08-01: no lo apliques todavia, se explica alli
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: lector-deployments
  namespace: rutas-norte-pro
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list"]          # solo lectura, solo deployments
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: informes-ocupacion-lector
  namespace: rutas-norte-pro
subjects:
  - kind: ServiceAccount
    name: informes-ocupacion
    namespace: rutas-norte-pro
roleRef:
  kind: Role
  name: lector-deployments
  apiGroup: rbac.authorization.k8s.io

Fíjate en el subjects: ahí es donde la ServiceAccount que hemos creado hoy se conecta con los permisos. Las dos piezas encajan, pero son objetos distintos y responsabilidades distintas.

Limpiamos el pod de pruebas:

kubectl delete pod consultor-api -n rutas-norte-pro

  1. imagePullSecrets en la ServiceAccount

Recuperamos un cabo suelto de la lección de Secrets. Allí declaramos imagePullSecrets en cada pod para poder descargar imágenes de registry.rutasnorte.example:

    spec:
      imagePullSecrets:
        - name: registry-rutasnorte      # hay que repetirlo en CADA pod

Repetirlo en cada Deployment, cada Job y cada CronJob es tedioso y, sobre todo, fácil de olvidar: el fallo aparece semanas después, cuando alguien añade un componente nuevo y se encuentra con un ImagePullBackOff incomprensible.

La ServiceAccount es el sitio correcto:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
automountServiceAccountToken: false
imagePullSecrets:
  - name: registry-rutasnorte

Y el Deployment queda limpio:

    spec:
      serviceAccountName: api-reservas    # hereda el imagePullSecrets
      containers:
        - name: api
          image: registry.rutasnorte.example/api-reservas:2.5.0

Cómo funciona: cuando se crea un pod, un controlador de admisión copia los imagePullSecrets de la ServiceAccount al spec del pod. Puedes verlo:

kubectl get pod api-reservas-8b5c7d9e2-4mkqp -n rutas-norte-pro \
  -o jsonpath='{.spec.imagePullSecrets}'; echo
[{"name":"registry-rutasnorte"}]

El manifiesto no lo tenía y el objeto sí. Es el mismo fenómeno que con el LimitRanger de la lección anterior: un controlador de admisión ha mutado el objeto.

Tres detalles:

  1. Se suman, no se sustituyen. Si el pod declara los suyos, se combinan con los de la ServiceAccount.
  2. El Secret debe existir en el mismo namespace. Hay que crearlo en cada uno de los tres entornos.
  3. No es retroactivo. Los pods ya creados no lo heredan; hay que recrearlos.

Y un truco muy común para no repetirlo por componente: añadirlo a la ServiceAccount default de cada namespace, de modo que cualquier pod que no especifique otra lo herede.

kubectl patch serviceaccount default -n rutas-norte-pro \
  -p '{"imagePullSecrets":[{"name":"registry-rutasnorte"}]}'
serviceaccount/default patched

Es útil, pero recuerda que no exime de crear ServiceAccounts dedicadas: imagePullSecrets es lo único razonable que debe llevar la default.

  1. La ServiceAccount dice quién eres, RBAC dice qué puedes hacer

Es la frase que resume la lección y conviene grabarla:

La ServiceAccount es la identidad; RBAC son los permisos. Son dos cosas independientes y ambas hacen falta.

flowchart LR
    SA["ServiceAccount<br/>informes-ocupacion<br/>QUIEN eres"] --> T["Token JWT<br/>montado en el pod"]
    T --> AU["Autenticacion<br/>el apiserver te identifica"]
    AU --> RB["RoleBinding<br/>conecta identidad y permisos"]
    RB --> R["Role<br/>QUE puedes hacer"]
    R --> OK["Peticion permitida"]
    AU -.->|"sin RoleBinding"| NO["403 Forbidden"]

Las combinaciones posibles y lo que significan:

ServiceAccount RBAC Resultado
Dedicada Sin permisos Se identifica, no puede hacer nada. El estado actual de Rutas Norte
Dedicada Permisos mínimos El objetivo: cada componente hace exactamente lo suyo
default compartida Permisos amplios El antipatrón: todo el namespace hereda todo
Dedicada cluster-admin Identidad perfecta, permisos catastróficos
Sin token montado Cualquiera No puede hablar con la API. Lo correcto para 5 de 6 componentes

Fíjate en la última fila, porque es la conclusión más importante en términos prácticos: la mejor forma de gestionar los permisos de un pod que no necesita la API es que no tenga token. Ningún RBAC mal configurado puede hacerle daño si no hay credencial que usar.

Lo que queda para 08-01: los objetos Role y ClusterRole (qué verbos sobre qué recursos), los RoleBinding y ClusterRoleBinding (a quién), la agregación de roles, los roles predefinidos del clúster (view, edit, admin, cluster-admin), y la escalada de privilegios que hay que evitar. Todo eso se apoya en la identidad que has creado hoy.

  1. Federación de identidad con las nubes

Un último apunte para que sepas que existe, porque es lo que usarás si Rutas Norte pasa a un clúster gestionado.

El problema: informes-ocupacion debe escribir sus informes en un bucket de almacenamiento en la nube (s3://informes-rutasnorte/). La forma antigua sería crear una clave de acceso permanente en el proveedor y guardarla en un Secret. Eso reintroduce todos los problemas que vimos en la lección de Secrets: una credencial estática, que hay que rotar a mano y que no caduca.

La solución moderna se llama federación de identidad de cargas de trabajo: el proveedor de nube confía en el emisor de tokens del clúster de Kubernetes y acepta el token de la ServiceAccount como prueba de identidad, entregando a cambio credenciales temporales.

flowchart LR
    P["Pod con SA<br/>informes-ocupacion"] --> T["Token proyectado<br/>audience: sts.amazonaws.com"]
    T --> S["STS de la nube<br/>verifica la firma del<br/>emisor OIDC del cluster"]
    S --> C["Credenciales TEMPORALES<br/>caducan en 1 hora"]
    C --> B["Bucket de informes"]

Cómo se llama en cada proveedor:

Proveedor Nombre Cómo se asocia
AWS (EKS) IRSA (IAM Roles for Service Accounts) o Pod Identity Anotación eks.amazonaws.com/role-arn en la ServiceAccount
Google (GKE) Workload Identity Anotación iam.gke.io/gcp-service-account
Azure (AKS) Workload Identity Anotación azure.workload.identity/client-id + etiqueta en el pod

Un ejemplo, solo para que reconozcas el patrón:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: informes-ocupacion
  namespace: rutas-norte-pro
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/rutasnorte-informes

Con esa anotación, el SDK de AWS dentro del pod obtiene credenciales temporales automáticamente, sin ninguna clave estática en ningún Secret. La ServiceAccount de Kubernetes se convierte en la identidad reconocida también fuera del clúster.

Las ventajas son las mismas que ya conoces del token proyectado: nada permanente, rotación automática, ámbito limitado, y revocación inmediata al borrar la asociación. El detalle de configurarlo en cada proveedor es tema de Kubernetes Gestionado.

Errores Comunes y Consejos

Error Síntoma Solución
Usar la ServiceAccount default Sin trazabilidad, permisos compartidos Una ServiceAccount por componente
Dejar el token montado en todos los pods Superficie de ataque innecesaria automountServiceAccountToken: false
Cambiar la ServiceAccount sin recrear pods Los pods viejos conservan la anterior kubectl rollout restart
Usar serviceAccount en vez de serviceAccountName Funciona pero está obsoleto serviceAccountName
Leer el token una sola vez al arrancar 401 exactamente una hora después Releerlo en cada petición, o usar una biblioteca cliente
Usar curl -k contra el apiserver Vulnerable a suplantación --cacert /var/run/secrets/.../ca.crt
Confundir 401 y 403 Se busca el problema donde no está 401 = token; 403 = permisos
Crear un Secret de token permanente Credencial eterna e irrevocable kubectl create token --duration
Esperar que crear una SA conceda permisos Todo devuelve 403 Kubernetes deniega por defecto: hace falta RBAC
ServiceAccount de otro namespace El pod no arranca Deben estar en el mismo namespace
imagePullSecrets repetido en cada pod Se olvida en el componente nuevo Declararlo en la ServiceAccount
Guardar claves estáticas de la nube en Secrets Credenciales sin rotar Federación de identidad

Consejos:

  1. Crea la ServiceAccount junto al Deployment, en el mismo fichero. Que nunca haya un componente sin identidad propia.
  2. automountServiceAccountToken: false por defecto en la plantilla base. Que montar el token sea una decisión consciente, no el comportamiento heredado.
  3. Audita periódicamente qué pods llevan token. El comando jq del apartado 7 debería devolver solo componentes del sistema y los que de verdad lo necesitan.
  4. Usa kubectl auth can-i --list --as=... en las revisiones. Es la forma rápida de ver los permisos efectivos de una identidad antes de aprobar un cambio.
  5. Nunca concedas permisos a la default. Es la vía más rápida a una escalada de privilegios en todo el namespace.

Ejercicios

Ejercicio 1: Dar identidad a la plataforma

  1. Crea las seis ServiceAccounts de Rutas Norte en rutas-norte-pro con el esquema de etiquetas del módulo 2, poniendo automountServiceAccountToken: false en todas menos en informes-ocupacion.
  2. Modifica los cinco Deployments existentes para que usen su ServiceAccount y recrea los pods.
  3. Demuestra con un solo comando que cada pod usa la suya.
  4. Demuestra que tienda-web ya no tiene el directorio del token y que informes-ocupacion sí.
  5. Escribe un comando que liste todos los pods del clúster que tienen el token montado y explica cuáles de ellos son legítimos.

Ejercicio 2: Hablar con la API y entender el 403

  1. Crea un pod consultor-api con la ServiceAccount informes-ocupacion y el token montado.
  2. Desde dentro, consulta /version y /apis/authentication.k8s.io/v1/selfsubjectreviews y muestra la identidad completa.
  3. Intenta listar los pods del namespace, captura el código HTTP y el mensaje completo, e identifica en él el verbo, el recurso, el grupo de API y el ámbito.
  4. Descodifica la carga útil del token y localiza sub, exp, el pod al que está ligado y su duración en minutos.
  5. Intenta hacer la misma petición sin la cabecera Authorization y con un token manipulado (cambia un carácter). Explica los códigos que obtienes en cada caso y por qué son distintos.

Ejercicio 3: Auditoría de identidad y superficie de ataque

Te encargan una auditoría de identidad de la plataforma antes de una revisión de seguridad.

  1. Elabora una tabla con los seis componentes indicando: ServiceAccount, si monta token, si tiene imagePullSecrets y qué permisos efectivos tiene.
  2. Identifica qué pods del clúster (incluido kube-system) montan token y clasifícalos en "legítimo" o "revisar".
  3. Simula un incidente: un atacante logra ejecutar código dentro del contenedor de tienda-web. Enumera qué puede hacer con la configuración actual y qué habría podido hacer antes de esta lección.
  4. Explica por qué imagePullSecrets en la ServiceAccount default es aceptable pero conceder permisos RBAC a la default no lo es.
  5. Propón tres medidas adicionales de endurecimiento, indicando en qué lección del curso se estudia cada una.

Soluciones

Solución 1

El fichero k8s/base/serviceaccounts.yaml es el del apartado 4. Aplicación y modificación de los Deployments:

kubectl apply -f k8s/base/serviceaccounts.yaml

for C in tienda-web api-reservas postgres-reservas redis-cache worker-notificaciones; do
  kubectl patch deployment $C -n rutas-norte-pro -p \
    "{\"spec\":{\"template\":{\"spec\":{\"serviceAccountName\":\"$C\",
       \"automountServiceAccountToken\":false}}}}"
done

kubectl rollout status deploy/api-reservas -n rutas-norte-pro
serviceaccount/tienda-web created
serviceaccount/api-reservas created
serviceaccount/postgres-reservas created
serviceaccount/redis-cache created
serviceaccount/worker-notificaciones created
serviceaccount/informes-ocupacion created
deployment.apps/tienda-web patched
deployment.apps/api-reservas patched
deployment.apps/postgres-reservas patched
deployment.apps/redis-cache patched
deployment.apps/worker-notificaciones patched
deployment "api-reservas" successfully rolled out
kubectl get pods -n rutas-norte-pro -o custom-columns=\
'POD:.metadata.name,SA:.spec.serviceAccountName,TOKEN:.spec.automountServiceAccountToken'
POD                                     SA                      TOKEN
api-reservas-8b5c7d9e2-4mkqp            api-reservas            false
api-reservas-8b5c7d9e2-9wnzr            api-reservas            false
postgres-reservas-6e9g7c0d5-l3nqx       postgres-reservas       false
redis-cache-7d0e9g8c6-wo5ry             redis-cache             false
tienda-web-6c8d0g5e9-i8qol              tienda-web              false
worker-notificaciones-7g8e0d9c5-mn4qu   worker-notificaciones   false
kubectl exec -n rutas-norte-pro deploy/tienda-web -- ls /var/run/secrets/kubernetes.io/ 2>&1
kubectl run prueba-informes --image=curlimages/curl:8.10.1 -n rutas-norte-pro \
  --overrides='{"spec":{"serviceAccountName":"informes-ocupacion"}}' \
  --restart=Never -- sleep 60
sleep 5
kubectl exec -n rutas-norte-pro prueba-informes -- ls /var/run/secrets/kubernetes.io/serviceaccount/
ls: /var/run/secrets/kubernetes.io/: No such file or directory
command terminated with exit code 1

pod/prueba-informes created
ca.crt
namespace
token

La auditoría de tokens montados:

kubectl get pods -A -o json | jq -r '
  .items[] |
  select(.spec.automountServiceAccountToken != false) |
  "\(.metadata.namespace)\t\(.metadata.name)\t\(.spec.serviceAccountName)"' | column -t
kube-system       coredns-7db6d8ff4d-2vqxn          coredns
kube-system       kube-proxy-8xvmk                  kube-proxy
kube-system       storage-provisioner               storage-provisioner
kube-system       metrics-server-7d9f8c6b4-x2mkl    metrics-server
ingress-nginx     ingress-nginx-controller-9wnzr    ingress-nginx
rutas-norte-pro   prueba-informes                   informes-ocupacion

Todos legítimos: CoreDNS vigila Services y Endpoints, kube-proxy también, metrics-server publica una API agregada, el controlador de Ingress lee objetos Ingress, y informes-ocupacion es el nuestro. De Rutas Norte, solo uno de seis.

Solución 2

kubectl apply -f consultor-api.yaml
kubectl exec -it consultor-api -n rutas-norte-pro -- sh

Dentro del pod:

SA=/var/run/secrets/kubernetes.io/serviceaccount
TOKEN=$(cat $SA/token); NS=$(cat $SA/namespace); API=https://kubernetes.default.svc

curl -s --cacert $SA/ca.crt -H "Authorization: Bearer $TOKEN" $API/version | head -4

curl -s --cacert $SA/ca.crt -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -X POST $API/apis/authentication.k8s.io/v1/selfsubjectreviews \
  -d '{"apiVersion":"authentication.k8s.io/v1","kind":"SelfSubjectReview"}' \
  | grep username
{
  "major": "1",
  "minor": "30",
  "gitVersion": "v1.30.4",

    "username": "system:serviceaccount:rutas-norte-pro:informes-ocupacion",
curl -s -w "\nHTTP: %{http_code}\n" --cacert $SA/ca.crt \
  -H "Authorization: Bearer $TOKEN" $API/api/v1/namespaces/$NS/pods | grep -E "message|HTTP"
  "message": "pods is forbidden: User \"system:serviceaccount:rutas-norte-pro:informes-ocupacion\"
   cannot list resource \"pods\" in API group \"\" in the namespace \"rutas-norte-pro\"",
HTTP: 403

Descomposición del mensaje:

Elemento Valor
Verbo list
Recurso pods
Grupo de API "" (core)
Ámbito namespace rutas-norte-pro
Identidad system:serviceaccount:rutas-norte-pro:informes-ocupacion

La carga útil del token:

cut -d. -f2 $SA/token | base64 -d 2>/dev/null | tr ',' '\n' | grep -E '"sub"|"exp"|"iat"|"pod"'
"sub":"system:serviceaccount:rutas-norte-pro:informes-ocupacion"
"exp":1785703921
"iat":1785700321
"pod":{"name":"consultor-api"
(1785703921 - 1785700321) / 60 = 60 minutos

Sin cabecera y con token manipulado:

curl -s -o /dev/null -w "sin token:  %{http_code}\n" --cacert $SA/ca.crt \
  $API/api/v1/namespaces/$NS/pods
curl -s -o /dev/null -w "token malo: %{http_code}\n" --cacert $SA/ca.crt \
  -H "Authorization: Bearer ${TOKEN}X" $API/api/v1/namespaces/$NS/pods
sin token:  403
token malo: 401

La diferencia es sutil e instructiva:

  • Sin cabecera → 403. No es que el apiserver no sepa quién eres: te clasifica como el usuario anónimo system:anonymous, que es una identidad válida sin permisos. Es un caso de autenticación exitosa como anónimo y autorización denegada. (Si el clúster tuviera desactivado el acceso anónimo, sí darías 401.)
  • Token manipulado → 401. La firma del JWT no valida, así que el apiserver no puede establecer ninguna identidad. Es un fallo de autenticación puro.

Esa distinción es la que te dirá, en un incidente real, si tu problema es la credencial o los permisos.

Solución 3

  1. La tabla de identidad:
Componente ServiceAccount ¿Token? imagePullSecrets Permisos efectivos
tienda-web tienda-web No registry-rutasnorte Ninguno (sin credencial)
api-reservas api-reservas No registry-rutasnorte Ninguno (sin credencial)
postgres-reservas postgres-reservas No — (imagen pública) Ninguno (sin credencial)
redis-cache redis-cache No — (imagen pública) Ninguno (sin credencial)
worker-notificaciones worker-notificaciones No registry-rutasnorte Ninguno (sin credencial)
informes-ocupacion informes-ocupacion registry-rutasnorte Solo selfsubject*, /healthz, /version
  1. Clasificación de los pods con token, con el comando de la solución 1: todos los de kube-system y el controlador de Ingress son legítimos (necesitan vigilar objetos de la API para funcionar). informes-ocupacion es legítimo y previsto. Si apareciera cualquier otro pod de rutas-norte-*, sería un "revisar" inmediato.

  2. El incidente simulado. Un atacante con ejecución de código en tienda-web:

Antes de esta lección Ahora
Leer el token de la SA : /var/run/secrets/.../token No existe el fichero
Identificarse ante la API Sí, como ...:default No: sería system:anonymous
Enumerar pods, services, deployments Depende de RBAC sobre default No
Leer los Secrets del namespace Si default tuviera get secrets, sí: incluida la contraseña de postgres-reservas con los datos personales de los clientes No
Crear pods para escalar privilegios Depende de RBAC No
Alcanzar postgres-reservas por red (el namespace no aísla la red) Sí, todavía

La cuarta fila es la que da la medida del riesgo: en un clúster donde alguien hubiera concedido permisos a la default "para probar", una vulnerabilidad en un servidor de ficheros estáticos habría permitido leer las credenciales de la base de datos con los datos personales de todos los clientes de Rutas Norte. Quitar el token elimina esa ruta por completo.

La última fila señala lo que no resuelve esta lección: la red sigue plana. Cualquier pod del namespace puede abrir una conexión TCP a postgres-reservas:5432. Eso lo cierran las políticas de red.

  1. imagePullSecrets en la default es aceptable porque no concede ninguna capacidad a los pods: solo permite al kubelet descargar imágenes del registro privado, que es una operación previa al arranque y no una acción que el proceso del contenedor pueda ejercer. El contenido de ese Secret ni siquiera es visible desde dentro del pod.

Conceder permisos RBAC a la default no lo es porque la default la usa, por definición, todo pod que no especifique otra cosa: cualquier pod nuevo, cualquier pod de depuración, cualquier manifiesto pegado de internet. Un permiso concedido ahí se otorga a un conjunto abierto e impredecible de cargas de trabajo, presentes y futuras. Es lo contrario de una decisión explícita.

  1. Tres medidas de endurecimiento adicionales:
Medida Qué aporta Lección
RBAC con permisos mínimos Que informes-ocupacion pueda leer solo lo justo, y nadie más nada 08-01
NetworkPolicies Que solo api-reservas y worker-notificaciones puedan abrir conexiones a postgres-reservas:5432 04-06
SecurityContext y Pod Security Standards Contenedor sin root, sistema de ficheros de solo lectura, sin capacidades y sin escalada de privilegios: dificulta que la ejecución de código llegue a ser útil 08-02 y 08-03

Las tres, junto con lo de hoy, forman las capas de una defensa en profundidad: identidad mínima, permisos mínimos, red mínima y contenedor mínimo.

Conclusión

Has cerrado el módulo 3 dando identidad a la plataforma. Sabes que Kubernetes distingue entre usuarios, que no son objetos del clúster y los gestiona un sistema externo, y ServiceAccounts, que sí lo son, tienen namespace y se identifican como system:serviceaccount:<ns>:<nombre>. Conoces las cinco razones para no usar la default —es compartida, impide la trazabilidad, impide revocar de forma selectiva, monta un token que casi nadie necesita y tienta a concederle permisos— y has creado una ServiceAccount dedicada para cada uno de los seis componentes de Rutas Norte, asignándola con serviceAccountName.

Entiendes cómo ha cambiado el token: de aquellos Secrets con JWT eternos, no ligados a ningún pod y guardados en etcd, a los tokens proyectados de la API TokenRequest, que caducan en una hora, los rota el kubelet al 80 % de su vida, están atados al UID de un pod concreto, viven solo en tmpfs y se invalidan al borrar el pod. Sabes qué hay en /var/run/secrets/kubernetes.io/serviceaccount/token, ca.crt y namespace— y para qué sirve cada fichero, incluido el ca.crt que evita tener que usar --insecure. Y tienes clara la precedencia de automountServiceAccountToken, con el patrón recomendado: false en la ServiceAccount como red de seguridad y true explícito solo donde de verdad se necesita. En Rutas Norte, cinco de seis componentes ya no llevan credencial alguna dentro, lo que elimina de raíz el primer movimiento de cualquier guion de escalada de privilegios.

Has hablado con la API desde dentro de un pod con curl, montando la petición a mano con la CA, el token y el Service kubernetes.default.svc; has preguntado quién eres con SelfSubjectReview; y has recibido un 403 Forbidden que sabes descomponer en verbo, recurso, grupo de API y ámbito, distinguiéndolo del 401 que señala un problema de credencial y no de permisos. Ese 403 no era un fallo: era la demostración de que Kubernetes deniega por defecto y de que crear una identidad no concede absolutamente nada. Has movido los imagePullSecrets a la ServiceAccount para no repetirlos ni olvidarlos, y sabes que existe la federación de identidad con las nubes para que ni siquiera las credenciales externas tengan que ser estáticas.

Con esto cierras el módulo 3 y la plataforma Rutas Norte está mucho más sana que hace seis lecciones. La configuración vive fuera de la imagen, en ConfigMaps por entorno; la contraseña de postgres-reservas ya no está en Git, sino en un Secret montado en memoria con solo las claves que cada componente necesita; las variables de entorno están catalogadas componente a componente y entorno a entorno, con la Downward API dando trazabilidad a cada petición; los tres entornos tienen cuota y LimitRange, de modo que un error en desarrollo no puede tocar producción; cada pod tiene una clase de QoS asignada con criterio de negocio, con postgres-reservas como Guaranteed y último superviviente; y cada componente tiene su propia identidad ante la API, casi siempre sin credencial montada.

Pero la plataforma sigue teniendo un agujero muy visible: la red es completamente plana. Cualquier pod de rutas-norte-pro puede abrir una conexión a postgres-reservas:5432, y ningún cliente de internet puede llegar todavía a www.rutasnorte.example ni a api.rutasnorte.example, porque nuestros Services son todos ClusterIP y solo existen dentro del clúster. El módulo 4, Redes en Kubernetes, ataca eso de principio a fin: cómo funciona realmente la red del clúster y el modelo de "cada pod con su IP", los tipos de Service más allá de ClusterIP, el DNS interno que llevamos usando sin explicar del todo, los controladores de Ingress que por fin publicarán la tienda y la API en sus dominios, los certificados TLS gestionados automáticamente con cert-manager, y las políticas de red que impedirán que nadie que no sea api-reservas o worker-notificaciones pueda siquiera intentar conectarse a la base de datos con los datos personales de los clientes.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados