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