From fa6d5bd3baefbdc24e520b8976ab43aeb26787c5 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 29 May 2026 15:26:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(secrets):=20scoped=20shared=20secrets=20?= =?UTF-8?q?=E2=80=94=20backend=20+=20API=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/api/router.go | 10 + internal/api/shared_secrets.go | 272 ++++++++++++++++++ internal/store/models.go | 23 ++ internal/store/shared_secrets.go | 186 ++++++++++++ internal/store/shared_secrets_test.go | 218 ++++++++++++++ internal/store/store.go | 19 ++ internal/workload/plugin/env.go | 77 ++++- .../plugin/source/dockerfile/deploy.go | 2 +- .../workload/plugin/source/image/image.go | 20 +- .../workload/plugin/source/static/deploy.go | 2 +- .../source/static/state_integration_test.go | 10 +- 11 files changed, 814 insertions(+), 25 deletions(-) create mode 100644 internal/api/shared_secrets.go create mode 100644 internal/store/shared_secrets.go create mode 100644 internal/store/shared_secrets_test.go diff --git a/internal/api/router.go b/internal/api/router.go index 158c491..21d2437 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/api/shared_secrets.go b/internal/api/shared_secrets.go new file mode 100644 index 0000000..b25590d --- /dev/null +++ b/internal/api/shared_secrets.go @@ -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'" + } +} diff --git a/internal/store/models.go b/internal/store/models.go index 56a243d..1f462bb 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -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 diff --git a/internal/store/shared_secrets.go b/internal/store/shared_secrets.go new file mode 100644 index 0000000..e1b2417 --- /dev/null +++ b/internal/store/shared_secrets.go @@ -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 +} diff --git a/internal/store/shared_secrets_test.go b/internal/store/shared_secrets_test.go new file mode 100644 index 0000000..f7bc3c5 --- /dev/null +++ b/internal/store/shared_secrets_test.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go index 32a79dd..8690053 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 { diff --git a/internal/workload/plugin/env.go b/internal/workload/plugin/env.go index d60aebe..175bcec 100644 --- a/internal/workload/plugin/env.go +++ b/internal/workload/plugin/env.go @@ -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 } diff --git a/internal/workload/plugin/source/dockerfile/deploy.go b/internal/workload/plugin/source/dockerfile/deploy.go index fc84afa..4b01505 100644 --- a/internal/workload/plugin/source/dockerfile/deploy.go +++ b/internal/workload/plugin/source/dockerfile/deploy.go @@ -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() diff --git a/internal/workload/plugin/source/image/image.go b/internal/workload/plugin/source/image/image.go index af8af8f..4061718 100644 --- a/internal/workload/plugin/source/image/image.go +++ b/internal/workload/plugin/source/image/image.go @@ -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 { diff --git a/internal/workload/plugin/source/static/deploy.go b/internal/workload/plugin/source/static/deploy.go index 7fb4111..44ed877 100644 --- a/internal/workload/plugin/source/static/deploy.go +++ b/internal/workload/plugin/source/static/deploy.go @@ -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" { diff --git a/internal/workload/plugin/source/static/state_integration_test.go b/internal/workload/plugin/source/static/state_integration_test.go index 86eb2b8..f85eee8 100644 --- a/internal/workload/plugin/source/static/state_integration_test.go +++ b/internal/workload/plugin/source/static/state_integration_test.go @@ -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) }