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:
parent
7e23f58fbb
commit
d431e9014e
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue