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
+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()