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:
2026-05-29 15:26:09 +03:00
parent bd7a11d4e7
commit fa6d5bd3ba
11 changed files with 814 additions and 25 deletions
+23
View File
@@ -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
+186
View File
@@ -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
}
+218
View File
@@ -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
}
+19
View File
@@ -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 {