Repository navigation
Expand file tree
/
Copy pathcontent_writer.go
More file actions
199 lines (170 loc) · 6.05 KB
/
Copy pathcontent_writer.go
File metadata and controls
199 lines (170 loc) · 6.05 KB
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
// SPDX-License-Identifier: Apache-2.0
package git
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"sync"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"github.com/ConfigButler/gitops-reverser/internal/sanitize"
"github.com/ConfigButler/gitops-reverser/internal/telemetry"
"github.com/ConfigButler/gitops-reverser/internal/types"
)
type resourceMeta struct {
Identifier types.ResourceIdentifier
UID string
ResourceVersion string
Generation int64
}
type sensitiveMarker struct {
UID string
ResourceVersion string
Generation int64
}
type contentWriter struct {
encryptor Encryptor
sensitiveResources types.SensitiveResourcePolicy
// encryptionScope partitions encryption cache entries so cached bytes never cross
// repo/path/key boundaries (e.g. different GitTargets or rotated identities).
encryptionScope string
mu sync.RWMutex
encryptedCache map[string][]byte
markers map[string]sensitiveMarker
}
type eventContentWriter interface {
buildContentForWrite(ctx context.Context, target placementTarget, event Event) ([]byte, error)
filePathForIdentifier(id types.ResourceIdentifier) string
isSensitiveIdentifier(id types.ResourceIdentifier) bool
}
func newContentWriter(sensitiveResources types.SensitiveResourcePolicy) *contentWriter {
return &contentWriter{
sensitiveResources: sensitiveResources,
encryptedCache: make(map[string][]byte),
markers: make(map[string]sensitiveMarker),
}
}
func (w *contentWriter) setEncryptor(encryptor Encryptor, scope string) {
w.mu.Lock()
defer w.mu.Unlock()
w.encryptor = encryptor
w.encryptionScope = scope
}
// buildContentForWrite renders event content to stable ordered YAML and applies
// sensitive-resource encryption when configured. target is the GitTarget the write is for, which
// secret_encryptions_total is labelled by.
func (w *contentWriter) buildContentForWrite(
ctx context.Context, target placementTarget, event Event,
) ([]byte, error) {
content, err := sanitize.MarshalToOrderedYAML(event.Object)
if err != nil {
return nil, fmt.Errorf("failed to marshal object to YAML: %w", err)
}
if !w.isSensitiveIdentifier(event.Identifier) {
return content, nil
}
return w.encryptSensitiveContent(ctx, target, event, content)
}
func (w *contentWriter) filePathForIdentifier(id types.ResourceIdentifier) string {
return generateFilePath(id, w.sensitiveResources)
}
func (w *contentWriter) isSensitiveIdentifier(id types.ResourceIdentifier) bool {
return w.sensitiveResources.IsSensitive(id.Group, id.Resource)
}
func (w *contentWriter) encryptSensitiveContent(
ctx context.Context, target placementTarget, event Event, plain []byte,
) ([]byte, error) {
meta := buildResourceMeta(event)
identityKey := meta.Identifier.Key()
digest := sha256.Sum256(plain)
currentMarker := sensitiveMarker{
UID: meta.UID,
ResourceVersion: meta.ResourceVersion,
Generation: meta.Generation,
}
w.mu.RLock()
encryptor := w.encryptor
scope := w.encryptionScope
w.mu.RUnlock()
scopedIdentityKey := identityKey
if strings.TrimSpace(scope) != "" {
scopedIdentityKey = fmt.Sprintf("%s:%s", scope, identityKey)
}
cacheKey := fmt.Sprintf("%s:%x", scopedIdentityKey, digest)
w.mu.RLock()
cached, ok := w.cachedEncryptedContent(ctx, target, scopedIdentityKey, cacheKey, currentMarker)
w.mu.RUnlock()
if ok {
return cached, nil
}
if encryptor == nil {
return nil, errors.New("secret encryption is required but no encryptor is configured")
}
encrypted, err := encryptor.Encrypt(ctx, plain, ResourceMeta(meta))
if err != nil {
recordSecretEncryption(ctx, target, secretEncryptionFailed)
return nil, fmt.Errorf("secret encryption failed: %w", err)
}
recordSecretEncryption(ctx, target, secretEncryptionEncrypted)
w.mu.Lock()
w.encryptedCache[cacheKey] = append([]byte(nil), encrypted...)
w.markers[scopedIdentityKey] = currentMarker
w.mu.Unlock()
return encrypted, nil
}
func (w *contentWriter) cachedEncryptedContent(
ctx context.Context,
target placementTarget,
identityKey, cacheKey string,
currentMarker sensitiveMarker,
) ([]byte, bool) {
lastMarker, markerExists := w.markers[identityKey]
if !markerExists || lastMarker != currentMarker {
return nil, false
}
cached, ok := w.encryptedCache[cacheKey]
if !ok {
return nil, false
}
recordSecretEncryption(ctx, target, secretEncryptionCached)
return append([]byte(nil), cached...), true
}
func buildResourceMeta(event Event) resourceMeta {
meta := resourceMeta{
Identifier: event.Identifier,
}
if event.Object == nil {
return meta
}
meta.UID = string(event.Object.GetUID())
meta.ResourceVersion = event.Object.GetResourceVersion()
meta.Generation = event.Object.GetGeneration()
return meta
}
// Secret encryption outcome label values. The set partitions every decision the encryption path
// makes about one document: it ran and produced ciphertext, it ran and failed, or it did not run
// because unchanged content was reused.
const (
secretEncryptionEncrypted = "encrypted"
secretEncryptionFailed = "failed"
secretEncryptionCached = "cached"
)
// recordSecretEncryption counts one encryption decision.
//
// One counter with an outcome label, where there used to be five counters over two populations:
// `attempts` was incremented immediately before Encrypt and so was exactly success + failures, and
// `cache_hits` and `marker_skips` were incremented on consecutive lines of one branch and could
// never differ. A share of one total is also the ratio the old "cache effectiveness" query was
// reaching for, which divided two counters over disjoint populations.
//
// It names the GitTarget, so "whose Secrets are being rejected" is a selector rather than a log
// search. A batch built by a test carries no target and publishes the empty pair.
func recordSecretEncryption(ctx context.Context, target placementTarget, outcome string) {
if telemetry.SecretEncryptionsTotal == nil {
return
}
telemetry.SecretEncryptionsTotal.Add(ctx, 1,
metric.WithAttributes(append(target.attrs(), attribute.String("outcome", outcome))...))
}