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()