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

Commit 80461ee

Browse files
authored
Harden Vault DB lease handling (#65)
* Renew and track Vault DB leases * Revoke Vault DB leases on renewal failure
1 parent 73f03d2 commit 80461ee

4 files changed

Lines changed: 254 additions & 4 deletions

File tree

internal/connectors/vaultdb/client.go

Lines changed: 71 additions & 0 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
}
@@ -79,6 +93,7 @@ func (c *HTTPClient) GenerateCredentials(ctx context.Context, role string) (*Lea
7993
var payload struct {
8094
LeaseID string `json:"lease_id"`
8195
LeaseDuration int64 `json:"lease_duration"`
96+
Renewable bool `json:"renewable"`
8297
Data struct {
8398
Username string `json:"username"`
8499
Password string `json:"password"`
@@ -92,15 +107,71 @@ func (c *HTTPClient) GenerateCredentials(ctx context.Context, role string) (*Lea
92107
Password: payload.Data.Password,
93108
LeaseID: payload.LeaseID,
94109
LeaseDuration: time.Duration(payload.LeaseDuration) * time.Second,
110+
Renewable: payload.Renewable,
95111
}, nil
96112
}
97113

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+
98120
func (c *HTTPClient) RevokeLease(ctx context.Context, leaseID string) error {
99121
return resilience.Retry(ctx, c.revokeRetry, func(ctx context.Context) error {
100122
return c.revokeLeaseOnce(ctx, leaseID)
101123
})
102124
}
103125

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+
104175
func (c *HTTPClient) revokeLeaseOnce(ctx context.Context, leaseID string) error {
105176
body, err := json.Marshal(map[string]string{"lease_id": leaseID})
106177
if err != nil {

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: 59 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

@@ -117,6 +119,14 @@ func (c *Connector) Issue(ctx context.Context, req core.IssueRequest) (*core.Iss
117119
if err != nil {
118120
return nil, err
119121
}
122+
extendedLease, leaseExpiresAt, err := c.extendLeaseForGrant(ctx, lease, req.Grant.ExpiresAt)
123+
if err != nil {
124+
if lease != nil && lease.LeaseID != "" {
125+
_ = c.client.RevokeLease(ctx, lease.LeaseID)
126+
}
127+
return nil, err
128+
}
129+
lease = extendedLease
120130
dsn, err := renderDSN(c.roleDSNs[req.Resource.Name], lease)
121131
if err != nil {
122132
return nil, err
@@ -125,16 +135,17 @@ func (c *Connector) Issue(ctx context.Context, req core.IssueRequest) (*core.Iss
125135
return &core.IssuedArtifact{
126136
Kind: core.ArtifactKindWrappedSecret,
127137
Metadata: map[string]string{
128-
"artifact_id": "art_" + req.Grant.ID,
129-
"lease_id": lease.LeaseID,
130-
"db_role": req.Resource.Name,
138+
"artifact_id": "art_" + req.Grant.ID,
139+
"lease_id": lease.LeaseID,
140+
"lease_expires_at": leaseExpiresAt.UTC().Format(time.RFC3339),
141+
"db_role": req.Resource.Name,
131142
},
132143
SecretData: map[string]string{
133144
"username": lease.Username,
134145
"password": lease.Password,
135146
"dsn": dsn,
136147
},
137-
ExpiresAt: minTime(req.Grant.ExpiresAt, time.Now().Add(lease.LeaseDuration)),
148+
ExpiresAt: minTime(req.Grant.ExpiresAt, leaseExpiresAt),
138149
}, nil
139150
}
140151

@@ -203,6 +214,50 @@ func renderDSN(renderPattern string, lease *LeaseCredentials) (string, error) {
203214
return builder.String(), nil
204215
}
205216

217+
func (c *Connector) extendLeaseForGrant(ctx context.Context, lease *LeaseCredentials, grantExpiresAt time.Time) (*LeaseCredentials, time.Time, error) {
218+
now := time.Now()
219+
leaseExpiresAt := now.Add(lease.LeaseDuration)
220+
if grantExpiresAt.IsZero() || !grantExpiresAt.After(leaseExpiresAt) {
221+
return lease, leaseExpiresAt, nil
222+
}
223+
if lease.LeaseID == "" || !lease.Renewable {
224+
return nil, time.Time{}, fmt.Errorf("%w: vault lease expires before the requested grant ttl and is not renewable", core.ErrForbidden)
225+
}
226+
227+
remaining := time.Until(grantExpiresAt)
228+
renewed, err := c.client.RenewLease(ctx, lease.LeaseID, remaining)
229+
if err != nil {
230+
return nil, time.Time{}, err
231+
}
232+
merged := mergeLeaseCredentials(lease, renewed)
233+
leaseExpiresAt = time.Now().Add(merged.LeaseDuration)
234+
if grantExpiresAt.After(leaseExpiresAt) {
235+
return nil, time.Time{}, fmt.Errorf("%w: vault lease renewal for %q was capped before the requested grant ttl", core.ErrForbidden, lease.LeaseID)
236+
}
237+
return merged, leaseExpiresAt, nil
238+
}
239+
240+
func mergeLeaseCredentials(current *LeaseCredentials, updated *LeaseCredentials) *LeaseCredentials {
241+
if updated == nil {
242+
return current
243+
}
244+
merged := *current
245+
if updated.Username != "" {
246+
merged.Username = updated.Username
247+
}
248+
if updated.Password != "" {
249+
merged.Password = updated.Password
250+
}
251+
if updated.LeaseID != "" {
252+
merged.LeaseID = updated.LeaseID
253+
}
254+
if updated.LeaseDuration > 0 {
255+
merged.LeaseDuration = updated.LeaseDuration
256+
}
257+
merged.Renewable = updated.Renewable
258+
return &merged
259+
}
260+
206261
func minTime(a time.Time, b time.Time) time.Time {
207262
if a.IsZero() {
208263
return b

0 commit comments

Comments
 (0)