nixfleet_control_plane/db/
revocations.rs1use anyhow::{Context, Result};
4use chrono::{DateTime, Utc};
5use rusqlite::{Connection, params};
6use std::sync::Mutex;
7
8pub struct Revocations<'a> {
9 pub(super) conn: &'a Mutex<Connection>,
10}
11
12impl Revocations<'_> {
13 pub fn revoke_cert(
15 &self,
16 hostname: &str,
17 not_before: DateTime<Utc>,
18 reason: Option<&str>,
19 revoked_by: Option<&str>,
20 ) -> Result<()> {
21 super::read(self.conn, |c| {
22 c.execute(
23 "INSERT INTO cert_revocations(hostname, not_before, reason, revoked_by)
24 VALUES (?1, ?2, ?3, ?4)
25 ON CONFLICT(hostname) DO UPDATE SET
26 not_before = excluded.not_before,
27 reason = excluded.reason,
28 revoked_at = datetime('now'),
29 revoked_by = excluded.revoked_by",
30 params![hostname, not_before.to_rfc3339(), reason, revoked_by],
31 )
32 .context("upsert cert_revocations")?;
33 Ok(())
34 })
35 }
36
37 pub fn cert_revoked_before(&self, hostname: &str) -> Result<Option<DateTime<Utc>>> {
39 super::read(self.conn, |c| {
40 match c.query_row(
41 "SELECT not_before FROM cert_revocations WHERE hostname = ?1",
42 params![hostname],
43 |r| r.get::<_, String>(0),
44 ) {
45 Ok(s) => Ok(Some(
46 s.parse::<DateTime<Utc>>()
47 .context("parse revocation timestamp")?,
48 )),
49 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
50 Err(e) => Err(e.into()),
51 }
52 })
53 }
54
55 pub fn retain_only(&self, keep: &[&str]) -> Result<usize> {
62 super::read(self.conn, |c| {
63 if keep.is_empty() {
64 let n = c
65 .execute("DELETE FROM cert_revocations", [])
66 .context("clear cert_revocations")?;
67 return Ok(n);
68 }
69 let placeholders = vec!["?"; keep.len()].join(",");
70 let sql =
71 format!("DELETE FROM cert_revocations WHERE hostname NOT IN ({placeholders})");
72 let n = c
73 .execute(&sql, rusqlite::params_from_iter(keep.iter()))
74 .context("retain_only cert_revocations")?;
75 Ok(n)
76 })
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use super::super::test_helpers::fresh_db;
83 use chrono::Utc;
84
85 #[test]
86 fn cert_revocation_upserts() {
87 let db = fresh_db();
88 assert!(
89 db.revocations()
90 .cert_revoked_before("test-host")
91 .unwrap()
92 .is_none()
93 );
94 let t1 = Utc::now();
95 db.revocations()
96 .revoke_cert("test-host", t1, Some("compromised"), Some("operator"))
97 .unwrap();
98 let r1 = db
99 .revocations()
100 .cert_revoked_before("test-host")
101 .unwrap()
102 .unwrap();
103 assert_eq!(r1.timestamp(), t1.timestamp());
105 let t2 = Utc::now() + chrono::Duration::seconds(60);
106 db.revocations()
107 .revoke_cert("test-host", t2, None, None)
108 .unwrap();
109 let r2 = db
110 .revocations()
111 .cert_revoked_before("test-host")
112 .unwrap()
113 .unwrap();
114 assert!(r2 >= r1);
115 }
116
117 #[test]
118 fn retain_only_deletes_absent_hostnames() {
119 let db = fresh_db();
120 let t = Utc::now();
121 for host in ["alpha", "beta", "gamma"] {
122 db.revocations()
123 .revoke_cert(host, t, Some("test"), Some("operator"))
124 .unwrap();
125 }
126
127 db.revocations().retain_only(&["beta"]).unwrap();
129 assert!(
130 db.revocations()
131 .cert_revoked_before("alpha")
132 .unwrap()
133 .is_none()
134 );
135 assert!(
136 db.revocations()
137 .cert_revoked_before("beta")
138 .unwrap()
139 .is_some()
140 );
141 assert!(
142 db.revocations()
143 .cert_revoked_before("gamma")
144 .unwrap()
145 .is_none()
146 );
147
148 db.revocations().retain_only(&[]).unwrap();
150 assert!(
151 db.revocations()
152 .cert_revoked_before("beta")
153 .unwrap()
154 .is_none()
155 );
156 }
157}