forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
at master 9.1 kB view raw
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 secretData := map[string]interface{}{ 102 "value": secret.Value, 103 "repo": string(secret.Repo), 104 "key": secret.Key, 105 "created_at": secret.CreatedAt.Format(time.RFC3339), 106 "created_by": secret.CreatedBy.String(), 107 } 108 109 v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath) 110 resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData) 111 if err != nil { 112 v.logger.Error("failed to write secret", "path", secretPath, "error", err) 113 return fmt.Errorf("failed to store secret in openbao: %w", err) 114 } 115 116 v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime) 117 118 v.logger.Debug("verifying secret was written", "path", secretPath) 119 readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 120 if err != nil { 121 v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err) 122 return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err) 123 } 124 125 if readBack == nil || readBack.Data == nil { 126 v.logger.Error("secret verification returned empty data", "path", secretPath) 127 return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath) 128 } 129 130 v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version) 131 return nil 132} 133 134func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 135 secretPath := v.buildSecretPath(secret.Repo, secret.Key) 136 137 // check if secret exists 138 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 139 if err != nil || existing == nil { 140 return ErrKeyNotFound 141 } 142 143 err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath) 144 if err != nil { 145 return fmt.Errorf("failed to delete secret from openbao: %w", err) 146 } 147 148 v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key) 149 return nil 150} 151 152func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 153 repoPath := v.buildRepoPath(repo) 154 155 secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 156 if err != nil { 157 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 158 return []LockedSecret{}, nil 159 } 160 return nil, fmt.Errorf("failed to list secrets: %w", err) 161 } 162 163 if secretsList == nil || secretsList.Data == nil { 164 return []LockedSecret{}, nil 165 } 166 167 keys, ok := secretsList.Data["keys"].([]interface{}) 168 if !ok { 169 return []LockedSecret{}, nil 170 } 171 172 var secrets []LockedSecret 173 174 for _, keyInterface := range keys { 175 key, ok := keyInterface.(string) 176 if !ok { 177 continue 178 } 179 180 secretPath := fmt.Sprintf("%s/%s", repoPath, key) 181 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 182 if err != nil { 183 v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err) 184 continue 185 } 186 187 if secretData == nil || secretData.Data == nil { 188 continue 189 } 190 191 data := secretData.Data 192 193 createdAtStr, ok := data["created_at"].(string) 194 if !ok { 195 createdAtStr = time.Now().Format(time.RFC3339) 196 } 197 198 createdAt, err := time.Parse(time.RFC3339, createdAtStr) 199 if err != nil { 200 createdAt = time.Now() 201 } 202 203 createdByStr, ok := data["created_by"].(string) 204 if !ok { 205 createdByStr = "" 206 } 207 208 keyStr, ok := data["key"].(string) 209 if !ok { 210 keyStr = key 211 } 212 213 secret := LockedSecret{ 214 Key: keyStr, 215 Repo: repo, 216 CreatedAt: createdAt, 217 CreatedBy: syntax.DID(createdByStr), 218 } 219 220 secrets = append(secrets, secret) 221 } 222 223 v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets)) 224 return secrets, nil 225} 226 227func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 228 repoPath := v.buildRepoPath(repo) 229 230 secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 231 if err != nil { 232 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 233 return []UnlockedSecret{}, nil 234 } 235 return nil, fmt.Errorf("failed to list secrets: %w", err) 236 } 237 238 if secretsList == nil || secretsList.Data == nil { 239 return []UnlockedSecret{}, nil 240 } 241 242 keys, ok := secretsList.Data["keys"].([]interface{}) 243 if !ok { 244 return []UnlockedSecret{}, nil 245 } 246 247 var secrets []UnlockedSecret 248 249 for _, keyInterface := range keys { 250 key, ok := keyInterface.(string) 251 if !ok { 252 continue 253 } 254 255 secretPath := fmt.Sprintf("%s/%s", repoPath, key) 256 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 257 if err != nil { 258 v.logger.Warn("failed to read secret", "path", secretPath, "error", err) 259 continue 260 } 261 262 if secretData == nil || secretData.Data == nil { 263 continue 264 } 265 266 data := secretData.Data 267 268 valueStr, ok := data["value"].(string) 269 if !ok { 270 v.logger.Warn("secret missing value", "path", secretPath) 271 continue 272 } 273 274 createdAtStr, ok := data["created_at"].(string) 275 if !ok { 276 createdAtStr = time.Now().Format(time.RFC3339) 277 } 278 279 createdAt, err := time.Parse(time.RFC3339, createdAtStr) 280 if err != nil { 281 createdAt = time.Now() 282 } 283 284 createdByStr, ok := data["created_by"].(string) 285 if !ok { 286 createdByStr = "" 287 } 288 289 keyStr, ok := data["key"].(string) 290 if !ok { 291 keyStr = key 292 } 293 294 secret := UnlockedSecret{ 295 Key: keyStr, 296 Value: valueStr, 297 Repo: repo, 298 CreatedAt: createdAt, 299 CreatedBy: syntax.DID(createdByStr), 300 } 301 302 secrets = append(secrets, secret) 303 } 304 305 v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets)) 306 return secrets, nil 307} 308 309// buildRepoPath creates a safe path for a repository 310func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string { 311 // convert DidSlashRepo to a safe path by replacing special characters 312 repoPath := strings.ReplaceAll(string(repo), "/", "_") 313 repoPath = strings.ReplaceAll(repoPath, ":", "_") 314 repoPath = strings.ReplaceAll(repoPath, ".", "_") 315 return fmt.Sprintf("repos/%s", repoPath) 316} 317 318// buildSecretPath creates a path for a specific secret 319func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string { 320 return path.Join(v.buildRepoPath(repo), key) 321}