fa6d5bd3ba
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.
187 lines
6.1 KiB
Go
187 lines
6.1 KiB
Go
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
|
|
}
|