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:
parent
fb4e26c798
commit
58e12941e9
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue