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).