feat(rotators): abstract Rotator with macro registration + EnvFileRotator + 4-step orchestrator

Rotator base exposes the four lifecycle methods as abstract; subclasses
register at compile time via 'register_as :kind' macro. REGISTRY is
populated as soon as the rotator file is required - drop a new file in
src/cre/rotators/ and the orchestrator can dispatch to it.

EnvFileRotator implements the simplest rotation:
- generate: 32 random bytes -> base64-urlsafe (no padding)
- apply: write to PATH.pending atomically (in-place line replace, 0600)
- verify: parse pending file, confirm key=value present and non-empty
- commit: rename PATH.pending -> PATH (atomic on POSIX)
- rollback_apply: unlink PATH.pending

RotationOrchestrator runs the 4-step contract with full event publishing,
state machine transitions in DB (generating -> applying -> verifying ->
committing -> completed | failed), and rollback_apply on apply/verify
failure. 8 specs cover happy path + raised-during-apply + rollback verification.
This commit is contained in:
CarterPerez-dev 2026-04-29 00:59:01 -04:00
parent 538395ebcc
commit bad50daad2
6 changed files with 452 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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