feat(persistence): PostgreSQL adapter with append-only audit trigger

- All 4 repos mirror the SQLite contracts using PG-native types
  (UUID, BYTEA, JSONB, BIGSERIAL, TIMESTAMPTZ, SMALLINT)
- audit_no_modify() trigger refuses UPDATE/DELETE/TRUNCATE on audit_events
- pg_advisory_xact_lock for cross-process row locking
- Integration tests verify trigger fires correctly on tamper attempt
- Test cleanup helper temporarily disables trigger to reset between specs
- 4 integration specs all green against PostgreSQL 16
This commit is contained in:
CarterPerez-dev 2026-04-29 00:39:53 -04:00
parent 58e12941e9
commit 5971cbd33e
7 changed files with 567 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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