feat(rotators): Vault dynamic-secrets rotator + thin Vault client

Vault::Client wraps token-auth REST: read_dynamic, revoke_lease,
renew_lease, health. Errors surface as VaultError carrying status.

VaultDynamicRotator's rotation contract leans on Vault as the secret
factory:
- generate: read_dynamic (Vault issues new creds + lease)
- apply: no-op (Vault already provisioned)
- verify: lease renewal acts as liveness check
- commit: revoke OLD lease (tracked in current_lease_id tag)
- rollback_apply: revoke NEW lease

8 unit specs cover client method round-trips, rotator full path with
old-lease revocation, verify-on-Vault-error handling, rollback, and
the no-old-lease pass-through case.
This commit is contained in:
CarterPerez-dev 2026-04-29 01:04:12 -04:00
parent 7e23f58fbb
commit d431e9014e
4 changed files with 312 additions and 0 deletions

View File

@ -0,0 +1,95 @@
# ===================
# ©AngelaMos | 2026
# vault_dynamic_spec.cr
# ===================
require "../../spec_helper"
require "webmock"
require "../../../src/cre/rotators/vault_dynamic"
WebMock.allow_net_connect = false
private def vault_credential(current_lease : String? = nil)
tags = {"role_path" => "database/creds/myrole"} of String => String
tags["current_lease_id"] = current_lease if current_lease
CRE::Domain::Credential.new(
id: UUID.random,
external_id: "database/creds/myrole",
kind: CRE::Domain::CredentialKind::VaultDynamic,
name: "myrole",
tags: tags,
)
end
private def vault_client
CRE::Vault::Client.new(addr: "http://vault.test", token: "tok")
end
describe CRE::Rotators::VaultDynamicRotator do
before_each { WebMock.reset }
it "executes the full 4-step contract with lease revocation on commit" do
cred = vault_credential(current_lease: "database/creds/myrole/old")
WebMock.stub(:get, "http://vault.test/v1/database/creds/myrole")
.to_return(body: %({"lease_id":"database/creds/myrole/new","lease_duration":3600,"data":{"username":"u","password":"p"}}))
rotator = CRE::Rotators::VaultDynamicRotator.new(vault_client)
rotator.can_rotate?(cred).should be_true
new_secret = rotator.generate(cred)
new_secret.metadata["lease_id"].should eq "database/creds/myrole/new"
new_secret.metadata["old_lease_id"].should eq "database/creds/myrole/old"
rotator.apply(cred, new_secret) # no-op
WebMock.stub(:put, "http://vault.test/v1/sys/leases/renew")
.to_return(body: %({"lease_id":"database/creds/myrole/new","lease_duration":3600}))
rotator.verify(cred, new_secret).should be_true
revoked = false
WebMock.stub(:put, "http://vault.test/v1/sys/leases/revoke")
.with(body: %({"lease_id":"database/creds/myrole/old"}))
.to_return { |_| revoked = true; HTTP::Client::Response.new(200, body: "{}") }
rotator.commit(cred, new_secret)
revoked.should be_true
end
it "verify returns false on Vault error" do
cred = vault_credential
WebMock.stub(:put, "http://vault.test/v1/sys/leases/renew")
.to_return(status: 403, body: %({"errors":["denied"]}))
rotator = CRE::Rotators::VaultDynamicRotator.new(vault_client)
s = CRE::Domain::NewSecret.new(
ciphertext: "{}".to_slice,
metadata: {"lease_id" => "x"},
)
rotator.verify(cred, s).should be_false
end
it "rollback_apply revokes the new lease" do
cred = vault_credential
rotator = CRE::Rotators::VaultDynamicRotator.new(vault_client)
s = CRE::Domain::NewSecret.new(
ciphertext: "{}".to_slice,
metadata: {"lease_id" => "new-lease-id"},
)
revoked = false
WebMock.stub(:put, "http://vault.test/v1/sys/leases/revoke")
.with(body: %({"lease_id":"new-lease-id"}))
.to_return { |_| revoked = true; HTTP::Client::Response.new(200, body: "{}") }
rotator.rollback_apply(cred, s)
revoked.should be_true
end
it "skips lease revocation when no current_lease_id" do
cred = vault_credential # no current_lease_id
WebMock.stub(:get, "http://vault.test/v1/database/creds/myrole")
.to_return(body: %({"lease_id":"new","lease_duration":3600,"data":{"username":"u","password":"p"}}))
rotator = CRE::Rotators::VaultDynamicRotator.new(vault_client)
s = rotator.generate(cred)
# commit should be a no-op (no old lease to revoke)
rotator.commit(cred, s)
# If a stub was missing webmock would have raised; absence proves no PUT happened.
end
end

View File

@ -0,0 +1,57 @@
# ===================
# ©AngelaMos | 2026
# client_spec.cr
# ===================
require "../../spec_helper"
require "webmock"
require "../../../src/cre/vault/client"
WebMock.allow_net_connect = false
private def fresh_client
CRE::Vault::Client.new(addr: "http://vault.test", token: "test-token")
end
describe CRE::Vault::Client do
before_each { WebMock.reset }
it "reads a dynamic secret" do
WebMock.stub(:get, "http://vault.test/v1/database/creds/myrole")
.with(headers: {"X-Vault-Token" => "test-token"})
.to_return(body: %({
"lease_id":"database/creds/myrole/abc",
"lease_duration":3600,
"data":{"username":"v-token-myrole-xyz","password":"hunter2"}
}))
secret = fresh_client.read_dynamic("database/creds/myrole")
secret.lease_id.should eq "database/creds/myrole/abc"
secret.lease_duration.should eq 3600
secret.username.should eq "v-token-myrole-xyz"
secret.password.should eq "hunter2"
end
it "revokes a lease" do
called = false
WebMock.stub(:put, "http://vault.test/v1/sys/leases/revoke")
.with(body: %({"lease_id":"database/creds/myrole/abc"}))
.to_return { |_| called = true; HTTP::Client::Response.new(200, body: "{}") }
fresh_client.revoke_lease("database/creds/myrole/abc")
called.should be_true
end
it "renews a lease" do
WebMock.stub(:put, "http://vault.test/v1/sys/leases/renew")
.to_return(body: %({"lease_id":"x","lease_duration":7200}))
fresh_client.renew_lease("x").should eq 7200
end
it "raises VaultError on non-2xx" do
WebMock.stub(:get, "http://vault.test/v1/database/creds/missing")
.to_return(status: 404, body: %({"errors":["role missing"]}))
expect_raises(CRE::Vault::VaultError) do
fresh_client.read_dynamic("database/creds/missing")
end
end
end

View File

@ -0,0 +1,85 @@
# ===================
# ©AngelaMos | 2026
# vault_dynamic.cr
# ===================
require "json"
require "../vault/client"
require "./rotator"
module CRE::Rotators
# VaultDynamicRotator manages dynamic-secrets-engine credentials in HashiCorp
# Vault. Vault itself is the secret factory: we ask it for fresh creds and
# revoke old leases on commit.
#
# Required Credential.tags:
# "role_path" - e.g. "database/creds/my-postgres-role"
# Optional "current_lease_id" - the lease to revoke on commit; if absent
# the rotator only revokes the NEW lease on rollback (apply step).
class VaultDynamicRotator < Rotator
register_as :vault_dynamic
def initialize(@client : Vault::Client)
end
def kind : Symbol
:vault_dynamic
end
def can_rotate?(c : Domain::Credential) : Bool
c.kind.vault_dynamic? && !c.tag("role_path").nil?
end
def generate(c : Domain::Credential) : Domain::NewSecret
raise RotatorError.new("missing 'role_path' tag") unless can_rotate?(c)
role_path = c.tag("role_path").not_nil!
ds = @client.read_dynamic(role_path)
payload = {
"username" => ds.username,
"password" => ds.password,
}.to_json
Domain::NewSecret.new(
ciphertext: payload.to_slice,
metadata: {
"lease_id" => ds.lease_id,
"lease_duration" => ds.lease_duration.to_s,
"old_lease_id" => c.tag("current_lease_id") || "",
"username" => ds.username,
},
)
end
def apply(c : Domain::Credential, s : Domain::NewSecret) : Nil
_ = {c, s}
# Vault already issued the new credentials and they're live. No-op.
end
def verify(c : Domain::Credential, s : Domain::NewSecret) : Bool
_ = c
lease_id = s.metadata["lease_id"]?
return false if lease_id.nil? || lease_id.empty?
# Lease renewal acts as a liveness check: if the lease is invalid Vault
# will return non-2xx and we get an exception.
@client.renew_lease(lease_id, increment: 0)
true
rescue
false
end
def commit(c : Domain::Credential, s : Domain::NewSecret) : Nil
_ = c
old = s.metadata["old_lease_id"]?
return if old.nil? || old.empty?
@client.revoke_lease(old)
end
def rollback_apply(c : Domain::Credential, s : Domain::NewSecret) : Nil
_ = c
lease_id = s.metadata["lease_id"]?
return if lease_id.nil? || lease_id.empty?
@client.revoke_lease(lease_id) rescue nil
end
end
end

View File

@ -0,0 +1,75 @@
# ===================
# ©AngelaMos | 2026
# client.cr
# ===================
require "http/client"
require "json"
module CRE::Vault
class VaultError < Exception
getter status : Int32
def initialize(message : String, @status : Int32)
super(message)
end
end
class Client
record DynamicSecret,
lease_id : String,
lease_duration : Int32,
username : String,
password : String
def initialize(@addr : String, @token : String)
end
def read_dynamic(role_path : String) : DynamicSecret
json = http_get("/v1/#{role_path}")
data = json["data"]
lease_id = json["lease_id"].as_s
lease_duration = json["lease_duration"].as_i
DynamicSecret.new(
lease_id: lease_id,
lease_duration: lease_duration,
username: data["username"].as_s,
password: data["password"].as_s,
)
end
def revoke_lease(lease_id : String) : Nil
http_put("/v1/sys/leases/revoke", {"lease_id" => lease_id}.to_json)
end
def renew_lease(lease_id : String, increment : Int32 = 0) : Int32
payload = increment > 0 ? {"lease_id" => lease_id, "increment" => increment} : {"lease_id" => lease_id}
json = http_put("/v1/sys/leases/renew", payload.to_json)
json["lease_duration"].as_i
end
def health : Hash(String, JSON::Any)
json = http_get("/v1/sys/health")
json.as_h
end
private def http_get(path : String) : JSON::Any
uri = URI.parse(@addr + path)
headers = HTTP::Headers{"X-Vault-Token" => @token}
response = HTTP::Client.get(uri.to_s, headers: headers)
raise VaultError.new("vault GET #{path}: #{response.body[0, 200]?}", response.status_code) unless response.status_code < 300
JSON.parse(response.body)
end
private def http_put(path : String, body : String) : JSON::Any
uri = URI.parse(@addr + path)
headers = HTTP::Headers{
"X-Vault-Token" => @token,
"Content-Type" => "application/json",
}
response = HTTP::Client.put(uri.to_s, headers: headers, body: body)
raise VaultError.new("vault PUT #{path}: #{response.body[0, 200]?}", response.status_code) unless response.status_code < 300
response.body.empty? ? JSON::Any.new(Hash(String, JSON::Any).new) : JSON.parse(response.body)
end
end
end