v3.0.0-alpha.1 6fdddb8

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)
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}

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