+2
-4
spindle/config/config.go
+2
-4
spindle/config/config.go
+55
-149
spindle/secrets/openbao.go
+55
-149
spindle/secrets/openbao.go
···
6
"log/slog"
7
"path"
8
"strings"
9
-
"sync"
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
···
16
type OpenBaoManager struct {
17
client *vault.Client
18
mountPath string
19
-
roleID string
20
-
secretID string
21
-
stopCh chan struct{}
22
-
tokenMu sync.RWMutex
23
logger *slog.Logger
24
}
25
···
31
}
32
}
33
34
-
func NewOpenBaoManager(address, roleID, secretID string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) {
35
-
if address == "" {
36
-
return nil, fmt.Errorf("address cannot be empty")
37
-
}
38
-
if roleID == "" {
39
-
return nil, fmt.Errorf("role_id cannot be empty")
40
-
}
41
-
if secretID == "" {
42
-
return nil, fmt.Errorf("secret_id cannot be empty")
43
}
44
45
config := vault.DefaultConfig()
46
-
config.Address = address
47
48
client, err := vault.NewClient(config)
49
if err != nil {
50
return nil, fmt.Errorf("failed to create openbao client: %w", err)
51
}
52
53
-
// Authenticate using AppRole
54
-
err = authenticateAppRole(client, roleID, secretID)
55
-
if err != nil {
56
-
return nil, fmt.Errorf("failed to authenticate with AppRole: %w", err)
57
-
}
58
-
59
manager := &OpenBaoManager{
60
client: client,
61
mountPath: "spindle", // default KV v2 mount path
62
-
roleID: roleID,
63
-
secretID: secretID,
64
-
stopCh: make(chan struct{}),
65
logger: logger,
66
}
67
···
69
opt(manager)
70
}
71
72
-
go manager.tokenRenewalLoop()
73
-
74
-
return manager, nil
75
-
}
76
-
77
-
// authenticateAppRole authenticates the client using AppRole method
78
-
func authenticateAppRole(client *vault.Client, roleID, secretID string) error {
79
-
authData := map[string]interface{}{
80
-
"role_id": roleID,
81
-
"secret_id": secretID,
82
-
}
83
-
84
-
resp, err := client.Logical().Write("auth/approle/login", authData)
85
-
if err != nil {
86
-
return fmt.Errorf("failed to login with AppRole: %w", err)
87
-
}
88
-
89
-
if resp == nil || resp.Auth == nil {
90
-
return fmt.Errorf("no auth info returned from AppRole login")
91
-
}
92
-
93
-
client.SetToken(resp.Auth.ClientToken)
94
-
return nil
95
-
}
96
-
97
-
// stop stops the token renewal goroutine
98
-
func (v *OpenBaoManager) Stop() {
99
-
close(v.stopCh)
100
-
}
101
-
102
-
// tokenRenewalLoop runs in a background goroutine to automatically renew or re-authenticate tokens
103
-
func (v *OpenBaoManager) tokenRenewalLoop() {
104
-
ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds
105
-
defer ticker.Stop()
106
-
107
-
for {
108
-
select {
109
-
case <-v.stopCh:
110
-
return
111
-
case <-ticker.C:
112
-
ctx := context.Background()
113
-
if err := v.ensureValidToken(ctx); err != nil {
114
-
v.logger.Error("openbao token renewal failed", "error", err)
115
-
}
116
-
}
117
-
}
118
-
}
119
-
120
-
// ensureValidToken checks if the current token is valid and renews or re-authenticates if needed
121
-
func (v *OpenBaoManager) ensureValidToken(ctx context.Context) error {
122
-
v.tokenMu.Lock()
123
-
defer v.tokenMu.Unlock()
124
-
125
-
// check current token info
126
-
tokenInfo, err := v.client.Auth().Token().LookupSelf()
127
-
if err != nil {
128
-
// token is invalid, need to re-authenticate
129
-
v.logger.Warn("token lookup failed, re-authenticating", "error", err)
130
-
return v.reAuthenticate()
131
-
}
132
-
133
-
if tokenInfo == nil || tokenInfo.Data == nil {
134
-
return v.reAuthenticate()
135
-
}
136
-
137
-
// check TTL
138
-
ttlRaw, ok := tokenInfo.Data["ttl"]
139
-
if !ok {
140
-
return v.reAuthenticate()
141
-
}
142
-
143
-
var ttl int64
144
-
switch t := ttlRaw.(type) {
145
-
case int64:
146
-
ttl = t
147
-
case float64:
148
-
ttl = int64(t)
149
-
case int:
150
-
ttl = int64(t)
151
-
default:
152
-
return v.reAuthenticate()
153
}
154
155
-
// if TTL is less than 5 minutes, try to renew
156
-
if ttl < 300 {
157
-
v.logger.Info("token ttl low, attempting renewal", "ttl_seconds", ttl)
158
-
159
-
renewResp, err := v.client.Auth().Token().RenewSelf(3600) // 1h
160
-
if err != nil {
161
-
v.logger.Warn("token renewal failed, re-authenticating", "error", err)
162
-
return v.reAuthenticate()
163
-
}
164
-
165
-
if renewResp == nil || renewResp.Auth == nil {
166
-
v.logger.Warn("token renewal returned no auth info, re-authenticating")
167
-
return v.reAuthenticate()
168
-
}
169
-
170
-
v.logger.Info("token renewed successfully", "new_ttl_seconds", renewResp.Auth.LeaseDuration)
171
-
}
172
-
173
-
return nil
174
}
175
176
-
// reAuthenticate performs a fresh authentication using AppRole
177
-
func (v *OpenBaoManager) reAuthenticate() error {
178
-
v.logger.Info("re-authenticating with approle")
179
180
-
err := authenticateAppRole(v.client, v.roleID, v.secretID)
181
if err != nil {
182
-
return fmt.Errorf("re-authentication failed: %w", err)
183
}
184
185
-
v.logger.Info("re-authentication successful")
186
return nil
187
}
188
189
func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
190
-
v.tokenMu.RLock()
191
-
defer v.tokenMu.RUnlock()
192
if err := ValidateKey(secret.Key); err != nil {
193
return err
194
}
195
196
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
197
-
198
-
fmt.Println(v.mountPath, secretPath)
199
200
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
201
if err == nil && existing != nil {
202
return ErrKeyAlreadyPresent
203
}
204
···
210
"created_by": secret.CreatedBy.String(),
211
}
212
213
-
_, err = v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData)
214
if err != nil {
215
return fmt.Errorf("failed to store secret in openbao: %w", err)
216
}
217
218
return nil
219
}
220
221
func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
222
-
v.tokenMu.RLock()
223
-
defer v.tokenMu.RUnlock()
224
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
225
226
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
227
if err != nil || existing == nil {
228
return ErrKeyNotFound
···
233
return fmt.Errorf("failed to delete secret from openbao: %w", err)
234
}
235
236
return nil
237
}
238
239
func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) {
240
-
v.tokenMu.RLock()
241
-
defer v.tokenMu.RUnlock()
242
repoPath := v.buildRepoPath(repo)
243
244
-
secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
245
if err != nil {
246
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
247
return []LockedSecret{}, nil
···
266
continue
267
}
268
269
-
secretPath := path.Join(repoPath, key)
270
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
271
if err != nil {
272
-
continue // Skip secrets we can't read
273
}
274
275
if secretData == nil || secretData.Data == nil {
···
308
secrets = append(secrets, secret)
309
}
310
311
return secrets, nil
312
}
313
314
func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) {
315
-
v.tokenMu.RLock()
316
-
defer v.tokenMu.RUnlock()
317
repoPath := v.buildRepoPath(repo)
318
319
-
secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
320
if err != nil {
321
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
322
return []UnlockedSecret{}, nil
···
341
continue
342
}
343
344
-
secretPath := path.Join(repoPath, key)
345
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
346
if err != nil {
347
continue
348
}
349
···
355
356
valueStr, ok := data["value"].(string)
357
if !ok {
358
-
continue // skip secrets without values
359
}
360
361
createdAtStr, ok := data["created_at"].(string)
···
389
secrets = append(secrets, secret)
390
}
391
392
return secrets, nil
393
}
394
395
-
// buildRepoPath creates an OpenBao path for a repository
396
func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string {
397
// convert DidSlashRepo to a safe path by replacing special characters
398
repoPath := strings.ReplaceAll(string(repo), "/", "_")
···
401
return fmt.Sprintf("repos/%s", repoPath)
402
}
403
404
-
// buildSecretPath creates an OpenBao path for a specific secret
405
func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string {
406
return path.Join(v.buildRepoPath(repo), key)
407
}
···
6
"log/slog"
7
"path"
8
"strings"
9
"time"
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
···
15
type OpenBaoManager struct {
16
client *vault.Client
17
mountPath string
18
logger *slog.Logger
19
}
20
···
26
}
27
}
28
29
+
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
30
+
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
31
+
// The proxy handles all authentication automatically via Auto-Auth
32
+
func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) {
33
+
if proxyAddress == "" {
34
+
return nil, fmt.Errorf("proxy address cannot be empty")
35
}
36
37
config := vault.DefaultConfig()
38
+
config.Address = proxyAddress
39
40
client, err := vault.NewClient(config)
41
if err != nil {
42
return nil, fmt.Errorf("failed to create openbao client: %w", err)
43
}
44
45
manager := &OpenBaoManager{
46
client: client,
47
mountPath: "spindle", // default KV v2 mount path
48
logger: logger,
49
}
50
···
52
opt(manager)
53
}
54
55
+
if err := manager.testConnection(); err != nil {
56
+
return nil, fmt.Errorf("failed to connect to bao proxy: %w", err)
57
}
58
59
+
logger.Info("successfully connected to bao proxy", "address", proxyAddress)
60
+
return manager, nil
61
}
62
63
+
// testConnection verifies that we can connect to the proxy
64
+
func (v *OpenBaoManager) testConnection() error {
65
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
66
+
defer cancel()
67
68
+
// try token self-lookup as a quick way to verify proxy works
69
+
// and is authenticated
70
+
_, err := v.client.Auth().Token().LookupSelfWithContext(ctx)
71
if err != nil {
72
+
return fmt.Errorf("proxy connection test failed: %w", err)
73
}
74
75
return nil
76
}
77
78
func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
79
if err := ValidateKey(secret.Key); err != nil {
80
return err
81
}
82
83
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
84
+
v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath)
85
86
+
// Check if secret already exists
87
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
88
if err == nil && existing != nil {
89
+
v.logger.Debug("secret already exists", "path", secretPath)
90
return ErrKeyAlreadyPresent
91
}
92
···
98
"created_by": secret.CreatedBy.String(),
99
}
100
101
+
v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath)
102
+
resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData)
103
if err != nil {
104
+
v.logger.Error("failed to write secret", "path", secretPath, "error", err)
105
return fmt.Errorf("failed to store secret in openbao: %w", err)
106
}
107
108
+
v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime)
109
+
110
+
v.logger.Debug("verifying secret was written", "path", secretPath)
111
+
readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
112
+
if err != nil {
113
+
v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err)
114
+
return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err)
115
+
}
116
+
117
+
if readBack == nil || readBack.Data == nil {
118
+
v.logger.Error("secret verification returned empty data", "path", secretPath)
119
+
return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath)
120
+
}
121
+
122
+
v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version)
123
return nil
124
}
125
126
func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
127
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
128
129
+
// check if secret exists
130
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
131
if err != nil || existing == nil {
132
return ErrKeyNotFound
···
137
return fmt.Errorf("failed to delete secret from openbao: %w", err)
138
}
139
140
+
v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key)
141
return nil
142
}
143
144
func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) {
145
repoPath := v.buildRepoPath(repo)
146
147
+
secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
148
if err != nil {
149
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
150
return []LockedSecret{}, nil
···
169
continue
170
}
171
172
+
secretPath := fmt.Sprintf("%s/%s", repoPath, key)
173
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
174
if err != nil {
175
+
v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err)
176
+
continue
177
}
178
179
if secretData == nil || secretData.Data == nil {
···
212
secrets = append(secrets, secret)
213
}
214
215
+
v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets))
216
return secrets, nil
217
}
218
219
func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) {
220
repoPath := v.buildRepoPath(repo)
221
222
+
secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
223
if err != nil {
224
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
225
return []UnlockedSecret{}, nil
···
244
continue
245
}
246
247
+
secretPath := fmt.Sprintf("%s/%s", repoPath, key)
248
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
249
if err != nil {
250
+
v.logger.Warn("failed to read secret", "path", secretPath, "error", err)
251
continue
252
}
253
···
259
260
valueStr, ok := data["value"].(string)
261
if !ok {
262
+
v.logger.Warn("secret missing value", "path", secretPath)
263
+
continue
264
}
265
266
createdAtStr, ok := data["created_at"].(string)
···
294
secrets = append(secrets, secret)
295
}
296
297
+
v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets))
298
return secrets, nil
299
}
300
301
+
// buildRepoPath creates a safe path for a repository
302
func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string {
303
// convert DidSlashRepo to a safe path by replacing special characters
304
repoPath := strings.ReplaceAll(string(repo), "/", "_")
···
307
return fmt.Sprintf("repos/%s", repoPath)
308
}
309
310
+
// buildSecretPath creates a path for a specific secret
311
func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string {
312
return path.Join(v.buildRepoPath(repo), key)
313
}
+59
-84
spindle/secrets/openbao_test.go
+59
-84
spindle/secrets/openbao_test.go
···
16
secrets map[string]UnlockedSecret // key: repo_key format
17
shouldError bool
18
errorToReturn error
19
-
stopped bool
20
}
21
22
func NewMockOpenBaoManager() *MockOpenBaoManager {
···
31
func (m *MockOpenBaoManager) ClearError() {
32
m.shouldError = false
33
m.errorToReturn = nil
34
-
}
35
-
36
-
func (m *MockOpenBaoManager) Stop() {
37
-
m.stopped = true
38
-
}
39
-
40
-
func (m *MockOpenBaoManager) IsStopped() bool {
41
-
return m.stopped
42
}
43
44
func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string {
···
118
}
119
}
120
121
func TestOpenBaoManagerInterface(t *testing.T) {
122
var _ Manager = (*OpenBaoManager)(nil)
123
}
···
125
func TestNewOpenBaoManager(t *testing.T) {
126
tests := []struct {
127
name string
128
-
address string
129
-
roleID string
130
-
secretID string
131
opts []OpenBaoManagerOpt
132
expectError bool
133
errorContains string
134
}{
135
{
136
-
name: "empty address",
137
-
address: "",
138
-
roleID: "test-role-id",
139
-
secretID: "test-secret-id",
140
opts: nil,
141
expectError: true,
142
-
errorContains: "address cannot be empty",
143
},
144
{
145
-
name: "empty role_id",
146
-
address: "http://localhost:8200",
147
-
roleID: "",
148
-
secretID: "test-secret-id",
149
opts: nil,
150
-
expectError: true,
151
-
errorContains: "role_id cannot be empty",
152
},
153
{
154
-
name: "empty secret_id",
155
-
address: "http://localhost:8200",
156
-
roleID: "test-role-id",
157
-
secretID: "",
158
-
opts: nil,
159
-
expectError: true,
160
-
errorContains: "secret_id cannot be empty",
161
},
162
}
163
164
for _, tt := range tests {
165
t.Run(tt.name, func(t *testing.T) {
166
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
167
-
manager, err := NewOpenBaoManager(tt.address, tt.roleID, tt.secretID, logger, tt.opts...)
168
169
if tt.expectError {
170
assert.Error(t, err)
171
assert.Nil(t, manager)
172
assert.Contains(t, err.Error(), tt.errorContains)
173
} else {
174
-
// For valid configurations, we expect an error during authentication
175
-
// since we're not connecting to a real OpenBao server
176
-
assert.Error(t, err)
177
-
assert.Nil(t, manager)
178
}
179
})
180
}
···
253
assert.Equal(t, "custom-mount", manager.mountPath)
254
}
255
256
-
func TestOpenBaoManager_Stop(t *testing.T) {
257
-
// Create a manager with minimal setup
258
-
manager := &OpenBaoManager{
259
-
mountPath: "test",
260
-
stopCh: make(chan struct{}),
261
-
}
262
-
263
-
// Verify the manager implements Stopper interface
264
-
var stopper Stopper = manager
265
-
assert.NotNil(t, stopper)
266
-
267
-
// Call Stop and verify it doesn't panic
268
-
assert.NotPanics(t, func() {
269
-
manager.Stop()
270
-
})
271
-
272
-
// Verify the channel was closed
273
-
select {
274
-
case <-manager.stopCh:
275
-
// Channel was closed as expected
276
-
default:
277
-
t.Error("Expected stop channel to be closed after Stop()")
278
-
}
279
-
}
280
-
281
-
func TestOpenBaoManager_StopperInterface(t *testing.T) {
282
-
manager := &OpenBaoManager{}
283
-
284
-
// Verify that OpenBaoManager implements the Stopper interface
285
-
_, ok := interface{}(manager).(Stopper)
286
-
assert.True(t, ok, "OpenBaoManager should implement Stopper interface")
287
-
}
288
-
289
-
// Test MockOpenBaoManager interface compliance
290
-
func TestMockOpenBaoManagerInterface(t *testing.T) {
291
-
var _ Manager = (*MockOpenBaoManager)(nil)
292
-
var _ Stopper = (*MockOpenBaoManager)(nil)
293
-
}
294
-
295
func TestMockOpenBaoManager_AddSecret(t *testing.T) {
296
tests := []struct {
297
name string
···
563
assert.NoError(t, err)
564
}
565
566
-
func TestMockOpenBaoManager_Stop(t *testing.T) {
567
-
mock := NewMockOpenBaoManager()
568
-
569
-
assert.False(t, mock.IsStopped())
570
-
571
-
mock.Stop()
572
-
573
-
assert.True(t, mock.IsStopped())
574
-
}
575
-
576
func TestMockOpenBaoManager_Integration(t *testing.T) {
577
tests := []struct {
578
name string
···
628
})
629
}
630
}
···
16
secrets map[string]UnlockedSecret // key: repo_key format
17
shouldError bool
18
errorToReturn error
19
}
20
21
func NewMockOpenBaoManager() *MockOpenBaoManager {
···
30
func (m *MockOpenBaoManager) ClearError() {
31
m.shouldError = false
32
m.errorToReturn = nil
33
}
34
35
func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string {
···
109
}
110
}
111
112
+
// Test MockOpenBaoManager interface compliance
113
+
func TestMockOpenBaoManagerInterface(t *testing.T) {
114
+
var _ Manager = (*MockOpenBaoManager)(nil)
115
+
}
116
+
117
func TestOpenBaoManagerInterface(t *testing.T) {
118
var _ Manager = (*OpenBaoManager)(nil)
119
}
···
121
func TestNewOpenBaoManager(t *testing.T) {
122
tests := []struct {
123
name string
124
+
proxyAddr string
125
opts []OpenBaoManagerOpt
126
expectError bool
127
errorContains string
128
}{
129
{
130
+
name: "empty proxy address",
131
+
proxyAddr: "",
132
opts: nil,
133
expectError: true,
134
+
errorContains: "proxy address cannot be empty",
135
},
136
{
137
+
name: "valid proxy address",
138
+
proxyAddr: "http://localhost:8200",
139
opts: nil,
140
+
expectError: true, // Will fail because no real proxy is running
141
+
errorContains: "failed to connect to bao proxy",
142
},
143
{
144
+
name: "with mount path option",
145
+
proxyAddr: "http://localhost:8200",
146
+
opts: []OpenBaoManagerOpt{WithMountPath("custom-mount")},
147
+
expectError: true, // Will fail because no real proxy is running
148
+
errorContains: "failed to connect to bao proxy",
149
},
150
}
151
152
for _, tt := range tests {
153
t.Run(tt.name, func(t *testing.T) {
154
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
155
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
156
157
if tt.expectError {
158
assert.Error(t, err)
159
assert.Nil(t, manager)
160
assert.Contains(t, err.Error(), tt.errorContains)
161
} else {
162
+
assert.NoError(t, err)
163
+
assert.NotNil(t, manager)
164
}
165
})
166
}
···
239
assert.Equal(t, "custom-mount", manager.mountPath)
240
}
241
242
func TestMockOpenBaoManager_AddSecret(t *testing.T) {
243
tests := []struct {
244
name string
···
510
assert.NoError(t, err)
511
}
512
513
func TestMockOpenBaoManager_Integration(t *testing.T) {
514
tests := []struct {
515
name string
···
565
})
566
}
567
}
568
+
569
+
func TestOpenBaoManager_ProxyConfiguration(t *testing.T) {
570
+
tests := []struct {
571
+
name string
572
+
proxyAddr string
573
+
description string
574
+
}{
575
+
{
576
+
name: "default_localhost",
577
+
proxyAddr: "http://127.0.0.1:8200",
578
+
description: "Should connect to default localhost proxy",
579
+
},
580
+
{
581
+
name: "custom_host",
582
+
proxyAddr: "http://bao-proxy:8200",
583
+
description: "Should connect to custom proxy host",
584
+
},
585
+
{
586
+
name: "https_proxy",
587
+
proxyAddr: "https://127.0.0.1:8200",
588
+
description: "Should connect to HTTPS proxy",
589
+
},
590
+
}
591
+
592
+
for _, tt := range tests {
593
+
t.Run(tt.name, func(t *testing.T) {
594
+
t.Log("Testing scenario:", tt.description)
595
+
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
596
+
597
+
// All these will fail because no real proxy is running
598
+
// but we can test that the configuration is properly accepted
599
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
600
+
assert.Error(t, err) // Expected because no real proxy
601
+
assert.Nil(t, manager)
602
+
assert.Contains(t, err.Error(), "failed to connect to bao proxy")
603
+
})
604
+
}
605
+
}
+13
-6
spindle/secrets/policy.hcl
+13
-6
spindle/secrets/policy.hcl
···
1
-
# KV v2 data operations
2
-
path "spindle/data/*" {
3
capabilities = ["create", "read", "update", "delete", "list"]
4
}
5
6
-
# KV v2 metadata operations (needed for listing)
7
path "spindle/metadata/*" {
8
capabilities = ["list", "read", "delete"]
9
}
10
11
-
# Root path access (needed for mount-level operations)
12
-
path "spindle/*" {
13
-
capabilities = ["list"]
14
}
15
···
1
+
# Allow full access to the spindle KV mount
2
+
path "spindle/*" {
3
capabilities = ["create", "read", "update", "delete", "list"]
4
}
5
6
+
path "spindle/data/*" {
7
+
capabilities = ["create", "read", "update", "delete"]
8
+
}
9
+
10
path "spindle/metadata/*" {
11
capabilities = ["list", "read", "delete"]
12
}
13
14
+
# Allow listing mounts (for connection testing)
15
+
path "sys/mounts" {
16
+
capabilities = ["read"]
17
}
18
19
+
# Allow token self-lookup (for health checks)
20
+
path "auth/token/lookup-self" {
21
+
capabilities = ["read"]
22
+
}
+4
-12
spindle/server.go
+4
-12
spindle/server.go
···
71
var vault secrets.Manager
72
switch cfg.Server.Secrets.Provider {
73
case "openbao":
74
-
if cfg.Server.Secrets.OpenBao.Addr == "" {
75
-
return fmt.Errorf("openbao address is required when using openbao secrets provider")
76
-
}
77
-
if cfg.Server.Secrets.OpenBao.RoleID == "" {
78
-
return fmt.Errorf("openbao role_id is required when using openbao secrets provider")
79
-
}
80
-
if cfg.Server.Secrets.OpenBao.SecretID == "" {
81
-
return fmt.Errorf("openbao secret_id is required when using openbao secrets provider")
82
}
83
vault, err = secrets.NewOpenBaoManager(
84
-
cfg.Server.Secrets.OpenBao.Addr,
85
-
cfg.Server.Secrets.OpenBao.RoleID,
86
-
cfg.Server.Secrets.OpenBao.SecretID,
87
logger,
88
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
89
)
90
if err != nil {
91
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
92
}
93
-
logger.Info("using openbao secrets provider", "address", cfg.Server.Secrets.OpenBao.Addr, "mount", cfg.Server.Secrets.OpenBao.Mount)
94
case "sqlite", "":
95
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
96
if err != nil {
···
71
var vault secrets.Manager
72
switch cfg.Server.Secrets.Provider {
73
case "openbao":
74
+
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
75
+
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
76
}
77
vault, err = secrets.NewOpenBaoManager(
78
+
cfg.Server.Secrets.OpenBao.ProxyAddr,
79
logger,
80
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
81
)
82
if err != nil {
83
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
84
}
85
+
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
86
case "sqlite", "":
87
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
88
if err != nil {