1use crate::db::DbKind;
14use crate::error::KipukaError;
15
16pub const SCHEMA_VERSION: i32 = 6;
18
19const MIGRATION_V1_SQLITE: &str = r#"
23-- Schema version tracking
24CREATE TABLE IF NOT EXISTS schema_version (
25 version INTEGER NOT NULL,
26 applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
27);
28
29-- Certificate inventory
30CREATE TABLE IF NOT EXISTS certificates (
31 id INTEGER PRIMARY KEY AUTOINCREMENT,
32 serial TEXT NOT NULL UNIQUE,
33 subject_dn TEXT NOT NULL,
34 issuer_dn TEXT NOT NULL,
35 not_before TEXT NOT NULL,
36 not_after TEXT NOT NULL,
37 der_encoded BLOB NOT NULL,
38 ca_id TEXT NOT NULL,
39 profile TEXT,
40 status TEXT NOT NULL DEFAULT 'active'
41 CHECK (status IN ('active', 'revoked', 'expired')),
42 revocation_reason TEXT,
43 revocation_time TEXT,
44 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
45);
46
47CREATE INDEX IF NOT EXISTS idx_certificates_serial ON certificates (serial);
48CREATE INDEX IF NOT EXISTS idx_certificates_subject_dn ON certificates (subject_dn);
49CREATE INDEX IF NOT EXISTS idx_certificates_ca_id ON certificates (ca_id);
50CREATE INDEX IF NOT EXISTS idx_certificates_status ON certificates (status);
51CREATE INDEX IF NOT EXISTS idx_certificates_not_after ON certificates (not_after);
52
53-- OTP storage (DB backend)
54CREATE TABLE IF NOT EXISTS otp_tokens (
55 id INTEGER PRIMARY KEY AUTOINCREMENT,
56 token_hash TEXT NOT NULL,
57 entity_id TEXT NOT NULL,
58 label TEXT,
59 profile TEXT,
60 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
61 expires_at TEXT NOT NULL,
62 max_uses INTEGER NOT NULL DEFAULT 1,
63 current_uses INTEGER NOT NULL DEFAULT 0,
64 revoked INTEGER NOT NULL DEFAULT 0,
65 revoked_at TEXT
66);
67
68CREATE INDEX IF NOT EXISTS idx_otp_tokens_entity_id ON otp_tokens (entity_id);
69CREATE INDEX IF NOT EXISTS idx_otp_tokens_expires_at ON otp_tokens (expires_at);
70CREATE INDEX IF NOT EXISTS idx_otp_tokens_hash ON otp_tokens (entity_id, token_hash);
71
72-- Audit event trail (NIAP CA PP FAU_GEN.1)
73CREATE TABLE IF NOT EXISTS audit_events (
74 id INTEGER PRIMARY KEY AUTOINCREMENT,
75 timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
76 event_type TEXT NOT NULL,
77 actor TEXT,
78 target TEXT,
79 detail_json TEXT,
80 source_ip TEXT,
81 session_id TEXT
82);
83
84CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp ON audit_events (timestamp);
85CREATE INDEX IF NOT EXISTS idx_audit_events_event_type ON audit_events (event_type);
86CREATE INDEX IF NOT EXISTS idx_audit_events_actor ON audit_events (actor);
87
88-- CA health tracking
89CREATE TABLE IF NOT EXISTS ca_health (
90 id INTEGER PRIMARY KEY AUTOINCREMENT,
91 ca_id TEXT NOT NULL UNIQUE,
92 status TEXT NOT NULL DEFAULT 'unknown'
93 CHECK (status IN ('healthy', 'unhealthy', 'unknown')),
94 last_check TEXT,
95 last_success TEXT,
96 last_failure TEXT,
97 consecutive_failures INTEGER NOT NULL DEFAULT 0,
98 response_latency_ms INTEGER
99);
100
101CREATE INDEX IF NOT EXISTS idx_ca_health_ca_id ON ca_health (ca_id);
102
103-- Enrollment request log
104CREATE TABLE IF NOT EXISTS enrollment_requests (
105 id INTEGER PRIMARY KEY AUTOINCREMENT,
106 request_type TEXT NOT NULL
107 CHECK (request_type IN ('enroll', 'reenroll', 'serverkeygen', 'fullcmc')),
108 csr_hash TEXT NOT NULL,
109 ca_id TEXT NOT NULL,
110 label TEXT,
111 auth_method TEXT NOT NULL,
112 entity_id TEXT,
113 status TEXT NOT NULL DEFAULT 'pending'
114 CHECK (status IN ('pending', 'issued', 'rejected')),
115 certificate_id INTEGER REFERENCES certificates(id),
116 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
117 completed_at TEXT
118);
119
120CREATE INDEX IF NOT EXISTS idx_enrollment_requests_status ON enrollment_requests (status);
121CREATE INDEX IF NOT EXISTS idx_enrollment_requests_ca_id ON enrollment_requests (ca_id);
122CREATE INDEX IF NOT EXISTS idx_enrollment_requests_entity_id ON enrollment_requests (entity_id);
123CREATE INDEX IF NOT EXISTS idx_enrollment_requests_csr_hash ON enrollment_requests (csr_hash);
124
125-- Server-generated keys
126CREATE TABLE IF NOT EXISTS server_generated_keys (
127 id INTEGER PRIMARY KEY AUTOINCREMENT,
128 enrollment_id INTEGER NOT NULL REFERENCES enrollment_requests(id),
129 key_type TEXT NOT NULL,
130 key_size INTEGER NOT NULL,
131 archived INTEGER NOT NULL DEFAULT 0,
132 archive_id TEXT,
133 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
134);
135
136CREATE INDEX IF NOT EXISTS idx_server_generated_keys_enrollment_id ON server_generated_keys (enrollment_id);
137
138-- STAR (Short-Term Automatic Renewal) tables (RFC 8739)
139CREATE TABLE IF NOT EXISTS star_orders (
140 id TEXT PRIMARY KEY,
141 subject_dn TEXT NOT NULL,
142 key_type TEXT NOT NULL,
143 profile TEXT NOT NULL,
144 renewal_interval_secs INTEGER NOT NULL,
145 lifetime_end TEXT NOT NULL,
146 max_renewals INTEGER NOT NULL,
147 current_renewals INTEGER NOT NULL DEFAULT 0,
148 status TEXT NOT NULL DEFAULT 'active'
149 CHECK (status IN ('active', 'cancelled', 'completed', 'expired')),
150 requestor_dn TEXT,
151 ca_id TEXT NOT NULL,
152 csr_der BLOB NOT NULL,
153 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
154 cancelled_at TEXT
155);
156
157CREATE TABLE IF NOT EXISTS star_certificates (
158 id INTEGER PRIMARY KEY AUTOINCREMENT,
159 star_order_id TEXT NOT NULL REFERENCES star_orders(id),
160 serial_number TEXT NOT NULL,
161 certificate_der BLOB NOT NULL,
162 not_before TEXT NOT NULL,
163 not_after TEXT NOT NULL,
164 renewal_number INTEGER NOT NULL,
165 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
166);
167
168CREATE INDEX IF NOT EXISTS idx_star_certs_order ON star_certificates(star_order_id, renewal_number DESC);
169"#;
170
171const MIGRATION_V1_POSTGRES: &str = r#"
175-- Schema version tracking
176CREATE TABLE IF NOT EXISTS schema_version (
177 version INTEGER NOT NULL,
178 applied_at TEXT NOT NULL DEFAULT (to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
179);
180
181-- Enum types
182DO $$ BEGIN
183 CREATE TYPE certificate_status AS ENUM ('active', 'revoked', 'expired');
184EXCEPTION WHEN duplicate_object THEN NULL;
185END $$;
186
187DO $$ BEGIN
188 CREATE TYPE enrollment_type AS ENUM ('enroll', 'reenroll', 'serverkeygen', 'fullcmc');
189EXCEPTION WHEN duplicate_object THEN NULL;
190END $$;
191
192DO $$ BEGIN
193 CREATE TYPE enrollment_status AS ENUM ('pending', 'issued', 'rejected');
194EXCEPTION WHEN duplicate_object THEN NULL;
195END $$;
196
197DO $$ BEGIN
198 CREATE TYPE ca_health_status AS ENUM ('healthy', 'unhealthy', 'unknown');
199EXCEPTION WHEN duplicate_object THEN NULL;
200END $$;
201
202DO $$ BEGIN
203 CREATE TYPE star_order_status AS ENUM ('active', 'cancelled', 'completed', 'expired');
204EXCEPTION WHEN duplicate_object THEN NULL;
205END $$;
206
207-- Certificate inventory
208CREATE TABLE IF NOT EXISTS certificates (
209 id BIGSERIAL PRIMARY KEY,
210 serial TEXT NOT NULL UNIQUE,
211 subject_dn TEXT NOT NULL,
212 issuer_dn TEXT NOT NULL,
213 not_before TEXT NOT NULL,
214 not_after TEXT NOT NULL,
215 der_encoded BYTEA NOT NULL,
216 ca_id TEXT NOT NULL,
217 profile TEXT,
218 status certificate_status NOT NULL DEFAULT 'active',
219 revocation_reason TEXT,
220 revocation_time TEXT,
221 created_at TEXT NOT NULL DEFAULT (to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
222);
223
224CREATE INDEX IF NOT EXISTS idx_certificates_serial ON certificates (serial);
225CREATE INDEX IF NOT EXISTS idx_certificates_subject_dn ON certificates (subject_dn);
226CREATE INDEX IF NOT EXISTS idx_certificates_ca_id ON certificates (ca_id);
227CREATE INDEX IF NOT EXISTS idx_certificates_status ON certificates (status);
228CREATE INDEX IF NOT EXISTS idx_certificates_not_after ON certificates (not_after);
229
230-- OTP storage
231CREATE TABLE IF NOT EXISTS otp_tokens (
232 id BIGSERIAL PRIMARY KEY,
233 token_hash TEXT NOT NULL,
234 entity_id TEXT NOT NULL,
235 label TEXT,
236 profile TEXT,
237 created_at TEXT NOT NULL DEFAULT (to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
238 expires_at TEXT NOT NULL,
239 max_uses INTEGER NOT NULL DEFAULT 1,
240 current_uses INTEGER NOT NULL DEFAULT 0,
241 revoked BOOLEAN NOT NULL DEFAULT FALSE,
242 revoked_at TEXT
243);
244
245CREATE INDEX IF NOT EXISTS idx_otp_tokens_entity_id ON otp_tokens (entity_id);
246CREATE INDEX IF NOT EXISTS idx_otp_tokens_expires_at ON otp_tokens (expires_at);
247CREATE INDEX IF NOT EXISTS idx_otp_tokens_hash ON otp_tokens (entity_id, token_hash);
248
249-- Audit event trail
250CREATE TABLE IF NOT EXISTS audit_events (
251 id BIGSERIAL PRIMARY KEY,
252 timestamp TEXT NOT NULL DEFAULT (to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
253 event_type TEXT NOT NULL,
254 actor TEXT,
255 target TEXT,
256 detail_json TEXT,
257 source_ip TEXT,
258 session_id TEXT
259);
260
261CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp ON audit_events (timestamp);
262CREATE INDEX IF NOT EXISTS idx_audit_events_event_type ON audit_events (event_type);
263CREATE INDEX IF NOT EXISTS idx_audit_events_actor ON audit_events (actor);
264
265-- CA health tracking
266CREATE TABLE IF NOT EXISTS ca_health (
267 id BIGSERIAL PRIMARY KEY,
268 ca_id TEXT NOT NULL UNIQUE,
269 status ca_health_status NOT NULL DEFAULT 'unknown',
270 last_check TEXT,
271 last_success TEXT,
272 last_failure TEXT,
273 consecutive_failures INTEGER NOT NULL DEFAULT 0,
274 response_latency_ms INTEGER
275);
276
277CREATE INDEX IF NOT EXISTS idx_ca_health_ca_id ON ca_health (ca_id);
278
279-- Enrollment request log
280CREATE TABLE IF NOT EXISTS enrollment_requests (
281 id BIGSERIAL PRIMARY KEY,
282 request_type enrollment_type NOT NULL,
283 csr_hash TEXT NOT NULL,
284 ca_id TEXT NOT NULL,
285 label TEXT,
286 auth_method TEXT NOT NULL,
287 entity_id TEXT,
288 status enrollment_status NOT NULL DEFAULT 'pending',
289 certificate_id BIGINT REFERENCES certificates(id),
290 created_at TEXT NOT NULL DEFAULT (to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
291 completed_at TEXT
292);
293
294CREATE INDEX IF NOT EXISTS idx_enrollment_requests_status ON enrollment_requests (status);
295CREATE INDEX IF NOT EXISTS idx_enrollment_requests_ca_id ON enrollment_requests (ca_id);
296CREATE INDEX IF NOT EXISTS idx_enrollment_requests_entity_id ON enrollment_requests (entity_id);
297CREATE INDEX IF NOT EXISTS idx_enrollment_requests_csr_hash ON enrollment_requests (csr_hash);
298
299-- Server-generated keys
300CREATE TABLE IF NOT EXISTS server_generated_keys (
301 id BIGSERIAL PRIMARY KEY,
302 enrollment_id BIGINT NOT NULL REFERENCES enrollment_requests(id),
303 key_type TEXT NOT NULL,
304 key_size INTEGER NOT NULL,
305 archived BOOLEAN NOT NULL DEFAULT FALSE,
306 archive_id TEXT,
307 created_at TEXT NOT NULL DEFAULT (to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
308);
309
310CREATE INDEX IF NOT EXISTS idx_server_generated_keys_enrollment_id ON server_generated_keys (enrollment_id);
311
312-- STAR (Short-Term Automatic Renewal) tables (RFC 8739)
313CREATE TABLE IF NOT EXISTS star_orders (
314 id TEXT PRIMARY KEY,
315 subject_dn TEXT NOT NULL,
316 key_type TEXT NOT NULL,
317 profile TEXT NOT NULL,
318 renewal_interval_secs INTEGER NOT NULL,
319 lifetime_end TEXT NOT NULL,
320 max_renewals INTEGER NOT NULL,
321 current_renewals INTEGER NOT NULL DEFAULT 0,
322 status star_order_status NOT NULL DEFAULT 'active',
323 requestor_dn TEXT,
324 ca_id TEXT NOT NULL,
325 csr_der BYTEA NOT NULL,
326 created_at TEXT NOT NULL DEFAULT (to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
327 cancelled_at TEXT
328);
329
330CREATE TABLE IF NOT EXISTS star_certificates (
331 id BIGSERIAL PRIMARY KEY,
332 star_order_id TEXT NOT NULL REFERENCES star_orders(id),
333 serial_number TEXT NOT NULL,
334 certificate_der BYTEA NOT NULL,
335 not_before TEXT NOT NULL,
336 not_after TEXT NOT NULL,
337 renewal_number INTEGER NOT NULL,
338 created_at TEXT NOT NULL DEFAULT (to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
339);
340
341CREATE INDEX IF NOT EXISTS idx_star_certs_order ON star_certificates(star_order_id, renewal_number DESC);
342"#;
343
344const MIGRATION_V1_MARIADB: &str = r#"
348-- Schema version tracking
349CREATE TABLE IF NOT EXISTS schema_version (
350 version INT NOT NULL,
351 applied_at TEXT NOT NULL DEFAULT (DATE_FORMAT(NOW(6), '%Y-%m-%dT%H:%i:%S.%fZ'))
352) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
353
354-- Certificate inventory
355CREATE TABLE IF NOT EXISTS certificates (
356 id BIGINT AUTO_INCREMENT PRIMARY KEY,
357 serial VARCHAR(255) NOT NULL UNIQUE,
358 subject_dn TEXT NOT NULL,
359 issuer_dn TEXT NOT NULL,
360 not_before TEXT NOT NULL,
361 not_after TEXT NOT NULL,
362 der_encoded LONGBLOB NOT NULL,
363 ca_id VARCHAR(255) NOT NULL,
364 profile VARCHAR(255),
365 status ENUM('active', 'revoked', 'expired') NOT NULL DEFAULT 'active',
366 revocation_reason VARCHAR(255),
367 revocation_time TEXT,
368 created_at TEXT NOT NULL DEFAULT (DATE_FORMAT(NOW(6), '%Y-%m-%dT%H:%i:%S.%fZ'))
369) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
370
371CREATE INDEX IF NOT EXISTS idx_certificates_serial ON certificates (serial);
372CREATE INDEX IF NOT EXISTS idx_certificates_subject_dn ON certificates (subject_dn(255));
373CREATE INDEX IF NOT EXISTS idx_certificates_ca_id ON certificates (ca_id);
374CREATE INDEX IF NOT EXISTS idx_certificates_status ON certificates (status);
375CREATE INDEX IF NOT EXISTS idx_certificates_not_after ON certificates (not_after);
376
377-- OTP storage
378CREATE TABLE IF NOT EXISTS otp_tokens (
379 id BIGINT AUTO_INCREMENT PRIMARY KEY,
380 token_hash VARCHAR(512) NOT NULL,
381 entity_id VARCHAR(255) NOT NULL,
382 label VARCHAR(255),
383 profile VARCHAR(255),
384 created_at VARCHAR(64) NOT NULL DEFAULT (DATE_FORMAT(NOW(6), '%Y-%m-%dT%H:%i:%S.%fZ')),
385 expires_at VARCHAR(64) NOT NULL,
386 max_uses INT NOT NULL DEFAULT 1,
387 current_uses INT NOT NULL DEFAULT 0,
388 revoked BOOLEAN NOT NULL DEFAULT FALSE,
389 revoked_at VARCHAR(64)
390) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
391
392CREATE INDEX IF NOT EXISTS idx_otp_tokens_entity_id ON otp_tokens (entity_id);
393CREATE INDEX IF NOT EXISTS idx_otp_tokens_expires_at ON otp_tokens (expires_at);
394CREATE INDEX IF NOT EXISTS idx_otp_tokens_hash ON otp_tokens (entity_id, token_hash);
395
396-- Audit event trail
397CREATE TABLE IF NOT EXISTS audit_events (
398 id BIGINT AUTO_INCREMENT PRIMARY KEY,
399 timestamp TEXT NOT NULL DEFAULT (DATE_FORMAT(NOW(6), '%Y-%m-%dT%H:%i:%S.%fZ')),
400 event_type VARCHAR(255) NOT NULL,
401 actor VARCHAR(255),
402 target VARCHAR(255),
403 detail_json TEXT,
404 source_ip VARCHAR(45),
405 session_id VARCHAR(255)
406) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
407
408CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp ON audit_events (timestamp);
409CREATE INDEX IF NOT EXISTS idx_audit_events_event_type ON audit_events (event_type);
410CREATE INDEX IF NOT EXISTS idx_audit_events_actor ON audit_events (actor);
411
412-- CA health tracking
413CREATE TABLE IF NOT EXISTS ca_health (
414 id BIGINT AUTO_INCREMENT PRIMARY KEY,
415 ca_id VARCHAR(255) NOT NULL UNIQUE,
416 status ENUM('healthy', 'unhealthy', 'unknown') NOT NULL DEFAULT 'unknown',
417 last_check TEXT,
418 last_success TEXT,
419 last_failure TEXT,
420 consecutive_failures INT NOT NULL DEFAULT 0,
421 response_latency_ms INT
422) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
423
424CREATE INDEX IF NOT EXISTS idx_ca_health_ca_id ON ca_health (ca_id);
425
426-- Enrollment request log
427CREATE TABLE IF NOT EXISTS enrollment_requests (
428 id BIGINT AUTO_INCREMENT PRIMARY KEY,
429 request_type ENUM('enroll', 'reenroll', 'serverkeygen', 'fullcmc') NOT NULL,
430 csr_hash VARCHAR(255) NOT NULL,
431 ca_id VARCHAR(255) NOT NULL,
432 label VARCHAR(255),
433 auth_method VARCHAR(255) NOT NULL,
434 entity_id VARCHAR(255),
435 status ENUM('pending', 'issued', 'rejected') NOT NULL DEFAULT 'pending',
436 certificate_id BIGINT,
437 created_at TEXT NOT NULL DEFAULT (DATE_FORMAT(NOW(6), '%Y-%m-%dT%H:%i:%S.%fZ')),
438 completed_at TEXT,
439 CONSTRAINT fk_enrollment_certificate
440 FOREIGN KEY (certificate_id) REFERENCES certificates(id)
441) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
442
443CREATE INDEX IF NOT EXISTS idx_enrollment_requests_status ON enrollment_requests (status);
444CREATE INDEX IF NOT EXISTS idx_enrollment_requests_ca_id ON enrollment_requests (ca_id);
445CREATE INDEX IF NOT EXISTS idx_enrollment_requests_entity_id ON enrollment_requests (entity_id);
446CREATE INDEX IF NOT EXISTS idx_enrollment_requests_csr_hash ON enrollment_requests (csr_hash);
447
448-- Server-generated keys
449CREATE TABLE IF NOT EXISTS server_generated_keys (
450 id BIGINT AUTO_INCREMENT PRIMARY KEY,
451 enrollment_id BIGINT NOT NULL,
452 key_type VARCHAR(255) NOT NULL,
453 key_size INT NOT NULL,
454 archived BOOLEAN NOT NULL DEFAULT FALSE,
455 archive_id VARCHAR(255),
456 created_at TEXT NOT NULL DEFAULT (DATE_FORMAT(NOW(6), '%Y-%m-%dT%H:%i:%S.%fZ')),
457 CONSTRAINT fk_sgk_enrollment
458 FOREIGN KEY (enrollment_id) REFERENCES enrollment_requests(id)
459) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
460
461CREATE INDEX IF NOT EXISTS idx_server_generated_keys_enrollment_id ON server_generated_keys (enrollment_id);
462
463-- STAR (Short-Term Automatic Renewal) tables (RFC 8739)
464CREATE TABLE IF NOT EXISTS star_orders (
465 id VARCHAR(255) PRIMARY KEY,
466 subject_dn TEXT NOT NULL,
467 key_type VARCHAR(255) NOT NULL,
468 profile TEXT NOT NULL,
469 renewal_interval_secs INT NOT NULL,
470 lifetime_end TEXT NOT NULL,
471 max_renewals INT NOT NULL,
472 current_renewals INT NOT NULL DEFAULT 0,
473 status ENUM('active', 'cancelled', 'completed', 'expired') NOT NULL DEFAULT 'active',
474 requestor_dn TEXT,
475 ca_id VARCHAR(255) NOT NULL,
476 csr_der LONGBLOB NOT NULL,
477 created_at TEXT NOT NULL DEFAULT (DATE_FORMAT(NOW(6), '%Y-%m-%dT%H:%i:%S.%fZ')),
478 cancelled_at TEXT
479) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
480
481CREATE TABLE IF NOT EXISTS star_certificates (
482 id BIGINT AUTO_INCREMENT PRIMARY KEY,
483 star_order_id VARCHAR(255) NOT NULL,
484 serial_number VARCHAR(255) NOT NULL,
485 certificate_der LONGBLOB NOT NULL,
486 not_before TEXT NOT NULL,
487 not_after TEXT NOT NULL,
488 renewal_number INT NOT NULL,
489 created_at TEXT NOT NULL DEFAULT (DATE_FORMAT(NOW(6), '%Y-%m-%dT%H:%i:%S.%fZ')),
490 CONSTRAINT fk_star_certs_order
491 FOREIGN KEY (star_order_id) REFERENCES star_orders(id)
492) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
493
494CREATE INDEX IF NOT EXISTS idx_star_certs_order ON star_certificates(star_order_id, renewal_number DESC);
495"#;
496
497const MIGRATION_V3_SQLITE: &str = r#"
501CREATE TABLE IF NOT EXISTS pending_csrs (
502 id INTEGER PRIMARY KEY AUTOINCREMENT,
503 csr_der BLOB NOT NULL,
504 ca_id TEXT NOT NULL,
505 subject_dn TEXT,
506 identity TEXT NOT NULL,
507 auth_method TEXT,
508 status TEXT NOT NULL DEFAULT 'pending'
509 CHECK (status IN ('pending', 'signed', 'rejected', 'expired')),
510 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
511 signed_at TEXT,
512 certificate_id INTEGER REFERENCES certificates(id)
513);
514
515CREATE INDEX IF NOT EXISTS idx_pending_csrs_ca_id ON pending_csrs (ca_id);
516CREATE INDEX IF NOT EXISTS idx_pending_csrs_status ON pending_csrs (status);
517CREATE INDEX IF NOT EXISTS idx_pending_csrs_identity ON pending_csrs (identity);
518"#;
519
520const MIGRATION_V3_POSTGRES: &str = r#"
524DO $$ BEGIN
525 CREATE TYPE pending_csr_status AS ENUM ('pending', 'signed', 'rejected', 'expired');
526EXCEPTION WHEN duplicate_object THEN NULL;
527END $$;
528
529CREATE TABLE IF NOT EXISTS pending_csrs (
530 id BIGSERIAL PRIMARY KEY,
531 csr_der BYTEA NOT NULL,
532 ca_id TEXT NOT NULL,
533 subject_dn TEXT,
534 identity TEXT NOT NULL,
535 auth_method TEXT,
536 status pending_csr_status NOT NULL DEFAULT 'pending',
537 created_at TEXT NOT NULL DEFAULT (to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
538 signed_at TEXT,
539 certificate_id BIGINT REFERENCES certificates(id)
540);
541
542CREATE INDEX IF NOT EXISTS idx_pending_csrs_ca_id ON pending_csrs (ca_id);
543CREATE INDEX IF NOT EXISTS idx_pending_csrs_status ON pending_csrs (status);
544CREATE INDEX IF NOT EXISTS idx_pending_csrs_identity ON pending_csrs (identity);
545"#;
546
547const MIGRATION_V3_MARIADB: &str = r#"
551CREATE TABLE IF NOT EXISTS pending_csrs (
552 id BIGINT AUTO_INCREMENT PRIMARY KEY,
553 csr_der LONGBLOB NOT NULL,
554 ca_id VARCHAR(255) NOT NULL,
555 subject_dn TEXT,
556 identity VARCHAR(255) NOT NULL,
557 auth_method VARCHAR(255),
558 status ENUM('pending', 'signed', 'rejected', 'expired') NOT NULL DEFAULT 'pending',
559 created_at TEXT NOT NULL DEFAULT (DATE_FORMAT(NOW(6), '%Y-%m-%dT%H:%i:%S.%fZ')),
560 signed_at TEXT,
561 certificate_id BIGINT,
562 CONSTRAINT fk_pending_csrs_certificate
563 FOREIGN KEY (certificate_id) REFERENCES certificates(id)
564) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
565
566CREATE INDEX IF NOT EXISTS idx_pending_csrs_ca_id ON pending_csrs (ca_id);
567CREATE INDEX IF NOT EXISTS idx_pending_csrs_status ON pending_csrs (status);
568CREATE INDEX IF NOT EXISTS idx_pending_csrs_identity ON pending_csrs (identity);
569"#;
570
571pub async fn run_migrations(pool: &sqlx::AnyPool, kind: DbKind) -> Result<(), KipukaError> {
576 let current = current_version(pool).await?;
578
579 if current < 1 {
580 tracing::info!("applying migration v1 (initial schema)");
581
582 let migration_sql = match kind {
583 DbKind::Sqlite => MIGRATION_V1_SQLITE,
584 DbKind::Postgres => MIGRATION_V1_POSTGRES,
585 DbKind::MariaDb => MIGRATION_V1_MARIADB,
586 };
587
588 for statement in split_sql_statements(migration_sql) {
594 let stripped: String = statement
595 .lines()
596 .filter(|l| !l.trim_start().starts_with("--"))
597 .collect::<Vec<_>>()
598 .join("\n");
599 let trimmed = stripped.trim();
600 if trimmed.is_empty() {
601 continue;
602 }
603 sqlx::query(sqlx::AssertSqlSafe(trimmed.to_string()))
604 .execute(pool)
605 .await
606 .map_err(|e| KipukaError::Db(format!("migration v1 failed on [{trimmed}]: {e}")))?;
607 }
608
609 sqlx::query("INSERT INTO schema_version (version) VALUES (1)")
611 .execute(pool)
612 .await
613 .map_err(|e| KipukaError::Db(format!("recording schema version: {e}")))?;
614
615 tracing::info!("migration v1 applied successfully");
616 }
617
618 if current < 2 {
619 tracing::info!("applying migration v2 (OTP hash index)");
620 sqlx::query(
621 "CREATE INDEX IF NOT EXISTS idx_otp_tokens_hash ON otp_tokens (entity_id, token_hash)",
622 )
623 .execute(pool)
624 .await
625 .map_err(|e| KipukaError::Db(format!("migration v2 failed: {e}")))?;
626
627 sqlx::query("INSERT INTO schema_version (version) VALUES (2)")
628 .execute(pool)
629 .await
630 .map_err(|e| KipukaError::Db(format!("recording schema version: {e}")))?;
631
632 tracing::info!("migration v2 applied successfully");
633 }
634
635 if current < 3 {
636 tracing::info!("applying migration v3 (pending_csrs table)");
637
638 let migration_sql = match kind {
639 DbKind::Sqlite => MIGRATION_V3_SQLITE,
640 DbKind::Postgres => MIGRATION_V3_POSTGRES,
641 DbKind::MariaDb => MIGRATION_V3_MARIADB,
642 };
643
644 for statement in split_sql_statements(migration_sql) {
645 let stripped: String = statement
646 .lines()
647 .filter(|l| !l.trim_start().starts_with("--"))
648 .collect::<Vec<_>>()
649 .join("\n");
650 let trimmed = stripped.trim();
651 if trimmed.is_empty() {
652 continue;
653 }
654 sqlx::query(sqlx::AssertSqlSafe(trimmed.to_string()))
655 .execute(pool)
656 .await
657 .map_err(|e| KipukaError::Db(format!("migration v3 failed on [{trimmed}]: {e}")))?;
658 }
659
660 sqlx::query("INSERT INTO schema_version (version) VALUES (3)")
661 .execute(pool)
662 .await
663 .map_err(|e| KipukaError::Db(format!("recording schema version: {e}")))?;
664
665 tracing::info!("migration v3 applied successfully");
666 }
667
668 if current < 4 {
669 sqlx::query("CREATE TABLE IF NOT EXISTS audit_writer_lock (id INTEGER PRIMARY KEY, revision INTEGER NOT NULL)")
670 .execute(pool).await.map_err(|e| KipukaError::Db(format!("audit lock migration: {e}")))?;
671 let insert = match kind {
672 DbKind::MariaDb => "INSERT IGNORE INTO audit_writer_lock (id, revision) VALUES (1, 0)",
673 _ => {
674 "INSERT INTO audit_writer_lock (id, revision) VALUES (1, 0) ON CONFLICT (id) DO NOTHING"
675 }
676 };
677 sqlx::query(insert)
678 .execute(pool)
679 .await
680 .map_err(|e| KipukaError::Db(e.to_string()))?;
681 sqlx::query("INSERT INTO schema_version (version) VALUES (4)")
682 .execute(pool)
683 .await
684 .map_err(|e| KipukaError::Db(e.to_string()))?;
685 }
686
687 if current < 5 {
688 if kind == DbKind::MariaDb {
689 sqlx::query("ALTER TABLE star_orders MODIFY COLUMN profile TEXT NOT NULL")
691 .execute(pool)
692 .await
693 .map_err(|e| KipukaError::Db(format!("STAR policy migration: {e}")))?;
694 }
695 sqlx::query("INSERT INTO schema_version (version) VALUES (5)")
696 .execute(pool)
697 .await
698 .map_err(|e| KipukaError::Db(e.to_string()))?;
699 }
700
701 if current < 6 {
702 tracing::info!("applying migration v6 (audit hash-chain columns)");
703 let alters: &[&str] = match kind {
711 DbKind::Postgres => &[
712 "ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS prev_hash TEXT",
713 "ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS record_hash TEXT",
714 ],
715 DbKind::MariaDb => &[
716 "ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS prev_hash TEXT",
717 "ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS record_hash TEXT",
718 ],
719 DbKind::Sqlite => &[
720 "ALTER TABLE audit_events ADD COLUMN prev_hash TEXT",
721 "ALTER TABLE audit_events ADD COLUMN record_hash TEXT",
722 ],
723 };
724 for stmt in alters {
725 sqlx::query(*stmt)
726 .execute(pool)
727 .await
728 .map_err(|e| KipukaError::Db(format!("migration v6 failed on [{stmt}]: {e}")))?;
729 }
730 sqlx::query("INSERT INTO schema_version (version) VALUES (6)")
731 .execute(pool)
732 .await
733 .map_err(|e| KipukaError::Db(e.to_string()))?;
734 tracing::info!("migration v6 applied successfully");
735 }
736
737 Ok(())
738}
739
740fn split_sql_statements(sql: &str) -> Vec<&str> {
746 let mut statements = Vec::new();
747 let bytes = sql.as_bytes();
748 let len = bytes.len();
749 let mut start = 0;
750 let mut i = 0;
751 let mut in_dollar_quote = false;
752 let mut dollar_tag = String::new();
753
754 while i < len {
755 if bytes[i] == b'$' && !in_dollar_quote {
756 if let Some(tag) = read_dollar_tag(sql, i) {
758 in_dollar_quote = true;
759 dollar_tag = tag.clone();
760 i += tag.len();
761 continue;
762 }
763 } else if bytes[i] == b'$' && in_dollar_quote {
764 if sql[i..].starts_with(&dollar_tag) {
766 in_dollar_quote = false;
767 i += dollar_tag.len();
768 dollar_tag.clear();
769 continue;
770 }
771 }
772
773 if bytes[i] == b';' && !in_dollar_quote {
774 let stmt = &sql[start..i];
775 if !stmt.trim().is_empty() {
776 statements.push(stmt);
777 }
778 start = i + 1;
779 }
780
781 i += 1;
782 }
783
784 let tail = &sql[start..];
786 if !tail.trim().is_empty() {
787 statements.push(tail);
788 }
789
790 statements
791}
792
793fn read_dollar_tag(sql: &str, pos: usize) -> Option<String> {
797 let rest = &sql[pos..];
798 if !rest.starts_with('$') {
799 return None;
800 }
801
802 for (j, ch) in rest[1..].char_indices() {
804 if ch == '$' {
805 let tag = &rest[..j + 2]; return Some(tag.to_string());
807 }
808 if !ch.is_alphanumeric() && ch != '_' {
809 break;
810 }
811 }
812
813 None
814}
815
816async fn current_version(pool: &sqlx::AnyPool) -> Result<i32, KipukaError> {
819 let row = sqlx::query_as::<_, (i32,)>("SELECT COALESCE(MAX(version), 0) FROM schema_version")
821 .fetch_one(pool)
822 .await;
823
824 match row {
825 Ok((version,)) => Ok(version),
826 Err(_) => {
827 Ok(0)
829 }
830 }
831}