Pular para o conteúdo principal

ExternalSecret

O recurso ExternalSecret é o coração do External Secrets Operator (ESO). É ele que diz:

  • O que você quer buscar no provider (ex: AWS Secrets Manager)
  • De onde (qual chave, qual secret)
  • Como isso vira uma Secret nativa no Kubernetes

Estrutura Básica do Objeto

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: minha-secret-k8s
namespace: meu-namespace
spec:
# Define com que frequência o ESO sincroniza os dados do provider pro cluster.
# Se o secret mudar no provider, ele é atualizado no K8s nesse intervalo.
#Exemplo: "1h", "15s", "5m", "1h".
refreshInterval: 1m
# Diz qual SecretStore ou ClusterSecretStore usar para buscar as secrets.
secretStoreRef:
name: aws-secretsmanager
kind: SecretStore # ou ClusterSecretStore
# Define o nome do Secret do Kubernetes que será criado e como ele será gerenciado. Falaremos mais adiante
target:
name: minha-secret-k8s
creationPolicy: Owner
# Lista de chaves individuais que você quer extrair do provider.
data:
- secretKey: DB_USER # Será o nome da chave no secret do kubernetes
remoteRef:
key: my-app/credentials # Nome secret no provider (ex: Secrets Manager)
property: username # chave interna (se for um JSON ou estrutura aninhada)
- secretKey: DB_PASS
remoteRef:
key: my-app/credentials
property: password

Falando um pouco de creationPolicy, pode ser:

  • Owner: ESO cria e deleta o Secret conforme esse objeto.
  • Merge: atualiza só os campos definidos, mantendo os outros. Se já existir o Secret minha-secret-k8s com outras chaves (ex: DB_PASS, API_KEY), essas não serão apagadas. Apenas o valor de DB_USER será atualizado a cada sync. DB_PASS existindo na secret que será criada não será alterado.
  • None: Se o Secret já existir, ESO tenta ler os dados e sincronizar os campos definidos, mas não cria nem sobrescreve.

Bom, o que eu tenho no meu cluster. Somente um ClusterSecretStore

❯ kubectl get clustersecretstores.external-secrets.io        
NAME AGE STATUS CAPABILITIES READY
aws-secrets-manager 3d4h Valid ReadWrite True

❯ k get secretstores.external-secrets.io --all-namespaces
No resources found

Na Aws eu tenho a seguinte secret, essa secret possui a tag da condição que eu criei.

alt text

Ela possui dois valores iniciais.

alt text

Vamos aplicar o manifesto abaixo.

#externalsecret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-test-app
namespace: default # Namespace
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: minha-secret-k8s
creationPolicy: Owner
data:
- secretKey: DB_USER
remoteRef:
key: test/app
property: username
- secretKey: DB_PASS
remoteRef:
key: test/app
property: password

Aplicando isso temos

kubectl apply -f externalsecret.yaml -n default

O que esperamos é ter a secret chamada minha-secret-k8s com os valores DB_USER e DB_PASS setados.

❯ k get secrets -n default                            
NAME TYPE DATA AGE
minha-secret-k8s Opaque 2 18m

❯ kubectl get secret minha-secret-k8s -n default -o jsonpath="{.data}" | jq

{
"DB_PASS": "cGFzc3dvcmQxMjM=",
"DB_USER": "YWRtaW4="
}

❯ kubectl get secret minha-secret-k8s -n default -o jsonpath="{.data.DB_PASS}" | base64 --decode
password123

Ou seja, estamos mapeando, agora vou mudar o valor de password para pass12345 direto na AWS. Precisamos esperar 1 minuto para ver o resultado.

### Nada ainda..
❯ kubectl get secret minha-secret-k8s -n default -o jsonpath="{.data.DB_PASS}" | base64 --decode
password123
# Uns 1 minuto depois...
❯ kubectl get secret minha-secret-k8s -n default -o jsonpath="{.data.DB_PASS}" | base64 --decode
pass12345

Se eu deletar a secret?

❯ kubectl delete secrets minha-secret-k8s -n default                                                  
secret "minha-secret-k8s" deleted

# Já criou...
❯ kubectl get secrets -n default
NAME TYPE DATA AGE
minha-secret-k8s Opaque 2 4s

❯ kubectl get secret minha-secret-k8s -n default -o jsonpath="{.data.DB_PASS}" | base64 --decode
pass12345

Para que possamos deletar essa secret precisamos deletar o externalsecret.

❯ k delete externalsecrets.external-secrets.io -n default external-secret-test-app              
externalsecret.external-secrets.io "external-secret-test-app" deleted

❯ k get secrets -n default
No resources found in default namespace.

Porém podemos pegar de uma vez só todos os valores, porém sem mapeá-los. Aplique este manifesto.

#externalsecret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-test-app
namespace: default # Namespace
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: minha-secret-k8s
creationPolicy: Owner
dataFrom: # Estamos pegando todas as propriedades da chave test/app
- extract:
key: test/app

Porém agora temos exatamente a propriedade como o nome da chave do secret criado.

❯ kubectl get secret minha-secret-k8s -n default -o jsonpath="{.data}" | jq                

{
"password": "cGFzczEyMzQ1",
"username": "YWRtaW4="
}

Isso facilita muito. Se focarmos em declarar as propriedades como queremos que apareçam no secret não precisamos fazer o remap como vimos anteriormente. Esse é o cenário mais comum e cobre cerca de 90% dos casos.

Vamos deletar novamente para recomeçar uns casos avançados

❯ kubectl delete externalsecrets.external-secrets.io -n default external-secret-test-app                         
externalsecret.external-secrets.io "external-secret-test-app" deleted

Avançado

Agora vamos criar uma nova chave no AWS Secret Manager chamada test/app2 com as propriedades port: 22 e url: test.local.

Temos duas chaves test/app e test/app2. O regex para elas é ^test/app[0-9]?$

  • ^ = início
  • test/app = começa com test/app
  • [0-9]? → opcionalmente termina com um dígito de 0 a 9
  • $ = fim

Vamos aplicar esse manifesto.

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-mixed # Novo nome!
namespace: default
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: minha-secret-k8s
creationPolicy: Owner
dataFrom:
- find:
name:
regexp: ^test/app[0-9]?$
❯ kubectl apply -f externalsecret.yaml                                            
externalsecret.external-secrets.io/external-secret-mixed created

❯ k get secrets -n default
No resources found in default namespace.

❯ kubectl describe externalsecrets.external-secrets.io external-secret-mixed
Name: external-secret-mixed
Namespace: default
Labels: <none>
Annotations: <none>
API Version: external-secrets.io/v1
Kind: ExternalSecret
Metadata:
Creation Timestamp: 2025-07-01T00:26:11Z
Generation: 1
Resource Version: 3346996
UID: c32d52bf-af96-4be0-bbbf-2858175220c9
Spec:
Data From:
Find:
Conversion Strategy: Default
Decoding Strategy: None
Name:
Regexp: ^test/app[0-9]?$
Refresh Interval: 1m
Secret Store Ref:
Kind: ClusterSecretStore
Name: aws-secrets-manager
Target:
Creation Policy: Owner
Deletion Policy: Retain
Name: minha-secret-k8s
Status:
Binding:
Name:
Conditions:
Last Transition Time: 2025-07-01T00:26:11Z
Message: could not get secret data from provider
Reason: SecretSyncedError
Status: False
Type: Ready
Refresh Time: <nil>
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning UpdateFailed 8m24s external-secrets error processing spec.dataFrom[0].find, err: error getting all secrets: operation error Secrets Manager: ListSecrets, https response error StatusCode: 400, RequestID: 5db57613-8f1e-445d-b7d9-86d874a3bad6, api error AccessDeniedException: User: arn:aws:sts::XXXXXXXXXXXXXX:assumed-role/external-secrets/external-secrets-provider-aws is not authorized to perform: secretsmanager:ListSecrets because no identity-based policy allows the secretsmanager:ListSecrets action

Veja o que aconteceu... Não temos permissão para ListSecrets, mas colocamos isso nas permissões. Na verdade o problema é que ListSecrets e ListSecretVersionIds não aceitam condições para tags, então precisamos isolar isso. Se foi abrangente e removeu as condições não deve ter tido problemas.

A nova política deveria ficar assim.

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SecretsManagerAccess",
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:GetResourcePolicy",
"secretsmanager:DescribeSecret",
"secretsmanager:BatchGetSecretValue"
],
"Resource": [
"*"
],
"Condition": {
"StringEquals": {
"secretsmanager:ResourceTag/external-secrets": "true"
}
}
},
{
"Sid": "SecretsManagerList",
"Effect": "Allow",
"Action": [
"secretsmanager:ListSecrets",
"secretsmanager:ListSecretVersionIds"
],
"Resource": [
"*"
]
},
{
"Sid": "SSMParameterStoreAccess",
"Effect": "Allow",
"Action": [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath"
],
"Resource": [
"*"
],
"Condition": {
"StringEquals": {
"ssm:ResourceTag/external-secrets": "true"
}
}
},
{
"Sid": "SSMParameterStoreDescribe",
"Effect": "Allow",
"Action": [
"ssm:DescribeParameters"
],
"Resource": [
"*"
]
}
]
}

E temos uma secret de secrets. Uma única Secret que agrega tudo que foi capturado via regex.

❯ k get secrets -n default                                                  
NAME TYPE DATA AGE
minha-secret-k8s Opaque 2 5s

❯ kubectl get secret minha-secret-k8s -n default -o jsonpath="{.data}" | jq
{
"test_app": "eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiJwYXNzMTIzNDUifQ==",
"test_app2": "eyJwb3J0IjoiMjIiLCJ1cmwiOiJ0ZXN0LmxvY2FsIn0="
}

❯ kubectl get secret minha-secret-k8s -n default -o jsonpath="{.data.test_app2}" | base64 --decode
{"port":"22","url":"test.local"}

❯ kubectl get secret minha-secret-k8s -n default -o jsonpath="{.data.test_app}" | base64 --decode

{"username":"admin","password":"pass12345"}

Se você quisesse uma secret pegasse somente as propriedades precisaria mergear isso manualmente.

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-mixed # Novo nome!
namespace: default
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: minha-secret-k8s
creationPolicy: Owner
data:
- secretKey: username
remoteRef:
key: test/app
property: username
- secretKey: password
remoteRef:
key: test/app
property: password
- secretKey: port
remoteRef:
key: test/app2
property: port
- secretKey: url
remoteRef:
key: test/app2
property: url

Aplicando esse manifesto acima teríamos isso.

❯ kubectl get secret minha-secret-k8s -n default -o jsonpath="{.data}" | jq              

{
"password": "cGFzczEyMzQ1",
"port": "MjI=",
"url": "dGVzdC5sb2NhbA==",
"username": "YWRtaW4="
}

Acredito que em algum momento no futuro eles farão o merge de json fields porém poderia causar confusão se em várias secrets houver as mesmas propriedades.

Template

Assim como uma secret pode ter labels, type, annotation, etc no kubernetes, podemos expandir um pouco mais a parte de target para definir os metadados da secret que será criada. O data vem do SecretStore, mas o resto podemos definir.

##....
target:
name: minha-secret
creationPolicy: Owner
template:
metadata:
labels:
app: minha-app
type: kubernetes.io/basic-auth
##...

Agora vamos direto ao ponto: como estruturar a secret no AWS Secrets Manager pra que ela funcione com o tipo kubernetes.io/basic-auth.

Obrigatóriamente ela espera:

data:
username: <base64>
password: <base64>

Então, no AWS Secrets Manager, você precisa de um secret com esse conteúdo (em JSON):

{
"username": "admin",
"password": "supersecure"
}

Ou declarar as duas propriedades como fizemos anteriormente com test/app. Podemos então utilizar test/app para esse cenário.

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-auth
namespace: default # Namespace
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: app-auth
creationPolicy: Owner
template:
metadata:
labels:
app: my-app
type: kubernetes.io/basic-auth
dataFrom:
- extract:
key: test/app

Aplicando isso temos então secrets com types diferentes.

❯ k get secrets           
NAME TYPE DATA AGE
app-auth kubernetes.io/basic-auth 2 9s
minha-secret-k8s Opaque 4 24m

❯ k describe secrets app-auth
Name: app-auth
Namespace: default
Labels: app=my-app
reconcile.external-secrets.io/created-by=7e7c363e3e6e4dc68f4dd649c26d7de4b845e87e58d7b50bce7a9acb
reconcile.external-secrets.io/managed=true
Annotations: reconcile.external-secrets.io/data-hash: 97f42b65dc28d4275985c8d338f8f4cdc8322180a8fd4c43807ec771

Type: kubernetes.io/basic-auth

Data
====
password: 9 bytes
username: 5 bytes

O mesmo vale para outros types.

  • type: kubernetes.io/ssh-auth espera:

    data:
    ssh-privatekey: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVkt...
  • type: kubernetes.io/tls espera:

    data:
    tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t...
    tls.key: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVkt...
  • type: kubernetes.io/dockerconfigjson espera:

    {
    "auths": {
    "https://index.docker.io/v1/": {
    "username": "myuser",
    "password": "mypass",
    "auth": "bXl1c2VyOm15cGFzcw=="
    }
    }
    }

    Que iria se converter em:

    data:
    .dockerconfigjson: <base64 do JSON acima>

Go Template Engine

É uma funcionalidade do External Secrets Operator que permite você gerar o conteúdo do Secret Kubernetes usando templates Go, em vez de só copiar os valores direto do provider.

Você pode montar arquivos, config YAML, JSON, ou o que quiser — usando variáveis que são preenchidas com os valores secretos buscados. A grande maioria das vezes usamos de forma básica mas isso é muito poderoso.

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-go-template
namespace: default
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
# Geralmente usamos o data depois, mas vou colocar antes pois a ordem não importa. É só para entender isso como uma entrada de valores.
data: # Dados Mapeados Que vem do AWS Secret Manager
- secretKey: USERNAME
remoteRef:
key: test/app
property: username
- secretKey: PASSWORD
remoteRef:
key: test/app
property: password
# Vamo
target:
name: minha-secret # Nome da nossa secret
creationPolicy: Owner
template:
engineVersion: v2 # Esse é o que chamamos de Go template
data: # Esperamos montar isso, uma chave chamada config.yaml que possui uma representação yaml dentro usando valores de entrada.
config.yaml: |
user: {{ .USERNAME }}
pass: {{ .PASSWORD }}
# Aplicando
❯ kubectl apply -f externalsecret.yaml
externalsecret.external-secrets.io/external-secret-go-template created

❯ k get secrets
NAME TYPE DATA AGE
app-auth kubernetes.io/basic-auth 2 67m
minha-secret Opaque 1 38s # Aqui!
minha-secret-k8s Opaque 4 92m

# Dentro de config.yaml esperamos um texto no formato yaml, mas aqui ele mostra só a chave valor em json.
❯ kubectl get secret minha-secret -n default -o jsonpath="{.data}" | jq

{
"config.yaml": "dXNlcjogYWRtaW4KcGFzczogcGFzczEyMzQ1Cg=="
}

# Se analisarmos o conteúdo de config.yaml veremos que é um yaml.
❯ kubectl get secret minha-secret -n default -o jsonpath="{.data.config\.yaml}" | base64 --decode

user: admin
pass: pass12345

Agora vamos mostrar o tamanho do poder disso.

Imagine que você tem um segredo AWS Secrets Manager que tem essa estrutura JSON (complexa, com múltiplas propriedades):

{
"username": "admin",
"password": "senha123",
"db": {
"host": "db.example.com",
"port": 5432,
"replica": true
},
"features": {
"enableFeatureX": true,
"enableFeatureY": false
}
}

Vamos gerar um secret Kubernetes com:

  • Um arquivo config.yaml formatado bonitinho, com valores condicionais.
  • Um arquivo features.json gerado só se features existir e habilita cada feature dinamicamente.
  • Usar lógica condicional e loops.

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-advanced
namespace: default
spec:
refreshInterval: 30m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore

target:
name: minha-secret-avancada
creationPolicy: Owner
template:
engineVersion: v2
type: Opaque
data:
config.yaml: |
# Configuração do Banco
username: {{ .USERNAME }}
password: {{ .PASSWORD }}
host: {{ .DB.host }}
port: {{ .DB.port }}

{{- if .DB.replica }}
replica: enabled
{{- else }}
replica: disabled
{{- end }}

features.json: |
{
{{- if .FEATURES }}
"enableFeatureX": {{ .FEATURES.enableFeatureX }},
"enableFeatureY": {{ .FEATURES.enableFeatureY }}
{{- else }}
"enableFeatureX": false,
"enableFeatureY": false
{{- end }}
}
data:
- secretKey: USERNAME
remoteRef:
key: test/app
property: username
- secretKey: PASSWORD
remoteRef:
key: test/app
property: password
- secretKey: DB
remoteRef:
key: test/app
property: db
- secretKey: FEATURES
remoteRef:
key: test/app
property: features

.USERNAME, .PASSWORD são strings simples.

.DB e .FEATURES são objetos aninhados, e você pode acessar propriedades internas via {{ .DB.host }} e {{ .FEATURES.enableFeatureX }}.

Usa if pra ativar a replica e o JSON de features baseado na existência e valor dos dados reais.

Gera múltiplos arquivos no Secret (config.yaml e features.json).