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
@@ -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()
+12 -8
View File
@@ -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)
}