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 ErrServiceEmpty = errors.New("service name cannot be empty")
23 ErrInvalidServiceName = errors.New("invalid service name: must start with a letter, contain only lowercase letters, numbers, and hyphens, be at most 63 characters, and not end with a hyphen")
24)
25
26// Service implements the SecretService interface.
27type Service struct {
28 store out.DomainSecretStore
29 log zerowrap.Logger
30 eventBus out.EventPublisher
31}
32
33// NewService creates a new secrets service.
34func NewService(store out.DomainSecretStore, log zerowrap.Logger, eventBus out.EventPublisher) *Service {
35 return &Service{
36 store: store,
37 log: log,
38 eventBus: eventBus,
39 }
40}
41
42// publishSecretsChanged publishes a secrets.changed event.
43// Failures are logged but do not fail the mutation - secret storage
44// is the source of truth; the event is a best-effort notification.
45func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operation string, keys []string) {
46 if s.eventBus == nil {
47 return
48 }
49 log := zerowrap.FromCtx(ctx)
50 payload := domain.SecretsChangedPayload{
51 Domain: domainName,
52 Operation: operation,
53 Keys: keys,
54 }
55 if err := s.eventBus.Publish(domain.EventSecretsChanged, payload); err != nil {
56 log.Warn().Err(err).Str("domain", domainName).Msg("failed to publish secrets.changed event")
57 }
58}
59
60// sortedKeys returns the keys of a map in sorted order.
61func sortedKeys(m map[string]string) []string {
62 keys := make([]string, 0, len(m))
63 for k := range m {
64 keys = append(keys, k)
65 }
66 sort.Strings(keys)
67 return keys
68}
69
70// ListKeys returns the list of secret keys for a domain (not values).
71func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) {
72 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
73 zerowrap.FieldLayer: "usecase",
74 zerowrap.FieldUseCase: "ListKeys",
75 "domain": domain,
76 })
77 log := zerowrap.FromCtx(ctx)
78
79 if err := ValidateDomain(domain); err != nil {
80 log.Warn().Err(err).Msg("domain validation failed")
81 return nil, err
82 }
83
84 keys, err := s.store.ListKeys(domain)
85 if err != nil {
86 log.Error().Err(err).Msg("failed to list secret keys")
87 return nil, err
88 }
89
90 log.Debug().Int("count", len(keys)).Msg("listed secret keys")
91 return keys, nil
92}
93
94// ListKeysWithAttachments returns the list of secret keys for a domain
95// along with any attachment secrets for containers associated with the domain.
96func (s *Service) ListKeysWithAttachments(ctx context.Context, domain string) ([]string, []out.AttachmentSecrets, error) {
97 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
98 zerowrap.FieldLayer: "usecase",
99 zerowrap.FieldUseCase: "ListKeysWithAttachments",
100 "domain": domain,
101 })
102 log := zerowrap.FromCtx(ctx)
103
104 if err := ValidateDomain(domain); err != nil {
105 log.Warn().Err(err).Msg("domain validation failed")
106 return nil, nil, err
107 }
108
109 // Get domain secrets
110 keys, err := s.store.ListKeys(domain)
111 if err != nil {
112 log.Error().Err(err).Msg("failed to list secret keys")
113 return nil, nil, err
114 }
115
116 // Get attachment secrets
117 attachments, err := s.store.ListAttachmentKeys(domain)
118 if err != nil {
119 log.Warn().Err(err).Msg("failed to list attachment secrets, continuing without them")
120 attachments = nil // Don't fail, just return empty attachments
121 }
122
123 log.Debug().
124 Int("domain_keys", len(keys)).
125 Int("attachments", len(attachments)).
126 Msg("listed secret keys with attachments")
127
128 return keys, attachments, nil
129}
130
131// GetAll returns all secrets for a domain as a key-value map.
132func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) {
133 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
134 zerowrap.FieldLayer: "usecase",
135 zerowrap.FieldUseCase: "GetAll",
136 "domain": domain,
137 })
138 log := zerowrap.FromCtx(ctx)
139
140 if err := ValidateDomain(domain); err != nil {
141 log.Warn().Err(err).Msg("domain validation failed")
142 return nil, err
143 }
144
145 secrets, err := s.store.GetAll(domain)
146 if err != nil {
147 log.Error().Err(err).Msg("failed to get secrets")
148 return nil, err
149 }
150
151 log.Debug().Int("count", len(secrets)).Msg("retrieved secrets")
152 return secrets, nil
153}
154
155// Set sets or updates multiple secrets for a domain, merging with existing.
156func (s *Service) Set(ctx context.Context, domain string, secrets map[string]string) error {
157 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
158 zerowrap.FieldLayer: "usecase",
159 zerowrap.FieldUseCase: "Set",
160 "domain": domain,
161 })
162 log := zerowrap.FromCtx(ctx)
163
164 if err := ValidateDomain(domain); err != nil {
165 log.Warn().Err(err).Msg("domain validation failed")
166 return err
167 }
168
169 if err := s.store.Set(domain, secrets); err != nil {
170 log.Error().Err(err).Msg("failed to set secrets")
171 return err
172 }
173
174 s.publishSecretsChanged(ctx, domain, "set", sortedKeys(secrets))
175
176 log.Info().Int("count", len(secrets)).Msg("secrets set")
177 return nil
178}
179
180// Delete removes a specific secret key from a domain.
181func (s *Service) Delete(ctx context.Context, domain, key string) error {
182 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
183 zerowrap.FieldLayer: "usecase",
184 zerowrap.FieldUseCase: "Delete",
185 "domain": domain,
186 "key": key,
187 })
188 log := zerowrap.FromCtx(ctx)
189
190 if err := ValidateDomain(domain); err != nil {
191 log.Warn().Err(err).Msg("domain validation failed")
192 return err
193 }
194
195 if err := s.store.Delete(domain, key); err != nil {
196 log.Error().Err(err).Msg("failed to delete secret")
197 return err
198 }
199
200 s.publishSecretsChanged(ctx, domain, "delete", []string{key})
201
202 log.Info().Msg("secret deleted")
203 return nil
204}
205
206// SetAttachment sets or updates multiple secrets for an attachment container.
207func (s *Service) SetAttachment(ctx context.Context, domainName, service string, secrets map[string]string) error {
208 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
209 zerowrap.FieldLayer: "usecase",
210 zerowrap.FieldUseCase: "SetAttachment",
211 "domain": domainName,
212 "service": service,
213 })
214 log := zerowrap.FromCtx(ctx)
215
216 if err := ValidateDomain(domainName); err != nil {
217 log.Warn().Err(err).Msg("domain validation failed")
218 return err
219 }
220
221 if err := validateServiceName(service); err != nil {
222 log.Warn().Err(err).Msg("service name validation failed")
223 return err
224 }
225
226 containerName := resolveContainerName(domainName, service)
227
228 if err := s.store.SetAttachment(containerName, secrets); err != nil {
229 log.Error().Err(err).Msg("failed to set attachment secrets")
230 return err
231 }
232
233 s.publishSecretsChanged(ctx, domainName, "set", sortedKeys(secrets))
234
235 log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set")
236 return nil
237}
238
239// DeleteAttachment removes a specific secret key from an attachment container.
240func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key string) error {
241 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
242 zerowrap.FieldLayer: "usecase",
243 zerowrap.FieldUseCase: "DeleteAttachment",
244 "domain": domainName,
245 "service": service,
246 "key": key,
247 })
248 log := zerowrap.FromCtx(ctx)
249
250 if err := ValidateDomain(domainName); err != nil {
251 log.Warn().Err(err).Msg("domain validation failed")
252 return err
253 }
254
255 if err := validateServiceName(service); err != nil {
256 log.Warn().Err(err).Msg("service name validation failed")
257 return err
258 }
259
260 containerName := resolveContainerName(domainName, service)
261
262 if err := s.store.DeleteAttachment(containerName, key); err != nil {
263 log.Error().Err(err).Msg("failed to delete attachment secret")
264 return err
265 }
266
267 s.publishSecretsChanged(ctx, domainName, "delete", []string{key})
268
269 log.Info().Str("container", containerName).Msg("attachment secret deleted")
270 return nil
271}
272
273// validateServiceName checks that a service name is safe and follows Docker-style conventions.
274func validateServiceName(service string) error {
275 if service == "" {
276 return ErrServiceEmpty
277 }
278 // DNS labels are limited to 63 characters (RFC 1035)
279 if len(service) > 63 {
280 return ErrInvalidServiceName
281 }
282 // Docker-style: lowercase letter start, then lowercase alphanumeric + hyphens
283 for i, c := range service {
284 if i == 0 {
285 if c < 'a' || c > 'z' {
286 return ErrInvalidServiceName
287 }
288 continue
289 }
290 if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
291 return ErrInvalidServiceName
292 }
293 }
294 // Trailing hyphen is not allowed in DNS labels
295 if service[len(service)-1] == '-' {
296 return ErrInvalidServiceName
297 }
298 return nil
299}
300
301// resolveContainerName builds the container name from a domain and service.
302// Ensures the total name does not exceed Docker's 255-character limit.
303func resolveContainerName(domainName, service string) string {
304 sanitized := domain.SanitizeDomainForContainer(domainName)
305 name := "gordon-" + sanitized + "-" + service
306
307 // Docker container names are limited to 255 characters
308 if len(name) > 255 {
309 // Truncate the domain part to fit, keeping room for "gordon-", "-", and service name
310 maxDomainLen := 255 - 7 - 1 - len(service) // 7 for "gordon-", 1 for "-"
311 if maxDomainLen > 0 {
312 name = "gordon-" + sanitized[:maxDomainLen] + "-" + service
313 }
314 }
315 return name
316}
317
318// ValidateDomain validates that a domain is safe to use for secret storage.
319// This prevents path traversal attacks and ensures the domain is valid.
320func ValidateDomain(domainName string) error {
321 // Check for empty domain
322 if domainName == "" {
323 return ErrDomainEmpty
324 }
325
326 // Check domain length (max DNS name length is 253)
327 if len(domainName) > 253 {
328 return ErrDomainTooLong
329 }
330
331 // Check for path traversal attempts
332 if strings.Contains(domainName, "..") {
333 return ErrDomainPathTraversal
334 }
335
336 // Check for null bytes (can cause issues in file paths)
337 if strings.ContainsRune(domainName, '\x00') {
338 return ErrDomainInvalidChars
339 }
340
341 // Reuse the env-file sanitizer so validation matches the on-disk naming
342 // scheme and rejects ambiguous names that would collide after sanitization.
343 if _, err := domain.SanitizeDomainForEnvFile(domainName); err != nil {
344 return ErrDomainInvalidChars
345 }
346
347 return nil
348}