1use crate::db::DbKind;
14use crate::error::KipukaError;
15
16pub const SCHEMA_VERSION: i32 = 2;
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 VARCHAR(255) 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
497pub async fn run_migrations(pool: &sqlx::AnyPool, kind: DbKind) -> Result<(), KipukaError> {
502 let current = current_version(pool).await?;
504
505 if current < 1 {
506 tracing::info!("applying migration v1 (initial schema)");
507
508 let migration_sql = match kind {
509 DbKind::Sqlite => MIGRATION_V1_SQLITE,
510 DbKind::Postgres => MIGRATION_V1_POSTGRES,
511 DbKind::MariaDb => MIGRATION_V1_MARIADB,
512 };
513
514 for statement in split_sql_statements(migration_sql) {
520 let stripped: String = statement
521 .lines()
522 .filter(|l| !l.trim_start().starts_with("--"))
523 .collect::<Vec<_>>()
524 .join("\n");
525 let trimmed = stripped.trim();
526 if trimmed.is_empty() {
527 continue;
528 }
529 sqlx::query(trimmed)
530 .execute(pool)
531 .await
532 .map_err(|e| KipukaError::Db(format!("migration v1 failed on [{trimmed}]: {e}")))?;
533 }
534
535 sqlx::query("INSERT INTO schema_version (version) VALUES (1)")
537 .execute(pool)
538 .await
539 .map_err(|e| KipukaError::Db(format!("recording schema version: {e}")))?;
540
541 tracing::info!("migration v1 applied successfully");
542 }
543
544 if current < 2 {
545 tracing::info!("applying migration v2 (OTP hash index)");
546 sqlx::query(
547 "CREATE INDEX IF NOT EXISTS idx_otp_tokens_hash ON otp_tokens (entity_id, token_hash)",
548 )
549 .execute(pool)
550 .await
551 .map_err(|e| KipukaError::Db(format!("migration v2 failed: {e}")))?;
552
553 sqlx::query("INSERT INTO schema_version (version) VALUES (2)")
554 .execute(pool)
555 .await
556 .map_err(|e| KipukaError::Db(format!("recording schema version: {e}")))?;
557
558 tracing::info!("migration v2 applied successfully");
559 }
560
561 Ok(())
562}
563
564fn split_sql_statements(sql: &str) -> Vec<&str> {
570 let mut statements = Vec::new();
571 let bytes = sql.as_bytes();
572 let len = bytes.len();
573 let mut start = 0;
574 let mut i = 0;
575 let mut in_dollar_quote = false;
576 let mut dollar_tag = String::new();
577
578 while i < len {
579 if bytes[i] == b'$' && !in_dollar_quote {
580 if let Some(tag) = read_dollar_tag(sql, i) {
582 in_dollar_quote = true;
583 dollar_tag = tag.clone();
584 i += tag.len();
585 continue;
586 }
587 } else if bytes[i] == b'$' && in_dollar_quote {
588 if sql[i..].starts_with(&dollar_tag) {
590 in_dollar_quote = false;
591 i += dollar_tag.len();
592 dollar_tag.clear();
593 continue;
594 }
595 }
596
597 if bytes[i] == b';' && !in_dollar_quote {
598 let stmt = &sql[start..i];
599 if !stmt.trim().is_empty() {
600 statements.push(stmt);
601 }
602 start = i + 1;
603 }
604
605 i += 1;
606 }
607
608 let tail = &sql[start..];
610 if !tail.trim().is_empty() {
611 statements.push(tail);
612 }
613
614 statements
615}
616
617fn read_dollar_tag(sql: &str, pos: usize) -> Option<String> {
621 let rest = &sql[pos..];
622 if !rest.starts_with('$') {
623 return None;
624 }
625
626 for (j, ch) in rest[1..].char_indices() {
628 if ch == '$' {
629 let tag = &rest[..j + 2]; return Some(tag.to_string());
631 }
632 if !ch.is_alphanumeric() && ch != '_' {
633 break;
634 }
635 }
636
637 None
638}
639
640async fn current_version(pool: &sqlx::AnyPool) -> Result<i32, KipukaError> {
643 let row = sqlx::query_as::<_, (i32,)>("SELECT COALESCE(MAX(version), 0) FROM schema_version")
645 .fetch_one(pool)
646 .await;
647
648 match row {
649 Ok((version,)) => Ok(version),
650 Err(_) => {
651 Ok(0)
653 }
654 }
655}