nixfleet_control_plane/db/
revocations.rs

1//! Agent-cert revocation list (hard state); replayed each tick from signed sidecar.
2
3use 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    /// Upsert: any cert with notBefore < `not_before` is rejected; re-revoking moves it forward.
14    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    /// Caller compares against the presented cert's notBefore.
38    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    /// Reconcile the table with the signed sidecar: delete every row
56    /// whose hostname is not in `keep`. Returns the number of rows
57    /// deleted. Closes the de-revoke gap - `revoke_cert` is
58    /// upsert-only, so an entry leaving the signed list otherwise
59    /// sticks around in the DB forever and silently keeps rejecting
60    /// the host on every mTLS request.
61    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        // RFC3339 round-trip loses sub-second precision.
104        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        // Reconcile to a list with only beta - alpha + gamma should be gone.
128        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        // Empty `keep` clears everything (operator wiped fleet.nix list).
149        db.revocations().retain_only(&[]).unwrap();
150        assert!(
151            db.revocations()
152                .cert_revoked_before("beta")
153                .unwrap()
154                .is_none()
155        );
156    }
157}