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}