feat(policy): Crystal Policy as Code DSL with macros + compile-time enum autocast

- Policy struct: matcher Proc, max_age, warn_at, enforce_action, notify_channels, triggers
- Builder validates all required fields at .build, raises BuilderError with the policy name
- DSL exposes top-level 'policy' method via 'with builder yield' so all
  builder methods (description, match, max_age, enforce, notify_via, etc.)
  are receiver-less inside the block
- Symbol literals autocast to Action/Channel/Trigger enums on direct calls;
  Symbol overload for splat parameters (notify_via :telegram, :email)
- Evaluator subscribes to bus, fires PolicyViolation + RotationScheduled
  (rotate_immediately) or AlertRaised (notify_only/quarantine) when overdue
- 15 unit specs cover Policy.matches?/overdue?/in_warning_window?,
  Builder validation, DSL syntax with closures, evaluator action dispatch
This commit is contained in:
CarterPerez-dev 2026-04-29 00:56:29 -04:00
parent 2ba81cfd82
commit 538395ebcc
8 changed files with 679 additions and 0 deletions

View File

@ -0,0 +1,50 @@
# ===================
# ©AngelaMos | 2026
# builder_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/policy/builder"
describe CRE::Policy::Builder do
it "builds a complete policy" do
b = CRE::Policy::Builder.new("p1")
b.description("desc")
b.match { |c| c.kind.env_file? }
b.max_age(30.days)
b.warn_at(25.days)
b.enforce(CRE::Policy::Action::RotateImmediately)
b.notify_via(CRE::Policy::Channel::Telegram, CRE::Policy::Channel::StructuredLog)
b.on_rotation_failure(CRE::Policy::Action::Quarantine)
p = b.build
p.name.should eq "p1"
p.description.should eq "desc"
p.max_age.should eq 30.days
p.warn_at.should eq 25.days
p.enforce_action.should eq CRE::Policy::Action::RotateImmediately
p.notify_channels.size.should eq 2
p.trigger_action_for(CRE::Policy::Trigger::OnRotationFailure).should eq CRE::Policy::Action::Quarantine
end
it "raises on missing match" do
b = CRE::Policy::Builder.new("p")
b.max_age(7.days)
b.enforce(CRE::Policy::Action::NotifyOnly)
expect_raises(CRE::Policy::BuilderError, /match/) { b.build }
end
it "raises on missing max_age" do
b = CRE::Policy::Builder.new("p")
b.match { |_c| true }
b.enforce(CRE::Policy::Action::NotifyOnly)
expect_raises(CRE::Policy::BuilderError, /max_age/) { b.build }
end
it "raises on missing enforce" do
b = CRE::Policy::Builder.new("p")
b.match { |_c| true }
b.max_age(7.days)
expect_raises(CRE::Policy::BuilderError, /enforce/) { b.build }
end
end

View File

@ -0,0 +1,76 @@
# ===================
# ©AngelaMos | 2026
# dsl_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/policy/dsl"
describe "Policy DSL" do
before_each { CRE::Policy.clear_registry! }
it "registers a policy with full DSL syntax" do
policy "production-databases" do
description "Prod DB rotation"
match { |c| c.kind.database? && c.tag(:env) == "prod" }
max_age 30.days
warn_at 25.days
enforce :rotate_immediately
notify_via :telegram, :structured_log
on_rotation_failure :quarantine
end
CRE::Policy.registry.size.should eq 1
p = CRE::Policy.registry.first
p.name.should eq "production-databases"
p.description.should eq "Prod DB rotation"
p.max_age.should eq 30.days
p.warn_at.should eq 25.days
p.enforce_action.should eq CRE::Policy::Action::RotateImmediately
p.notify_channels.should contain(CRE::Policy::Channel::Telegram)
p.trigger_action_for(CRE::Policy::Trigger::OnRotationFailure).should eq CRE::Policy::Action::Quarantine
end
it "supports symbol autocast for enum params" do
policy "x" do
match { |_c| true }
max_age 1.day
enforce :notify_only
notify_via :email, :pagerduty
end
p = CRE::Policy.registry.first
p.enforce_action.should eq CRE::Policy::Action::NotifyOnly
p.notify_channels.should eq [CRE::Policy::Channel::Email, CRE::Policy::Channel::PagerDuty]
end
it "matcher is a real Crystal closure that captures state" do
threshold = 100
policy "captured" do
match { |c| c.tags["score"]?.try(&.to_i.>=(threshold)) || false }
max_age 1.day
enforce :notify_only
end
p = CRE::Policy.registry.first
above = CRE::Domain::Credential.new(
id: UUID.random, external_id: "a", kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {"score" => "150"} of String => String,
)
below = CRE::Domain::Credential.new(
id: UUID.random, external_id: "b", kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {"score" => "50"} of String => String,
)
p.matches?(above).should be_true
p.matches?(below).should be_false
end
it "raises BuilderError for missing required fields" do
expect_raises(CRE::Policy::BuilderError, /match/) do
policy "incomplete" do
max_age 1.day
enforce :notify_only
end
end
end
end

View File

@ -0,0 +1,165 @@
# ===================
# ©AngelaMos | 2026
# evaluator_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/policy/evaluator"
require "../../../src/cre/policy/dsl"
require "../../../src/cre/persistence/sqlite/sqlite_persistence"
private def fresh_persistence
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
persist
end
# Drain non-blocking; returns whatever events arrived without waiting.
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
# Run the bus dispatcher inline for a few ticks so subscribers see events.
private def settle(bus : CRE::Engine::EventBus, duration : Time::Span = 0.1.seconds)
sleep duration
end
describe CRE::Policy::Evaluator do
before_each { CRE::Policy.clear_registry! }
it "publishes PolicyViolation + RotationScheduled when overdue with rotate_immediately" do
policy "test-rotate" do
match { |c| c.kind.env_file? }
max_age 7.days
enforce :rotate_immediately
end
persist = fresh_persistence
persist.credentials.insert(
CRE::Domain::Credential.new(
id: UUID.random, external_id: "x",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
created_at: Time.utc - 30.days,
updated_at: Time.utc - 30.days,
)
)
bus = CRE::Engine::EventBus.new
ch = bus.subscribe(buffer: 256, overflow: CRE::Engine::EventBus::Overflow::Block)
bus.run
CRE::Policy::Evaluator.new(bus, persist).evaluate_all
settle(bus)
events = drain(ch)
types = events.map(&.class.name)
types.should contain "CRE::Events::PolicyViolation"
types.should contain "CRE::Events::RotationScheduled"
ensure
bus.try(&.stop)
persist.try(&.close)
end
it "publishes AlertRaised for notify_only action" do
policy "test-notify" do
match { |c| c.kind.env_file? }
max_age 7.days
enforce :notify_only
end
persist = fresh_persistence
persist.credentials.insert(
CRE::Domain::Credential.new(
id: UUID.random, external_id: "y",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
created_at: Time.utc - 30.days, updated_at: Time.utc - 30.days,
)
)
bus = CRE::Engine::EventBus.new
ch = bus.subscribe(buffer: 256, overflow: CRE::Engine::EventBus::Overflow::Block)
bus.run
CRE::Policy::Evaluator.new(bus, persist).evaluate_all
settle(bus)
events = drain(ch)
types = events.map(&.class.name)
types.should contain "CRE::Events::PolicyViolation"
types.should contain "CRE::Events::AlertRaised"
types.should_not contain "CRE::Events::RotationScheduled"
ensure
bus.try(&.stop)
persist.try(&.close)
end
it "does not fire for fresh credentials" do
policy "test-fresh" do
match { |c| c.kind.env_file? }
max_age 30.days
enforce :rotate_immediately
end
persist = fresh_persistence
persist.credentials.insert(
CRE::Domain::Credential.new(
id: UUID.random, external_id: "f",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
)
)
bus = CRE::Engine::EventBus.new
ch = bus.subscribe(buffer: 256, overflow: CRE::Engine::EventBus::Overflow::Block)
bus.run
CRE::Policy::Evaluator.new(bus, persist).evaluate_all
settle(bus)
drain(ch).should be_empty
ensure
bus.try(&.stop)
persist.try(&.close)
end
it "skips policies that don't match the credential" do
policy "github-only" do
match { |c| c.kind.github_pat? }
max_age 7.days
enforce :rotate_immediately
end
persist = fresh_persistence
persist.credentials.insert(
CRE::Domain::Credential.new(
id: UUID.random, external_id: "envx",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
updated_at: Time.utc - 30.days,
)
)
bus = CRE::Engine::EventBus.new
ch = bus.subscribe(buffer: 256, overflow: CRE::Engine::EventBus::Overflow::Block)
bus.run
CRE::Policy::Evaluator.new(bus, persist).evaluate_all
settle(bus)
drain(ch).should be_empty
ensure
bus.try(&.stop)
persist.try(&.close)
end
end

View File

@ -0,0 +1,92 @@
# ===================
# ©AngelaMos | 2026
# policy_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/policy/policy"
describe CRE::Policy::Policy do
it "matches via the matcher" do
p = CRE::Policy::Policy.new(
name: "p1",
description: nil,
matcher: ->(c : CRE::Domain::Credential) { c.kind.env_file? },
max_age: 30.days,
warn_at: nil,
enforce_action: CRE::Policy::Action::NotifyOnly,
notify_channels: [] of CRE::Policy::Channel,
triggers: {} of CRE::Policy::Trigger => CRE::Policy::Action,
)
matching = CRE::Domain::Credential.new(
id: UUID.random, external_id: "x", kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
)
other = CRE::Domain::Credential.new(
id: UUID.random, external_id: "y", kind: CRE::Domain::CredentialKind::GithubPat,
name: "n", tags: {} of String => String,
)
p.matches?(matching).should be_true
p.matches?(other).should be_false
end
it "detects overdue based on updated_at + max_age" do
p = CRE::Policy::Policy.new(
name: "p", description: nil,
matcher: ->(_c : CRE::Domain::Credential) { true },
max_age: 7.days, warn_at: nil,
enforce_action: CRE::Policy::Action::NotifyOnly,
notify_channels: [] of CRE::Policy::Channel,
triggers: {} of CRE::Policy::Trigger => CRE::Policy::Action,
)
fresh = CRE::Domain::Credential.new(
id: UUID.random, external_id: "f",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
updated_at: Time.utc - 1.day,
)
stale = CRE::Domain::Credential.new(
id: UUID.random, external_id: "s",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
updated_at: Time.utc - 30.days,
)
p.overdue?(fresh).should be_false
p.overdue?(stale).should be_true
end
it "computes warning window" do
p = CRE::Policy::Policy.new(
name: "p", description: nil,
matcher: ->(_c : CRE::Domain::Credential) { true },
max_age: 30.days, warn_at: 25.days,
enforce_action: CRE::Policy::Action::NotifyOnly,
notify_channels: [] of CRE::Policy::Channel,
triggers: {} of CRE::Policy::Trigger => CRE::Policy::Action,
)
young = CRE::Domain::Credential.new(
id: UUID.random, external_id: "y", kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
updated_at: Time.utc - 10.days,
)
warning = CRE::Domain::Credential.new(
id: UUID.random, external_id: "w", kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
updated_at: Time.utc - 27.days,
)
overdue = CRE::Domain::Credential.new(
id: UUID.random, external_id: "o", kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
updated_at: Time.utc - 31.days,
)
p.in_warning_window?(young).should be_false
p.in_warning_window?(warning).should be_true
p.in_warning_window?(overdue).should be_false
end
end

View File

@ -0,0 +1,87 @@
# ===================
# ©AngelaMos | 2026
# builder.cr
# ===================
require "./policy"
module CRE::Policy
class BuilderError < Exception; end
class Builder
@name : String
@description : String?
@matcher : Matcher?
@max_age : Time::Span?
@warn_at : Time::Span?
@enforce_action : Action?
@notify_channels : Array(Channel)
@triggers : Hash(Trigger, Action)
def initialize(@name : String)
@notify_channels = [] of Channel
@triggers = {} of Trigger => Action
end
def description(text : String) : Nil
@description = text
end
def match(&block : Domain::Credential -> Bool) : Nil
@matcher = block
end
def max_age(span : Time::Span) : Nil
@max_age = span
end
def warn_at(span : Time::Span) : Nil
@warn_at = span
end
def enforce(action : Action) : Nil
@enforce_action = action
end
def notify_via(*ch : Channel) : Nil
ch.each { |c| @notify_channels << c }
end
def notify_via(*ch : Symbol) : Nil
ch.each do |s|
parsed = Channel.parse?(s.to_s)
raise BuilderError.new("unknown channel '#{s}' in policy '#{@name}' (valid: #{Channel.values.map(&.to_s).join(", ")})") if parsed.nil?
@notify_channels << parsed
end
end
def on_rotation_failure(action : Action) : Nil
@triggers[Trigger::OnRotationFailure] = action
end
def on_drift_detected(action : Action) : Nil
@triggers[Trigger::OnDriftDetected] = action
end
def on_policy_violation(action : Action) : Nil
@triggers[Trigger::OnPolicyViolation] = action
end
def build : Policy
matcher = @matcher || raise BuilderError.new("policy '#{@name}' is missing match{} block")
max_age = @max_age || raise BuilderError.new("policy '#{@name}' is missing max_age")
enforce_action = @enforce_action || raise BuilderError.new("policy '#{@name}' is missing enforce")
Policy.new(
name: @name,
description: @description,
matcher: matcher,
max_age: max_age,
warn_at: @warn_at,
enforce_action: enforce_action,
notify_channels: @notify_channels,
triggers: @triggers,
)
end
end
end

View File

@ -0,0 +1,29 @@
# ===================
# ©AngelaMos | 2026
# dsl.cr
# ===================
require "./builder"
require "./policy"
# Top-level `policy` method makes the DSL feel native:
#
# require "cre/policy/dsl"
#
# policy "production-databases" do
# description "All prod DB credentials rotate every 30 days"
# match { |c| c.kind.database? && c.tag(:env) == "prod" }
# max_age 30.days
# enforce :rotate_immediately
# notify_via :telegram, :structured_log
# end
#
# The `with builder yield` makes every Builder method (description, match,
# max_age, enforce, notify_via, on_rotation_failure, on_drift_detected) callable
# without a receiver inside the block. Symbol literals autocast to enum values
# so typos like `enforce :rotate_immediatly` fail at compile time.
def policy(name : String, &block)
builder = CRE::Policy::Builder.new(name)
with builder yield
CRE::Policy::REGISTRY << builder.build
end

View File

@ -0,0 +1,100 @@
# ===================
# ©AngelaMos | 2026
# evaluator.cr
# ===================
require "log"
require "./policy"
require "../engine/event_bus"
require "../events/credential_events"
require "../events/system_events"
require "../persistence/persistence"
module CRE::Policy
class Evaluator
Log = ::Log.for("cre.policy.evaluator")
@ch : ::Channel(Events::Event)?
@running : Bool
def initialize(
@bus : Engine::EventBus,
@persistence : Persistence::Persistence,
@policies : Array(Policy) = REGISTRY.dup,
)
@running = false
end
def start : Nil
@running = true
ch = @bus.subscribe(buffer: 64, overflow: Engine::EventBus::Overflow::Block)
@ch = ch
spawn(name: "policy-evaluator") 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
def evaluate_all(now : Time = Time.utc) : Nil
@persistence.credentials.all.each { |c| evaluate(c, now) }
end
def evaluate(c : Domain::Credential, now : Time = Time.utc) : Nil
matching = @policies.select(&.matches?(c))
return if matching.empty?
# Most specific match wins on conflicts (last-match wins as a simple tiebreaker)
policy = matching.last
return unless policy.overdue?(c, now)
@bus.publish Events::PolicyViolation.new(
c.id,
policy.name,
"credential exceeded max_age=#{policy.max_age} (last update #{c.updated_at.to_rfc3339})",
)
case policy.enforce_action
in Action::RotateImmediately
@bus.publish Events::RotationScheduled.new(c.id, c.kind.to_s)
in Action::NotifyOnly
@bus.publish Events::AlertRaised.new(
severity: Events::Severity::Warn,
message: "policy '#{policy.name}' violated by credential '#{c.name}' (#{c.id})",
)
in Action::Quarantine
@bus.publish Events::AlertRaised.new(
severity: Events::Severity::Critical,
message: "policy '#{policy.name}' triggered quarantine on credential '#{c.id}'",
)
end
rescue ex
Log.error(exception: ex) { "policy evaluation failed for #{c.id}" }
end
private def handle(ev : Events::Event) : Nil
case ev
when Events::SchedulerTick
evaluate_all
when Events::CredentialDiscovered
if c = @persistence.credentials.find(ev.credential_id)
evaluate(c)
end
when Events::RotationCompleted
if c = @persistence.credentials.find(ev.credential_id)
evaluate(c)
end
end
end
end
end

View File

@ -0,0 +1,80 @@
# ===================
# ©AngelaMos | 2026
# policy.cr
# ===================
require "../domain/credential"
module CRE::Policy
enum Action
RotateImmediately
NotifyOnly
Quarantine
end
enum Channel
Telegram
Email
StructuredLog
PagerDuty
end
enum Trigger
OnRotationFailure
OnDriftDetected
OnPolicyViolation
end
alias Matcher = Domain::Credential -> Bool
class Policy
getter name : String
getter description : String?
getter matcher : Matcher
getter max_age : Time::Span
getter warn_at : Time::Span?
getter enforce_action : Action
getter notify_channels : Array(Channel)
getter triggers : Hash(Trigger, Action)
def initialize(
@name : String,
@description : String?,
@matcher : Matcher,
@max_age : Time::Span,
@warn_at : Time::Span?,
@enforce_action : Action,
@notify_channels : Array(Channel),
@triggers : Hash(Trigger, Action),
)
end
def matches?(c : Domain::Credential) : Bool
@matcher.call(c)
end
def overdue?(c : Domain::Credential, now : Time = Time.utc) : Bool
(now - c.updated_at) > @max_age
end
def in_warning_window?(c : Domain::Credential, now : Time = Time.utc) : Bool
return false unless w = @warn_at
age = now - c.updated_at
age > w && age <= @max_age
end
def trigger_action_for(trigger : Trigger) : Action?
@triggers[trigger]?
end
end
REGISTRY = [] of Policy
def self.registry : Array(Policy)
REGISTRY
end
def self.clear_registry! : Nil
REGISTRY.clear
end
end