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:
@@ -441,6 +441,16 @@ func (s *Server) Router() chi.Router {
|
||||
r.Delete("/metric-alert-rules/{id}", s.deleteMetricAlertRule)
|
||||
})
|
||||
|
||||
// Shared secrets (env vars shared across workloads by scope).
|
||||
r.Get("/shared-secrets", s.listSharedSecrets)
|
||||
r.Get("/shared-secrets/{id}", s.getSharedSecret)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.AdminOnly)
|
||||
r.Post("/shared-secrets", s.createSharedSecret)
|
||||
r.Patch("/shared-secrets/{id}", s.updateSharedSecret)
|
||||
r.Delete("/shared-secrets/{id}", s.deleteSharedSecret)
|
||||
})
|
||||
|
||||
// System resources (read-only).
|
||||
r.Get("/system/stats", s.getSystemStats)
|
||||
r.Get("/system/stats/history", s.getSystemStatsHistory)
|
||||
|
||||
@@ -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'"
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,29 @@ type WorkloadEnv struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SharedSecret is an env var shared across workloads by scope. Resolved
|
||||
// into a workload's container env as a low-precedence default (overridden
|
||||
// by image cfg.Env and workload_env).
|
||||
type SharedSecret struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // the env KEY
|
||||
Value string `json:"value"` // ciphertext when Encrypted; never returned decrypted by the API
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Scope string `json:"scope"` // global | app
|
||||
AppID string `json:"app_id"` // set when scope == app; "" for global
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Shared-secret scope enum: a secret is either applied to every workload
|
||||
// (global) or only to workloads whose app_id matches (app).
|
||||
const (
|
||||
SharedSecretScopeGlobal = "global"
|
||||
SharedSecretScopeApp = "app"
|
||||
)
|
||||
|
||||
// VolumeScope defines the sharing scope for a volume mount.
|
||||
// Valid scopes: instance, stage, project, project_named, named, ephemeral.
|
||||
type VolumeScope string
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CreateSharedSecret inserts a new shared-secret row after validating its
|
||||
// scope/app_id pairing and non-empty name. The caller is responsible for
|
||||
// encrypting Value when Encrypted is set (mirroring workload_env) — the
|
||||
// store treats Value as opaque bytes.
|
||||
func (s *Store) CreateSharedSecret(sec SharedSecret) (SharedSecret, error) {
|
||||
if err := validateSharedSecret(&sec); err != nil {
|
||||
return SharedSecret{}, err
|
||||
}
|
||||
now := Now()
|
||||
if sec.ID == "" {
|
||||
sec.ID = uuid.New().String()
|
||||
}
|
||||
sec.CreatedAt = now
|
||||
sec.UpdatedAt = now
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO shared_secrets
|
||||
(id, name, value, encrypted, scope, app_id, description, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sec.ID, sec.Name, sec.Value, BoolToInt(sec.Encrypted), sec.Scope, sec.AppID,
|
||||
sec.Description, BoolToInt(sec.Enabled), sec.CreatedAt, sec.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return SharedSecret{}, fmt.Errorf("insert shared secret: %w", translateSQLError(err))
|
||||
}
|
||||
return sec, nil
|
||||
}
|
||||
|
||||
// ListSharedSecrets returns every shared secret, ordered by scope then
|
||||
// name for stable UI rendering (globals first).
|
||||
func (s *Store) ListSharedSecrets() ([]SharedSecret, error) {
|
||||
return s.querySharedSecrets(
|
||||
`SELECT id, name, value, encrypted, scope, app_id, description, enabled, created_at, updated_at
|
||||
FROM shared_secrets
|
||||
ORDER BY CASE scope WHEN 'global' THEN 0 ELSE 1 END, app_id, name`,
|
||||
)
|
||||
}
|
||||
|
||||
// GetSharedSecret fetches one shared secret by id or returns ErrNotFound.
|
||||
func (s *Store) GetSharedSecret(id string) (SharedSecret, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, name, value, encrypted, scope, app_id, description, enabled, created_at, updated_at
|
||||
FROM shared_secrets WHERE id = ?`, id,
|
||||
)
|
||||
sec, err := scanSharedSecretRow(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SharedSecret{}, fmt.Errorf("shared secret %s: %w", id, ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return SharedSecret{}, fmt.Errorf("query shared secret: %w", err)
|
||||
}
|
||||
return sec, nil
|
||||
}
|
||||
|
||||
// UpdateSharedSecret overwrites the editable columns of a shared-secret
|
||||
// row. id is immutable; name/value/encrypted/scope/app_id/description/
|
||||
// enabled are overwritten wholesale (the API layer is responsible for
|
||||
// merging partial PATCH input onto the existing row first).
|
||||
func (s *Store) UpdateSharedSecret(sec SharedSecret) (SharedSecret, error) {
|
||||
if sec.ID == "" {
|
||||
return SharedSecret{}, fmt.Errorf("shared secret: id is required for update")
|
||||
}
|
||||
if err := validateSharedSecret(&sec); err != nil {
|
||||
return SharedSecret{}, err
|
||||
}
|
||||
sec.UpdatedAt = Now()
|
||||
res, err := s.db.Exec(
|
||||
`UPDATE shared_secrets
|
||||
SET name = ?, value = ?, encrypted = ?, scope = ?, app_id = ?,
|
||||
description = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
sec.Name, sec.Value, BoolToInt(sec.Encrypted), sec.Scope, sec.AppID,
|
||||
sec.Description, BoolToInt(sec.Enabled), sec.UpdatedAt, sec.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return SharedSecret{}, fmt.Errorf("update shared secret: %w", translateSQLError(err))
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return SharedSecret{}, fmt.Errorf("shared secret %s: %w", sec.ID, ErrNotFound)
|
||||
}
|
||||
return s.GetSharedSecret(sec.ID)
|
||||
}
|
||||
|
||||
// DeleteSharedSecret removes a shared secret by id, returning ErrNotFound
|
||||
// when no row matched.
|
||||
func (s *Store) DeleteSharedSecret(id string) error {
|
||||
res, err := s.db.Exec(`DELETE FROM shared_secrets WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete shared secret: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("shared secret %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListApplicableSharedSecrets returns ENABLED secrets that apply to a
|
||||
// workload in the given app: all global secrets plus the app's own.
|
||||
// Ordered global-first so callers can overlay app on top of global.
|
||||
func (s *Store) ListApplicableSharedSecrets(appID string) ([]SharedSecret, error) {
|
||||
return s.querySharedSecrets(
|
||||
`SELECT id, name, value, encrypted, scope, app_id, description, enabled, created_at, updated_at
|
||||
FROM shared_secrets
|
||||
WHERE enabled = 1 AND (scope = 'global' OR (scope = 'app' AND app_id = ?))
|
||||
ORDER BY CASE scope WHEN 'global' THEN 0 ELSE 1 END, name`,
|
||||
appID,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Store) querySharedSecrets(query string, args ...any) ([]SharedSecret, error) {
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query shared secrets: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []SharedSecret{}
|
||||
for rows.Next() {
|
||||
sec, err := scanSharedSecretRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanSharedSecretRows(rows *sql.Rows) (SharedSecret, error) {
|
||||
var sec SharedSecret
|
||||
var enc, enabled int
|
||||
if err := rows.Scan(
|
||||
&sec.ID, &sec.Name, &sec.Value, &enc, &sec.Scope, &sec.AppID,
|
||||
&sec.Description, &enabled, &sec.CreatedAt, &sec.UpdatedAt,
|
||||
); err != nil {
|
||||
return SharedSecret{}, fmt.Errorf("scan shared secret: %w", err)
|
||||
}
|
||||
sec.Encrypted = enc != 0
|
||||
sec.Enabled = enabled != 0
|
||||
return sec, nil
|
||||
}
|
||||
|
||||
func scanSharedSecretRow(row *sql.Row) (SharedSecret, error) {
|
||||
var sec SharedSecret
|
||||
var enc, enabled int
|
||||
if err := row.Scan(
|
||||
&sec.ID, &sec.Name, &sec.Value, &enc, &sec.Scope, &sec.AppID,
|
||||
&sec.Description, &enabled, &sec.CreatedAt, &sec.UpdatedAt,
|
||||
); err != nil {
|
||||
return SharedSecret{}, err
|
||||
}
|
||||
sec.Encrypted = enc != 0
|
||||
sec.Enabled = enabled != 0
|
||||
return sec, nil
|
||||
}
|
||||
|
||||
// validateSharedSecret enforces the per-row invariants: a non-empty name,
|
||||
// a valid scope, and a coherent scope/app_id pairing. When scope==app an
|
||||
// app_id is required; when scope==global the app_id is forced blank so the
|
||||
// unique index (scope, app_id, name) stays consistent for globals.
|
||||
func validateSharedSecret(sec *SharedSecret) error {
|
||||
if strings.TrimSpace(sec.Name) == "" {
|
||||
return fmt.Errorf("shared secret: name is required")
|
||||
}
|
||||
switch sec.Scope {
|
||||
case SharedSecretScopeGlobal:
|
||||
sec.AppID = ""
|
||||
case SharedSecretScopeApp:
|
||||
if strings.TrimSpace(sec.AppID) == "" {
|
||||
return fmt.Errorf("shared secret: app_id is required when scope is app")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("shared secret: invalid scope %q", sec.Scope)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateSharedSecret_Validates(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
in SharedSecret
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing name",
|
||||
in: SharedSecret{Scope: SharedSecretScopeGlobal},
|
||||
wantErr: "name is required",
|
||||
},
|
||||
{
|
||||
name: "invalid scope",
|
||||
in: SharedSecret{Name: "FOO", Scope: "team"},
|
||||
wantErr: "invalid scope",
|
||||
},
|
||||
{
|
||||
name: "app scope without app_id",
|
||||
in: SharedSecret{Name: "FOO", Scope: SharedSecretScopeApp},
|
||||
wantErr: "app_id is required",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := s.CreateSharedSecret(c.in)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", c.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantErr) {
|
||||
t.Fatalf("error mismatch: got %q want substring %q", err.Error(), c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSharedSecret_GlobalForcesBlankAppID(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
got, err := s.CreateSharedSecret(SharedSecret{
|
||||
Name: "GLOBAL_KEY", Value: "v", Scope: SharedSecretScopeGlobal,
|
||||
AppID: "should-be-cleared", Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if got.AppID != "" {
|
||||
t.Errorf("global secret AppID = %q, want empty", got.AppID)
|
||||
}
|
||||
if got.ID == "" {
|
||||
t.Error("id should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndGetSharedSecret(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
created, err := s.CreateSharedSecret(SharedSecret{
|
||||
Name: "API_KEY", Value: "ciphertext", Encrypted: true,
|
||||
Scope: SharedSecretScopeApp, AppID: "app1", Description: "d", Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, err := s.GetSharedSecret(created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Name != "API_KEY" || got.Value != "ciphertext" || !got.Encrypted {
|
||||
t.Errorf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
if got.Scope != SharedSecretScopeApp || got.AppID != "app1" {
|
||||
t.Errorf("scope/app mismatch: %+v", got)
|
||||
}
|
||||
if !got.Enabled {
|
||||
t.Error("enabled lost on round-trip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSharedSecret_NotFound(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.GetSharedSecret("nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSharedSecret(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
created, _ := s.CreateSharedSecret(SharedSecret{
|
||||
Name: "K", Value: "v1", Scope: SharedSecretScopeGlobal, Enabled: true,
|
||||
})
|
||||
created.Value = "v2"
|
||||
created.Description = "updated"
|
||||
created.Enabled = false
|
||||
got, err := s.UpdateSharedSecret(created)
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if got.Value != "v2" {
|
||||
t.Errorf("value not updated: %q", got.Value)
|
||||
}
|
||||
if got.Description != "updated" {
|
||||
t.Errorf("description not updated: %q", got.Description)
|
||||
}
|
||||
if got.Enabled {
|
||||
t.Error("enabled=false not applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSharedSecret_NotFound(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
_, err := s.UpdateSharedSecret(SharedSecret{
|
||||
ID: "missing", Name: "K", Scope: SharedSecretScopeGlobal,
|
||||
})
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound updating missing secret, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSharedSecret(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
created, _ := s.CreateSharedSecret(SharedSecret{
|
||||
Name: "K", Value: "v", Scope: SharedSecretScopeGlobal, Enabled: true,
|
||||
})
|
||||
if err := s.DeleteSharedSecret(created.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := s.GetSharedSecret(created.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
if err := s.DeleteSharedSecret(created.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound deleting twice, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedSecret_UniquePerScopeAppName(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.CreateSharedSecret(SharedSecret{
|
||||
Name: "DUP", Value: "a", Scope: SharedSecretScopeGlobal, Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
// Same scope+name collides on the unique index.
|
||||
if _, err := s.CreateSharedSecret(SharedSecret{
|
||||
Name: "DUP", Value: "b", Scope: SharedSecretScopeGlobal, Enabled: true,
|
||||
}); err == nil {
|
||||
t.Fatal("expected unique-index violation for duplicate global key")
|
||||
}
|
||||
// Same name under an app scope is a distinct row.
|
||||
if _, err := s.CreateSharedSecret(SharedSecret{
|
||||
Name: "DUP", Value: "c", Scope: SharedSecretScopeApp, AppID: "app1", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("app-scoped same name should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListApplicableSharedSecrets(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
// Two globals (one disabled), one app1 secret, one app2 secret.
|
||||
mustCreate(t, s, SharedSecret{Name: "G_ONE", Value: "g1", Scope: SharedSecretScopeGlobal, Enabled: true})
|
||||
mustCreate(t, s, SharedSecret{Name: "G_OFF", Value: "off", Scope: SharedSecretScopeGlobal, Enabled: false})
|
||||
mustCreate(t, s, SharedSecret{Name: "A_ONE", Value: "a1", Scope: SharedSecretScopeApp, AppID: "app1", Enabled: true})
|
||||
mustCreate(t, s, SharedSecret{Name: "A_TWO", Value: "a2", Scope: SharedSecretScopeApp, AppID: "app2", Enabled: true})
|
||||
|
||||
got, err := s.ListApplicableSharedSecrets("app1")
|
||||
if err != nil {
|
||||
t.Fatalf("applicable: %v", err)
|
||||
}
|
||||
// app1 sees the enabled global + its own; not the disabled global, not app2's.
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 applicable secrets, got %d: %+v", len(got), got)
|
||||
}
|
||||
// Global must come first so callers can overlay app on top.
|
||||
if got[0].Name != "G_ONE" {
|
||||
t.Errorf("expected global first, got %q", got[0].Name)
|
||||
}
|
||||
if got[1].Name != "A_ONE" {
|
||||
t.Errorf("expected app1 secret second, got %q", got[1].Name)
|
||||
}
|
||||
for _, sec := range got {
|
||||
if sec.AppID == "app2" {
|
||||
t.Errorf("app1 must not see app2's secret: %+v", sec)
|
||||
}
|
||||
if !sec.Enabled {
|
||||
t.Errorf("disabled secret leaked into applicable set: %+v", sec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListApplicableSharedSecrets_NoAppOnlyGlobals(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
mustCreate(t, s, SharedSecret{Name: "G", Value: "g", Scope: SharedSecretScopeGlobal, Enabled: true})
|
||||
mustCreate(t, s, SharedSecret{Name: "A", Value: "a", Scope: SharedSecretScopeApp, AppID: "app1", Enabled: true})
|
||||
|
||||
// An ungrouped workload (appID == "") sees only globals.
|
||||
got, err := s.ListApplicableSharedSecrets("")
|
||||
if err != nil {
|
||||
t.Fatalf("applicable: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "G" {
|
||||
t.Fatalf("ungrouped workload should see only the global, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustCreate(t *testing.T, s *Store, sec SharedSecret) SharedSecret {
|
||||
t.Helper()
|
||||
out, err := s.CreateSharedSecret(sec)
|
||||
if err != nil {
|
||||
t.Fatalf("create shared secret %q: %v", sec.Name, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -426,6 +426,25 @@ func (s *Store) runMigrations() error {
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_metric_alert_rules_workload ON metric_alert_rules(workload_id)`,
|
||||
// shared_secrets: env vars shared across workloads by scope. Scope
|
||||
// "global" applies to every workload; "app" applies only to
|
||||
// workloads whose app_id matches. Resolved into a workload's
|
||||
// container env as a low-precedence default (see
|
||||
// internal/workload/plugin/env.go).
|
||||
`CREATE TABLE IF NOT EXISTS shared_secrets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
encrypted INTEGER NOT NULL DEFAULT 1,
|
||||
scope TEXT NOT NULL,
|
||||
app_id TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_shared_secrets_scope_name ON shared_secrets(scope, app_id, name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_shared_secrets_app ON shared_secrets(app_id)`,
|
||||
}
|
||||
for _, t := range observabilityTables {
|
||||
if _, err := s.db.Exec(t); err != nil {
|
||||
|
||||
@@ -6,9 +6,50 @@ import (
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
)
|
||||
|
||||
// BuildWorkloadEnv flattens workload_env rows into the KEY=VALUE list Docker
|
||||
// expects. Shared by the source plugins (static, dockerfile) so they all
|
||||
// handle decrypt failures the same way.
|
||||
// ResolveSharedSecrets returns the applicable shared secrets (global, then
|
||||
// app-scoped overlaying global) as a decrypted KEY->VALUE map. Decrypt
|
||||
// failures log + skip the one entry (mirroring BuildWorkloadEnv). Best-effort:
|
||||
// a store error logs and returns an empty map so a shared-secret outage never
|
||||
// fails a deploy.
|
||||
//
|
||||
// The store orders the rows global-first (then app), so iterating in order and
|
||||
// writing into the map makes a later app-scoped entry with the same Name
|
||||
// overwrite the global default — the intended global < app precedence.
|
||||
//
|
||||
// NOTE: the compose plugin intentionally does NOT call this — compose env is
|
||||
// YAML-defined and shared-secret support for compose is an explicit
|
||||
// out-of-scope follow-up.
|
||||
func ResolveSharedSecrets(deps Deps, appID, sourceName string) map[string]string {
|
||||
merged := map[string]string{}
|
||||
rows, err := deps.Store.ListApplicableSharedSecrets(appID)
|
||||
if err != nil {
|
||||
slog.Warn(sourceName+": list shared secrets", "app", appID, "error", err)
|
||||
return merged
|
||||
}
|
||||
for _, sec := range rows {
|
||||
value := sec.Value
|
||||
if sec.Encrypted {
|
||||
decrypted, err := crypto.Decrypt(deps.EncKey, sec.Value)
|
||||
if err != nil {
|
||||
slog.Warn(sourceName+": decrypt shared secret",
|
||||
"app", appID, "name", sec.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
value = decrypted
|
||||
}
|
||||
merged[sec.Name] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// BuildWorkloadEnv flattens the applicable shared secrets plus workload_env
|
||||
// rows into the KEY=VALUE list Docker expects. Shared by the source plugins
|
||||
// (static, dockerfile) so they all handle decrypt failures the same way.
|
||||
//
|
||||
// Shared secrets are the low-precedence base layer; workload_env rows overlay
|
||||
// them so a workload's own config always wins on a key conflict. A workload
|
||||
// with no applicable shared secrets starts from an empty base, so the output
|
||||
// is identical to the workload_env-only behavior that predated shared secrets.
|
||||
//
|
||||
// Encrypted rows are decrypted lazily so plaintext never lives in the store
|
||||
// output. A decrypt failure logs and skips the entry rather than failing the
|
||||
@@ -16,16 +57,21 @@ import (
|
||||
// entry would be worse than running with the variable unset and surfacing the
|
||||
// warning.
|
||||
//
|
||||
// sourceName is the slog prefix the caller wants on the two warning lines
|
||||
// (e.g. "static source" / "dockerfile source") so existing log scrapers keep
|
||||
// matching the per-source message text.
|
||||
func BuildWorkloadEnv(deps Deps, workloadID, sourceName string) []string {
|
||||
// appID is the workload's app_id (plugin.Workload.GroupID), used to resolve
|
||||
// app-scoped shared secrets. sourceName is the slog prefix the caller wants on
|
||||
// the warning lines (e.g. "static source" / "dockerfile source") so existing
|
||||
// log scrapers keep matching the per-source message text.
|
||||
func BuildWorkloadEnv(deps Deps, workloadID, appID, sourceName string) []string {
|
||||
// Base layer: shared secrets (global, then app overlaying global).
|
||||
merged := ResolveSharedSecrets(deps, appID, sourceName)
|
||||
|
||||
rows, err := deps.Store.ListWorkloadEnv(workloadID)
|
||||
if err != nil {
|
||||
slog.Warn(sourceName+": list workload env", "workload", workloadID, "error", err)
|
||||
return nil
|
||||
// Still return whatever shared secrets resolved; a workload_env
|
||||
// outage shouldn't drop the shared defaults a deploy already has.
|
||||
return flattenEnvMap(merged)
|
||||
}
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, e := range rows {
|
||||
value := e.Value
|
||||
if e.Encrypted {
|
||||
@@ -37,7 +83,18 @@ func BuildWorkloadEnv(deps Deps, workloadID, sourceName string) []string {
|
||||
}
|
||||
value = decrypted
|
||||
}
|
||||
out = append(out, e.Key+"="+value)
|
||||
merged[e.Key] = value // workload_env overrides shared secrets
|
||||
}
|
||||
return flattenEnvMap(merged)
|
||||
}
|
||||
|
||||
// flattenEnvMap turns a KEY->VALUE map into the KEY=VALUE slice Docker
|
||||
// expects. Order is unspecified (map iteration) — Docker treats env as a
|
||||
// set, and callers that need determinism sort downstream.
|
||||
func flattenEnvMap(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k, v := range m {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
|
||||
return fmt.Errorf("docker build: %w", err)
|
||||
}
|
||||
|
||||
env := plugin.BuildWorkloadEnv(deps, w.ID, "dockerfile source")
|
||||
env := plugin.BuildWorkloadEnv(deps, w.ID, w.GroupID, "dockerfile source")
|
||||
containerPort := strconv.Itoa(cfg.Port)
|
||||
|
||||
settings, err := deps.Store.GetSettings()
|
||||
|
||||
@@ -544,16 +544,20 @@ func buildRegistryAuth(deps plugin.Deps, registryName string) (string, error) {
|
||||
return docker.EncodeRegistryAuth(username, token, reg.URL)
|
||||
}
|
||||
|
||||
// buildEnv flattens cfg.Env plus the workload_env overrides into the
|
||||
// KEY=VALUE list Docker expects. workload_env wins on key conflict and
|
||||
// encrypted rows are decrypted lazily so plaintext never lives in the
|
||||
// store output. If a decrypt fails the value is skipped with a warning —
|
||||
// failing the whole deploy because one rotated key bricked one env entry
|
||||
// would be a worse outcome than the missing variable.
|
||||
// buildEnv flattens shared secrets, cfg.Env, and the workload_env overrides
|
||||
// into the KEY=VALUE list Docker expects. Precedence (lowest→highest):
|
||||
// shared secrets (global, then app) < cfg.Env (image-only defaults) <
|
||||
// workload_env. So the image's baked-in env wins over a shared default, and
|
||||
// the workload's own overrides win over everything. Encrypted rows are
|
||||
// decrypted lazily so plaintext never lives in the store output. If a decrypt
|
||||
// fails the value is skipped with a warning — failing the whole deploy because
|
||||
// one rotated key bricked one env entry would be a worse outcome than the
|
||||
// missing variable.
|
||||
func buildEnv(deps plugin.Deps, w plugin.Workload, cfg Config) []string {
|
||||
merged := make(map[string]string, len(cfg.Env))
|
||||
// Base layer: shared secrets (global, then app overlaying global).
|
||||
merged := plugin.ResolveSharedSecrets(deps, w.GroupID, "image source")
|
||||
for k, v := range cfg.Env {
|
||||
merged[k] = v
|
||||
merged[k] = v // cfg.Env overlays shared secrets
|
||||
}
|
||||
overrides, err := deps.Store.ListWorkloadEnv(w.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -215,7 +215,7 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
|
||||
return fmt.Errorf("build image: %w", err)
|
||||
}
|
||||
|
||||
env := plugin.BuildWorkloadEnv(deps, w.ID, "static source")
|
||||
env := plugin.BuildWorkloadEnv(deps, w.ID, w.GroupID, "static source")
|
||||
|
||||
containerPort := "80"
|
||||
if mode == "deno" {
|
||||
|
||||
@@ -300,7 +300,7 @@ func TestBuildEnv_PlainValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
got := plugin.BuildWorkloadEnv(deps, wid, "static source")
|
||||
got := plugin.BuildWorkloadEnv(deps, wid, "", "static source")
|
||||
gotSet := map[string]bool{}
|
||||
for _, line := range got {
|
||||
gotSet[line] = true
|
||||
@@ -331,7 +331,7 @@ func TestBuildEnv_DecryptsEncryptedValues(t *testing.T) {
|
||||
t.Fatalf("seed encrypted env: %v", err)
|
||||
}
|
||||
|
||||
got := plugin.BuildWorkloadEnv(deps, wid, "static source")
|
||||
got := plugin.BuildWorkloadEnv(deps, wid, "", "static source")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("buildEnv returned %d, want 1: %v", len(got), got)
|
||||
}
|
||||
@@ -363,7 +363,7 @@ func TestBuildEnv_SkipsRowsThatFailToDecrypt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
got := plugin.BuildWorkloadEnv(deps, wid, "static source")
|
||||
got := plugin.BuildWorkloadEnv(deps, wid, "", "static source")
|
||||
// Expect AAA_GOOD and CCC_PLAIN; BBB_BAD silently skipped. Check by
|
||||
// set membership so the assertion doesn't depend on ListWorkloadEnv
|
||||
// preserving any particular order.
|
||||
@@ -393,7 +393,7 @@ func TestBuildEnv_SkipsRowsThatFailToDecrypt(t *testing.T) {
|
||||
|
||||
func TestBuildEnv_EmptyOnMissingWorkload(t *testing.T) {
|
||||
deps, _ := testDeps(t)
|
||||
got := plugin.BuildWorkloadEnv(deps, "wid-no-env", "static source")
|
||||
got := plugin.BuildWorkloadEnv(deps, "wid-no-env", "", "static source")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("buildEnv returned %d, want 0: %v", len(got), got)
|
||||
}
|
||||
@@ -414,7 +414,7 @@ func TestBuildEnv_StoreFailurePropagatesAsEmpty(t *testing.T) {
|
||||
}
|
||||
deps := plugin.Deps{Store: st}
|
||||
|
||||
got := plugin.BuildWorkloadEnv(deps, "anything", "static source")
|
||||
got := plugin.BuildWorkloadEnv(deps, "anything", "", "static source")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("buildEnv returned %d, want 0 on store failure: %v", len(got), got)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user