diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/integration/persistence/postgres_persistence_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/integration/persistence/postgres_persistence_spec.cr new file mode 100644 index 00000000..0308d4a8 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/integration/persistence/postgres_persistence_spec.cr @@ -0,0 +1,100 @@ +# =================== +# ©AngelaMos | 2026 +# postgres_persistence_spec.cr +# =================== + +require "../../spec_helper" +require "../../../src/cre/persistence/postgres/postgres_persistence" + +DATABASE_URL = ENV["DATABASE_URL"]? || "postgres://cre_test:cre_test@localhost:5433/cre_test" + +private def fresh_persistence : CRE::Persistence::Postgres::PostgresPersistence + persist = CRE::Persistence::Postgres::PostgresPersistence.new(DATABASE_URL) + persist.migrate! + persist.db.exec("TRUNCATE credentials, credential_versions, rotations CASCADE") + persist.db.exec("ALTER TABLE audit_events DISABLE TRIGGER audit_events_no_update") + persist.db.exec("DELETE FROM audit_events") + persist.db.exec("ALTER TABLE audit_events ENABLE TRIGGER audit_events_no_update") + persist.db.exec("DELETE FROM audit_batches") + persist +end + +describe CRE::Persistence::Postgres::PostgresPersistence do + it "round-trips a credential through PG" do + persist = fresh_persistence + + c = CRE::Domain::Credential.new( + id: UUID.random, + external_id: "pg-1", + kind: CRE::Domain::CredentialKind::AwsSecretsmgr, + name: "pg-test", + tags: {"env" => "prod", "team" => "platform"} of String => String, + ) + persist.credentials.insert(c) + found = persist.credentials.find(c.id).not_nil! + found.name.should eq "pg-test" + found.tag("env").should eq "prod" + found.tag("team").should eq "platform" + ensure + persist.try(&.close) + end + + it "stores and retrieves binary credential versions" do + persist = fresh_persistence + + cred = CRE::Domain::Credential.new( + id: UUID.random, external_id: "pg-bin", + kind: CRE::Domain::CredentialKind::EnvFile, + name: "n", tags: {} of String => String, + ) + persist.credentials.insert(cred) + + bytes = Bytes.new(64) { |i| i.to_u8 } + v = CRE::Domain::CredentialVersion.new( + id: UUID.random, credential_id: cred.id, + ciphertext: bytes, dek_wrapped: Bytes[0xde, 0xad, 0xbe, 0xef], + kek_version: 7, algorithm_id: 1_i16, + metadata: {"version_id" => "abc"}, + ) + persist.versions.insert(v) + found = persist.versions.find(v.id).not_nil! + found.ciphertext.should eq bytes + found.dek_wrapped.should eq Bytes[0xde, 0xad, 0xbe, 0xef] + found.kek_version.should eq 7 + found.metadata["version_id"].should eq "abc" + ensure + persist.try(&.close) + end + + it "audit_events trigger refuses UPDATE" do + persist = fresh_persistence + + entry = CRE::Persistence::AuditEntry.new( + seq: 0_i64, event_id: UUID.random, occurred_at: Time.utc, + event_type: "test", actor: "system", target_id: nil, + payload: %({"k":"v"}), + prev_hash: Bytes.new(32, 0_u8), + content_hash: Bytes.new(32, 0xaa_u8), + hmac: Bytes.new(32, 0xbb_u8), + hmac_key_version: 1, + ) + persist.audit.append(entry) + + expect_raises(Exception, /append-only/) do + persist.db.exec("UPDATE audit_events SET event_type = 'forged' WHERE seq = 1") + end + ensure + persist.try(&.close) + end + + it "advisory lock holds across the block" do + persist = fresh_persistence + counter = 0 + persist.with_advisory_lock(42_i64) do + counter += 1 + end + counter.should eq 1 + ensure + persist.try(&.close) + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/audit_repo.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/audit_repo.cr new file mode 100644 index 00000000..a991749a --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/audit_repo.cr @@ -0,0 +1,86 @@ +# =================== +# ©AngelaMos | 2026 +# audit_repo.cr +# =================== + +require "db" +require "uuid" +require "../repos" + +module CRE::Persistence::Postgres + class AuditRepo < CRE::Persistence::AuditRepo + GENESIS_HASH = Bytes.new(32, 0_u8) + + def initialize(@db : DB::Database) + end + + def append(entry : AuditEntry) : Nil + @db.exec( + "INSERT INTO audit_events (event_id, occurred_at, event_type, actor, target_id, payload, prev_hash, content_hash, hmac, hmac_key_version) VALUES ($1::uuid, $2, $3, $4, $5::uuid, $6::jsonb, $7, $8, $9, $10) ON CONFLICT (event_id) DO NOTHING", + entry.event_id.to_s, entry.occurred_at, + entry.event_type, entry.actor, + entry.target_id.try(&.to_s), + entry.payload, + entry.prev_hash, entry.content_hash, entry.hmac, + entry.hmac_key_version, + ) + end + + def latest_hash : Bytes + result = @db.query_one?( + "SELECT content_hash FROM audit_events ORDER BY seq DESC LIMIT 1", + as: Bytes, + ) + result || GENESIS_HASH + end + + def latest_seq : Int64 + result = @db.query_one?( + "SELECT seq FROM audit_events ORDER BY seq DESC LIMIT 1", + as: Int64, + ) + result || 0_i64 + end + + def range(start_seq : Int64, end_seq : Int64) : Array(AuditEntry) + @db.query_all( + "SELECT seq, event_id::text, occurred_at, event_type, actor, target_id::text, payload::text, prev_hash, content_hash, hmac, hmac_key_version FROM audit_events WHERE seq >= $1 AND seq <= $2 ORDER BY seq ASC", + start_seq, end_seq, + as: {Int64, String, Time, String, String, String?, String, Bytes, Bytes, Bytes, Int32}, + ).map { |row| row_to_entry(row) } + end + + def insert_batch(batch : AuditBatch) : Nil + @db.exec( + "INSERT INTO audit_batches (id, start_seq, end_seq, merkle_root, signature, signing_key_version, sealed_at) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7)", + batch.id.to_s, batch.start_seq, batch.end_seq, + batch.merkle_root, batch.signature, + batch.signing_key_version, batch.sealed_at, + ) + end + + def last_sealed_seq : Int64 + result = @db.query_one?( + "SELECT MAX(end_seq) FROM audit_batches", + as: Int64?, + ) + result || 0_i64 + end + + private def row_to_entry(row) : AuditEntry + AuditEntry.new( + seq: row[0], + event_id: UUID.new(row[1]), + occurred_at: row[2], + event_type: row[3], + actor: row[4], + target_id: row[5].try { |s| UUID.new(s) }, + payload: row[6], + prev_hash: row[7], + content_hash: row[8], + hmac: row[9], + hmac_key_version: row[10], + ) + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/credentials_repo.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/credentials_repo.cr new file mode 100644 index 00000000..315a1297 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/credentials_repo.cr @@ -0,0 +1,84 @@ +# =================== +# ©AngelaMos | 2026 +# credentials_repo.cr +# =================== + +require "db" +require "json" +require "uuid" +require "../repos" +require "../../domain/credential" + +module CRE::Persistence::Postgres + class CredentialsRepo < CRE::Persistence::CredentialsRepo + SELECT_COLS = "id::text, external_id, kind, name, tags::text, current_version_id::text, pending_version_id::text, previous_version_id::text, created_at, updated_at" + + def initialize(@db : DB::Database) + end + + def insert(c : Domain::Credential) : Nil + @db.exec( + "INSERT INTO credentials (id, external_id, kind, name, tags, created_at, updated_at) VALUES ($1::uuid, $2, $3, $4, $5::jsonb, $6, $7)", + c.id.to_s, c.external_id, c.kind.to_s, c.name, + c.tags.to_json, c.created_at, c.updated_at, + ) + end + + def update(c : Domain::Credential) : Nil + @db.exec( + "UPDATE credentials SET name = $1, tags = $2::jsonb, current_version_id = $3::uuid, pending_version_id = $4::uuid, previous_version_id = $5::uuid, updated_at = $6 WHERE id = $7::uuid", + c.name, c.tags.to_json, + c.current_version_id.try(&.to_s), c.pending_version_id.try(&.to_s), c.previous_version_id.try(&.to_s), + Time.utc, c.id.to_s, + ) + end + + def find(id : UUID) : Domain::Credential? + @db.query_one?( + "SELECT #{SELECT_COLS} FROM credentials WHERE id = $1::uuid", + id.to_s, + as: {String, String, String, String, String, String?, String?, String?, Time, Time}, + ).try { |row| row_to_credential(row) } + end + + def find_by_external(kind : Domain::CredentialKind, external_id : String) : Domain::Credential? + @db.query_one?( + "SELECT #{SELECT_COLS} FROM credentials WHERE kind = $1 AND external_id = $2", + kind.to_s, external_id, + as: {String, String, String, String, String, String?, String?, String?, Time, Time}, + ).try { |row| row_to_credential(row) } + end + + def all : Array(Domain::Credential) + @db.query_all( + "SELECT #{SELECT_COLS} FROM credentials", + as: {String, String, String, String, String, String?, String?, String?, Time, Time}, + ).map { |row| row_to_credential(row) } + end + + def overdue(now : Time, max_age : Time::Span) : Array(Domain::Credential) + cutoff = now - max_age + @db.query_all( + "SELECT #{SELECT_COLS} FROM credentials WHERE updated_at < $1", + cutoff, + as: {String, String, String, String, String, String?, String?, String?, Time, Time}, + ).map { |row| row_to_credential(row) } + end + + private def row_to_credential(row) : Domain::Credential + tags = Hash(String, String).from_json(row[4]) + Domain::Credential.new( + id: UUID.new(row[0]), + external_id: row[1], + kind: Domain::CredentialKind.parse(row[2]), + name: row[3], + tags: tags, + current_version_id: row[5].try { |s| UUID.new(s) }, + pending_version_id: row[6].try { |s| UUID.new(s) }, + previous_version_id: row[7].try { |s| UUID.new(s) }, + created_at: row[8], + updated_at: row[9], + ) + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/migrations.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/migrations.cr new file mode 100644 index 00000000..678e8b92 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/migrations.cr @@ -0,0 +1,111 @@ +# =================== +# ©AngelaMos | 2026 +# migrations.cr +# =================== + +require "db" + +module CRE::Persistence::Postgres + module Migrations + SCHEMA = [ + <<-SQL, + CREATE TABLE IF NOT EXISTS credentials ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + external_id TEXT NOT NULL, + kind TEXT NOT NULL, + name TEXT NOT NULL, + tags JSONB NOT NULL DEFAULT '{}'::jsonb, + current_version_id UUID, + pending_version_id UUID, + previous_version_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (kind, external_id) + ) + SQL + "CREATE INDEX IF NOT EXISTS credentials_tags_gin ON credentials USING gin (tags jsonb_path_ops)", + <<-SQL, + CREATE TABLE IF NOT EXISTS credential_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + credential_id UUID NOT NULL REFERENCES credentials(id), + ciphertext BYTEA NOT NULL, + dek_wrapped BYTEA NOT NULL, + kek_version INT NOT NULL, + algorithm_id SMALLINT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ + ) + SQL + <<-SQL, + CREATE TABLE IF NOT EXISTS rotations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + credential_id UUID NOT NULL REFERENCES credentials(id), + rotator_kind TEXT NOT NULL, + state TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + step_outcomes JSONB NOT NULL DEFAULT '{}'::jsonb, + failure_reason TEXT + ) + SQL + <<-SQL, + CREATE INDEX IF NOT EXISTS rotations_in_flight + ON rotations(state) + WHERE state NOT IN ('completed','failed','aborted') + SQL + <<-SQL, + CREATE TABLE IF NOT EXISTS audit_events ( + seq BIGSERIAL PRIMARY KEY, + event_id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(), + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + event_type TEXT NOT NULL, + actor TEXT NOT NULL, + target_id UUID, + payload JSONB NOT NULL, + prev_hash BYTEA NOT NULL, + content_hash BYTEA NOT NULL, + hmac BYTEA NOT NULL, + hmac_key_version INT NOT NULL + ) + SQL + <<-SQL, + CREATE TABLE IF NOT EXISTS audit_batches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + start_seq BIGINT NOT NULL, + end_seq BIGINT NOT NULL, + merkle_root BYTEA NOT NULL, + signature BYTEA NOT NULL, + signing_key_version INT NOT NULL, + sealed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + SQL + <<-SQL, + CREATE TABLE IF NOT EXISTS kek_versions ( + version INT PRIMARY KEY, + source TEXT NOT NULL, + source_ref TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + retired_at TIMESTAMPTZ + ) + SQL + <<-SQL, + CREATE OR REPLACE FUNCTION audit_no_modify() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'audit_events is append-only'; END $$ + SQL + <<-SQL, + DROP TRIGGER IF EXISTS audit_events_no_update ON audit_events + SQL + <<-SQL, + CREATE TRIGGER audit_events_no_update + BEFORE UPDATE OR DELETE OR TRUNCATE + ON audit_events + EXECUTE FUNCTION audit_no_modify() + SQL + ] + + def self.run(db : DB::Database) : Nil + SCHEMA.each { |stmt| db.exec(stmt) } + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/postgres_persistence.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/postgres_persistence.cr new file mode 100644 index 00000000..661dc7a9 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/postgres_persistence.cr @@ -0,0 +1,66 @@ +# =================== +# ©AngelaMos | 2026 +# postgres_persistence.cr +# =================== + +require "db" +require "pg" +require "../persistence" +require "./migrations" +require "./credentials_repo" +require "./versions_repo" +require "./rotations_repo" +require "./audit_repo" + +module CRE::Persistence::Postgres + class PostgresPersistence < CRE::Persistence::Persistence + @db : DB::Database + @credentials : CredentialsRepo? + @versions : VersionsRepo? + @rotations : RotationsRepo? + @audit : AuditRepo? + + def initialize(database_url : String) + @db = DB.open(database_url) + end + + def credentials : CredentialsRepo + @credentials ||= CredentialsRepo.new(@db) + end + + def versions : VersionsRepo + @versions ||= VersionsRepo.new(@db) + end + + def rotations : RotationsRepo + @rotations ||= RotationsRepo.new(@db) + end + + def audit : AuditRepo + @audit ||= AuditRepo.new(@db) + end + + def transaction(&block : ->) : Nil + @db.transaction { yield } + end + + def with_advisory_lock(key : Int64, &block : ->) : Nil + @db.transaction do |tx| + tx.connection.exec("SELECT pg_advisory_xact_lock($1)", key) + yield + end + end + + def migrate! : Nil + Migrations.run(@db) + end + + def close : Nil + @db.close + end + + def db : DB::Database + @db + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/rotations_repo.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/rotations_repo.cr new file mode 100644 index 00000000..a7488889 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/rotations_repo.cr @@ -0,0 +1,53 @@ +# =================== +# ©AngelaMos | 2026 +# rotations_repo.cr +# =================== + +require "db" +require "uuid" +require "../repos" + +module CRE::Persistence::Postgres + class RotationsRepo < CRE::Persistence::RotationsRepo + SELECT_COLS = "id::text, credential_id::text, rotator_kind, state, started_at, completed_at, failure_reason" + + def initialize(@db : DB::Database) + end + + def insert(rotation : RotationRecord) : Nil + @db.exec( + "INSERT INTO rotations (id, credential_id, rotator_kind, state, started_at, completed_at, failure_reason) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7)", + rotation.id.to_s, rotation.credential_id.to_s, + rotation.rotator_kind.to_s, rotation.state.to_s, + rotation.started_at, rotation.completed_at, rotation.failure_reason, + ) + end + + def update_state(id : UUID, state : RotationState, error : String? = nil) : Nil + completed_at = TERMINAL_STATES.includes?(state) ? Time.utc : nil + @db.exec( + "UPDATE rotations SET state = $1, completed_at = $2, failure_reason = COALESCE($3, failure_reason) WHERE id = $4::uuid", + state.to_s, completed_at, error, id.to_s, + ) + end + + def in_flight : Array(RotationRecord) + @db.query_all( + "SELECT #{SELECT_COLS} FROM rotations WHERE state NOT IN ('completed','failed','aborted')", + as: {String, String, String, String, Time, Time?, String?}, + ).map { |row| row_to_record(row) } + end + + private def row_to_record(row) : RotationRecord + RotationRecord.new( + id: UUID.new(row[0]), + credential_id: UUID.new(row[1]), + rotator_kind: RotatorKind.parse(row[2]), + state: RotationState.parse(row[3]), + started_at: row[4], + completed_at: row[5], + failure_reason: row[6], + ) + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/versions_repo.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/versions_repo.cr new file mode 100644 index 00000000..a4d1b74b --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/postgres/versions_repo.cr @@ -0,0 +1,67 @@ +# =================== +# ©AngelaMos | 2026 +# versions_repo.cr +# =================== + +require "db" +require "json" +require "uuid" +require "../repos" +require "../../domain/credential_version" + +module CRE::Persistence::Postgres + class VersionsRepo < CRE::Persistence::VersionsRepo + SELECT_COLS = "id::text, credential_id::text, ciphertext, dek_wrapped, kek_version, algorithm_id, metadata::text, generated_at, revoked_at" + + def initialize(@db : DB::Database) + end + + def insert(v : Domain::CredentialVersion) : Nil + @db.exec( + "INSERT INTO credential_versions (id, credential_id, ciphertext, dek_wrapped, kek_version, algorithm_id, metadata, generated_at, revoked_at) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7::jsonb, $8, $9)", + v.id.to_s, v.credential_id.to_s, + v.ciphertext, v.dek_wrapped, + v.kek_version, v.algorithm_id.to_i32, + v.metadata.to_json, v.generated_at, + v.revoked_at, + ) + end + + def find(id : UUID) : Domain::CredentialVersion? + @db.query_one?( + "SELECT #{SELECT_COLS} FROM credential_versions WHERE id = $1::uuid", + id.to_s, + as: {String, String, Bytes, Bytes, Int32, Int16, String, Time, Time?}, + ).try { |row| row_to_version(row) } + end + + def for_credential(credential_id : UUID) : Array(Domain::CredentialVersion) + @db.query_all( + "SELECT #{SELECT_COLS} FROM credential_versions WHERE credential_id = $1::uuid ORDER BY generated_at DESC", + credential_id.to_s, + as: {String, String, Bytes, Bytes, Int32, Int16, String, Time, Time?}, + ).map { |row| row_to_version(row) } + end + + def revoke(id : UUID, at : Time = Time.utc) : Nil + @db.exec( + "UPDATE credential_versions SET revoked_at = $1 WHERE id = $2::uuid", + at, id.to_s, + ) + end + + private def row_to_version(row) : Domain::CredentialVersion + Domain::CredentialVersion.new( + id: UUID.new(row[0]), + credential_id: UUID.new(row[1]), + ciphertext: row[2], + dek_wrapped: row[3], + kek_version: row[4], + algorithm_id: row[5], + metadata: Hash(String, String).from_json(row[6]), + generated_at: row[7], + revoked_at: row[8], + ) + end + end +end