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 }