diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/engine/rotation_orchestrator_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/engine/rotation_orchestrator_spec.cr new file mode 100644 index 00000000..6b98dd1c --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/engine/rotation_orchestrator_spec.cr @@ -0,0 +1,131 @@ +# =================== +# ©AngelaMos | 2026 +# rotation_orchestrator_spec.cr +# =================== + +require "../../spec_helper" +require "../../../src/cre/engine/rotation_orchestrator" +require "../../../src/cre/persistence/sqlite/sqlite_persistence" +require "../../../src/cre/rotators/env_file" + +private def drain(ch : ::Channel(CRE::Events::Event)) : Array(CRE::Events::Event) + out = [] of CRE::Events::Event + loop do + select + when ev = ch.receive + out << ev + else + break + end + end + out +end + +private def env_credential(path : String, key : String) : CRE::Domain::Credential + CRE::Domain::Credential.new( + id: UUID.random, + external_id: "#{path}::#{key}", + kind: CRE::Domain::CredentialKind::EnvFile, + name: key, + tags: {"path" => path, "key" => key} of String => String, + ) +end + +describe CRE::Engine::RotationOrchestrator do + it "publishes the full event sequence on success" do + tmp = File.tempfile("cre_rot_") { |f| f << "K=v\n" } + cred = env_credential(tmp.path, "K") + + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:") + persist.migrate! + persist.credentials.insert(cred) + + bus = CRE::Engine::EventBus.new + ch = bus.subscribe + bus.run + + orchestrator = CRE::Engine::RotationOrchestrator.new(bus, persist) + state = orchestrator.run(cred, CRE::Rotators::EnvFileRotator.new) + + sleep 0.1.seconds + state.completed?.should be_true + + events = drain(ch).map(&.class.name) + events.should contain "CRE::Events::RotationStarted" + events.count("CRE::Events::RotationStepCompleted").should eq 4 + events.should contain "CRE::Events::RotationCompleted" + events.should_not contain "CRE::Events::RotationFailed" + + # rotation row recorded as completed + persist.rotations.in_flight.size.should eq 0 + ensure + bus.try(&.stop) + persist.try(&.close) + tmp.try(&.delete) + end + + it "handles a rotator that raises during apply via rollback" do + tmp = File.tempfile("cre_rot_fail_") { |f| f << "K=v\n" } + cred = env_credential(tmp.path, "K") + + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:") + persist.migrate! + persist.credentials.insert(cred) + + bus = CRE::Engine::EventBus.new + ch = bus.subscribe + bus.run + + failing = FailingRotator.new + state = CRE::Engine::RotationOrchestrator.new(bus, persist).run(cred, failing) + sleep 0.1.seconds + + state.failed?.should be_true + failing.rolled_back.should be_true + + events = drain(ch).map(&.class.name) + events.should contain "CRE::Events::RotationStepFailed" + events.should contain "CRE::Events::RotationFailed" + ensure + bus.try(&.stop) + persist.try(&.close) + tmp.try(&.delete) + end +end + +class FailingRotator < CRE::Rotators::Rotator + property rolled_back = false + + def kind : Symbol + :env_file + end + + def can_rotate?(c : CRE::Domain::Credential) : Bool + _ = c + true + end + + def generate(c : CRE::Domain::Credential) : CRE::Domain::NewSecret + _ = c + CRE::Domain::NewSecret.new(ciphertext: "x".to_slice) + end + + def apply(c : CRE::Domain::Credential, s : CRE::Domain::NewSecret) : Nil + _ = {c, s} + raise CRE::Rotators::RotatorError.new("apply boom") + end + + def verify(c : CRE::Domain::Credential, s : CRE::Domain::NewSecret) : Bool + _ = {c, s} + true + end + + def commit(c : CRE::Domain::Credential, s : CRE::Domain::NewSecret) : Nil + _ = {c, s} + end + + def rollback_apply(c : CRE::Domain::Credential, s : CRE::Domain::NewSecret) : Nil + _ = {c, s} + @rolled_back = true + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/env_file_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/env_file_spec.cr new file mode 100644 index 00000000..5b286a98 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/env_file_spec.cr @@ -0,0 +1,91 @@ +# =================== +# ©AngelaMos | 2026 +# env_file_spec.cr +# =================== + +require "../../spec_helper" +require "../../../src/cre/rotators/env_file" + +private def credential_for(path : String, key : String, bytes : Int32 = 32) + CRE::Domain::Credential.new( + id: UUID.random, + external_id: "#{path}::#{key}", + kind: CRE::Domain::CredentialKind::EnvFile, + name: key, + tags: {"path" => path, "key" => key, "bytes" => bytes.to_s} of String => String, + ) +end + +describe CRE::Rotators::EnvFileRotator do + it "executes the full 4-step contract" do + tmp = File.tempfile("cre_env_test_") do |f| + f << "API_KEY=oldvalue\nOTHER=keep\n" + end + path = tmp.path + cred = credential_for(path, "API_KEY") + rotator = CRE::Rotators::EnvFileRotator.new + + rotator.can_rotate?(cred).should be_true + + new_secret = rotator.generate(cred) + new_secret.metadata["key"].should eq "API_KEY" + new_secret.ciphertext.size.should be > 0 + + rotator.apply(cred, new_secret) + File.exists?("#{path}.pending").should be_true + rotator.verify(cred, new_secret).should be_true + + rotator.commit(cred, new_secret) + File.exists?("#{path}.pending").should be_false + + final = File.read(path) + new_value = String.new(new_secret.ciphertext) + final.includes?("API_KEY=#{new_value}").should be_true + final.includes?("OTHER=keep").should be_true + final.includes?("API_KEY=oldvalue").should be_false + ensure + tmp.try(&.delete) + end + + it "rollback_apply removes the pending file" do + tmp = File.tempfile("cre_env_rb_") do |f| + f << "K=v\n" + end + cred = credential_for(tmp.path, "K") + rotator = CRE::Rotators::EnvFileRotator.new + + s = rotator.generate(cred) + rotator.apply(cred, s) + File.exists?("#{tmp.path}.pending").should be_true + + rotator.rollback_apply(cred, s) + File.exists?("#{tmp.path}.pending").should be_false + File.read(tmp.path).should eq "K=v\n" + ensure + tmp.try(&.delete) + end + + it "can_rotate? returns false without required tags" do + bad_cred = CRE::Domain::Credential.new( + id: UUID.random, external_id: "x", + kind: CRE::Domain::CredentialKind::EnvFile, + name: "x", tags: {} of String => String, + ) + CRE::Rotators::EnvFileRotator.new.can_rotate?(bad_cred).should be_false + end + + it "creates the file if missing" do + path = File.tempname("cre_env_new_", ".env") + cred = credential_for(path, "FRESH") + rotator = CRE::Rotators::EnvFileRotator.new + + s = rotator.generate(cred) + rotator.apply(cred, s) + rotator.verify(cred, s).should be_true + rotator.commit(cred, s) + File.exists?(path).should be_true + File.read(path).includes?("FRESH=").should be_true + ensure + File.delete(path) if path && File.exists?(path) + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/rotator_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/rotator_spec.cr new file mode 100644 index 00000000..897abb63 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/rotator_spec.cr @@ -0,0 +1,20 @@ +# =================== +# ©AngelaMos | 2026 +# rotator_spec.cr +# =================== + +require "../../spec_helper" +require "../../../src/cre/rotators/rotator" +require "../../../src/cre/rotators/env_file" + +describe CRE::Rotators::Rotator do + it "registers concrete rotator subclasses via register_as macro" do + CRE::Rotators::Rotator::REGISTRY[:env_file]?.should_not be_nil + CRE::Rotators::Rotator.for(:env_file).should eq CRE::Rotators::EnvFileRotator + CRE::Rotators::Rotator.registered_kinds.should contain(:env_file) + end + + it "for returns nil for unknown kinds" do + CRE::Rotators::Rotator.for(:nonexistent).should be_nil + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/engine/rotation_orchestrator.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/engine/rotation_orchestrator.cr new file mode 100644 index 00000000..899315e8 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/engine/rotation_orchestrator.cr @@ -0,0 +1,93 @@ +# =================== +# ©AngelaMos | 2026 +# rotation_orchestrator.cr +# =================== + +require "log" +require "uuid" +require "./event_bus" +require "../events/credential_events" +require "../rotators/rotator" +require "../persistence/persistence" +require "../persistence/repos" + +module CRE::Engine + class VerifyFailed < Exception; end + + class RotationOrchestrator + Log = ::Log.for("cre.rotator") + + def initialize(@bus : EventBus, @persistence : Persistence::Persistence) + end + + def run(c : Domain::Credential, rotator : Rotators::Rotator) : Persistence::RotationState + rotation_id = UUID.random + record = Persistence::RotationRecord.new( + id: rotation_id, + credential_id: c.id, + rotator_kind: kind_to_enum(rotator.kind), + state: Persistence::RotationState::Generating, + started_at: Time.utc, + completed_at: nil, + failure_reason: nil, + ) + @persistence.rotations.insert(record) + @bus.publish Events::RotationStarted.new(c.id, rotation_id, rotator.kind.to_s) + + new_secret = nil + current_step = :generate + begin + @bus.publish Events::RotationStepStarted.new(c.id, rotation_id, :generate) + new_secret = rotator.generate(c) + @persistence.rotations.update_state(rotation_id, Persistence::RotationState::Applying) + @bus.publish Events::RotationStepCompleted.new(c.id, rotation_id, :generate) + + current_step = :apply + @bus.publish Events::RotationStepStarted.new(c.id, rotation_id, :apply) + rotator.apply(c, new_secret) + @persistence.rotations.update_state(rotation_id, Persistence::RotationState::Verifying) + @bus.publish Events::RotationStepCompleted.new(c.id, rotation_id, :apply) + + current_step = :verify + @bus.publish Events::RotationStepStarted.new(c.id, rotation_id, :verify) + ok = rotator.verify(c, new_secret) + raise VerifyFailed.new("verify returned false") unless ok + @persistence.rotations.update_state(rotation_id, Persistence::RotationState::Committing) + @bus.publish Events::RotationStepCompleted.new(c.id, rotation_id, :verify) + + current_step = :commit + @bus.publish Events::RotationStepStarted.new(c.id, rotation_id, :commit) + rotator.commit(c, new_secret) + @persistence.rotations.update_state(rotation_id, Persistence::RotationState::Completed) + @bus.publish Events::RotationStepCompleted.new(c.id, rotation_id, :commit) + + @bus.publish Events::RotationCompleted.new(c.id, rotation_id) + Persistence::RotationState::Completed + rescue ex + if ns = new_secret + if current_step == :apply || current_step == :verify + begin + rotator.rollback_apply(c, ns) + rescue rb + Log.error(exception: rb) { "rollback_apply failed for credential #{c.id}" } + end + end + end + @persistence.rotations.update_state(rotation_id, Persistence::RotationState::Failed, ex.message || ex.class.name) + @bus.publish Events::RotationStepFailed.new(c.id, rotation_id, current_step, ex.message || ex.class.name) + @bus.publish Events::RotationFailed.new(c.id, rotation_id, ex.message || ex.class.name) + Persistence::RotationState::Failed + end + end + + private def kind_to_enum(kind : Symbol) : Persistence::RotatorKind + case kind + when :aws_secretsmgr then Persistence::RotatorKind::AwsSecretsmgr + when :vault_dynamic then Persistence::RotatorKind::VaultDynamic + when :github_pat then Persistence::RotatorKind::GithubPat + when :env_file then Persistence::RotatorKind::EnvFile + else raise "unknown rotator kind #{kind}" + end + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/env_file.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/env_file.cr new file mode 100644 index 00000000..201a6a3d --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/env_file.cr @@ -0,0 +1,78 @@ +# =================== +# ©AngelaMos | 2026 +# env_file.cr +# =================== + +require "../crypto/random" +require "./rotator" + +module CRE::Rotators + # EnvFileRotator manages credentials stored as KEY=value lines in a .env file. + # The rotation produces fresh random bytes (base64-encoded) and atomically + # swaps the live file on commit using temp+rename. + # + # Credential.tags must include: + # "path" - absolute path to the .env file + # "key" - the key whose value rotates + class EnvFileRotator < Rotator + register_as :env_file + + DEFAULT_BYTES = 32 + + def kind : Symbol + :env_file + end + + def can_rotate?(c : Domain::Credential) : Bool + c.kind.env_file? && !c.tag("path").nil? && !c.tag("key").nil? + end + + def generate(c : Domain::Credential) : Domain::NewSecret + raise RotatorError.new("missing 'path' or 'key' tag") unless can_rotate?(c) + bytes = (c.tag("bytes") || DEFAULT_BYTES.to_s).to_i + raw = CRE::Crypto::Random.bytes(bytes) + encoded = Base64.urlsafe_encode(raw, padding: false) + Domain::NewSecret.new( + ciphertext: encoded.to_slice, + metadata: {"key" => c.tag("key").not_nil!}, + ) + end + + def apply(c : Domain::Credential, s : Domain::NewSecret) : Nil + path = c.tag("path").not_nil! + key = c.tag("key").not_nil! + pending_path = "#{path}.pending" + + existing = File.exists?(path) ? File.read(path) : "" + lines = existing.lines.reject { |line| line.strip.starts_with?("#{key}=") } + new_value = String.new(s.ciphertext) + lines << "#{key}=#{new_value}\n" + + File.write(pending_path, lines.join, perm: 0o600) + end + + def verify(c : Domain::Credential, s : Domain::NewSecret) : Bool + path = c.tag("path").not_nil! + key = c.tag("key").not_nil! + pending_path = "#{path}.pending" + return false unless File.exists?(pending_path) + + content = File.read(pending_path) + expected_line = "#{key}=#{String.new(s.ciphertext)}" + content.includes?(expected_line) && content.bytesize > 0 + end + + def commit(c : Domain::Credential, _s : Domain::NewSecret) : Nil + path = c.tag("path").not_nil! + pending_path = "#{path}.pending" + raise RotatorError.new("pending file missing at commit time: #{pending_path}") unless File.exists?(pending_path) + File.rename(pending_path, path) + end + + def rollback_apply(c : Domain::Credential, _s : Domain::NewSecret) : Nil + path = c.tag("path").not_nil! + pending_path = "#{path}.pending" + File.delete(pending_path) if File.exists?(pending_path) + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/rotator.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/rotator.cr new file mode 100644 index 00000000..093187f1 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/rotator.cr @@ -0,0 +1,39 @@ +# =================== +# ©AngelaMos | 2026 +# rotator.cr +# =================== + +require "../domain/credential" +require "../domain/new_secret" + +module CRE::Rotators + class RotatorError < Exception; end + + abstract class Rotator + REGISTRY = {} of Symbol => Rotator.class + + abstract def kind : Symbol + abstract def can_rotate?(c : Domain::Credential) : Bool + + abstract def generate(c : Domain::Credential) : Domain::NewSecret + abstract def apply(c : Domain::Credential, s : Domain::NewSecret) : Nil + abstract def verify(c : Domain::Credential, s : Domain::NewSecret) : Bool + abstract def commit(c : Domain::Credential, s : Domain::NewSecret) : Nil + + # Default no-op; rotators override when apply() creates reversible side effects. + def rollback_apply(c : Domain::Credential, s : Domain::NewSecret) : Nil + end + + macro register_as(kind) + ::CRE::Rotators::Rotator::REGISTRY[{{ kind }}] = self + end + + def self.for(kind : Symbol) : Rotator.class | Nil + REGISTRY[kind]? + end + + def self.registered_kinds : Array(Symbol) + REGISTRY.keys + end + end +end