v0.29.0 55fcc8f

ci_settings.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"encoding/base64"
8	"errors"
9	"fmt"
10	"slices"
11	"strings"
12
13	"github.com/bnema/zerowrap"
14
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports"
17)
18
19// CISettings is the use cases of secrets, variables and environments (ADR 0019,
20// CI-3): the owner side served by the API, and the resolution of what a job
21// sees.
22//
23// Secrets are write-only: no method returns a value but Resolve, which gives
24// them to the planner for the job of a trusted run. Values are sealed with the
25// sealer before they are stored; without one (no ERMITE_SECRETS_KEY) secrets
26// cannot be set and jobs get none, while names can still be listed and deleted.
27// The values are never logged.
28type CISettings struct {
29	meta   ports.RepoMetaStore
30	store  ports.CISettingsStore
31	sealer ports.SecretSealer // nil: no key
32	clock  ports.Clock
33}
34
35var (
36	_ ports.SecretService      = (*CISettings)(nil)
37	_ ports.VariableService    = (*CISettings)(nil)
38	_ ports.EnvironmentService = (*CISettings)(nil)
39	_ ports.CIJobSettings      = (*CISettings)(nil)
40)
41
42// NewCISettings builds the settings use cases. sealer is nil when the server
43// has no key. When there is one, it records the key check beside the secrets or
44// compares it with the recorded one: another key than the one the secrets were
45// sealed with is domain.ErrSecretsKeyMismatch, and the server must not start.
46func NewCISettings(ctx context.Context, meta ports.RepoMetaStore, store ports.CISettingsStore, sealer ports.SecretSealer, clock ports.Clock) (*CISettings, error) {
47	log := zerowrap.FromCtx(ctx)
48	if sealer == nil {
49		log.Warn().Msg("ERMITE_SECRETS_KEY is not set: secrets cannot be set and jobs receive none")
50	} else if err := store.BindSecretsKey(ctx, sealer.KeyCheck()); err != nil {
51		return nil, err
52	}
53	return &CISettings{meta: meta, store: store, sealer: sealer, clock: clock}, nil
54}
55
56// settingsAccess is the access rule of every call: secrets:write, never a token
57// limited to branch prefixes (it never holds the owner's rights, ADR 0027; with
58// secrets it could read back any value by pushing a workflow). The instance
59// scope also needs a token that is not restricted to repositories. A repository
60// that does not exist or that the caller cannot read is not found.
61func (s *CISettings) settingsAccess(ctx context.Context, p domain.Principal, scope domain.SettingsScope) error {
62	if !scope.Valid() {
63		return &domain.InvalidFieldsError{Fields: []domain.FieldViolation{{Field: "scope", Reason: "is required and must name the instance, a repository or one of its environments"}}}
64	}
65	if p.IsAnonymous() {
66		return domain.ErrUnauthenticated
67	}
68	if p.IsBranchLimited() {
69		return fmt.Errorf("%w: a token limited to branch prefixes may not manage secrets and variables", domain.ErrForbidden)
70	}
71	if scope.Kind == domain.ScopeKindInstance {
72		if !p.HasScope(domain.ScopeSecretsWrite) || len(p.Repos()) > 0 {
73			return fmt.Errorf("%w: the instance scope needs the %s scope on a token that is not limited to some repositories", domain.ErrForbidden, domain.ScopeSecretsWrite)
74		}
75		return nil
76	}
77	return s.repoAccess(ctx, p, scope.Repo)
78}
79
80func (s *CISettings) repoAccess(ctx context.Context, p domain.Principal, name domain.RepoName) error {
81	if p.IsAnonymous() {
82		return domain.ErrUnauthenticated
83	}
84	if p.IsBranchLimited() {
85		return fmt.Errorf("%w: a token limited to branch prefixes may not manage secrets and variables", domain.ErrForbidden)
86	}
87	repo, err := lookupRepo(ctx, s.meta, name)
88	if err != nil {
89		return err
90	}
91	if p.Allows(domain.ScopeSecretsWrite, name) {
92		return nil
93	}
94	if !canRead(p, repo) {
95		return domain.ErrNotFound
96	}
97	return fmt.Errorf("%w: this needs the %s scope on %s", domain.ErrForbidden, domain.ScopeSecretsWrite, name)
98}
99
100func logSetting(ctx context.Context, p domain.Principal, what string, scope domain.SettingsScope, name string) {
101	ctxLog(ctx).Info().Str("scope", scope.Key()).Str("name", name).
102		Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg(what) // never the value
103}
104
105// SetSecret creates the secret or replaces its value.
106func (s *CISettings) SetSecret(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name, value string) (domain.Secret, error) {
107	if err := s.settingsAccess(ctx, p, scope); err != nil {
108		return domain.Secret{}, err
109	}
110	var v domain.Violations
111	up, err := domain.ParseSettingName(name)
112	if err != nil {
113		v.AddErr("name", err)
114	}
115	if err := domain.CheckSecretValue(value); err != nil {
116		v.AddErr("value", err)
117	}
118	if err := v.Err(); err != nil {
119		return domain.Secret{}, err
120	}
121	if s.sealer == nil {
122		return domain.Secret{}, fmt.Errorf("%w: the server has no ERMITE_SECRETS_KEY, so it cannot store secrets", domain.ErrPreconditionFailed)
123	}
124	sealed := s.sealer.Seal([]byte(value), domain.SecretAAD(scope, up))
125	sec, err := s.store.PutSecret(ctx, scope, up, sealed, s.clock.Now().UTC())
126	if err != nil {
127		return domain.Secret{}, err
128	}
129	logSetting(ctx, p, "secret set", scope, up)
130	return sec, nil
131}
132
133// ListSecrets lists the names of the secrets of one scope, ordered by name.
134func (s *CISettings) ListSecrets(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string) (ports.SecretPage, error) {
135	if err := s.settingsAccess(ctx, p, scope); err != nil {
136		return ports.SecretPage{}, err
137	}
138	n, err := pageSize(size)
139	if err != nil {
140		return ports.SecretPage{}, err
141	}
142	after, err := decodeNameCursor(token, secretPageTokenPrefix)
143	if err != nil {
144		return ports.SecretPage{}, err
145	}
146	sealed, err := s.store.ListSealedSecrets(ctx, scope)
147	if err != nil {
148		return ports.SecretPage{}, err
149	}
150	all := make([]domain.Secret, 0, len(sealed))
151	for _, x := range sealed {
152		all = append(all, domain.Secret{Name: x.Name, UpdatedAt: x.UpdatedAt}) // the sealed value stops here
153	}
154	slices.SortFunc(all, func(a, b domain.Secret) int { return strings.Compare(a.Name, b.Name) })
155	var page ports.SecretPage
156	page.Secrets, page.NextPageToken = pageAfter(all, n,
157		func(x domain.Secret) bool { return after == "" || x.Name > after },
158		func(x domain.Secret) string { return encodeNameCursor(secretPageTokenPrefix, x.Name) })
159	return page, nil
160}
161
162// DeleteSecret deletes a secret.
163func (s *CISettings) DeleteSecret(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) error {
164	if err := s.settingsAccess(ctx, p, scope); err != nil {
165		return err
166	}
167	up, err := domain.ParseSettingName(name)
168	if err != nil {
169		return notFoundOrInvalid(err)
170	}
171	if err := s.store.DeleteSecret(ctx, scope, up); err != nil {
172		return err
173	}
174	logSetting(ctx, p, "secret deleted", scope, up)
175	return nil
176}
177
178// notFoundOrInvalid maps a name that cannot exist to not found on the calls
179// that name an existing thing (delete, get): a name that breaks the rules is a
180// bad request on a write, but nothing is stored under it.
181func notFoundOrInvalid(err error) error {
182	var v domain.Violations
183	v.AddErr("name", err)
184	return v.Err()
185}
186
187// SetVariable creates the variable or replaces its value.
188func (s *CISettings) SetVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name, value string) (domain.Variable, error) {
189	if err := s.settingsAccess(ctx, p, scope); err != nil {
190		return domain.Variable{}, err
191	}
192	var v domain.Violations
193	up, err := domain.ParseSettingName(name)
194	if err != nil {
195		v.AddErr("name", err)
196	}
197	if err := domain.CheckVariableValue(value); err != nil {
198		v.AddErr("value", err)
199	}
200	if err := v.Err(); err != nil {
201		return domain.Variable{}, err
202	}
203	out, err := s.store.PutVariable(ctx, scope, up, value, s.clock.Now().UTC())
204	if err != nil {
205		return domain.Variable{}, err
206	}
207	logSetting(ctx, p, "variable set", scope, up)
208	return out, nil
209}
210
211// GetVariable returns one variable of one scope.
212func (s *CISettings) GetVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) (domain.Variable, error) {
213	if err := s.settingsAccess(ctx, p, scope); err != nil {
214		return domain.Variable{}, err
215	}
216	up, err := domain.ParseSettingName(name)
217	if err != nil {
218		return domain.Variable{}, notFoundOrInvalid(err)
219	}
220	return s.store.GetVariable(ctx, scope, up)
221}
222
223// ListVariables lists the variables of one scope, ordered by name.
224func (s *CISettings) ListVariables(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string) (ports.VariablePage, error) {
225	if err := s.settingsAccess(ctx, p, scope); err != nil {
226		return ports.VariablePage{}, err
227	}
228	n, err := pageSize(size)
229	if err != nil {
230		return ports.VariablePage{}, err
231	}
232	after, err := decodeNameCursor(token, variablePageTokenPrefix)
233	if err != nil {
234		return ports.VariablePage{}, err
235	}
236	all, err := s.store.ListVariables(ctx, scope)
237	if err != nil {
238		return ports.VariablePage{}, err
239	}
240	slices.SortFunc(all, func(a, b domain.Variable) int { return strings.Compare(a.Name, b.Name) })
241	var page ports.VariablePage
242	page.Variables, page.NextPageToken = pageAfter(all, n,
243		func(x domain.Variable) bool { return after == "" || x.Name > after },
244		func(x domain.Variable) string { return encodeNameCursor(variablePageTokenPrefix, x.Name) })
245	return page, nil
246}
247
248// DeleteVariable deletes a variable.
249func (s *CISettings) DeleteVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) error {
250	if err := s.settingsAccess(ctx, p, scope); err != nil {
251		return err
252	}
253	up, err := domain.ParseSettingName(name)
254	if err != nil {
255		return notFoundOrInvalid(err)
256	}
257	if err := s.store.DeleteVariable(ctx, scope, up); err != nil {
258		return err
259	}
260	logSetting(ctx, p, "variable deleted", scope, up)
261	return nil
262}
263
264// CreateEnvironment creates an environment; one that exists (without regard to
265// case) is returned unchanged.
266func (s *CISettings) CreateEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) (domain.Environment, error) {
267	if err := s.repoAccess(ctx, p, repo); err != nil {
268		return domain.Environment{}, err
269	}
270	env, err := domain.ParseEnvironmentName(name)
271	if err != nil {
272		var v domain.Violations
273		v.AddErr("name", err)
274		return domain.Environment{}, v.Err()
275	}
276	out, err := s.store.CreateEnvironment(ctx, repo, env, s.clock.Now().UTC())
277	if err != nil {
278		return domain.Environment{}, err
279	}
280	logSetting(ctx, p, "environment created", domain.EnvironmentScope(repo, env), "")
281	return out, nil
282}
283
284// ListEnvironments lists the environments of a repository, ordered by name.
285func (s *CISettings) ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (ports.EnvironmentPage, error) {
286	if err := s.repoAccess(ctx, p, repo); err != nil {
287		return ports.EnvironmentPage{}, err
288	}
289	n, err := pageSize(size)
290	if err != nil {
291		return ports.EnvironmentPage{}, err
292	}
293	after, err := decodeNameCursor(token, environmentPageTokenPrefix)
294	if err != nil {
295		return ports.EnvironmentPage{}, err
296	}
297	all, err := s.store.ListEnvironments(ctx, repo)
298	if err != nil {
299		return ports.EnvironmentPage{}, err
300	}
301	key := func(e domain.Environment) string { return domain.EnvironmentKey(e.Name) }
302	slices.SortFunc(all, func(a, b domain.Environment) int { return strings.Compare(key(a), key(b)) })
303	var page ports.EnvironmentPage
304	page.Environments, page.NextPageToken = pageAfter(all, n,
305		func(x domain.Environment) bool { return after == "" || key(x) > after },
306		func(x domain.Environment) string { return encodeNameCursor(environmentPageTokenPrefix, key(x)) })
307	return page, nil
308}
309
310// DeleteEnvironment deletes an environment with its secrets and variables.
311func (s *CISettings) DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error {
312	if err := s.repoAccess(ctx, p, repo); err != nil {
313		return err
314	}
315	env, err := domain.ParseEnvironmentName(name)
316	if err != nil {
317		return domain.ErrNotFound
318	}
319	if err := s.store.DeleteEnvironment(ctx, repo, env); err != nil {
320		return err
321	}
322	logSetting(ctx, p, "environment deleted", domain.EnvironmentScope(repo, env), "")
323	return nil
324}
325
326// Page tokens name the last item of the previous page.
327const (
328	secretPageTokenPrefix      = "sn1:"
329	variablePageTokenPrefix    = "vn1:"
330	environmentPageTokenPrefix = "en1:"
331)
332
333func encodeNameCursor(prefix, last string) string {
334	return base64.RawURLEncoding.EncodeToString([]byte(prefix + last))
335}
336
337// decodeNameCursor returns "" for the empty token.
338func decodeNameCursor(tok, prefix string) (string, error) {
339	if tok == "" {
340		return "", nil
341	}
342	raw, ok := decodeToken(tok, prefix)
343	if !ok || raw == "" {
344		return "", fmt.Errorf("%w: settings", domain.ErrInvalidPageToken)
345	}
346	return raw, nil
347}
348
349// Resolve gives the variables, and the secrets when wanted, that a job sees:
350// the instance's, overridden by the repository's, overridden by the
351// environment's. A job that names an environment creates it on first use.
352// Secrets are opened only for the names asked (or all of them), and a secret
353// that does not open is left out and logged by name, never by value.
354func (s *CISettings) Resolve(ctx context.Context, req ports.JobSettingsRequest) (ports.JobSettings, error) {
355	var out ports.JobSettings
356	scopes := []domain.SettingsScope{domain.InstanceScope(), domain.RepoScope(req.Repo)}
357	if req.Environment != "" {
358		if req.CreateEnvironment {
359			if _, err := s.store.CreateEnvironment(ctx, req.Repo, req.Environment, s.clock.Now().UTC()); err != nil && !errors.Is(err, domain.ErrNotFound) {
360				return out, err
361			}
362		}
363		scopes = append(scopes, domain.EnvironmentScope(req.Repo, req.Environment))
364	}
365	out.Vars = map[string]string{}
366	for _, scope := range scopes {
367		vars, err := s.store.ListVariables(ctx, scope)
368		if errors.Is(err, domain.ErrNotFound) {
369			continue // no such repository or environment (yet): nothing to add
370		}
371		if err != nil {
372			return ports.JobSettings{}, err
373		}
374		for _, v := range vars {
375			out.Vars[v.Name] = v.Value
376		}
377	}
378	if !req.WantSecrets || (!req.AllSecrets && len(req.SecretNames) == 0) {
379		return out, nil
380	}
381	if s.sealer == nil {
382		out.SecretsOff = true
383		return out, nil
384	}
385	names := make([]string, 0, len(req.SecretNames))
386	for _, n := range req.SecretNames {
387		names = append(names, strings.ToUpper(n))
388	}
389	slices.Sort(names)
390	names = slices.Compact(names)
391	out.Secrets = map[string]string{}
392	log := zerowrap.FromCtx(ctx)
393	// Scopes go from the lowest precedence to the highest, so a later value
394	// replaces an earlier one. A value that does not open is withheld, and
395	// takes away what a lower scope gave: the override must never fall back to
396	// the value it was meant to replace.
397	admit := func(scope domain.SettingsScope, x ports.SealedSecret) {
398		plain, err := s.sealer.Open(x.Sealed, domain.SecretAAD(scope, x.Name))
399		if err != nil {
400			delete(out.Secrets, x.Name)
401			log.Error().Str("scope", scope.Key()).Str("name", x.Name).Msg("a stored secret does not open: it is left out of the job")
402			return
403		}
404		out.Secrets[x.Name] = string(plain)
405	}
406	for _, scope := range scopes {
407		if req.AllSecrets {
408			sealed, err := s.store.ListSealedSecrets(ctx, scope)
409			if errors.Is(err, domain.ErrNotFound) {
410				continue
411			}
412			if err != nil {
413				return ports.JobSettings{}, err
414			}
415			for _, x := range sealed {
416				admit(scope, x)
417			}
418			continue
419		}
420		for _, n := range names { // point reads: only the secrets the job names
421			x, err := s.store.GetSealedSecret(ctx, scope, n)
422			if errors.Is(err, domain.ErrNotFound) {
423				continue
424			}
425			if err != nil {
426				return ports.JobSettings{}, err
427			}
428			admit(scope, x)
429		}
430	}
431	return out, nil
432}

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 2

Tags 44

↑↓ moveenter openesc close