feat: Secret management
Server Deploy / deploy (push) Successful in 1m33s

This commit is contained in:
domrichardson
2026-07-03 10:37:43 +01:00
parent c3c16083f7
commit 19596ff2a3
15 changed files with 1757 additions and 8 deletions
+707
View File
@@ -0,0 +1,707 @@
# 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 |
+4
View File
@@ -23,6 +23,10 @@ func main() {
}
log.Println("connected to MongoDB")
if err := services.EnsureSecretIndexes(); err != nil {
log.Printf("warning: failed to ensure secret indexes: %v", err)
}
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
if err := auth.InitRedis(redisAddr); err != nil {
log.Fatalf("failed to connect to Redis: %v", err)
+13
View File
@@ -23,6 +23,10 @@ func RegisterRoutes(r *gin.Engine) {
r.GET("/install", handleInstallScript)
r.GET("/update", handleUpdateScript)
// ESO read endpoint — bearer-token auth, not session auth, so Kubernetes
// External Secrets Operator can call it. Returns a group as flat JSON.
r.GET("/secrets/:group", secretsReadAuth(), esoGetGroup)
// Auth endpoints (no session required)
r.GET("/auth/login", auth.HandleLogin)
r.GET("/auth/callback", auth.HandleCallback)
@@ -49,6 +53,15 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.GET("/settings", getSettings)
apiGroup.PUT("/settings", saveSettings)
apiGroup.POST("/settings/secrets-token", rotateSecretsToken)
apiGroup.GET("/secrets", listSecretGroups)
apiGroup.POST("/secrets", createSecretGroup)
apiGroup.GET("/secrets/:group", getSecretGroup)
apiGroup.PUT("/secrets/:group", putSecretGroup)
apiGroup.POST("/secrets/:group/reveal", revealSecret)
apiGroup.DELETE("/secrets/:group", deleteSecretGroup)
apiGroup.DELETE("/secrets/:group/:key", deleteSecretKey)
apiGroup.GET("/keys", listKeys)
apiGroup.POST("/keys", createKey)
+188
View File
@@ -0,0 +1,188 @@
package api
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/services"
)
// groupNamePattern restricts group and key names to characters that are safe
// in URLs and Kubernetes/env contexts.
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
func validName(s string) bool {
return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s)
}
// secretsReadAuth validates the ESO bearer token on the public read endpoint.
func secretsReadAuth() gin.HandlerFunc {
return func(c *gin.Context) {
const prefix = "Bearer "
auth := c.GetHeader("Authorization")
if len(auth) <= len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
if !services.VerifySecretsReadToken(auth[len(prefix):]) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Next()
}
}
// esoGetGroup handles GET /secrets/:group for the External Secrets Operator.
// Returns a flat JSON object { "KEY": "value", ... }; 404 if the group is empty
// (ESO treats 404 as "deleted").
func esoGetGroup(c *gin.Context) {
group := c.Param("group")
values, err := services.GetSecretGroupDecrypted(group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
return
}
if len(values) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, values)
}
func listSecretGroups(c *gin.Context) {
groups, err := services.ListSecretGroups()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, groups)
}
// createSecretGroup handles POST /api/secrets. A group is implicit, so it must
// be created with at least one key/value pair.
func createSecretGroup(c *gin.Context) {
var body struct {
Group string `json:"group" binding:"required"`
Values map[string]string `json:"values"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validName(body.Group) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group name"})
return
}
if len(body.Values) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "a group must be created with at least one key"})
return
}
for k := range body.Values {
if !validName(k) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid key name: %s", k)})
return
}
}
if err := services.UpsertSecrets(body.Group, body.Values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
c.JSON(http.StatusCreated, gin.H{"group": body.Group})
}
func getSecretGroup(c *gin.Context) {
group := c.Param("group")
secrets, err := services.GetSecretGroup(group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(secrets) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets})
}
// putSecretGroup upserts one or more keys into an existing (or new) group.
func putSecretGroup(c *gin.Context) {
group := c.Param("group")
if !validName(group) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group name"})
return
}
var values map[string]string
if err := c.ShouldBindJSON(&values); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
return
}
if len(values) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "body must contain at least one key"})
return
}
for k := range values {
if !validName(k) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid key name: %s", k)})
return
}
}
if err := services.UpsertSecrets(group, values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
c.JSON(http.StatusOK, gin.H{"saved": true})
}
func revealSecret(c *gin.Context) {
group := c.Param("group")
var body struct {
Key string `json:"key" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
value, err := services.RevealSecret(group, body.Key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
c.JSON(http.StatusOK, gin.H{"value": value})
}
func deleteSecretKey(c *gin.Context) {
group := c.Param("group")
key := c.Param("key")
if err := services.DeleteSecret(group, key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func deleteSecretGroup(c *gin.Context) {
group := c.Param("group")
if err := services.DeleteSecretGroup(group); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func rotateSecretsToken(c *gin.Context) {
token, err := services.RotateSecretsReadToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
c.JSON(http.StatusOK, gin.H{"token": token})
}
+24
View File
@@ -0,0 +1,24 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Secret is a single key/value pair within a group. The value is stored
// encrypted (AES-256-GCM) and is never serialized to JSON.
type Secret struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Group string `bson:"group" json:"group"`
Key string `bson:"key" json:"key"`
EncryptedValue string `bson:"encrypted_value" json:"-"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// GroupSummary describes a group in the list view.
type GroupSummary struct {
Group string `json:"group"`
KeyCount int `json:"key_count"`
UpdatedAt time.Time `json:"updated_at"`
}
+18 -5
View File
@@ -1,6 +1,10 @@
package models
import "go.mongodb.org/mongo-driver/v2/bson"
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AlertSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
@@ -19,8 +23,17 @@ type EmailSettings struct {
UseTLS bool `bson:"use_tls" json:"use_tls"`
}
type Settings struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Alerts AlertSettings `bson:"alerts" json:"alerts"`
Email EmailSettings `bson:"email" json:"email"`
// SecretsSettings holds configuration for the secrets vault / ESO integration.
// The read token is stored as a SHA-256 hash and never returned to clients.
type SecretsSettings struct {
ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"`
ReadTokenSet bool `bson:"-" json:"read_token_set"`
RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"`
}
type Settings struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Alerts AlertSettings `bson:"alerts" json:"alerts"`
Email EmailSettings `bson:"email" json:"email"`
Secrets SecretsSettings `bson:"secrets" json:"secrets"`
}
+9 -2
View File
@@ -22,7 +22,9 @@ func encryptionKey() ([]byte, error) {
return key, nil
}
func encryptPrivateKey(plaintext string) (string, error) {
// encryptString encrypts a plaintext value with AES-256-GCM using the
// shared KEY_ENCRYPTION_KEY, returning hex(nonce + ciphertext).
func encryptString(plaintext string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
@@ -43,7 +45,8 @@ func encryptPrivateKey(plaintext string) (string, error) {
return hex.EncodeToString(sealed), nil
}
func decryptPrivateKey(ciphertextHex string) (string, error) {
// decryptString reverses encryptString.
func decryptString(ciphertextHex string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
@@ -70,3 +73,7 @@ func decryptPrivateKey(ciphertextHex string) (string, error) {
}
return string(plaintext), nil
}
func encryptPrivateKey(plaintext string) (string, error) { return encryptString(plaintext) }
func decryptPrivateKey(ciphertextHex string) (string, error) { return decryptString(ciphertextHex) }
+174
View File
@@ -0,0 +1,174 @@
package services
import (
"context"
"fmt"
"sort"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureSecretIndexes creates the unique compound index on (group, key).
func EnsureSecretIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := db.Col("secrets").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "group", Value: 1}, {Key: "key", Value: 1}},
Options: options.Index().SetUnique(true),
})
return err
}
// ListSecretGroups returns a summary of every group with its key count and
// most recent update time.
func ListSecretGroups() ([]models.GroupSummary, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pipeline := mongo.Pipeline{
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$group"},
{Key: "key_count", Value: bson.D{{Key: "$sum", Value: 1}}},
{Key: "updated_at", Value: bson.D{{Key: "$max", Value: "$updated_at"}}},
}}},
{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}},
}
cursor, err := db.Col("secrets").Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var rows []struct {
Group string `bson:"_id"`
KeyCount int `bson:"key_count"`
UpdatedAt time.Time `bson:"updated_at"`
}
if err := cursor.All(ctx, &rows); err != nil {
return nil, err
}
groups := make([]models.GroupSummary, 0, len(rows))
for _, r := range rows {
groups = append(groups, models.GroupSummary{
Group: r.Group,
KeyCount: r.KeyCount,
UpdatedAt: r.UpdatedAt,
})
}
return groups, nil
}
// GetSecretGroup returns the keys within a group, sorted by key name, without
// decrypted values.
func GetSecretGroup(group string) ([]models.Secret, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("secrets").Find(ctx, bson.M{"group": group},
options.Find().SetSort(bson.D{{Key: "key", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var docs []models.Secret
if err := cursor.All(ctx, &docs); err != nil {
return nil, err
}
return docs, nil
}
// GetSecretGroupDecrypted returns a flat map of key → plaintext value for a
// group. Used by the ESO read endpoint.
func GetSecretGroupDecrypted(group string) (map[string]string, error) {
docs, err := GetSecretGroup(group)
if err != nil {
return nil, err
}
result := make(map[string]string, len(docs))
for _, doc := range docs {
val, err := decryptString(doc.EncryptedValue)
if err != nil {
return nil, fmt.Errorf("decrypt %s/%s: %w", group, doc.Key, err)
}
result[doc.Key] = val
}
return result, nil
}
// RevealSecret returns the decrypted value of a single key.
func RevealSecret(group, key string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var doc models.Secret
err := db.Col("secrets").FindOne(ctx, bson.M{"group": group, "key": key}).Decode(&doc)
if err == mongo.ErrNoDocuments {
return "", fmt.Errorf("secret not found")
}
if err != nil {
return "", err
}
return decryptString(doc.EncryptedValue)
}
// UpsertSecrets encrypts and writes each key/value pair into the group.
func UpsertSecrets(group string, values map[string]string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for key, val := range values {
encrypted, err := encryptString(val)
if err != nil {
return fmt.Errorf("encrypt %s: %w", key, err)
}
_, err = db.Col("secrets").UpdateOne(ctx,
bson.M{"group": group, "key": key},
bson.M{"$set": bson.M{
"encrypted_value": encrypted,
"updated_at": time.Now(),
}},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
return err
}
}
return nil
}
// SortedKeys returns the map keys sorted — handy for stable audit messages.
func SortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// DeleteSecret removes a single key from a group.
func DeleteSecret(group, key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"group": group, "key": key})
return err
}
// DeleteSecretGroup removes an entire group and all its keys.
func DeleteSecretGroup(group string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"group": group})
return err
}
+54
View File
@@ -3,7 +3,11 @@ package services
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"log"
@@ -43,9 +47,59 @@ func GetSettings() (*models.Settings, error) {
if err != nil {
return nil, err
}
s.Secrets.ReadTokenSet = s.Secrets.ReadTokenHash != ""
return &s, nil
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// RotateSecretsReadToken generates a new ESO read token, stores its SHA-256
// hash, and returns the plaintext token exactly once.
func RotateSecretsReadToken() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := hex.EncodeToString(raw)
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{},
bson.M{"$set": bson.M{
"secrets.read_token_hash": hashToken(token),
"secrets.rotated_at": time.Now(),
}},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
return "", err
}
return token, nil
}
// VerifySecretsReadToken reports whether the supplied token matches the stored
// hash, using a constant-time comparison.
func VerifySecretsReadToken(token string) bool {
if token == "" {
return false
}
s, err := GetSettings()
if err != nil || s.Secrets.ReadTokenHash == "" {
return false
}
expected, err := hex.DecodeString(s.Secrets.ReadTokenHash)
if err != nil {
return false
}
got := sha256.Sum256([]byte(token))
return subtle.ConstantTimeCompare(expected, got[:]) == 1
}
func SaveSettings(alerts models.AlertSettings, email models.EmailSettings) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Secret } from "@/lib/api";
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
function SecretRow({ group, secret }: { group: string; secret: Secret }) {
const queryClient = useQueryClient();
const [revealed, setRevealed] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { mutate: reveal, isPending: revealing } = useMutation({
mutationFn: () => api.revealSecret(group, secret.key),
onSuccess: (res) => setRevealed(res.value),
});
const { mutate: remove, isPending: removing } = useMutation({
mutationFn: () => api.deleteSecret(group, secret.key),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["secret-group", group] }),
});
async function copy() {
if (revealed == null) return;
await navigator.clipboard.writeText(revealed);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Tr>
<Td>
<span className="font-mono font-medium text-text-primary">{secret.key}</span>
</Td>
<Td>
{revealed == null ? (
<span className="font-mono text-text-tertiary"></span>
) : (
<span className="font-mono text-xs break-all text-text-primary">{revealed}</span>
)}
</Td>
<Td>
<span className="text-text-secondary text-xs">
{new Date(secret.updated_at).toLocaleString()}
</span>
</Td>
<Td>
<div className="flex justify-end gap-2">
{revealed == null ? (
<Button variant="ghost" size="sm" loading={revealing} onClick={() => reveal()}>
Reveal
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
<Button variant="ghost" size="sm" onClick={() => setRevealed(null)}>
Hide
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
loading={removing}
className="text-danger hover:text-danger"
onClick={() => {
if (confirm(`Delete key "${secret.key}"?`)) remove();
}}
>
Delete
</Button>
</div>
</Td>
</Tr>
);
}
function AddKeyCard({ group }: { group: string }) {
const queryClient = useQueryClient();
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const { mutate: add, isPending, error } = useMutation({
mutationFn: () => api.putSecrets(group, { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-group", group] });
setKey("");
setValue("");
},
});
return (
<Card>
<CardHeader>
<CardTitle>Add / Update Key</CardTitle>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">
Adding a key that already exists overwrites its value. Others are left untouched.
</p>
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API_KEY"
className={`${inputClass} font-mono`}
/>
</div>
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Value</label>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="myapikey456"
className={`${inputClass} font-mono`}
/>
</div>
<Button
variant="primary"
loading={isPending}
disabled={!key.trim() || !value}
onClick={() => add()}
>
Save
</Button>
</div>
</Card>
);
}
export default function SecretGroupPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const group = decodeURIComponent(String(params.group));
const { data, isLoading, error } = useQuery({
queryKey: ["secret-group", group],
queryFn: () => api.getSecretGroup(group),
});
const { mutate: deleteGroup, isPending: deleting } = useMutation({
mutationFn: () => api.deleteSecretGroup(group),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
router.push("/secrets");
},
});
return (
<div className="p-8">
<Link href="/secrets" className="mb-4 inline-flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary">
Back to secrets
</Link>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-mono text-2xl font-bold text-text-primary">{group}</h1>
<p className="mt-1 text-sm text-text-secondary">
ESO reads this group at <span className="font-mono">GET /secrets/{group}</span>
</p>
</div>
<Button
variant="ghost"
className="text-danger hover:text-danger"
loading={deleting}
onClick={() => {
if (confirm(`Delete the entire "${group}" group and all its keys?`)) deleteGroup();
}}
>
Delete Group
</Button>
</div>
<div className="space-y-6">
<AddKeyCard group={group} />
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">
Failed to load group. It may have been deleted.
</div>
) : data && data.secrets.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Key</Th>
<Th>Value</Th>
<Th>Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{data.secrets.map((s: Secret) => (
<SecretRow key={s.key} group={group} secret={s} />
))}
</Tbody>
</Table>
) : (
<div className="py-16 text-center text-text-secondary">
This group has no keys. Add one above.
</div>
)}
</Card>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, SecretGroupSummary } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
function NewGroupModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const [group, setGroup] = useState("");
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const { mutate: create, isPending, error } = useMutation({
mutationFn: () =>
api.createSecretGroup(group.trim(), { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
onClose();
},
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-border bg-surface p-6">
<h2 className="mb-1 text-lg font-semibold text-text-primary">New Secret Group</h2>
<p className="mb-4 text-sm text-text-secondary">
A group must be created with at least one key. You can add more keys afterwards.
</p>
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Group name</label>
<input
type="text"
value={group}
onChange={(e) => setGroup(e.target.value)}
placeholder="e.g. myapp-prod"
className={`${inputClass} font-mono`}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">First key</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="DB_PASSWORD"
className={`${inputClass} font-mono`}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Value</label>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="supersecret123"
className={`${inputClass} font-mono`}
/>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
variant="primary"
loading={isPending}
disabled={!group.trim() || !key.trim() || !value}
onClick={() => create()}
>
Create Group
</Button>
</div>
</div>
</div>
);
}
export default function SecretsPage() {
const [showNew, setShowNew] = useState(false);
const { data: groups, isLoading, error } = useQuery({
queryKey: ["secret-groups"],
queryFn: api.listSecretGroups,
});
return (
<div className="p-8">
{showNew && <NewGroupModal onClose={() => setShowNew(false)} />}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Secrets</h1>
<p className="mt-1 text-sm text-text-secondary">
{groups?.length ?? 0} group{groups?.length !== 1 ? "s" : ""} · encrypted at rest, exposed to Kubernetes via ESO
</p>
</div>
<Button variant="primary" onClick={() => setShowNew(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New Group
</Button>
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">
Failed to load secrets. Is the backend running?
</div>
) : groups && groups.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Group</Th>
<Th>Keys</Th>
<Th>Last Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{groups.map((g: SecretGroupSummary) => (
<Tr key={g.group}>
<Td>
<span className="font-mono font-medium text-text-primary">{g.group}</span>
</Td>
<Td>
<span className="text-text-secondary">
{g.key_count} key{g.key_count !== 1 ? "s" : ""}
</span>
</Td>
<Td>
<span className="text-text-secondary text-xs">
{new Date(g.updated_at).toLocaleString()}
</span>
</Td>
<Td>
<Link href={`/secrets/${encodeURIComponent(g.group)}`}>
<Button variant="ghost" size="sm">View </Button>
</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<p className="text-text-secondary">No secret groups yet.</p>
<Button variant="primary" size="sm" className="mt-4" onClick={() => setShowNew(true)}>
Create your first group
</Button>
</div>
)}
</Card>
</div>
);
}
+86
View File
@@ -66,6 +66,85 @@ function Field({
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
function SecretsTokenCard({
tokenSet,
rotatedAt,
}: {
tokenSet: boolean;
rotatedAt?: string;
}) {
const queryClient = useQueryClient();
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const readUrl =
typeof window !== "undefined"
? `${window.location.origin}/secrets/<group>`
: "/secrets/<group>";
const { mutate: rotate, isPending } = useMutation({
mutationFn: api.rotateSecretsToken,
onSuccess: (res) => {
setToken(res.token);
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
async function copy() {
if (!token) return;
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Card>
<CardHeader>
<CardTitle>Secrets Read Token (ESO)</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer
token. Point your <span className="font-mono">ClusterSecretStore</span> at{" "}
<span className="font-mono text-text-primary">{readUrl}</span>.
</p>
<div className="mb-4 flex items-center gap-2 text-sm">
<span
className={`inline-block h-2 w-2 rounded-full ${tokenSet ? "bg-success" : "bg-text-tertiary"}`}
/>
<span className="text-text-secondary">
{tokenSet ? "A read token is configured" : "No read token configured yet"}
{tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`}
</span>
</div>
{token && (
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 p-3">
<p className="mb-2 text-xs font-medium text-warning">
Copy this token now it will not be shown again.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">
{token}
</code>
<Button type="button" variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
)}
<Button type="button" variant="primary" loading={isPending} onClick={() => rotate()}>
{tokenSet ? "Rotate Token" : "Generate Token"}
</Button>
{tokenSet && (
<p className="mt-2 text-xs text-text-tertiary">
Rotating invalidates the previous token. Update the Kubernetes secret afterwards.
</p>
)}
</Card>
);
}
export default function SettingsPage() {
const queryClient = useQueryClient();
@@ -302,6 +381,13 @@ export default function SettingsPage() {
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
</div>
</form>
<div className="mt-6 max-w-xl">
<SecretsTokenCard
tokenSet={settings?.secrets?.read_token_set ?? false}
rotatedAt={settings?.secrets?.rotated_at}
/>
</div>
</div>
);
}
+9
View File
@@ -27,6 +27,14 @@ function KeyIcon() {
);
}
function SecretIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
);
}
function AuditIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -47,6 +55,7 @@ function SettingsIcon() {
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings", label: "Settings", icon: <SettingsIcon /> },
];
+62
View File
@@ -69,9 +69,27 @@ export interface EmailSettings {
use_tls: boolean;
}
export interface SecretsSettings {
read_token_set: boolean;
rotated_at?: string;
}
export interface Settings {
alerts: AlertSettings;
email: EmailSettings;
secrets: SecretsSettings;
}
export interface SecretGroupSummary {
group: string;
key_count: number;
updated_at: string;
}
export interface Secret {
group: string;
key: string;
updated_at: string;
}
export interface NewServerResponse {
@@ -191,6 +209,50 @@ export const api = {
});
},
rotateSecretsToken(): Promise<{ token: string }> {
return request<{ token: string }>("/settings/secrets-token", { method: "POST" });
},
// Secrets
listSecretGroups(): Promise<SecretGroupSummary[]> {
return request<SecretGroupSummary[]>("/secrets");
},
createSecretGroup(group: string, values: Record<string, string>): Promise<{ group: string }> {
return request<{ group: string }>("/secrets", {
method: "POST",
body: JSON.stringify({ group, values }),
});
},
getSecretGroup(group: string): Promise<{ group: string; secrets: Secret[] }> {
return request<{ group: string; secrets: Secret[] }>(`/secrets/${encodeURIComponent(group)}`);
},
putSecrets(group: string, values: Record<string, string>): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>(`/secrets/${encodeURIComponent(group)}`, {
method: "PUT",
body: JSON.stringify(values),
});
},
revealSecret(group: string, key: string): Promise<{ value: string }> {
return request<{ value: string }>(`/secrets/${encodeURIComponent(group)}/reveal`, {
method: "POST",
body: JSON.stringify({ key }),
});
},
deleteSecret(group: string, key: string): Promise<void> {
return request<void>(`/secrets/${encodeURIComponent(group)}/${encodeURIComponent(key)}`, {
method: "DELETE",
});
},
deleteSecretGroup(group: string): Promise<void> {
return request<void>(`/secrets/${encodeURIComponent(group)}`, { method: "DELETE" });
},
// Keys
listKeys(): Promise<Key[]> {
return request<Key[]>("/keys");
File diff suppressed because one or more lines are too long