708 lines
17 KiB
Markdown
708 lines
17 KiB
Markdown
# Custom Secrets Vault + ESO Webhook Integration
|
|
|
|
Self-hosted secrets API at `https://keymanager.hostxtra.co.uk/secrets`
|
|
backed by MongoDB with Gin, exposed to K3s via ESO's Webhook provider.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
MongoDB (encrypted at rest)
|
|
└── secrets collection: { group, key, encryptedValue, updatedAt }
|
|
↑ CRUD via Go API (Gin)
|
|
Go API (keymanager secrets service)
|
|
└── GET /secrets/:group ← ESO webhook calls this
|
|
└── PUT /secrets/:group ← admin writes secrets to a group
|
|
└── DELETE /secrets/:group ← admin deletes entire group
|
|
└── DELETE /secrets/:group/:key ← admin deletes one key from a group
|
|
↓ bearer token auth (read token for ESO, admin token for writes)
|
|
ESO Webhook ClusterSecretStore
|
|
└── ExternalSecret (per namespace)
|
|
└── K8s Secret
|
|
└── Deployment env vars
|
|
```
|
|
|
|
**Data model:** A "group" is a logical namespace for a set of related secrets —
|
|
e.g. `myapp-prod`, `postgres`, `infra`. Each group contains one or more
|
|
key/value pairs stored individually as encrypted documents in MongoDB.
|
|
|
|
**Encryption:** AES-256-GCM per value, random nonce per write, master key
|
|
loaded from the `MASTER_KEY` environment variable (32-byte hex string).
|
|
|
|
---
|
|
|
|
## Part 1 — The Go API
|
|
|
|
### 1.1 — Project structure
|
|
|
|
```
|
|
keymanager/
|
|
├── cmd/
|
|
│ └── server/
|
|
│ └── main.go
|
|
├── internal/
|
|
│ ├── crypto/
|
|
│ │ └── crypto.go
|
|
│ ├── store/
|
|
│ │ └── store.go
|
|
│ └── api/
|
|
│ └── api.go
|
|
├── go.mod
|
|
└── Dockerfile
|
|
```
|
|
|
|
### 1.2 — `go.mod`
|
|
|
|
```
|
|
module github.com/yourusername/keymanager
|
|
|
|
go 1.23
|
|
|
|
require (
|
|
go.mongodb.org/mongo-driver v1.17.0
|
|
github.com/gin-gonic/gin v1.10.0
|
|
)
|
|
```
|
|
|
|
### 1.3 — `internal/crypto/crypto.go`
|
|
|
|
AES-256-GCM encryption. Each value gets a unique random nonce so identical
|
|
plaintext values produce different ciphertext on every write.
|
|
|
|
```go
|
|
package crypto
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"io"
|
|
)
|
|
|
|
// Encrypt encrypts plaintext using AES-256-GCM.
|
|
// Returns base64(nonce + ciphertext).
|
|
func Encrypt(key []byte, plaintext string) (string, error) {
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return "", err
|
|
}
|
|
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
|
}
|
|
|
|
// Decrypt decrypts a base64(nonce + ciphertext) produced by Encrypt.
|
|
func Decrypt(key []byte, encoded string) (string, error) {
|
|
data, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(data) < gcm.NonceSize() {
|
|
return "", errors.New("ciphertext too short")
|
|
}
|
|
nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plaintext), nil
|
|
}
|
|
```
|
|
|
|
### 1.4 — `internal/store/store.go`
|
|
|
|
MongoDB storage. Each document represents one key within a group.
|
|
|
|
```go
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"go.mongodb.org/mongo-driver/bson"
|
|
"go.mongodb.org/mongo-driver/mongo"
|
|
"go.mongodb.org/mongo-driver/mongo/options"
|
|
)
|
|
|
|
type SecretDoc struct {
|
|
Group string `bson:"group"`
|
|
Key string `bson:"key"`
|
|
EncryptedValue string `bson:"encryptedValue"`
|
|
UpdatedAt time.Time `bson:"updatedAt"`
|
|
}
|
|
|
|
type Store struct {
|
|
col *mongo.Collection
|
|
}
|
|
|
|
func New(client *mongo.Client, dbName string) (*Store, error) {
|
|
col := client.Database(dbName).Collection("secrets")
|
|
|
|
// Unique compound index on (group, key)
|
|
_, err := col.Indexes().CreateOne(context.Background(), mongo.IndexModel{
|
|
Keys: bson.D{{Key: "group", Value: 1}, {Key: "key", Value: 1}},
|
|
Options: options.Index().SetUnique(true),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Store{col: col}, nil
|
|
}
|
|
|
|
// GetGroup returns all SecretDocs belonging to the given group.
|
|
func (s *Store) GetGroup(ctx context.Context, group string) ([]SecretDoc, error) {
|
|
cursor, err := s.col.Find(ctx, bson.M{"group": group})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var docs []SecretDoc
|
|
if err := cursor.All(ctx, &docs); err != nil {
|
|
return nil, err
|
|
}
|
|
return docs, nil
|
|
}
|
|
|
|
// Upsert writes or updates a single key within a group.
|
|
func (s *Store) Upsert(ctx context.Context, group, key, encryptedValue string) error {
|
|
filter := bson.M{"group": group, "key": key}
|
|
update := bson.M{"$set": bson.M{
|
|
"encryptedValue": encryptedValue,
|
|
"updatedAt": time.Now(),
|
|
}}
|
|
_, err := s.col.UpdateOne(ctx, filter, update, options.Update().SetUpsert(true))
|
|
return err
|
|
}
|
|
|
|
// DeleteGroup removes all keys belonging to a group.
|
|
func (s *Store) DeleteGroup(ctx context.Context, group string) error {
|
|
_, err := s.col.DeleteMany(ctx, bson.M{"group": group})
|
|
return err
|
|
}
|
|
|
|
// DeleteKey removes a single key from a group.
|
|
func (s *Store) DeleteKey(ctx context.Context, group, key string) error {
|
|
_, err := s.col.DeleteOne(ctx, bson.M{"group": group, "key": key})
|
|
return err
|
|
}
|
|
```
|
|
|
|
### 1.5 — `internal/api/api.go`
|
|
|
|
Gin handlers. Two token tiers: ESO gets a read-only token, admins get a write token.
|
|
|
|
```go
|
|
package api
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"net/http"
|
|
"os"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/yourusername/keymanager/internal/crypto"
|
|
"github.com/yourusername/keymanager/internal/store"
|
|
)
|
|
|
|
type API struct {
|
|
store *store.Store
|
|
masterKey []byte
|
|
esoToken string
|
|
adminToken string
|
|
}
|
|
|
|
func New(s *store.Store) *API {
|
|
keyHex := os.Getenv("MASTER_KEY")
|
|
key, err := hex.DecodeString(keyHex)
|
|
if err != nil || len(key) != 32 {
|
|
panic("MASTER_KEY must be a 64-character hex string (32 bytes)")
|
|
}
|
|
return &API{
|
|
store: s,
|
|
masterKey: key,
|
|
esoToken: os.Getenv("ESO_TOKEN"),
|
|
adminToken: os.Getenv("ADMIN_TOKEN"),
|
|
}
|
|
}
|
|
|
|
func (a *API) RegisterRoutes(r *gin.Engine) {
|
|
secrets := r.Group("/secrets")
|
|
|
|
// Read routes — ESO token
|
|
secrets.GET("/:group", a.bearerAuth(a.esoToken), a.getGroup)
|
|
|
|
// Write routes — admin token
|
|
secrets.PUT("/:group", a.bearerAuth(a.adminToken), a.putGroup)
|
|
secrets.DELETE("/:group", a.bearerAuth(a.adminToken), a.deleteGroup)
|
|
secrets.DELETE("/:group/:key", a.bearerAuth(a.adminToken), a.deleteKey)
|
|
}
|
|
|
|
// bearerAuth returns a Gin middleware that validates a Bearer token.
|
|
func (a *API) bearerAuth(expected string) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
auth := c.GetHeader("Authorization")
|
|
const prefix = "Bearer "
|
|
if len(auth) <= len(prefix) || auth[:len(prefix)] != prefix {
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
|
return
|
|
}
|
|
token := auth[len(prefix):]
|
|
if token != expected {
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// getGroup handles GET /secrets/:group
|
|
// Returns a flat JSON object { "KEY": "value", ... } for ESO to consume.
|
|
// Returns 404 if the group has no secrets — ESO treats 404 as "deleted".
|
|
func (a *API) getGroup(c *gin.Context) {
|
|
group := c.Param("group")
|
|
|
|
docs, err := a.store.GetGroup(c.Request.Context(), group)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
|
|
return
|
|
}
|
|
if len(docs) == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
|
return
|
|
}
|
|
|
|
result := make(map[string]string, len(docs))
|
|
for _, doc := range docs {
|
|
val, err := crypto.Decrypt(a.masterKey, doc.EncryptedValue)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "decrypt error"})
|
|
return
|
|
}
|
|
result[doc.Key] = val
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// putGroup handles PUT /secrets/:group
|
|
// Body: { "KEY": "value", ... } — upserts each key in the group.
|
|
func (a *API) putGroup(c *gin.Context) {
|
|
group := c.Param("group")
|
|
|
|
var payload map[string]string
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
|
|
return
|
|
}
|
|
if len(payload) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "body must contain at least one key"})
|
|
return
|
|
}
|
|
|
|
for key, val := range payload {
|
|
encrypted, err := crypto.Encrypt(a.masterKey, val)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt error"})
|
|
return
|
|
}
|
|
if err := a.store.Upsert(c.Request.Context(), group, key, encrypted); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
|
|
return
|
|
}
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// deleteGroup handles DELETE /secrets/:group
|
|
// Removes the entire group and all its keys.
|
|
func (a *API) deleteGroup(c *gin.Context) {
|
|
group := c.Param("group")
|
|
|
|
if err := a.store.DeleteGroup(c.Request.Context(), group); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// deleteKey handles DELETE /secrets/:group/:key
|
|
// Removes a single key from a group.
|
|
func (a *API) deleteKey(c *gin.Context) {
|
|
group := c.Param("group")
|
|
key := c.Param("key")
|
|
|
|
if err := a.store.DeleteKey(c.Request.Context(), group, key); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
```
|
|
|
|
### 1.6 — `cmd/server/main.go`
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.mongodb.org/mongo-driver/mongo"
|
|
"go.mongodb.org/mongo-driver/mongo/options"
|
|
|
|
"github.com/yourusername/keymanager/internal/api"
|
|
"github.com/yourusername/keymanager/internal/store"
|
|
)
|
|
|
|
func main() {
|
|
mongoURI := os.Getenv("MONGODB_URI")
|
|
if mongoURI == "" {
|
|
mongoURI = "mongodb://localhost:27017"
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI))
|
|
if err != nil {
|
|
log.Fatalf("MongoDB connect: %v", err)
|
|
}
|
|
if err := client.Ping(ctx, nil); err != nil {
|
|
log.Fatalf("MongoDB ping: %v", err)
|
|
}
|
|
log.Println("Connected to MongoDB")
|
|
|
|
s, err := store.New(client, "keymanager")
|
|
if err != nil {
|
|
log.Fatalf("Store init: %v", err)
|
|
}
|
|
|
|
gin.SetMode(gin.ReleaseMode)
|
|
r := gin.New()
|
|
r.Use(gin.Logger(), gin.Recovery())
|
|
|
|
a := api.New(s)
|
|
a.RegisterRoutes(r)
|
|
|
|
log.Println("Secrets API listening on :8080")
|
|
if err := r.Run(":8080"); err != nil {
|
|
log.Fatalf("Server error: %v", err)
|
|
}
|
|
}
|
|
```
|
|
|
|
### 1.7 — `Dockerfile`
|
|
|
|
```dockerfile
|
|
FROM golang:1.23-alpine AS builder
|
|
WORKDIR /app
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
COPY . .
|
|
RUN go build -o secrets-api ./cmd/server
|
|
|
|
FROM alpine:3.20
|
|
RUN apk add --no-cache ca-certificates
|
|
WORKDIR /app
|
|
COPY --from=builder /app/secrets-api .
|
|
EXPOSE 8080
|
|
CMD ["./secrets-api"]
|
|
```
|
|
|
|
---
|
|
|
|
## Part 2 — Deploying the API
|
|
|
|
### 2.1 — Generate your secrets
|
|
|
|
```bash
|
|
# 32-byte master key — back this up in your password manager
|
|
openssl rand -hex 32
|
|
|
|
# ESO read token
|
|
openssl rand -hex 32
|
|
|
|
# Admin token
|
|
openssl rand -hex 32
|
|
```
|
|
|
|
### 2.2 — Docker Compose
|
|
|
|
```yaml
|
|
services:
|
|
secrets-api:
|
|
image: ghcr.io/yourusername/keymanager-secrets:latest
|
|
restart: unless-stopped
|
|
environment:
|
|
MONGODB_URI: mongodb://mongo:27017
|
|
MASTER_KEY: "<your-32-byte-hex-key>"
|
|
ESO_TOKEN: "<your-eso-token>"
|
|
ADMIN_TOKEN: "<your-admin-token>"
|
|
ports:
|
|
- "127.0.0.1:8082:8080"
|
|
```
|
|
|
|
### 2.3 — Caddyfile
|
|
|
|
```caddyfile
|
|
keymanager.hostxtra.co.uk {
|
|
# ... existing KeyManager routes ...
|
|
|
|
handle /secrets* {
|
|
reverse_proxy localhost:8082
|
|
}
|
|
}
|
|
```
|
|
|
|
```bash
|
|
caddy reload --config /etc/caddy/Caddyfile
|
|
```
|
|
|
|
### 2.4 — Smoke test
|
|
|
|
```bash
|
|
export ADMIN="<your-admin-token>"
|
|
export ESO="<your-eso-token>"
|
|
export BASE="https://keymanager.hostxtra.co.uk/secrets"
|
|
|
|
# Write a group
|
|
curl -s -X PUT $BASE/myapp-prod \
|
|
-H "Authorization: Bearer $ADMIN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"DB_PASSWORD": "supersecret123", "API_KEY": "myapikey456"}'
|
|
# → 204 No Content
|
|
|
|
# Read back (as ESO would)
|
|
curl -s $BASE/myapp-prod \
|
|
-H "Authorization: Bearer $ESO"
|
|
# → {"API_KEY":"myapikey456","DB_PASSWORD":"supersecret123"}
|
|
|
|
# Delete a single key
|
|
curl -s -X DELETE $BASE/myapp-prod/API_KEY \
|
|
-H "Authorization: Bearer $ADMIN"
|
|
# → 204 No Content
|
|
|
|
# Confirm it's gone
|
|
curl -s $BASE/myapp-prod \
|
|
-H "Authorization: Bearer $ESO"
|
|
# → {"DB_PASSWORD":"supersecret123"}
|
|
|
|
# Delete the whole group
|
|
curl -s -X DELETE $BASE/myapp-prod \
|
|
-H "Authorization: Bearer $ADMIN"
|
|
# → 204 No Content
|
|
|
|
# Confirm 404
|
|
curl -s -o /dev/null -w "%{http_code}" $BASE/myapp-prod \
|
|
-H "Authorization: Bearer $ESO"
|
|
# → 404
|
|
```
|
|
|
|
---
|
|
|
|
## Part 3 — ESO Webhook Integration
|
|
|
|
### 3.1 — Install ESO
|
|
|
|
```bash
|
|
helm repo add external-secrets https://charts.external-secrets.io
|
|
helm repo update
|
|
helm upgrade --install external-secrets external-secrets/external-secrets \
|
|
--namespace external-secrets \
|
|
--create-namespace \
|
|
--set installCRDs=true \
|
|
--wait
|
|
```
|
|
|
|
### 3.2 — Store ESO token as a K8s Secret
|
|
|
|
The `external-secrets.io/type=webhook` label is required — without it the
|
|
webhook provider is not permitted to read the secret.
|
|
|
|
```bash
|
|
kubectl create secret generic keymanager-eso-token \
|
|
--namespace external-secrets \
|
|
--from-literal=token="<your-eso-token>"
|
|
|
|
kubectl label secret keymanager-eso-token \
|
|
--namespace external-secrets \
|
|
external-secrets.io/type=webhook
|
|
```
|
|
|
|
### 3.3 — ClusterSecretStore
|
|
|
|
```yaml
|
|
# cluster-secret-store.yaml
|
|
apiVersion: external-secrets.io/v1
|
|
kind: ClusterSecretStore
|
|
metadata:
|
|
name: keymanager-store
|
|
spec:
|
|
provider:
|
|
webhook:
|
|
url: "https://keymanager.hostxtra.co.uk/secrets/{{ .remoteRef.key }}"
|
|
method: GET
|
|
result:
|
|
jsonPath: "$"
|
|
headers:
|
|
Content-Type: "application/json"
|
|
Authorization: "Bearer {{ .auth.token }}"
|
|
secrets:
|
|
- name: auth
|
|
secretRef:
|
|
name: keymanager-eso-token
|
|
namespace: external-secrets
|
|
```
|
|
|
|
```bash
|
|
kubectl apply -f cluster-secret-store.yaml
|
|
kubectl get clustersecretstore keymanager-store
|
|
```
|
|
|
|
### 3.4 — ExternalSecret
|
|
|
|
The `remoteRef.key` value is the group name — ESO substitutes it into the
|
|
URL template, calling `GET /secrets/myapp-prod`.
|
|
|
|
```yaml
|
|
# external-secret.yaml
|
|
apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: myapp
|
|
---
|
|
apiVersion: external-secrets.io/v1
|
|
kind: ExternalSecret
|
|
metadata:
|
|
name: myapp-secrets
|
|
namespace: myapp
|
|
spec:
|
|
refreshInterval: 15m
|
|
secretStoreRef:
|
|
name: keymanager-store
|
|
kind: ClusterSecretStore
|
|
target:
|
|
name: myapp-secrets
|
|
creationPolicy: Owner
|
|
dataFrom:
|
|
- extract:
|
|
key: myapp-prod # group name → GET /secrets/myapp-prod
|
|
```
|
|
|
|
Or to pull specific keys from a group:
|
|
|
|
```yaml
|
|
data:
|
|
- secretKey: DB_PASSWORD
|
|
remoteRef:
|
|
key: myapp-prod # group name
|
|
property: DB_PASSWORD # key within the group's JSON response
|
|
```
|
|
|
|
```bash
|
|
kubectl apply -f external-secret.yaml
|
|
|
|
kubectl get externalsecret myapp-secrets -n myapp
|
|
# STATUS: SecretSynced
|
|
|
|
# Decode and verify values
|
|
kubectl get secret myapp-secrets -n myapp -o json | \
|
|
jq '.data | map_values(@base64d)'
|
|
```
|
|
|
|
### 3.5 — Deployment
|
|
|
|
```yaml
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: myapp
|
|
namespace: myapp
|
|
spec:
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app: myapp
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app: myapp
|
|
annotations:
|
|
reloader.stakater.com/auto: "true" # optional: auto-restart on secret change
|
|
spec:
|
|
containers:
|
|
- name: myapp
|
|
image: your-image:latest
|
|
envFrom:
|
|
- secretRef:
|
|
name: myapp-secrets
|
|
```
|
|
|
|
---
|
|
|
|
## Part 4 — Day-to-Day Secret Management
|
|
|
|
```bash
|
|
export ADMIN="<your-admin-token>"
|
|
export BASE="https://keymanager.hostxtra.co.uk/secrets"
|
|
|
|
# Create or update a group (upserts — safe to re-run)
|
|
curl -s -X PUT $BASE/postgres \
|
|
-H "Authorization: Bearer $ADMIN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"POSTGRES_PASSWORD": "dbpass", "POSTGRES_USER": "app"}'
|
|
|
|
# Add a new key to an existing group (existing keys are untouched)
|
|
curl -s -X PUT $BASE/postgres \
|
|
-H "Authorization: Bearer $ADMIN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"POSTGRES_DB": "mydb"}'
|
|
|
|
# Remove a single key from a group
|
|
curl -s -X DELETE $BASE/postgres/POSTGRES_USER \
|
|
-H "Authorization: Bearer $ADMIN"
|
|
|
|
# Remove an entire group
|
|
curl -s -X DELETE $BASE/postgres \
|
|
-H "Authorization: Bearer $ADMIN"
|
|
|
|
# Force ESO to re-sync immediately after a rotation
|
|
kubectl annotate externalsecret myapp-secrets -n myapp \
|
|
force-sync=$(date +%s) --overwrite
|
|
```
|
|
|
|
---
|
|
|
|
## Quick Reference
|
|
|
|
| Endpoint | Token | Description |
|
|
|---|---|---|
|
|
| `GET /secrets/:group` | ESO token | Returns all keys in group as JSON |
|
|
| `PUT /secrets/:group` | Admin token | Upserts keys into group |
|
|
| `DELETE /secrets/:group` | Admin token | Deletes entire group |
|
|
| `DELETE /secrets/:group/:key` | Admin token | Deletes one key from group |
|