1// Package appsecrets implements out.SecretWriter on pass for v3 app
2// secrets at gordon/apps/<app>/<service>/<name>. Values never pass
3// through app state, diffs, logs, or backup metadata — only this
4// explicit write path and the deployment-time read path carry them.
5package appsecrets
6
7import (
8 "context"
9 "fmt"
10 "strings"
11 "time"
12
13 "github.com/bnema/zerowrap"
14
15 "github.com/bnema/gordon/internal/domain"
16 "github.com/bnema/gordon/pkg/childenv"
17)
18
19// Store writes app secret values through the pass CLI.
20type Store struct {
21 timeout time.Duration
22 log zerowrap.Logger
23}
24
25// NewStore creates a pass-backed app secret writer.
26func NewStore(log zerowrap.Logger) *Store {
27 return &Store{timeout: 30 * time.Second, log: log}
28}
29
30// SetSecret implements out.SecretWriter.
31func (s *Store) SetSecret(ctx context.Context, path, value string) error {
32 if err := validateAppSecretPath(path); err != nil {
33 return err
34 }
35 if strings.Contains(value, "\n") {
36 return fmt.Errorf("appsecrets: secret value must not contain newlines: %w", domain.ErrInvalidAppSpec)
37 }
38 if len(value) == 0 || len(value) > domain.MaxAppEnvValueLen {
39 return fmt.Errorf("appsecrets: secret value must be 1-%d bytes: %w", domain.MaxAppEnvValueLen, domain.ErrInvalidAppSpec)
40 }
41 timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout)
42 defer cancel()
43 cmd := childenv.CommandContext(timeoutCtx, "pass", "insert", "-m", "-f", path)
44 cmd.Stdin = strings.NewReader(value)
45 output, err := cmd.CombinedOutput()
46 if err != nil {
47 return fmt.Errorf("appsecrets: pass insert failed: %s: %w", strings.TrimSpace(string(output)), err)
48 }
49 s.log.Info().Str("path", path).Msg("appsecrets: secret written")
50 return nil
51}
52
53// DeleteSecret implements out.SecretWriter.
54func (s *Store) DeleteSecret(ctx context.Context, path string) error {
55 if err := validateAppSecretPath(path); err != nil {
56 return err
57 }
58 timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout)
59 defer cancel()
60 cmd := childenv.CommandContext(timeoutCtx, "pass", "rm", "-f", path)
61 output, err := cmd.CombinedOutput()
62 if err != nil {
63 return fmt.Errorf("appsecrets: pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)
64 }
65 s.log.Info().Str("path", path).Msg("appsecrets: secret deleted")
66 return nil
67}
68
69// validateAppSecretPath constrains writes to the app secret namespace.
70func validateAppSecretPath(path string) error {
71 rest, ok := strings.CutPrefix(path, "gordon/apps/")
72 if !ok || rest == "" {
73 return fmt.Errorf("appsecrets: path %q must live under gordon/apps/: %w", path, domain.ErrInvalidAppSpec)
74 }
75 for _, part := range strings.Split(rest, "/") {
76 if part == "" || part == "." || part == ".." || strings.ContainsAny(part, " \t\n\r\\\"'$`*?[]{}()|&;<>!") {
77 return fmt.Errorf("appsecrets: path %q has invalid segment: %w", path, domain.ErrInvalidAppSpec)
78 }
79 }
80 return nil
81}