Pular para o conteúdo principal

Workload Identity

A partir da v1.8, workloads não-humanos (pods K8s, instâncias EC2, funções Cloud Run, App Service) se autenticam no CipherVault apresentando identidade nativa do provedor (sem secret pré-compartilhado).

O CV troca a claim externa por um JWT CV de 1h vinculado a uma AppConnection. A partir daí, fluxo padrão (mTLS+DPoP).

Métodos suportados

TipoComo validaBind rulesVersão
k8s_saTokenReview API K8sallowed_namespaces, allowed_sa_names1.8
aws_iamSTS:GetCallerIdentity preassinadoallowed_arns (regex)1.8
gcp_iamJWT audience-bound + JWKS Googleexpected_audience, allowed_emails1.8
azure_msiJWT IMDS + JWKS tenant Azure ADexpected_audience, allowed_object_ids1.8
oidc_genericDiscovery .well-known/openid-configuration + JWKS cache 24hexpected_issuer, expected_audience, allowed_subjects (regex)3.0
spiffeJWT-SVID com trust_domain bundletrust_domain, allowed_spiffe_ids (regex)3.0

oidc_generic cobre HashiCorp Vault JWT auth, Okta, Auth0, Keycloak, qualquer provider OIDC compliant. spiffe cobre clusters SPIRE/Istio com SPIFFE IDs.

Endpoint público

POST /workload-identity/login

Sem autenticação prévia — o body apresenta credencial nativa do provider:

curl -X POST https://cv.acme.com.br/workload-identity/login \
-H "Content-Type: application/json" \
-d '{
"method_id": 12,
"k8s_token": "<JWT do projected ServiceAccount token>"
}'

# Resposta:
# {
# "cv_jwt": "eyJ...", // válido por 1h, vinculado a AppConnection
# "expires_at": "2026-05-05T15:00:00Z"
# }

Configuração — Kubernetes ServiceAccount

1. Criar method

curl -X POST https://cv.acme.com.br/workload-identity/methods \
-H "Authorization: Bearer $CV_TOKEN" \
-d '{
"name": "billing-api-prd-k8s",
"type": "k8s_sa",
"app_connection_id": 42,
"config": {
"issuer": "https://kubernetes.default.svc",
"ca_cert_pem": "-----BEGIN CERTIFICATE-----...",
"allowed_namespaces": ["billing"],
"allowed_sa_names": ["billing-api"]
}
}'

ca_cert_pem é o cert do API server. Em GKE/EKS, está disponível no TokenReview API endpoint.

2. Pod com projected ServiceAccount token

apiVersion: v1
kind: ServiceAccount
metadata:
name: billing-api
namespace: billing
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: billing-api
spec:
template:
spec:
serviceAccountName: billing-api
containers:
- name: api
image: acme/billing-api:1.4.0
volumeMounts:
- name: cv-token
mountPath: /var/run/cv
readOnly: true
volumes:
- name: cv-token
projected:
sources:
- serviceAccountToken:
path: token
audience: https://cv.acme.com.br
expirationSeconds: 3600

3. App troca token

import requests, json, time

def get_cv_jwt():
with open("/var/run/cv/token") as f:
k8s_token = f.read().strip()

r = requests.post(
"https://cv.acme.com.br/workload-identity/login",
json={"method_id": 12, "k8s_token": k8s_token},
)
return r.json()["cv_jwt"]

Configuração — AWS IAM

1. Method

curl -X POST https://cv.acme.com.br/workload-identity/methods \
-H "Authorization: Bearer $CV_TOKEN" \
-d '{
"name": "etl-job-aws",
"type": "aws_iam",
"app_connection_id": 42,
"config": {
"allowed_arns": [
"arn:aws:iam::123456789012:role/billing-etl-prd"
]
}
}'

2. App apresenta requisição preassinada

import boto3
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
import requests

session = boto3.Session()
credentials = session.get_credentials().get_frozen_credentials()

# Constrói GetCallerIdentity preassinado
sts_url = "https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15"
request = AWSRequest(method="GET", url=sts_url, headers={
"host": "sts.amazonaws.com",
"x-cv-audience": "https://cv.acme.com.br",
})
SigV4Auth(credentials, "sts", "us-east-1").add_auth(request)

# Passa headers preassinados pro CV
r = requests.post(
"https://cv.acme.com.br/workload-identity/login",
json={
"method_id": 13,
"aws_signed_url": str(request.url),
"aws_signed_headers": dict(request.headers),
},
)
cv_jwt = r.json()["cv_jwt"]

CV re-executa a request preassinada contra sts.amazonaws.com, valida que o ARN retornado bate com allowed_arns.

Configuração — GCP IAM

# Method
curl -X POST https://cv.acme.com.br/workload-identity/methods \
-d '{
"name": "cloud-run-jobs",
"type": "gcp_iam",
"app_connection_id": 42,
"config": {
"expected_audience": "https://cv.acme.com.br",
"allowed_emails": ["billing-job@my-project.iam.gserviceaccount.com"]
}
}'

# App (Cloud Run)
curl "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://cv.acme.com.br" \
-H "Metadata-Flavor: Google" > /tmp/gcp_jwt

curl -X POST https://cv.acme.com.br/workload-identity/login \
-d "{\"method_id\": 14, \"gcp_jwt\": \"$(cat /tmp/gcp_jwt)\"}"

Configuração — OIDC genérico (v3.0+)

Para qualquer provider OIDC compliant (Vault, Okta, Auth0, Keycloak):

curl -X POST https://cv.acme.com.br/workload-identity/methods \
-H "Authorization: Bearer $CV_TOKEN" \
-d '{
"name": "vault-jwt-prd",
"type": "oidc_generic",
"app_connection_id": 42,
"config": {
"expected_issuer": "https://vault.acme.com.br/v1/identity/oidc",
"expected_audience": "https://cv.acme.com.br",
"allowed_subjects": ["^role:billing-job-prd$"]
}
}'

# App troca JWT
curl -X POST https://cv.acme.com.br/workload-identity/login \
-d '{ "method_id": 16, "oidc_token": "<JWT do Vault/Okta/Auth0>" }'

Discovery automática via .well-known/openid-configuration. JWKS cacheado por 24h.

Configuração — SPIFFE/SPIRE (v3.0+)

Para workloads em clusters SPIRE / Istio com SPIFFE IDs:

curl -X POST https://cv.acme.com.br/workload-identity/methods \
-d '{
"name": "spire-prd",
"type": "spiffe",
"app_connection_id": 42,
"config": {
"trust_domain": "acme.com.br",
"trust_bundle_pem": "-----BEGIN CERTIFICATE-----...",
"expected_audience": "https://cv.acme.com.br",
"allowed_spiffe_ids": [
"^spiffe://acme\\.com\\.br/ns/billing/sa/api$"
]
}
}'

# App apresenta JWT-SVID
JWT_SVID=$(spire-agent api fetch jwt --audience https://cv.acme.com.br | jq -r .svid)
curl -X POST https://cv.acme.com.br/workload-identity/login \
-d "{ \"method_id\": 17, \"jwt_svid\": \"$JWT_SVID\" }"

Configuração — Azure MSI

# Method
curl -X POST https://cv.acme.com.br/workload-identity/methods \
-d '{
"name": "app-service-prd",
"type": "azure_msi",
"app_connection_id": 42,
"config": {
"expected_audience": "https://cv.acme.com.br",
"allowed_object_ids": ["abc123-..."]
}
}'

# App (App Service)
curl "${IDENTITY_ENDPOINT}?resource=https://cv.acme.com.br&api-version=2019-08-01" \
-H "X-IDENTITY-HEADER: ${IDENTITY_HEADER}" > /tmp/azure_jwt

curl -X POST https://cv.acme.com.br/workload-identity/login \
-d "{\"method_id\": 15, \"azure_jwt\": \"$(jq -r .access_token /tmp/azure_jwt)\"}"

Boas práticas

  • Bind rules estritasallowed_namespaces, allowed_arns, allowed_emails sempre populados
  • Audience match — o audience do token externo deve ser exatamente https://cv.acme.com.br (não *.acme.com.br)
  • Method por workload — não compartilhe method_id entre apps. Audit fica claro
  • TTL JWT CV — 1h é suficiente para a maioria dos casos. App pega novo quando expira
  • Não cache JWT CV agressivamente — se a credencial nativa for revogada (instance terminada, SA deletado), o JWT CV precisa expirar para refletir
  • Combine com Approvalsmfa_disable, siem_change, rbac_change exigem dual-control mesmo via Workload Identity