Monorepo for Tangled
tangled.org
1package secrets
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "path"
8 "strings"
9 "time"
10
11 "github.com/bluesky-social/indigo/atproto/syntax"
12 vault "github.com/openbao/openbao/api/v2"
13)
14
15type OpenBaoManager struct {
16 client *vault.Client
17 mountPath string
18 logger *slog.Logger
19 connectionTimeout time.Duration
20}
21
22type OpenBaoManagerOpt func(*OpenBaoManager)
23
24func WithMountPath(mountPath string) OpenBaoManagerOpt {
25 return func(v *OpenBaoManager) {
26 v.mountPath = mountPath
27 }
28}
29
30func WithConnectionTimeout(timeout time.Duration) OpenBaoManagerOpt {
31 return func(v *OpenBaoManager) {
32 v.connectionTimeout = timeout
33 }
34}
35
36// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
37// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
38// The proxy handles all authentication automatically via Auto-Auth
39func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) {
40 if proxyAddress == "" {
41 return nil, fmt.Errorf("proxy address cannot be empty")
42 }
43
44 config := vault.DefaultConfig()
45 config.Address = proxyAddress
46
47 client, err := vault.NewClient(config)
48 if err != nil {
49 return nil, fmt.Errorf("failed to create openbao client: %w", err)
50 }
51
52 manager := &OpenBaoManager{
53 client: client,
54 mountPath: "spindle", // default KV v2 mount path
55 logger: logger,
56 connectionTimeout: 10 * time.Second, // default connection timeout
57 }
58
59 for _, opt := range opts {
60 opt(manager)
61 }
62
63 if err := manager.testConnection(); err != nil {
64 return nil, fmt.Errorf("failed to connect to bao proxy: %w", err)
65 }
66
67 logger.Info("successfully connected to bao proxy", "address", proxyAddress)
68 return manager, nil
69}
70
71// testConnection verifies that we can connect to the proxy
72func (v *OpenBaoManager) testConnection() error {
73 ctx, cancel := context.WithTimeout(context.Background(), v.connectionTimeout)
74 defer cancel()
75
76 // try token self-lookup as a quick way to verify proxy works
77 // and is authenticated
78 _, err := v.client.Auth().Token().LookupSelfWithContext(ctx)
79 if err != nil {
80 return fmt.Errorf("proxy connection test failed: %w", err)
81 }
82
83 return nil
84}
85
86func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
87 if err := ValidateKey(secret.Key); err != nil {
88 return err
89 }
90
91 secretPath := v.buildSecretPath(secret.Repo, secret.Key)
92 v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath)
93
94 // Check if secret already exists
95 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
96 if err == nil && existing != nil {
97 v.logger.Debug("secret already exists", "path", secretPath)
98 return ErrKeyAlreadyPresent
99 }
100
101 createdAt := secret.CreatedAt
102 if createdAt.IsZero() {
103 createdAt = time.Now()
104 }
105 secretData := map[string]interface{}{
106 "value": secret.Value,
107 "repo": string(secret.Repo),
108 "key": secret.Key,
109 "created_at": createdAt.UTC().Format(time.RFC3339),
110 "created_by": secret.CreatedBy.String(),
111 }
112
113 v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath)
114 resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData)
115 if err != nil {
116 v.logger.Error("failed to write secret", "path", secretPath, "error", err)
117 return fmt.Errorf("failed to store secret in openbao: %w", err)
118 }
119
120 v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime)
121
122 v.logger.Debug("verifying secret was written", "path", secretPath)
123 readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
124 if err != nil {
125 v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err)
126 return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err)
127 }
128
129 if readBack == nil || readBack.Data == nil {
130 v.logger.Error("secret verification returned empty data", "path", secretPath)
131 return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath)
132 }
133
134 v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version)
135 return nil
136}
137
138func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
139 secretPath := v.buildSecretPath(secret.Repo, secret.Key)
140
141 // check if secret exists
142 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
143 if err != nil || existing == nil {
144 return ErrKeyNotFound
145 }
146
147 err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath)
148 if err != nil {
149 return fmt.Errorf("failed to delete secret from openbao: %w", err)
150 }
151
152 v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key)
153 return nil
154}
155
156func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo RepoIdentifier) ([]LockedSecret, error) {
157 repoPath := v.buildRepoPath(repo)
158
159 secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
160 if err != nil {
161 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
162 return []LockedSecret{}, nil
163 }
164 return nil, fmt.Errorf("failed to list secrets: %w", err)
165 }
166
167 if secretsList == nil || secretsList.Data == nil {
168 return []LockedSecret{}, nil
169 }
170
171 keys, ok := secretsList.Data["keys"].([]interface{})
172 if !ok {
173 return []LockedSecret{}, nil
174 }
175
176 var secrets []LockedSecret
177
178 for _, keyInterface := range keys {
179 key, ok := keyInterface.(string)
180 if !ok {
181 continue
182 }
183
184 secretPath := fmt.Sprintf("%s/%s", repoPath, key)
185 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
186 if err != nil {
187 v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err)
188 continue
189 }
190
191 if secretData == nil || secretData.Data == nil {
192 continue
193 }
194
195 data := secretData.Data
196
197 createdAtStr, ok := data["created_at"].(string)
198 if !ok {
199 createdAtStr = time.Now().Format(time.RFC3339)
200 }
201
202 createdAt, err := time.Parse(time.RFC3339, createdAtStr)
203 if err != nil {
204 createdAt = time.Now()
205 }
206
207 createdByStr, ok := data["created_by"].(string)
208 if !ok {
209 createdByStr = ""
210 }
211
212 keyStr, ok := data["key"].(string)
213 if !ok {
214 keyStr = key
215 }
216
217 secret := LockedSecret{
218 Key: keyStr,
219 Repo: repo,
220 CreatedAt: createdAt,
221 CreatedBy: syntax.DID(createdByStr),
222 }
223
224 secrets = append(secrets, secret)
225 }
226
227 v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets))
228 return secrets, nil
229}
230
231func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo RepoIdentifier) ([]UnlockedSecret, error) {
232 repoPath := v.buildRepoPath(repo)
233
234 secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
235 if err != nil {
236 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
237 return []UnlockedSecret{}, nil
238 }
239 return nil, fmt.Errorf("failed to list secrets: %w", err)
240 }
241
242 if secretsList == nil || secretsList.Data == nil {
243 return []UnlockedSecret{}, nil
244 }
245
246 keys, ok := secretsList.Data["keys"].([]interface{})
247 if !ok {
248 return []UnlockedSecret{}, nil
249 }
250
251 var secrets []UnlockedSecret
252
253 for _, keyInterface := range keys {
254 key, ok := keyInterface.(string)
255 if !ok {
256 continue
257 }
258
259 secretPath := fmt.Sprintf("%s/%s", repoPath, key)
260 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
261 if err != nil {
262 v.logger.Warn("failed to read secret", "path", secretPath, "error", err)
263 continue
264 }
265
266 if secretData == nil || secretData.Data == nil {
267 continue
268 }
269
270 data := secretData.Data
271
272 valueStr, ok := data["value"].(string)
273 if !ok {
274 v.logger.Warn("secret missing value", "path", secretPath)
275 continue
276 }
277
278 createdAtStr, ok := data["created_at"].(string)
279 if !ok {
280 createdAtStr = time.Now().Format(time.RFC3339)
281 }
282
283 createdAt, err := time.Parse(time.RFC3339, createdAtStr)
284 if err != nil {
285 createdAt = time.Now()
286 }
287
288 createdByStr, ok := data["created_by"].(string)
289 if !ok {
290 createdByStr = ""
291 }
292
293 keyStr, ok := data["key"].(string)
294 if !ok {
295 keyStr = key
296 }
297
298 secret := UnlockedSecret{
299 Key: keyStr,
300 Value: valueStr,
301 Repo: repo,
302 CreatedAt: createdAt,
303 CreatedBy: syntax.DID(createdByStr),
304 }
305
306 secrets = append(secrets, secret)
307 }
308
309 v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets))
310 return secrets, nil
311}
312
313// buildRepoPath creates a safe path for a repository
314func (v *OpenBaoManager) buildRepoPath(repo RepoIdentifier) string {
315 // convert RepoIdentifier to a safe path by replacing special characters
316 repoPath := strings.ReplaceAll(string(repo), "/", "_")
317 repoPath = strings.ReplaceAll(repoPath, ":", "_")
318 repoPath = strings.ReplaceAll(repoPath, ".", "_")
319 return fmt.Sprintf("repos/%s", repoPath)
320}
321
322// buildSecretPath creates a path for a specific secret
323func (v *OpenBaoManager) buildSecretPath(repo RepoIdentifier, key string) string {
324 return path.Join(v.buildRepoPath(repo), key)
325}