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