From d431e9014e68868f5c8d70d9bed228be52797b34 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 29 Apr 2026 01:04:12 -0400 Subject: [PATCH] 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. --- .../spec/unit/rotators/vault_dynamic_spec.cr | 95 +++++++++++++++++++ .../spec/unit/vault/client_spec.cr | 57 +++++++++++ .../src/cre/rotators/vault_dynamic.cr | 85 +++++++++++++++++ .../src/cre/vault/client.cr | 75 +++++++++++++++ 4 files changed, 312 insertions(+) create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/vault_dynamic_spec.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/vault/client_spec.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/vault_dynamic.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/vault/client.cr diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/vault_dynamic_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/vault_dynamic_spec.cr new file mode 100644 index 00000000..1571e890 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/vault_dynamic_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/vault/client_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/vault/client_spec.cr new file mode 100644 index 00000000..0f8ae663 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/vault/client_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/vault_dynamic.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/vault_dynamic.cr new file mode 100644 index 00000000..9648eb21 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/vault_dynamic.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/vault/client.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/vault/client.cr new file mode 100644 index 00000000..329628c6 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/vault/client.cr @@ -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