v2.30.0-pre 7341e0e

service.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close