1// Package secrets implements the secret management use case.
2package secrets
3
4import (
5 "context"
6 "errors"
7 "sort"
8 "strings"
9
10 "github.com/bnema/zerowrap"
11
12 "github.com/bnema/gordon/internal/boundaries/out"
13 "github.com/bnema/gordon/internal/domain"
14)
15
16// Domain validation errors.
17var (
18 ErrDomainEmpty = errors.New("domain cannot be empty")
19 ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters")
20 ErrDomainPathTraversal = errors.New("domain contains path traversal sequence")
21 ErrDomainInvalidChars = errors.New("domain contains invalid characters")
22)
23
24// Service implements the SecretService interface.
25type Service struct {
26 store out.DomainSecretStore
27 log zerowrap.Logger
28 eventBus out.EventPublisher
29}
30
31// NewService creates a new secrets service.
32func NewService(store out.DomainSecretStore, log zerowrap.Logger, eventBus out.EventPublisher) *Service {
33 return &Service{
34 store: store,
35 log: log,
36 eventBus: eventBus,
37 }
38}
39
40// publishSecretsChanged publishes a secrets.changed event.
41// Failures are logged but do not fail the mutation - secret storage
42// is the source of truth; the event is a best-effort notification.
43func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operation string, keys []string) {
44 if s.eventBus == nil {
45 return
46 }
47 log := zerowrap.FromCtx(ctx)
48 payload := domain.SecretsChangedPayload{
49 Domain: domainName,
50 Operation: operation,
51 Keys: keys,
52 }
53 if err := s.eventBus.Publish(domain.EventSecretsChanged, payload); err != nil {
54 log.Warn().Err(err).Str("domain", domainName).Msg("failed to publish secrets.changed event")
55 }
56}
57
58// sortedKeys returns the keys of a map in sorted order.
59func sortedKeys(m map[string]string) []string {
60 keys := make([]string, 0, len(m))
61 for k := range m {
62 keys = append(keys, k)
63 }
64 sort.Strings(keys)
65 return keys
66}
67
68// ListKeys returns the list of secret keys for a domain (not values).
69func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) {
70 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
71 zerowrap.FieldLayer: "usecase",
72 zerowrap.FieldUseCase: "ListKeys",
73 "domain": domain,
74 })
75 log := zerowrap.FromCtx(ctx)
76
77 if err := ValidateDomain(domain); err != nil {
78 log.Warn().Err(err).Msg("domain validation failed")
79 return nil, err
80 }
81
82 keys, err := s.store.ListKeys(domain)
83 if err != nil {
84 log.Error().Err(err).Msg("failed to list secret keys")
85 return nil, err
86 }
87
88 log.Debug().Int("count", len(keys)).Msg("listed secret keys")
89 return keys, nil
90}
91
92// GetAll returns all secrets for a domain as a key-value map.
93func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) {
94 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
95 zerowrap.FieldLayer: "usecase",
96 zerowrap.FieldUseCase: "GetAll",
97 "domain": domain,
98 })
99 log := zerowrap.FromCtx(ctx)
100
101 if err := ValidateDomain(domain); err != nil {
102 log.Warn().Err(err).Msg("domain validation failed")
103 return nil, err
104 }
105
106 secrets, err := s.store.GetAll(domain)
107 if err != nil {
108 log.Error().Err(err).Msg("failed to get secrets")
109 return nil, err
110 }
111
112 log.Debug().Int("count", len(secrets)).Msg("retrieved secrets")
113 return secrets, nil
114}
115
116// Set sets or updates multiple secrets for a domain, merging with existing.
117func (s *Service) Set(ctx context.Context, domain string, secrets map[string]string) error {
118 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
119 zerowrap.FieldLayer: "usecase",
120 zerowrap.FieldUseCase: "Set",
121 "domain": domain,
122 })
123 log := zerowrap.FromCtx(ctx)
124
125 if err := ValidateDomain(domain); err != nil {
126 log.Warn().Err(err).Msg("domain validation failed")
127 return err
128 }
129
130 if err := s.store.Set(domain, secrets); err != nil {
131 log.Error().Err(err).Msg("failed to set secrets")
132 return err
133 }
134
135 s.publishSecretsChanged(ctx, domain, "set", sortedKeys(secrets))
136
137 log.Info().Int("count", len(secrets)).Msg("secrets set")
138 return nil
139}
140
141// Delete removes a specific secret key from a domain.
142func (s *Service) Delete(ctx context.Context, domain, key string) error {
143 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
144 zerowrap.FieldLayer: "usecase",
145 zerowrap.FieldUseCase: "Delete",
146 "domain": domain,
147 "key": key,
148 })
149 log := zerowrap.FromCtx(ctx)
150
151 if err := ValidateDomain(domain); err != nil {
152 log.Warn().Err(err).Msg("domain validation failed")
153 return err
154 }
155
156 if err := s.store.Delete(domain, key); err != nil {
157 log.Error().Err(err).Msg("failed to delete secret")
158 return err
159 }
160
161 s.publishSecretsChanged(ctx, domain, "delete", []string{key})
162
163 log.Info().Msg("secret deleted")
164 return nil
165}
166
167// ValidateDomain validates that a domain is safe to use for secret storage.
168// This prevents path traversal attacks and ensures the domain is valid.
169func ValidateDomain(domainName string) error {
170 // Check for empty domain
171 if domainName == "" {
172 return ErrDomainEmpty
173 }
174
175 // Check domain length (max DNS name length is 253)
176 if len(domainName) > 253 {
177 return ErrDomainTooLong
178 }
179
180 // Check for path traversal attempts
181 if strings.Contains(domainName, "..") {
182 return ErrDomainPathTraversal
183 }
184
185 // Check for null bytes (can cause issues in file paths)
186 if strings.ContainsRune(domainName, '\x00') {
187 return ErrDomainInvalidChars
188 }
189
190 // Reuse the env-file sanitizer so validation matches the on-disk naming
191 // scheme and rejects ambiguous names that would collide after sanitization.
192 if _, err := domain.SanitizeDomainForEnvFile(domainName); err != nil {
193 return ErrDomainInvalidChars
194 }
195
196 return nil
197}