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:
parent
538395ebcc
commit
bad50daad2
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue