diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/persistence/sqlite/sqlite_persistence_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/persistence/sqlite/sqlite_persistence_spec.cr new file mode 100644 index 00000000..9ffb07bd --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/persistence/sqlite/sqlite_persistence_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/repos.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/repos.cr index c924c67f..016753b2 100644 --- a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/repos.cr +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/repos.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/audit_repo.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/audit_repo.cr new file mode 100644 index 00000000..c2d38041 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/audit_repo.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/credentials_repo.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/credentials_repo.cr new file mode 100644 index 00000000..ac7d4229 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/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::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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/migrations.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/migrations.cr new file mode 100644 index 00000000..78e681fd --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/migrations.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/rotations_repo.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/rotations_repo.cr new file mode 100644 index 00000000..df72595f --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/rotations_repo.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/sqlite_persistence.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/sqlite_persistence.cr new file mode 100644 index 00000000..58c61c69 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/sqlite_persistence.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/versions_repo.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/versions_repo.cr new file mode 100644 index 00000000..0688af6e --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/persistence/sqlite/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::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