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