diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/policy/builder_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/policy/builder_spec.cr new file mode 100644 index 00000000..3878313b --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/policy/builder_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/policy/dsl_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/policy/dsl_spec.cr new file mode 100644 index 00000000..1a2c29a1 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/policy/dsl_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/policy/evaluator_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/policy/evaluator_spec.cr new file mode 100644 index 00000000..29f291bc --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/policy/evaluator_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/policy/policy_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/policy/policy_spec.cr new file mode 100644 index 00000000..598f8f97 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/policy/policy_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/policy/builder.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/policy/builder.cr new file mode 100644 index 00000000..39d6b700 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/policy/builder.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/policy/dsl.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/policy/dsl.cr new file mode 100644 index 00000000..3cd3e9fd --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/policy/dsl.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/policy/evaluator.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/policy/evaluator.cr new file mode 100644 index 00000000..0ef4522e --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/policy/evaluator.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/policy/policy.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/policy/policy.cr new file mode 100644 index 00000000..b6f78450 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/policy/policy.cr @@ -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