Skip to content
This repository was archived by the owner on Apr 16, 2026. It is now read-only.

Commit 79fd5b5

Browse files
committed
Renew and track Vault DB leases
1 parent cb9cb2e commit 79fd5b5

5 files changed

Lines changed: 255 additions & 6 deletions

File tree

internal/bootstrap/service.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ func redisPoolStats(client *goredis.Client) func() *goredis.PoolStats {
510510
}
511511

512512
func loadPublicKey(path string) (any, error) {
513+
// #nosec G304 -- Operator-managed key file paths are supplied via trusted service configuration.
513514
contents, err := os.ReadFile(path)
514515
if err != nil {
515516
return nil, err
@@ -542,6 +543,7 @@ func loadEd25519PublicKey(path string) (ed25519.PublicKey, error) {
542543
}
543544

544545
func loadEd25519PrivateKey(path string) (ed25519.PrivateKey, error) {
546+
// #nosec G304 -- Operator-managed key file paths are supplied via trusted service configuration.
545547
contents, err := os.ReadFile(path)
546548
if err != nil {
547549
return nil, err
@@ -562,6 +564,7 @@ func loadEd25519PrivateKey(path string) (ed25519.PrivateKey, error) {
562564
}
563565

564566
func loadRSAPrivateKey(path string) (*rsa.PrivateKey, error) {
567+
// #nosec G304 -- Operator-managed key file paths are supplied via trusted service configuration.
565568
contents, err := os.ReadFile(path)
566569
if err != nil {
567570
return nil, err

internal/connectors/vaultdb/client.go

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"fmt"
88
"io"
9+
"math"
910
"net/http"
1011
"strings"
1112
"time"
@@ -19,6 +20,7 @@ type HTTPClientConfig struct {
1920
Token string
2021
Namespace string
2122
Client *http.Client
23+
RenewRetry resilience.RetryConfig
2224
RevokeRetry resilience.RetryConfig
2325
}
2426

@@ -27,6 +29,7 @@ type HTTPClient struct {
2729
token string
2830
namespace string
2931
client *http.Client
32+
renewRetry resilience.RetryConfig
3033
revokeRetry resilience.RetryConfig
3134
}
3235

@@ -45,11 +48,22 @@ func NewHTTPClient(cfg HTTPClientConfig) *HTTPClient {
4548
if revokeRetry.MaxDelay == 0 {
4649
revokeRetry.MaxDelay = time.Second
4750
}
51+
renewRetry := cfg.RenewRetry
52+
if renewRetry.MaxAttempts == 0 {
53+
renewRetry.MaxAttempts = 3
54+
}
55+
if renewRetry.InitialDelay == 0 {
56+
renewRetry.InitialDelay = 100 * time.Millisecond
57+
}
58+
if renewRetry.MaxDelay == 0 {
59+
renewRetry.MaxDelay = time.Second
60+
}
4861
return &HTTPClient{
4962
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
5063
token: cfg.Token,
5164
namespace: cfg.Namespace,
5265
client: client,
66+
renewRetry: renewRetry,
5367
revokeRetry: revokeRetry,
5468
}
5569
}
@@ -65,7 +79,9 @@ func (c *HTTPClient) GenerateCredentials(ctx context.Context, role string) (*Lea
6579
if err != nil {
6680
return nil, err
6781
}
68-
defer response.Body.Close()
82+
defer func() {
83+
_ = response.Body.Close()
84+
}()
6985
body, err := io.ReadAll(response.Body)
7086
if err != nil {
7187
return nil, err
@@ -77,6 +93,7 @@ func (c *HTTPClient) GenerateCredentials(ctx context.Context, role string) (*Lea
7793
var payload struct {
7894
LeaseID string `json:"lease_id"`
7995
LeaseDuration int64 `json:"lease_duration"`
96+
Renewable bool `json:"renewable"`
8097
Data struct {
8198
Username string `json:"username"`
8299
Password string `json:"password"`
@@ -90,15 +107,71 @@ func (c *HTTPClient) GenerateCredentials(ctx context.Context, role string) (*Lea
90107
Password: payload.Data.Password,
91108
LeaseID: payload.LeaseID,
92109
LeaseDuration: time.Duration(payload.LeaseDuration) * time.Second,
110+
Renewable: payload.Renewable,
93111
}, nil
94112
}
95113

114+
func (c *HTTPClient) RenewLease(ctx context.Context, leaseID string, increment time.Duration) (*LeaseCredentials, error) {
115+
return resilience.RetryValue(ctx, c.renewRetry, func(ctx context.Context) (*LeaseCredentials, error) {
116+
return c.renewLeaseOnce(ctx, leaseID, increment)
117+
})
118+
}
119+
96120
func (c *HTTPClient) RevokeLease(ctx context.Context, leaseID string) error {
97121
return resilience.Retry(ctx, c.revokeRetry, func(ctx context.Context) error {
98122
return c.revokeLeaseOnce(ctx, leaseID)
99123
})
100124
}
101125

126+
func (c *HTTPClient) renewLeaseOnce(ctx context.Context, leaseID string, increment time.Duration) (*LeaseCredentials, error) {
127+
payload, err := json.Marshal(map[string]any{
128+
"lease_id": leaseID,
129+
"increment": int(math.Ceil(increment.Seconds())),
130+
})
131+
if err != nil {
132+
return nil, resilience.Permanent(err)
133+
}
134+
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/sys/leases/renew", bytes.NewReader(payload))
135+
if err != nil {
136+
return nil, resilience.Permanent(err)
137+
}
138+
c.applyHeaders(request)
139+
request.Header.Set("Content-Type", "application/json")
140+
141+
response, err := c.client.Do(request)
142+
if err != nil {
143+
return nil, err
144+
}
145+
defer func() {
146+
_ = response.Body.Close()
147+
}()
148+
body, err := io.ReadAll(response.Body)
149+
if err != nil {
150+
return nil, err
151+
}
152+
if response.StatusCode >= 400 {
153+
wrapped := fmt.Errorf("%w: vault renew lease returned %d: %s", core.ErrForbidden, response.StatusCode, string(body))
154+
if response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= http.StatusInternalServerError {
155+
return nil, wrapped
156+
}
157+
return nil, resilience.Permanent(wrapped)
158+
}
159+
160+
var renewed struct {
161+
LeaseID string `json:"lease_id"`
162+
LeaseDuration int64 `json:"lease_duration"`
163+
Renewable bool `json:"renewable"`
164+
}
165+
if err := json.Unmarshal(body, &renewed); err != nil {
166+
return nil, resilience.Permanent(err)
167+
}
168+
return &LeaseCredentials{
169+
LeaseID: renewed.LeaseID,
170+
LeaseDuration: time.Duration(renewed.LeaseDuration) * time.Second,
171+
Renewable: renewed.Renewable,
172+
}, nil
173+
}
174+
102175
func (c *HTTPClient) revokeLeaseOnce(ctx context.Context, leaseID string) error {
103176
body, err := json.Marshal(map[string]string{"lease_id": leaseID})
104177
if err != nil {
@@ -115,7 +188,9 @@ func (c *HTTPClient) revokeLeaseOnce(ctx context.Context, leaseID string) error
115188
if err != nil {
116189
return err
117190
}
118-
defer response.Body.Close()
191+
defer func() {
192+
_ = response.Body.Close()
193+
}()
119194
payload, err := io.ReadAll(response.Body)
120195
if err != nil {
121196
return err

internal/connectors/vaultdb/client_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,19 @@ func TestHTTPClient_GenerateCredentialsAndRevokeLease(t *testing.T) {
2525
_, _ = w.Write([]byte(`{
2626
"lease_id":"database/creds/analytics_ro/123",
2727
"lease_duration":600,
28+
"renewable":true,
2829
"data":{"username":"dyn-user","password":"dyn-pass"}
2930
}`))
31+
case "/v1/sys/leases/renew":
32+
body, _ := io.ReadAll(r.Body)
33+
if string(body) != `{"increment":1800,"lease_id":"database/creds/analytics_ro/123"}` {
34+
t.Fatalf("renew body = %s, want lease renew payload", string(body))
35+
}
36+
_, _ = w.Write([]byte(`{
37+
"lease_id":"database/creds/analytics_ro/123",
38+
"lease_duration":1800,
39+
"renewable":true
40+
}`))
3041
case "/v1/sys/leases/revoke":
3142
body, _ := io.ReadAll(r.Body)
3243
if string(body) != `{"lease_id":"database/creds/analytics_ro/123"}` {
@@ -51,6 +62,13 @@ func TestHTTPClient_GenerateCredentialsAndRevokeLease(t *testing.T) {
5162
if lease.Username != "dyn-user" || lease.LeaseID != "database/creds/analytics_ro/123" {
5263
t.Fatalf("lease = %#v, want parsed vault lease", lease)
5364
}
65+
renewed, err := client.RenewLease(context.Background(), lease.LeaseID, 30*time.Minute)
66+
if err != nil {
67+
t.Fatalf("RenewLease() error = %v", err)
68+
}
69+
if renewed.LeaseDuration != 30*time.Minute {
70+
t.Fatalf("renewed lease = %#v, want renewed duration", renewed)
71+
}
5472
if err := client.RevokeLease(context.Background(), lease.LeaseID); err != nil {
5573
t.Fatalf("RevokeLease() error = %v", err)
5674
}

internal/connectors/vaultdb/connector.go

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ type LeaseCredentials struct {
1616
Password string
1717
LeaseID string
1818
LeaseDuration time.Duration
19+
Renewable bool
1920
}
2021

2122
type Client interface {
2223
GenerateCredentials(ctx context.Context, role string) (*LeaseCredentials, error)
24+
RenewLease(ctx context.Context, leaseID string, increment time.Duration) (*LeaseCredentials, error)
2325
RevokeLease(ctx context.Context, leaseID string) error
2426
}
2527

@@ -111,6 +113,10 @@ func (c *Connector) Issue(ctx context.Context, req core.IssueRequest) (*core.Iss
111113
if err != nil {
112114
return nil, err
113115
}
116+
lease, leaseExpiresAt, err := c.extendLeaseForGrant(ctx, lease, req.Grant.ExpiresAt)
117+
if err != nil {
118+
return nil, err
119+
}
114120
dsn, err := renderDSN(c.roleDSNs[req.Resource.Name], lease)
115121
if err != nil {
116122
return nil, err
@@ -119,16 +125,17 @@ func (c *Connector) Issue(ctx context.Context, req core.IssueRequest) (*core.Iss
119125
return &core.IssuedArtifact{
120126
Kind: core.ArtifactKindWrappedSecret,
121127
Metadata: map[string]string{
122-
"artifact_id": "art_" + req.Grant.ID,
123-
"lease_id": lease.LeaseID,
124-
"db_role": req.Resource.Name,
128+
"artifact_id": "art_" + req.Grant.ID,
129+
"lease_id": lease.LeaseID,
130+
"lease_expires_at": leaseExpiresAt.UTC().Format(time.RFC3339),
131+
"db_role": req.Resource.Name,
125132
},
126133
SecretData: map[string]string{
127134
"username": lease.Username,
128135
"password": lease.Password,
129136
"dsn": dsn,
130137
},
131-
ExpiresAt: minTime(req.Grant.ExpiresAt, time.Now().Add(lease.LeaseDuration)),
138+
ExpiresAt: minTime(req.Grant.ExpiresAt, leaseExpiresAt),
132139
}, nil
133140
}
134141

@@ -193,6 +200,50 @@ func renderDSN(renderPattern string, lease *LeaseCredentials) (string, error) {
193200
return builder.String(), nil
194201
}
195202

203+
func (c *Connector) extendLeaseForGrant(ctx context.Context, lease *LeaseCredentials, grantExpiresAt time.Time) (*LeaseCredentials, time.Time, error) {
204+
now := time.Now()
205+
leaseExpiresAt := now.Add(lease.LeaseDuration)
206+
if grantExpiresAt.IsZero() || !grantExpiresAt.After(leaseExpiresAt) {
207+
return lease, leaseExpiresAt, nil
208+
}
209+
if lease.LeaseID == "" || !lease.Renewable {
210+
return nil, time.Time{}, fmt.Errorf("%w: vault lease expires before the requested grant ttl and is not renewable", core.ErrForbidden)
211+
}
212+
213+
remaining := time.Until(grantExpiresAt)
214+
renewed, err := c.client.RenewLease(ctx, lease.LeaseID, remaining)
215+
if err != nil {
216+
return nil, time.Time{}, err
217+
}
218+
merged := mergeLeaseCredentials(lease, renewed)
219+
leaseExpiresAt = time.Now().Add(merged.LeaseDuration)
220+
if grantExpiresAt.After(leaseExpiresAt) {
221+
return nil, time.Time{}, fmt.Errorf("%w: vault lease renewal for %q was capped before the requested grant ttl", core.ErrForbidden, lease.LeaseID)
222+
}
223+
return merged, leaseExpiresAt, nil
224+
}
225+
226+
func mergeLeaseCredentials(current *LeaseCredentials, updated *LeaseCredentials) *LeaseCredentials {
227+
if updated == nil {
228+
return current
229+
}
230+
merged := *current
231+
if updated.Username != "" {
232+
merged.Username = updated.Username
233+
}
234+
if updated.Password != "" {
235+
merged.Password = updated.Password
236+
}
237+
if updated.LeaseID != "" {
238+
merged.LeaseID = updated.LeaseID
239+
}
240+
if updated.LeaseDuration > 0 {
241+
merged.LeaseDuration = updated.LeaseDuration
242+
}
243+
merged.Renewable = updated.Renewable
244+
return &merged
245+
}
246+
196247
func minTime(a time.Time, b time.Time) time.Time {
197248
if a.IsZero() {
198249
return b

0 commit comments

Comments
 (0)