forked from telia-oss/sidecred
-
Notifications
You must be signed in to change notification settings - Fork 0
/
sidecred.go
435 lines (375 loc) · 12.7 KB
/
sidecred.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
package sidecred
import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
"text/template"
"time"
"go.uber.org/zap"
)
// Validatable allows sidecred to ensure the validity of the opaque config values used for processing a request.
type Validatable interface {
Validate() error
}
// Config represents the user-defined configuration that should be passed when processing credentials using sidecred.
type Config interface {
Validatable
// Namespace (e.g. the name of a team, project or similar) to use when processing the credential requests.
Namespace() string
// Stores that can be targeted when mapping credentials.
Stores() []*StoreConfig
// Requests to map credentials to a secret store.
Requests() []*CredentialsMap
}
// CredentialsMap represents a mapping between one or more credential request and a target secret store.
type CredentialsMap struct {
// Store identifies the name or alias of the target secret store.
Store string
// Credentials that will be provisioned and written to the secret store.
Credentials []*CredentialRequest
}
// CredentialRequest is the structure used to request credentials in Sidecred.
type CredentialRequest struct {
// Type identifies the type of credential (and provider) for a request.
Type CredentialType `json:"type"`
// Name is an identifier that can be used for naming resources and
// credentials created by a sidecred.Provider. The exact usage for
// name is up to the individual provider.
Name string `json:"name"`
// Rotation is an override for the default rotation window
// measured in seconds.
// This will aid in cases where we want to be more granular
// for possibly longer running authentications or processes.
RotationWindow *Duration `json:"rotation_window"`
// Config holds the provider configuration for the requested credential.
Config json.RawMessage `json:"config"`
}
// UnmarshalConfig performs a strict JSON unmarshal of the config to the desired struct.
func (r *CredentialRequest) UnmarshalConfig(target interface{}) error {
if err := UnmarshalConfig(r.Config, target); err != nil {
return fmt.Errorf("%s request: unmarshal: %s", r.Type, err)
}
return nil
}
// hasValidCredentials returns true if there are already valid credentials
// for the request. This is determined by the last resource state.
func (r *CredentialRequest) hasValidCredentials(resource *Resource, rotationWindow time.Duration) bool {
if resource.Deposed {
return false
}
if r.Name != resource.ID {
return false
}
if !isEqualConfig(r.Config, resource.Config) {
return false
}
rotation := rotationWindow
if r.RotationWindow != nil {
rotation = r.RotationWindow.Duration
}
if resource.Expiration.Add(-rotation).Before(time.Now()) {
return false
}
return true
}
// UnmarshalConfig is a convenience method for performing a strict unmarshalling of a JSON config into a provided
// structure. If config is empty, no operation is performed by this function.
func UnmarshalConfig(config json.RawMessage, target interface{}) error {
if len(config) == 0 {
return nil
}
d := json.NewDecoder(bytes.NewReader(config))
d.DisallowUnknownFields()
return d.Decode(target)
}
// isEqualConfig is a convenience function for unmarshalling the JSON config
// from the request and resource structures, and performing a logical deep
// equality check instead of a byte equality check. This avoids errors due to
// structural (but non-logical) changes due to (de)serialization.
func isEqualConfig(b1, b2 []byte) bool {
var o1 interface{}
var o2 interface{}
// Allow the configurations to both be empty
if len(b1) == 0 && len(b2) == 0 {
return true
}
err := json.Unmarshal(b1, &o1)
if err != nil {
return false
}
err = json.Unmarshal(b2, &o2)
if err != nil {
return false
}
return reflect.DeepEqual(o1, o2)
}
// Duration implements JSON (un)marshal for time.Duration.
type Duration struct {
time.Duration
}
// MarshalJSON implements json.Marshaler.
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
// UnmarshalJSON implements json.Unmarshaler.
func (d *Duration) UnmarshalJSON(data []byte) error {
s, err := strconv.Unquote(string(data))
if err != nil {
return err
}
v, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("parse duration: %s", err)
}
d.Duration = v
return nil
}
// CredentialType ...
type CredentialType string
// Enumeration of known credential types.
const (
Randomized CredentialType = "random"
AWSSTS CredentialType = "aws:sts"
GithubDeployKey CredentialType = "github:deploy-key"
GithubAccessToken CredentialType = "github:access-token"
ArtifactoryAccessToken CredentialType = "artifactory:access-token"
)
// Provider returns the sidecred.ProviderType for the credential.
func (c CredentialType) Provider() ProviderType {
switch c {
case Randomized:
return Random
case AWSSTS:
return AWS
case GithubDeployKey, GithubAccessToken:
return Github
case ArtifactoryAccessToken:
return Artifactory
}
return ProviderType(c)
}
// Enumeration of known provider types.
const (
Random ProviderType = "random"
AWS ProviderType = "aws"
Github ProviderType = "github"
Artifactory ProviderType = "artifactory"
)
// ProviderType ...
type ProviderType string
// Provider is the interface that has to be satisfied by credential providers.
type Provider interface {
// Type returns the provider type.
Type() ProviderType
// Create the requested credentials. Any sidecred.Resource
// returned will be stored in state and used to determine
// when credentials need to be rotated.
Create(request *CredentialRequest) ([]*Credential, *Metadata, error)
// Destroy the specified resource. This is scheduled if
// a resource in the state has expired. For providers that
// are not stateful this should be a no-op.
Destroy(resource *Resource) error
}
// Metadata allows providers to pass additional information to be
// stored in the sidecred.ResourceState after successfully creating
// credentials.
type Metadata map[string]string
// Credential is a key/value pair returned by a sidecred.Provider.
type Credential struct {
// Name is the identifier for the credential.
Name string `json:"name,omitempty"`
// Value is the credential value (typically a secret).
Value string `json:"-"`
// Description returns a short description of the credential.
Description string `json:"-"`
// Expiration is the time at which the credential will have expired.
Expiration time.Time `json:"expiration"`
}
// Enumeration of known backends.
const (
Inprocess StoreType = "inprocess"
SecretsManager StoreType = "secretsmanager"
SSM StoreType = "ssm"
GithubSecrets StoreType = "github"
GithubDependabotSecrets StoreType = "github:dependabot"
)
// StoreType ...
type StoreType string
// StoreConfig is used to define the secret stores in the configuration for Sidecred.
type StoreConfig struct {
Type StoreType `json:"type"`
Name string `json:"name"`
Config json.RawMessage `json:"config,omitempty"`
}
// Alias returns a name that can be used to identify configured store. defaults to the StoreType.
func (c *StoreConfig) Alias() string {
if c.Name != "" {
return c.Name
}
return string(c.Type)
}
// SecretStore is implemented by store backends for secrets.
type SecretStore interface {
// Type returns the store type.
Type() StoreType
// Write a sidecred.Credential to the secret store.
Write(namespace string, secret *Credential, config json.RawMessage) (string, error)
// Read the specified secret by reference.
Read(path string, config json.RawMessage) (string, bool, error)
// Delete the specified secret. Should not return an error
// if the secret does not exist or has already been deleted.
Delete(path string, config json.RawMessage) error
}
// BuildSecretTemplate is a convenience function for building secret templates.
func BuildSecretTemplate(secretTemplate, namespace, name string) (string, error) {
t, err := template.New("path").Option("missingkey=error").Parse(secretTemplate)
if err != nil {
return "", err
}
var p strings.Builder
if err := t.Execute(&p, struct {
Namespace string
Name string
}{
Namespace: namespace,
Name: name,
}); err != nil {
return "", err
}
return p.String(), nil
}
// New returns a new instance of sidecred.Sidecred with the desired configuration.
func New(providers []Provider, stores []SecretStore, rotationWindow time.Duration, logger *zap.Logger) (*Sidecred, error) {
s := &Sidecred{
providers: make(map[ProviderType]Provider, len(providers)),
stores: make(map[StoreType]SecretStore, len(stores)),
rotationWindow: rotationWindow,
logger: logger,
}
for _, p := range providers {
s.providers[p.Type()] = p
}
for _, t := range stores {
s.stores[t.Type()] = t
}
return s, nil
}
// Sidecred is the underlying structure for the service.
type Sidecred struct {
providers map[ProviderType]Provider
stores map[StoreType]SecretStore
rotationWindow time.Duration
logger *zap.Logger
}
// Process a single sidecred.Request.
func (s *Sidecred) Process(config Config, state *State) error {
log := s.logger.With(zap.String("namespace", config.Namespace()))
log.Info("starting sidecred", zap.Int("requests", len(config.Requests())))
if err := config.Validate(); err != nil {
return fmt.Errorf("invalid config: %s", err)
}
RequestLoop:
for _, request := range config.Requests() {
var storeConfig *StoreConfig
for _, sc := range config.Stores() {
if sc.Alias() == request.Store {
storeConfig = sc
}
}
if storeConfig == nil {
log.Warn("could not find config for store", zap.String("store", request.Store))
continue RequestLoop
}
store, enabled := s.stores[storeConfig.Type]
if !enabled {
log.Warn("store type is not enabled", zap.String("storeType", string(storeConfig.Type)))
continue RequestLoop
}
CredentialLoop:
for _, r := range request.Credentials {
log := log.With(zap.String("type", string(r.Type)), zap.String("store", request.Store))
if r.Name == "" {
log.Warn("missing name in request")
continue CredentialLoop
}
p, ok := s.providers[r.Type.Provider()]
if !ok {
log.Warn("provider not configured")
continue CredentialLoop
}
log.Info("processing request", zap.String("name", r.Name))
for _, resource := range state.GetResourcesByID(r.Type, r.Name, storeConfig.Alias()) {
if r.hasValidCredentials(resource, s.rotationWindow) {
log.Info("found existing credentials", zap.String("name", r.Name))
continue CredentialLoop
}
}
creds, metadata, err := p.Create(r)
if err != nil {
log.Error("failed to provide credentials", zap.Error(err))
continue CredentialLoop
}
if len(creds) == 0 {
log.Error("no credentials returned by provider")
continue CredentialLoop
}
state.AddResource(newResource(r, storeConfig.Alias(), creds[0].Expiration, metadata))
log.Info("created new credentials", zap.Int("count", len(creds)))
for _, c := range creds {
path, err := store.Write(config.Namespace(), c, storeConfig.Config)
if err != nil {
log.Error("store credential", zap.String("name", c.Name), zap.Error(err))
continue
}
state.AddSecret(storeConfig, newSecret(r.Name, path, c.Expiration))
log.Debug("stored credential", zap.String("path", path))
}
log.Info("done processing")
}
}
for _, ps := range state.Providers {
// Reverse loop to handle index changes due to deleting items in the
// underlying array: https://stackoverflow.com/a/29006008
for i := len(ps.Resources) - 1; i >= 0; i-- {
resource := ps.Resources[i]
if resource.InUse && !resource.Deposed {
continue
}
provider, ok := s.providers[ps.Type]
if !ok {
log.Debug("missing provider for expired resource", zap.String("type", string(ps.Type)))
continue
}
log := s.logger.With(
zap.String("type", string(ps.Type)),
zap.String("id", resource.ID),
)
log.Info("destroying expired resource")
if err := provider.Destroy(resource); err != nil {
log.Error("destroy resource", zap.Error(err))
}
state.RemoveResource(resource)
}
}
for _, ss := range state.Stores {
log := log.With(zap.String("storeType", string(ss.StoreConfig.Type)))
orphans := state.ListOrphanedSecrets(ss.StoreConfig)
for i := len(orphans) - 1; i >= 0; i-- {
secret := orphans[i]
store, ok := s.stores[ss.StoreConfig.Type]
if !ok {
log.Debug("missing store for expired secret")
continue
}
log.Info("deleting orphaned secret", zap.String("path", secret.Path))
if err := store.Delete(secret.Path, ss.StoreConfig.Config); err != nil {
log.Error("delete secret", zap.String("path", secret.Path), zap.Error(err))
}
state.RemoveSecret(ss.StoreConfig, secret)
}
}
return nil
}