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