feat(secrets): scoped shared secrets — backend + API (Phase 1)
Secrets defined once and applied to many workloads by scope (global or per-app), encrypted at rest and resolved into container env as a low-precedence default layer: global-shared < app-shared < image cfg.Env < workload_env. A workload with no applicable shared secrets is byte-identical to the prior workload_env-only behavior. - store: shared_secrets table + CRUD + ListApplicableSharedSecrets (enabled global + app, global-first), UNIQUE(scope,app_id,name). - plugin.ResolveSharedSecrets + integration into BuildWorkloadEnv (static/dockerfile) and image buildEnv; best-effort — a shared-secret store/decrypt error never fails a deploy, and values are never logged. - REST CRUD at /api/shared-secrets (reads authed, mutations AdminOnly); values encrypted at the boundary via crypto.Encrypt and never returned (only a has_value flag), mirroring workload_env. UNIQUE collisions 409. Compose is out of scope (YAML-defined env). Frontend rule UI is Phase 2. Reviewed: go + security APPROVE (0 CRITICAL/HIGH); two MEDIUMs fixed (translateSQLError -> 409, no driver-message leak). Deferred defense-in- depth: json:"-" on the model value + a description length cap.
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// sharedSecretRow is the JSON shape returned to clients. The secret value is
|
||||
// NEVER returned — once stored it is write-only (mirroring workload_env). The
|
||||
// has_value flag lets the UI show whether a value is set without exposing it;
|
||||
// to rotate, the operator submits a new value.
|
||||
type sharedSecretRow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HasValue bool `json:"has_value"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Scope string `json:"scope"`
|
||||
AppID string `json:"app_id"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toSharedSecretRow(sec store.SharedSecret) sharedSecretRow {
|
||||
return sharedSecretRow{
|
||||
ID: sec.ID,
|
||||
Name: sec.Name,
|
||||
HasValue: sec.Value != "",
|
||||
Encrypted: sec.Encrypted,
|
||||
Scope: sec.Scope,
|
||||
AppID: sec.AppID,
|
||||
Description: sec.Description,
|
||||
Enabled: sec.Enabled,
|
||||
CreatedAt: sec.CreatedAt,
|
||||
UpdatedAt: sec.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// listSharedSecrets handles GET /api/shared-secrets. Values are redacted.
|
||||
func (s *Server) listSharedSecrets(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.store.ListSharedSecrets()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "list shared secrets")
|
||||
return
|
||||
}
|
||||
out := make([]sharedSecretRow, 0, len(rows))
|
||||
for _, sec := range rows {
|
||||
out = append(out, toSharedSecretRow(sec))
|
||||
}
|
||||
respondJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// getSharedSecret handles GET /api/shared-secrets/{id}. Value is redacted.
|
||||
func (s *Server) getSharedSecret(w http.ResponseWriter, r *http.Request) {
|
||||
sec, err := s.store.GetSharedSecret(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "shared secret")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "get shared secret")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, toSharedSecretRow(sec))
|
||||
}
|
||||
|
||||
// createSharedSecretRequest is the POST body. Encrypted=true (the default for
|
||||
// a non-empty value) causes the value to be encrypted at rest with the global
|
||||
// key before it ever reaches the store.
|
||||
type createSharedSecretRequest struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Encrypted *bool `json:"encrypted"` // defaults true
|
||||
Scope string `json:"scope"` // global | app
|
||||
AppID string `json:"app_id"` // required when scope == app
|
||||
Description string `json:"description"`
|
||||
Enabled *bool `json:"enabled"` // defaults true
|
||||
}
|
||||
|
||||
func (s *Server) createSharedSecret(w http.ResponseWriter, r *http.Request) {
|
||||
var req createSharedSecretRequest
|
||||
if !decodeJSONStrict(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if !validEnvKey(req.Name) {
|
||||
respondError(w, http.StatusBadRequest, "name must be a valid env key [A-Za-z_][A-Za-z0-9_]*")
|
||||
return
|
||||
}
|
||||
if msg := validateSharedSecretScope(req.Scope, req.AppID); msg != "" {
|
||||
respondError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
|
||||
encrypted := true
|
||||
if req.Encrypted != nil {
|
||||
encrypted = *req.Encrypted
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
value, err := s.encryptSecretValue(req.Value, encrypted)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "encrypt value")
|
||||
return
|
||||
}
|
||||
|
||||
sec, err := s.store.CreateSharedSecret(store.SharedSecret{
|
||||
Name: req.Name,
|
||||
Value: value,
|
||||
Encrypted: encrypted,
|
||||
Scope: req.Scope,
|
||||
AppID: strings.TrimSpace(req.AppID),
|
||||
Description: req.Description,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrUnique) {
|
||||
respondError(w, http.StatusConflict, "a shared secret with this scope and name already exists")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "create shared secret")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, toSharedSecretRow(sec))
|
||||
}
|
||||
|
||||
// updateSharedSecretRequest is the PATCH body. Every field is optional; nil
|
||||
// means "leave unchanged". A nil Value preserves the stored ciphertext (so a
|
||||
// metadata-only edit can't accidentally blank a secret); a non-nil Value
|
||||
// rotates it (re-encrypted under the effective Encrypted flag).
|
||||
type updateSharedSecretRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Value *string `json:"value"`
|
||||
Encrypted *bool `json:"encrypted"`
|
||||
Scope *string `json:"scope"`
|
||||
AppID *string `json:"app_id"`
|
||||
Description *string `json:"description"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *Server) updateSharedSecret(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
existing, err := s.store.GetSharedSecret(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "shared secret")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "get shared secret")
|
||||
return
|
||||
}
|
||||
|
||||
var req updateSharedSecretRequest
|
||||
if !decodeJSONStrict(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
merged := existing
|
||||
if req.Name != nil {
|
||||
merged.Name = strings.TrimSpace(*req.Name)
|
||||
if !validEnvKey(merged.Name) {
|
||||
respondError(w, http.StatusBadRequest, "name must be a valid env key [A-Za-z_][A-Za-z0-9_]*")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Encrypted != nil {
|
||||
merged.Encrypted = *req.Encrypted
|
||||
}
|
||||
if req.Scope != nil {
|
||||
merged.Scope = *req.Scope
|
||||
}
|
||||
if req.AppID != nil {
|
||||
merged.AppID = strings.TrimSpace(*req.AppID)
|
||||
}
|
||||
if req.Description != nil {
|
||||
merged.Description = *req.Description
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
merged.Enabled = *req.Enabled
|
||||
}
|
||||
if msg := validateSharedSecretScope(merged.Scope, merged.AppID); msg != "" {
|
||||
respondError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Value handling: only (re)encrypt when the caller supplied a new value.
|
||||
// Otherwise keep the stored ciphertext untouched — but if the Encrypted
|
||||
// flag flipped without a new value we cannot transcode the opaque stored
|
||||
// bytes, so reject that ambiguous request rather than corrupting the row.
|
||||
if req.Value != nil {
|
||||
v, encErr := s.encryptSecretValue(*req.Value, merged.Encrypted)
|
||||
if encErr != nil {
|
||||
respondError(w, http.StatusInternalServerError, "encrypt value")
|
||||
return
|
||||
}
|
||||
merged.Value = v
|
||||
} else if req.Encrypted != nil && *req.Encrypted != existing.Encrypted {
|
||||
respondError(w, http.StatusBadRequest, "changing 'encrypted' requires resubmitting 'value'")
|
||||
return
|
||||
}
|
||||
|
||||
sec, err := s.store.UpdateSharedSecret(merged)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "shared secret")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrUnique) {
|
||||
respondError(w, http.StatusConflict, "a shared secret with this scope and name already exists")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "update shared secret")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, toSharedSecretRow(sec))
|
||||
}
|
||||
|
||||
func (s *Server) deleteSharedSecret(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := s.store.DeleteSharedSecret(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "shared secret")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "delete shared secret")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
|
||||
// encryptSecretValue encrypts value with the global key when encrypted is set
|
||||
// and the value is non-empty; otherwise it returns the value unchanged. An
|
||||
// empty value stays empty (no value set) regardless of the flag.
|
||||
func (s *Server) encryptSecretValue(value string, encrypted bool) (string, error) {
|
||||
if !encrypted || value == "" {
|
||||
return value, nil
|
||||
}
|
||||
enc, err := crypto.Encrypt(s.encKey, value)
|
||||
if err != nil {
|
||||
slog.Error("encrypt shared secret value", "error", err)
|
||||
return "", err
|
||||
}
|
||||
return enc, nil
|
||||
}
|
||||
|
||||
// validateSharedSecretScope returns a non-empty 400 message when the scope /
|
||||
// app_id pairing is invalid; "" when valid. Mirrors the store-side invariant
|
||||
// so the API rejects with a clear message before hitting the store.
|
||||
func validateSharedSecretScope(scope, appID string) string {
|
||||
switch scope {
|
||||
case store.SharedSecretScopeGlobal:
|
||||
return ""
|
||||
case store.SharedSecretScopeApp:
|
||||
if strings.TrimSpace(appID) == "" {
|
||||
return "app_id is required when scope is 'app'"
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return "scope must be 'global' or 'app'"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user