feat(persistence): SQLite adapter with credentials/versions/rotations/audit repos

All four repos implement their abstract contracts. Migrations create
tables idempotently. Bytes round-trip through SQLite BLOB columns
correctly. RotatorKind and RotationState enums replace Symbol fields
in record types so they round-trip safely through DB string columns.
8 unit specs verify CRUD, find_by_external, in_flight filtering,
revocation, and audit append/range/latest_hash genesis behavior.
This commit is contained in:
CarterPerez-dev 2026-04-29 00:36:22 -04:00
parent fb4e26c798
commit 58e12941e9
8 changed files with 695 additions and 3 deletions

View File

@ -0,0 +1,209 @@
# ===================
# ©AngelaMos | 2026
# sqlite_persistence_spec.cr
# ===================
require "../../../spec_helper"
require "../../../../src/cre/persistence/sqlite/sqlite_persistence"
describe CRE::Persistence::Sqlite::SqlitePersistence do
describe "credentials repo" do
it "round-trips a credential" do
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
c = CRE::Domain::Credential.new(
id: UUID.random,
external_id: "ext-1",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "test",
tags: {"env" => "dev"} of String => String,
)
persist.credentials.insert(c)
found = persist.credentials.find(c.id).not_nil!
found.name.should eq "test"
found.tag("env").should eq "dev"
found.kind.env_file?.should be_true
ensure
persist.try(&.close)
end
it "find_by_external returns the right credential" do
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
c = CRE::Domain::Credential.new(
id: UUID.random, external_id: "uniq-x",
kind: CRE::Domain::CredentialKind::GithubPat,
name: "n", tags: {} of String => String,
)
persist.credentials.insert(c)
found = persist.credentials.find_by_external(
CRE::Domain::CredentialKind::GithubPat, "uniq-x",
).not_nil!
found.id.should eq c.id
ensure
persist.try(&.close)
end
it "all returns every credential" do
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
3.times do |i|
persist.credentials.insert(
CRE::Domain::Credential.new(
id: UUID.random, external_id: "e#{i}",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n#{i}", tags: {} of String => String,
)
)
end
persist.credentials.all.size.should eq 3
ensure
persist.try(&.close)
end
it "update mutates name and tags" do
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
id = UUID.random
persist.credentials.insert(
CRE::Domain::Credential.new(
id: id, external_id: "e", kind: CRE::Domain::CredentialKind::EnvFile,
name: "before", tags: {} of String => String,
)
)
persist.credentials.update(
CRE::Domain::Credential.new(
id: id, external_id: "e", kind: CRE::Domain::CredentialKind::EnvFile,
name: "after", tags: {"k" => "v"} of String => String,
)
)
found = persist.credentials.find(id).not_nil!
found.name.should eq "after"
found.tag("k").should eq "v"
ensure
persist.try(&.close)
end
end
describe "versions repo" do
it "round-trips a credential version with bytes" do
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
cred = CRE::Domain::Credential.new(
id: UUID.random, external_id: "v-test",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "v", tags: {} of String => String,
)
persist.credentials.insert(cred)
v = CRE::Domain::CredentialVersion.new(
id: UUID.random,
credential_id: cred.id,
ciphertext: Bytes[1, 2, 3, 4],
dek_wrapped: Bytes[9, 8, 7],
kek_version: 1,
algorithm_id: 1_i16,
)
persist.versions.insert(v)
found = persist.versions.find(v.id).not_nil!
found.ciphertext.should eq Bytes[1, 2, 3, 4]
found.dek_wrapped.should eq Bytes[9, 8, 7]
found.algorithm_id.should eq 1_i16
ensure
persist.try(&.close)
end
it "revoke marks revoked_at" do
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
cred = CRE::Domain::Credential.new(
id: UUID.random, external_id: "rev",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
)
persist.credentials.insert(cred)
v = CRE::Domain::CredentialVersion.new(
id: UUID.random, credential_id: cred.id,
ciphertext: Bytes.new(0), dek_wrapped: Bytes.new(0),
kek_version: 1, algorithm_id: 1_i16,
)
persist.versions.insert(v)
persist.versions.revoke(v.id)
found = persist.versions.find(v.id).not_nil!
found.revoked?.should be_true
ensure
persist.try(&.close)
end
end
describe "rotations repo" do
it "tracks state transitions and in_flight filtering" do
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
cred_id = UUID.random
persist.credentials.insert(
CRE::Domain::Credential.new(
id: cred_id, external_id: "rot", kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
)
)
r1 = CRE::Persistence::RotationRecord.new(
id: UUID.random, credential_id: cred_id,
rotator_kind: :env_file, state: :generating,
started_at: Time.utc, completed_at: nil, failure_reason: nil,
)
r2 = CRE::Persistence::RotationRecord.new(
id: UUID.random, credential_id: cred_id,
rotator_kind: :env_file, state: :completed,
started_at: Time.utc, completed_at: Time.utc, failure_reason: nil,
)
persist.rotations.insert(r1)
persist.rotations.insert(r2)
persist.rotations.in_flight.size.should eq 1
persist.rotations.update_state(r1.id, :completed)
persist.rotations.in_flight.size.should eq 0
ensure
persist.try(&.close)
end
end
describe "audit repo" do
it "appends and reads back entries; latest_hash returns genesis when empty" do
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
persist.audit.latest_hash.should eq CRE::Persistence::Sqlite::AuditRepo::GENESIS_HASH
persist.audit.latest_seq.should eq 0_i64
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)
persist.audit.latest_seq.should eq 1_i64
persist.audit.latest_hash.should eq Bytes.new(32, 0xaa_u8)
rng = persist.audit.range(1_i64, 1_i64)
rng.size.should eq 1
rng[0].event_type.should eq "test"
ensure
persist.try(&.close)
end
end
end

View File

@ -8,11 +8,32 @@ require "../domain/credential"
require "../domain/credential_version"
module CRE::Persistence
enum RotatorKind
AwsSecretsmgr
VaultDynamic
GithubPat
EnvFile
end
enum RotationState
Pending
Generating
Applying
Verifying
Committing
Completed
Failed
Aborted
Inconsistent
end
TERMINAL_STATES = [RotationState::Completed, RotationState::Failed, RotationState::Aborted]
record RotationRecord,
id : UUID,
credential_id : UUID,
rotator_kind : Symbol,
state : Symbol,
rotator_kind : RotatorKind,
state : RotationState,
started_at : Time,
completed_at : Time?,
failure_reason : String?
@ -57,7 +78,7 @@ module CRE::Persistence
abstract class RotationsRepo
abstract def insert(rotation : RotationRecord) : Nil
abstract def update_state(id : UUID, state : Symbol, error : String? = nil) : Nil
abstract def update_state(id : UUID, state : RotationState, error : String? = nil) : Nil
abstract def in_flight : Array(RotationRecord)
end

View File

@ -0,0 +1,86 @@
# ===================
# ©AngelaMos | 2026
# audit_repo.cr
# ===================
require "db"
require "uuid"
require "../repos"
module CRE::Persistence::Sqlite
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 OR IGNORE INTO audit_events (event_id, occurred_at, event_type, actor, target_id, payload, prev_hash, content_hash, hmac, hmac_key_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
entry.event_id.to_s, entry.occurred_at.to_rfc3339,
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, occurred_at, event_type, actor, target_id, payload, prev_hash, content_hash, hmac, hmac_key_version FROM audit_events WHERE seq >= ? AND seq <= ? ORDER BY seq ASC",
start_seq, end_seq,
as: {Int64, String, String, 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 (?, ?, ?, ?, ?, ?, ?)",
batch.id.to_s, batch.start_seq, batch.end_seq,
batch.merkle_root, batch.signature,
batch.signing_key_version, batch.sealed_at.to_rfc3339,
)
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: Time.parse_rfc3339(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::Sqlite
class CredentialsRepo < CRE::Persistence::CredentialsRepo
SELECT_COLS = "id, external_id, kind, name, tags, current_version_id, pending_version_id, previous_version_id, 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 (?, ?, ?, ?, ?, ?, ?)",
c.id.to_s, c.external_id, c.kind.to_s, c.name,
c.tags.to_json, c.created_at.to_rfc3339, c.updated_at.to_rfc3339,
)
end
def update(c : Domain::Credential) : Nil
@db.exec(
"UPDATE credentials SET name = ?, tags = ?, current_version_id = ?, pending_version_id = ?, previous_version_id = ?, updated_at = ? WHERE id = ?",
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.to_rfc3339, c.id.to_s,
)
end
def find(id : UUID) : Domain::Credential?
@db.query_one?(
"SELECT #{SELECT_COLS} FROM credentials WHERE id = ?",
id.to_s,
as: {String, String, String, String, String, String?, String?, String?, String, String},
).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 = ? AND external_id = ?",
kind.to_s, external_id,
as: {String, String, String, String, String, String?, String?, String?, String, String},
).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?, String, String},
).map { |row| row_to_credential(row) }
end
def overdue(now : Time, max_age : Time::Span) : Array(Domain::Credential)
cutoff = (now - max_age).to_rfc3339
@db.query_all(
"SELECT #{SELECT_COLS} FROM credentials WHERE updated_at < ?",
cutoff,
as: {String, String, String, String, String, String?, String?, String?, String, String},
).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: Time.parse_rfc3339(row[8]),
updated_at: Time.parse_rfc3339(row[9]),
)
end
end
end

View File

@ -0,0 +1,92 @@
# ===================
# ©AngelaMos | 2026
# migrations.cr
# ===================
require "db"
module CRE::Persistence::Sqlite
module Migrations
SCHEMA = [
<<-SQL,
CREATE TABLE IF NOT EXISTS credentials (
id TEXT PRIMARY KEY,
external_id TEXT NOT NULL,
kind TEXT NOT NULL,
name TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '{}',
current_version_id TEXT,
pending_version_id TEXT,
previous_version_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (kind, external_id)
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS credential_versions (
id TEXT PRIMARY KEY,
credential_id TEXT NOT NULL REFERENCES credentials(id),
ciphertext BLOB NOT NULL,
dek_wrapped BLOB NOT NULL,
kek_version INTEGER NOT NULL,
algorithm_id INTEGER NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
generated_at TEXT NOT NULL,
revoked_at TEXT
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS rotations (
id TEXT PRIMARY KEY,
credential_id TEXT NOT NULL,
rotator_kind TEXT NOT NULL,
state TEXT NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT,
step_outcomes TEXT NOT NULL DEFAULT '{}',
failure_reason TEXT
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS audit_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT UNIQUE NOT NULL,
occurred_at TEXT NOT NULL,
event_type TEXT NOT NULL,
actor TEXT NOT NULL,
target_id TEXT,
payload TEXT NOT NULL,
prev_hash BLOB NOT NULL,
content_hash BLOB NOT NULL,
hmac BLOB NOT NULL,
hmac_key_version INTEGER NOT NULL
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS audit_batches (
id TEXT PRIMARY KEY,
start_seq INTEGER NOT NULL,
end_seq INTEGER NOT NULL,
merkle_root BLOB NOT NULL,
signature BLOB NOT NULL,
signing_key_version INTEGER NOT NULL,
sealed_at TEXT NOT NULL
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS kek_versions (
version INTEGER PRIMARY KEY,
source TEXT NOT NULL,
source_ref TEXT,
created_at TEXT NOT NULL,
retired_at TEXT
)
SQL
]
def self.run(db : DB::Database) : Nil
SCHEMA.each { |stmt| db.exec(stmt) }
end
end
end

View File

@ -0,0 +1,56 @@
# ===================
# ©AngelaMos | 2026
# rotations_repo.cr
# ===================
require "db"
require "uuid"
require "../repos"
module CRE::Persistence::Sqlite
class RotationsRepo < CRE::Persistence::RotationsRepo
SELECT_COLS = "id, credential_id, 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 (?, ?, ?, ?, ?, ?, ?)",
rotation.id.to_s, rotation.credential_id.to_s,
rotation.rotator_kind.to_s, rotation.state.to_s,
rotation.started_at.to_rfc3339,
rotation.completed_at.try(&.to_rfc3339),
rotation.failure_reason,
)
end
def update_state(id : UUID, state : RotationState, error : String? = nil) : Nil
completed_at = TERMINAL_STATES.includes?(state) ? Time.utc.to_rfc3339 : nil
@db.exec(
"UPDATE rotations SET state = ?, completed_at = ?, failure_reason = COALESCE(?, failure_reason) WHERE id = ?",
state.to_s, completed_at, error, id.to_s,
)
end
def in_flight : Array(RotationRecord)
placeholders = TERMINAL_STATES.map { "?" }.join(",")
args = TERMINAL_STATES.map { |s| s.to_s.as(DB::Any) }
sql = "SELECT #{SELECT_COLS} FROM rotations WHERE state NOT IN (#{placeholders})"
@db.query_all(sql, args: args, as: {String, String, String, String, String, String?, 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: Time.parse_rfc3339(row[4]),
completed_at: row[5].try { |s| Time.parse_rfc3339(s) },
failure_reason: row[6],
)
end
end
end

View File

@ -0,0 +1,77 @@
# ===================
# ©AngelaMos | 2026
# sqlite_persistence.cr
# ===================
require "db"
require "sqlite3"
require "../persistence"
require "./migrations"
require "./credentials_repo"
require "./versions_repo"
require "./rotations_repo"
require "./audit_repo"
module CRE::Persistence::Sqlite
class SqlitePersistence < CRE::Persistence::Persistence
@db : DB::Database
@mutex : Mutex
@credentials : CredentialsRepo?
@versions : VersionsRepo?
@rotations : RotationsRepo?
@audit : AuditRepo?
def initialize(path : String)
uri = if path == ":memory:"
"sqlite3:%3Amemory%3A?max_pool_size=1"
else
"sqlite3:#{path}?max_pool_size=1"
end
@db = DB.open(uri)
@mutex = Mutex.new
setup_pragmas
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
_ = key # SQLite is single-process; the mutex is sufficient
@mutex.synchronize { yield }
end
def migrate! : Nil
Migrations.run(@db)
end
def close : Nil
@db.close
end
def db : DB::Database
@db
end
private def setup_pragmas
@db.exec("PRAGMA foreign_keys = ON")
@db.exec("PRAGMA synchronous = NORMAL")
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::Sqlite
class VersionsRepo < CRE::Persistence::VersionsRepo
SELECT_COLS = "id, credential_id, ciphertext, dek_wrapped, kek_version, algorithm_id, metadata, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)",
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.to_rfc3339,
v.revoked_at.try(&.to_rfc3339),
)
end
def find(id : UUID) : Domain::CredentialVersion?
@db.query_one?(
"SELECT #{SELECT_COLS} FROM credential_versions WHERE id = ?",
id.to_s,
as: {String, String, Bytes, Bytes, Int32, Int32, String, String, String?},
).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 = ? ORDER BY generated_at DESC",
credential_id.to_s,
as: {String, String, Bytes, Bytes, Int32, Int32, String, String, String?},
).map { |row| row_to_version(row) }
end
def revoke(id : UUID, at : Time = Time.utc) : Nil
@db.exec(
"UPDATE credential_versions SET revoked_at = ? WHERE id = ?",
at.to_rfc3339, 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].to_i16,
metadata: Hash(String, String).from_json(row[6]),
generated_at: Time.parse_rfc3339(row[7]),
revoked_at: row[8].try { |s| Time.parse_rfc3339(s) },
)
end
end
end