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:
@@ -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