feat(engine): typed event hierarchy + EventBus with fanout + AuditSubscriber + Engine
Events form a Crystal class hierarchy so subscribers pattern-match exhaustively. EventBus delivers to subscribers via Crystal channels with per-subscriber overflow policy (Block for audit, Drop for best-effort). AuditSubscriber listens for all rotation/policy/drift/alert events and serializes them into the hash-chained audit log via AuditLog.append. Engine wires Persistence + AuditLog + AuditSubscriber + EventBus together so callers just .start/.stop. 7 unit specs verify boot, fanout to N subscribers, drop-on-overflow semantics, and end-to-end event -> audit row.
This commit is contained in:
parent
fe5e9cd07b
commit
2ba81cfd82
|
|
@ -0,0 +1,62 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# audit_subscriber_spec.cr
|
||||
# ===================
|
||||
|
||||
require "../../spec_helper"
|
||||
require "../../../src/cre/engine/event_bus"
|
||||
require "../../../src/cre/engine/subscribers/audit_subscriber"
|
||||
require "../../../src/cre/audit/audit_log"
|
||||
require "../../../src/cre/persistence/sqlite/sqlite_persistence"
|
||||
require "../../../src/cre/events/credential_events"
|
||||
|
||||
describe CRE::Engine::Subscribers::AuditSubscriber do
|
||||
it "writes RotationCompleted events to the audit log" do
|
||||
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
|
||||
persist.migrate!
|
||||
log = CRE::Audit::AuditLog.new(persist, Bytes.new(32, 0_u8), 1, 1024)
|
||||
|
||||
bus = CRE::Engine::EventBus.new
|
||||
sub = CRE::Engine::Subscribers::AuditSubscriber.new(bus, log)
|
||||
sub.start
|
||||
bus.run
|
||||
|
||||
cred_id = UUID.random
|
||||
rot_id = UUID.random
|
||||
bus.publish(CRE::Events::RotationCompleted.new(cred_id, rot_id))
|
||||
sleep 0.1.seconds
|
||||
|
||||
persist.audit.latest_seq.should eq 1_i64
|
||||
entry = persist.audit.range(1_i64, 1_i64).first
|
||||
entry.event_type.should eq "rotation.completed"
|
||||
entry.target_id.should eq cred_id
|
||||
ensure
|
||||
bus.try(&.stop)
|
||||
sub.try(&.stop)
|
||||
persist.try(&.close)
|
||||
end
|
||||
|
||||
it "writes multiple event types correctly" do
|
||||
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
|
||||
persist.migrate!
|
||||
log = CRE::Audit::AuditLog.new(persist, Bytes.new(32, 0_u8), 1, 1024)
|
||||
bus = CRE::Engine::EventBus.new
|
||||
sub = CRE::Engine::Subscribers::AuditSubscriber.new(bus, log)
|
||||
sub.start
|
||||
bus.run
|
||||
|
||||
cred_id = UUID.random
|
||||
bus.publish(CRE::Events::PolicyViolation.new(cred_id, "test-policy", "stale"))
|
||||
bus.publish(CRE::Events::DriftDetected.new(cred_id, "h1", "h2"))
|
||||
sleep 0.1.seconds
|
||||
|
||||
persist.audit.latest_seq.should eq 2_i64
|
||||
entries = persist.audit.range(1_i64, 2_i64)
|
||||
entries.map(&.event_type).should eq ["policy.violation", "drift.detected"]
|
||||
log.verify_chain.should be_true
|
||||
ensure
|
||||
bus.try(&.stop)
|
||||
sub.try(&.stop)
|
||||
persist.try(&.close)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# engine_spec.cr
|
||||
# ===================
|
||||
|
||||
require "../../spec_helper"
|
||||
require "../../../src/cre/engine/engine"
|
||||
require "../../../src/cre/persistence/sqlite/sqlite_persistence"
|
||||
require "../../../src/cre/events/credential_events"
|
||||
|
||||
describe CRE::Engine::Engine do
|
||||
it "boots, accepts events, and shuts down cleanly" do
|
||||
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
|
||||
persist.migrate!
|
||||
|
||||
engine = CRE::Engine::Engine.new(persist, Bytes.new(32, 0_u8))
|
||||
engine.start
|
||||
|
||||
cred_id = UUID.random
|
||||
rot_id = UUID.random
|
||||
engine.bus.publish(CRE::Events::RotationCompleted.new(cred_id, rot_id))
|
||||
sleep 0.1.seconds
|
||||
|
||||
persist.audit.latest_seq.should eq 1_i64
|
||||
engine.audit_log.verify_chain.should be_true
|
||||
|
||||
engine.stop
|
||||
ensure
|
||||
persist.try(&.close)
|
||||
end
|
||||
|
||||
it "raises if started twice" do
|
||||
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
|
||||
persist.migrate!
|
||||
engine = CRE::Engine::Engine.new(persist, Bytes.new(32, 0_u8))
|
||||
engine.start
|
||||
expect_raises(Exception, "already started") { engine.start }
|
||||
engine.stop
|
||||
ensure
|
||||
persist.try(&.close)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# event_bus_spec.cr
|
||||
# ===================
|
||||
|
||||
require "../../spec_helper"
|
||||
require "../../../src/cre/engine/event_bus"
|
||||
require "../../../src/cre/events/system_events"
|
||||
|
||||
describe CRE::Engine::EventBus do
|
||||
it "delivers events to subscribers" do
|
||||
bus = CRE::Engine::EventBus.new
|
||||
bus.run
|
||||
received = [] of String
|
||||
received_mutex = Mutex.new
|
||||
ch = bus.subscribe(buffer: 16, overflow: CRE::Engine::EventBus::Overflow::Block)
|
||||
spawn do
|
||||
loop do
|
||||
ev = ch.receive
|
||||
received_mutex.synchronize { received << ev.class.name }
|
||||
rescue Channel::ClosedError
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
bus.publish(CRE::Events::AlertRaised.new(severity: CRE::Events::Severity::Warn, message: "hi"))
|
||||
sleep 0.1.seconds
|
||||
|
||||
received_mutex.synchronize { received.should contain("CRE::Events::AlertRaised") }
|
||||
ensure
|
||||
bus.try(&.stop)
|
||||
end
|
||||
|
||||
it "fans out to multiple subscribers" do
|
||||
bus = CRE::Engine::EventBus.new
|
||||
bus.run
|
||||
|
||||
counter1 = Atomic(Int32).new(0)
|
||||
counter2 = Atomic(Int32).new(0)
|
||||
ch1 = bus.subscribe
|
||||
ch2 = bus.subscribe
|
||||
spawn do
|
||||
loop do
|
||||
ch1.receive
|
||||
counter1.add(1)
|
||||
rescue Channel::ClosedError
|
||||
break
|
||||
end
|
||||
end
|
||||
spawn do
|
||||
loop do
|
||||
ch2.receive
|
||||
counter2.add(1)
|
||||
rescue Channel::ClosedError
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
3.times { bus.publish(CRE::Events::SchedulerTick.new) }
|
||||
sleep 0.1.seconds
|
||||
|
||||
counter1.get.should eq 3
|
||||
counter2.get.should eq 3
|
||||
ensure
|
||||
bus.try(&.stop)
|
||||
end
|
||||
|
||||
it "drops on Drop overflow when subscriber is slow" do
|
||||
bus = CRE::Engine::EventBus.new
|
||||
bus.run
|
||||
ch = bus.subscribe(buffer: 1, overflow: CRE::Engine::EventBus::Overflow::Drop)
|
||||
5.times { bus.publish(CRE::Events::SchedulerTick.new) }
|
||||
sleep 0.1.seconds
|
||||
# The slow subscriber's channel buffered at most 1 event; rest were dropped.
|
||||
# Drain non-blocking: we should be able to take 0 or 1 event before it would block.
|
||||
delivered = 0
|
||||
drained = false
|
||||
until drained
|
||||
select
|
||||
when ch.receive
|
||||
delivered += 1
|
||||
else
|
||||
drained = true
|
||||
end
|
||||
end
|
||||
delivered.should be <= 5
|
||||
delivered.should be < 5 # at least one was dropped
|
||||
ensure
|
||||
bus.try(&.stop)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# engine.cr
|
||||
# ===================
|
||||
|
||||
require "log"
|
||||
require "./event_bus"
|
||||
require "./subscribers/audit_subscriber"
|
||||
require "../audit/audit_log"
|
||||
require "../persistence/persistence"
|
||||
|
||||
module CRE::Engine
|
||||
class Engine
|
||||
Log = ::Log.for("cre.engine")
|
||||
|
||||
getter bus : EventBus
|
||||
getter persistence : Persistence::Persistence
|
||||
getter audit_log : Audit::AuditLog
|
||||
|
||||
@audit_subscriber : Subscribers::AuditSubscriber
|
||||
@started : Bool
|
||||
|
||||
def initialize(@persistence : Persistence::Persistence, hmac_key : Bytes, hmac_version : Int32 = 1, ratchet_every : Int32 = 1024)
|
||||
@bus = EventBus.new
|
||||
@audit_log = Audit::AuditLog.new(@persistence, hmac_key, hmac_version, ratchet_every)
|
||||
@audit_subscriber = Subscribers::AuditSubscriber.new(@bus, @audit_log)
|
||||
@started = false
|
||||
end
|
||||
|
||||
def start : Nil
|
||||
raise "engine already started" if @started
|
||||
@started = true
|
||||
@audit_subscriber.start
|
||||
@bus.run
|
||||
Log.info { "engine started" }
|
||||
end
|
||||
|
||||
def stop : Nil
|
||||
return unless @started
|
||||
Log.info { "engine stopping" }
|
||||
@bus.publish(Events::ShutdownRequested.new) rescue nil
|
||||
sleep 0.05.seconds # let subscribers see it
|
||||
@audit_subscriber.stop
|
||||
@bus.stop
|
||||
@started = false
|
||||
Log.info { "engine stopped" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# event_bus.cr
|
||||
# ===================
|
||||
|
||||
require "log"
|
||||
require "../events/event"
|
||||
|
||||
module CRE::Engine
|
||||
class EventBus
|
||||
Log = ::Log.for("cre.event_bus")
|
||||
|
||||
enum Overflow
|
||||
Block
|
||||
Drop
|
||||
end
|
||||
|
||||
record Subscription, channel : Channel(Events::Event), overflow : Overflow
|
||||
|
||||
@inbox : Channel(Events::Event)
|
||||
@subs : Array(Subscription)
|
||||
@subs_mutex : Mutex
|
||||
@running : Bool
|
||||
|
||||
def initialize(inbox_capacity : Int32 = 1024)
|
||||
@inbox = Channel(Events::Event).new(capacity: inbox_capacity)
|
||||
@subs = [] of Subscription
|
||||
@subs_mutex = Mutex.new
|
||||
@running = false
|
||||
end
|
||||
|
||||
def subscribe(buffer : Int32 = 64, overflow : Overflow = Overflow::Block) : Channel(Events::Event)
|
||||
ch = Channel(Events::Event).new(capacity: buffer)
|
||||
@subs_mutex.synchronize { @subs << Subscription.new(ch, overflow) }
|
||||
ch
|
||||
end
|
||||
|
||||
def publish(event : Events::Event) : Nil
|
||||
@inbox.send(event)
|
||||
end
|
||||
|
||||
def run : Nil
|
||||
@running = true
|
||||
spawn(name: "event-bus") do
|
||||
while @running
|
||||
begin
|
||||
ev = @inbox.receive
|
||||
rescue Channel::ClosedError
|
||||
break
|
||||
end
|
||||
@subs_mutex.synchronize { @subs.dup }.each { |s| dispatch(s, ev) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def stop : Nil
|
||||
@running = false
|
||||
@inbox.close
|
||||
@subs_mutex.synchronize do
|
||||
@subs.each(&.channel.close)
|
||||
end
|
||||
end
|
||||
|
||||
private def dispatch(sub : Subscription, ev : Events::Event) : Nil
|
||||
case sub.overflow
|
||||
in Overflow::Block
|
||||
sub.channel.send(ev)
|
||||
in Overflow::Drop
|
||||
select
|
||||
when sub.channel.send(ev)
|
||||
# delivered
|
||||
else
|
||||
Log.warn { "subscriber drop: #{ev.class.name}" }
|
||||
end
|
||||
end
|
||||
rescue Channel::ClosedError
|
||||
# subscriber gone; remove from list lazily on next dispatch
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# audit_subscriber.cr
|
||||
# ===================
|
||||
|
||||
require "../event_bus"
|
||||
require "../../audit/audit_log"
|
||||
require "../../events/credential_events"
|
||||
require "../../events/system_events"
|
||||
|
||||
module CRE::Engine::Subscribers
|
||||
class AuditSubscriber
|
||||
@ch : Channel(Events::Event)?
|
||||
@running : Bool
|
||||
|
||||
def initialize(@bus : EventBus, @log : Audit::AuditLog, @actor : String = "system")
|
||||
@running = false
|
||||
end
|
||||
|
||||
def start : Nil
|
||||
@running = true
|
||||
ch = @bus.subscribe(buffer: 256, overflow: EventBus::Overflow::Block)
|
||||
@ch = ch
|
||||
spawn(name: "audit-sub") do
|
||||
while @running
|
||||
begin
|
||||
ev = ch.receive
|
||||
rescue Channel::ClosedError
|
||||
break
|
||||
end
|
||||
handle(ev)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def stop : Nil
|
||||
@running = false
|
||||
@ch.try(&.close)
|
||||
end
|
||||
|
||||
private def handle(ev : Events::Event) : Nil
|
||||
case ev
|
||||
when Events::RotationCompleted
|
||||
@log.append("rotation.completed", @actor, ev.credential_id, {
|
||||
"rotation_id" => ev.rotation_id.to_s,
|
||||
})
|
||||
when Events::RotationFailed
|
||||
@log.append("rotation.failed", @actor, ev.credential_id, {
|
||||
"rotation_id" => ev.rotation_id.to_s,
|
||||
"reason" => ev.reason,
|
||||
})
|
||||
when Events::RotationStepCompleted
|
||||
@log.append("rotation.step.completed", @actor, ev.credential_id, {
|
||||
"rotation_id" => ev.rotation_id.to_s,
|
||||
"step" => ev.step.to_s,
|
||||
})
|
||||
when Events::RotationStepFailed
|
||||
@log.append("rotation.step.failed", @actor, ev.credential_id, {
|
||||
"rotation_id" => ev.rotation_id.to_s,
|
||||
"step" => ev.step.to_s,
|
||||
"error" => ev.error,
|
||||
})
|
||||
when Events::PolicyViolation
|
||||
@log.append("policy.violation", @actor, ev.credential_id, {
|
||||
"policy_name" => ev.policy_name,
|
||||
"reason" => ev.reason,
|
||||
})
|
||||
when Events::DriftDetected
|
||||
@log.append("drift.detected", @actor, ev.credential_id, {
|
||||
"expected_hash" => ev.expected_hash,
|
||||
"actual_hash" => ev.actual_hash,
|
||||
})
|
||||
when Events::CredentialDiscovered
|
||||
@log.append("credential.discovered", @actor, ev.credential_id, {} of String => String)
|
||||
when Events::AlertRaised
|
||||
@log.append("alert.raised", @actor, nil, {
|
||||
"severity" => ev.severity.to_s,
|
||||
"message" => ev.message,
|
||||
})
|
||||
end
|
||||
rescue ex
|
||||
EventBus::Log.error(exception: ex) { "audit subscriber failed to write" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# credential_events.cr
|
||||
# ===================
|
||||
|
||||
require "uuid"
|
||||
require "./event"
|
||||
|
||||
module CRE::Events
|
||||
abstract class CredentialEvent < Event
|
||||
getter credential_id : UUID
|
||||
|
||||
def initialize(@credential_id : UUID)
|
||||
super()
|
||||
end
|
||||
end
|
||||
|
||||
class CredentialDiscovered < CredentialEvent
|
||||
end
|
||||
|
||||
class PolicyViolation < CredentialEvent
|
||||
getter policy_name : String
|
||||
getter reason : String
|
||||
|
||||
def initialize(credential_id : UUID, @policy_name : String, @reason : String)
|
||||
super(credential_id)
|
||||
end
|
||||
end
|
||||
|
||||
class RotationScheduled < CredentialEvent
|
||||
getter rotator_kind : String
|
||||
|
||||
def initialize(credential_id : UUID, @rotator_kind : String)
|
||||
super(credential_id)
|
||||
end
|
||||
end
|
||||
|
||||
class RotationStarted < CredentialEvent
|
||||
getter rotation_id : UUID
|
||||
getter rotator_kind : String
|
||||
|
||||
def initialize(credential_id : UUID, @rotation_id : UUID, @rotator_kind : String)
|
||||
super(credential_id)
|
||||
end
|
||||
end
|
||||
|
||||
class RotationStepStarted < CredentialEvent
|
||||
getter rotation_id : UUID
|
||||
getter step : Symbol
|
||||
|
||||
def initialize(credential_id : UUID, @rotation_id : UUID, @step : Symbol)
|
||||
super(credential_id)
|
||||
end
|
||||
end
|
||||
|
||||
class RotationStepCompleted < CredentialEvent
|
||||
getter rotation_id : UUID
|
||||
getter step : Symbol
|
||||
|
||||
def initialize(credential_id : UUID, @rotation_id : UUID, @step : Symbol)
|
||||
super(credential_id)
|
||||
end
|
||||
end
|
||||
|
||||
class RotationStepFailed < CredentialEvent
|
||||
getter rotation_id : UUID
|
||||
getter step : Symbol
|
||||
getter error : String
|
||||
|
||||
def initialize(credential_id : UUID, @rotation_id : UUID, @step : Symbol, @error : String)
|
||||
super(credential_id)
|
||||
end
|
||||
end
|
||||
|
||||
class RotationCompleted < CredentialEvent
|
||||
getter rotation_id : UUID
|
||||
|
||||
def initialize(credential_id : UUID, @rotation_id : UUID)
|
||||
super(credential_id)
|
||||
end
|
||||
end
|
||||
|
||||
class RotationFailed < CredentialEvent
|
||||
getter rotation_id : UUID
|
||||
getter reason : String
|
||||
|
||||
def initialize(credential_id : UUID, @rotation_id : UUID, @reason : String)
|
||||
super(credential_id)
|
||||
end
|
||||
end
|
||||
|
||||
class DriftDetected < CredentialEvent
|
||||
getter expected_hash : String
|
||||
getter actual_hash : String
|
||||
|
||||
def initialize(credential_id : UUID, @expected_hash : String, @actual_hash : String)
|
||||
super(credential_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# event.cr
|
||||
# ===================
|
||||
|
||||
require "uuid"
|
||||
|
||||
module CRE::Events
|
||||
abstract class Event
|
||||
getter id : UUID = UUID.random
|
||||
getter occurred_at : Time = Time.utc
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# system_events.cr
|
||||
# ===================
|
||||
|
||||
require "./event"
|
||||
|
||||
module CRE::Events
|
||||
enum Severity
|
||||
Info
|
||||
Warn
|
||||
Critical
|
||||
end
|
||||
|
||||
class AlertRaised < Event
|
||||
getter severity : Severity
|
||||
getter message : String
|
||||
|
||||
def initialize(@severity : Severity, @message : String)
|
||||
super()
|
||||
end
|
||||
end
|
||||
|
||||
class SchedulerTick < Event
|
||||
end
|
||||
|
||||
class ShutdownRequested < Event
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue