Skip to main content

artifactize_core/server/
store.rs

1use std::path::Path;
2
3use base64::Engine;
4use rusqlite::{OptionalExtension, TransactionBehavior, params};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use tokio_rusqlite::Connection;
8
9use crate::broker::now;
10
11/// Random bytes in a bearer token: 256 bits, beyond guessing for the token's whole life.
12const TOKEN_SECRET_BYTES: usize = 32;
13
14pub const DATABASE: &str = "review-store.sqlite";
15const SCHEMA_VERSION: u32 = 3;
16/// Bound retained shared-review history, including small records that would not
17/// reach the byte cap; least-recently-used records are evicted above either limit.
18pub const MAX_ENTRIES: i64 = 100_000;
19/// Bound JSON payload storage in the shared review database independently of record count.
20pub const MAX_BYTES: i64 = 4 * 1024 * 1024 * 1024;
21
22#[derive(
23    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, clap::ValueEnum,
24)]
25#[serde(rename_all = "lowercase")]
26pub enum Scope {
27    /// Look up entries.
28    Read,
29    /// Publish runtime and Agent entries.
30    Publish,
31    /// Additionally publish Human sign-offs.
32    Human,
33}
34
35impl Scope {
36    pub fn name(self) -> &'static str {
37        match self {
38            Self::Read => "read",
39            Self::Publish => "publish",
40            Self::Human => "human",
41        }
42    }
43}
44
45#[derive(Debug, Clone, Serialize)]
46#[serde(rename_all = "camelCase")]
47pub struct Token {
48    pub name: crate::types::TokenName,
49    pub scopes: Vec<Scope>,
50    pub created_at: crate::types::Timestamp,
51    pub revoked_at: Option<crate::types::Timestamp>,
52}
53
54#[derive(Debug, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct Revocation {
57    pub name: crate::types::TokenName,
58    pub revoked_at: crate::types::Timestamp,
59    pub purged_entries: usize,
60}
61
62#[derive(Debug, thiserror::Error)]
63enum Error {
64    #[error(transparent)]
65    Sql(#[from] rusqlite::Error),
66    #[error(transparent)]
67    Json(#[from] serde_json::Error),
68    #[error("{0}")]
69    Invalid(String),
70}
71
72#[derive(Clone)]
73pub struct Store {
74    connection: Connection,
75}
76
77fn digest(token: &str) -> crate::types::Sha256Digest {
78    Sha256::digest(token.as_bytes())
79        .iter()
80        .map(|byte| format!("{byte:02x}"))
81        .collect::<String>()
82        .parse()
83        .expect("SHA-256 token digest")
84}
85
86/// Version 3 keeps every record of a 0.5 reuse key. Records of versions 1 and 2, keyed by
87/// (Eval definition hash, fingerprint) as 0.3 and 0.4 computed them, cannot be mapped to the new
88/// key and are dropped; tokens are kept.
89fn reuse_keys(db: &rusqlite::Transaction<'_>) -> Result<(), Error> {
90    db.execute_batch("DROP TABLE IF EXISTS entries;")?;
91    Ok(())
92}
93
94impl Store {
95    /// Open or create `review-store.sqlite`, separate from the local `state.sqlite`.
96    pub async fn open(state: &Path) -> Result<Self, String> {
97        let state = crate::store::state_dir(Some(state))?;
98        crate::platform::create_private_dir_all(&state).map_err(|e| e.to_string())?;
99        for suffix in ["", "-wal", "-shm"] {
100            let path = state.join(format!("{DATABASE}{suffix}"));
101            match crate::platform::path_kind(&path) {
102                Ok(crate::platform::FileKind::File) => {}
103                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
104                Ok(_) => {
105                    return Err(format!(
106                        "Review store files must be regular files: {}",
107                        crate::platform::path_text(&path)
108                    ));
109                }
110                Err(error) => return Err(error.to_string()),
111            }
112        }
113        let connection = Connection::open(state.join(DATABASE))
114            .await
115            .map_err(|e| e.to_string())?;
116        connection
117            .call(|db| -> Result<(), Error> {
118                db.busy_timeout(crate::store::SQLITE_BUSY_TIMEOUT)?;
119                let version: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
120                if version > SCHEMA_VERSION {
121                    return Err(Error::Invalid(format!(
122                        "Unsupported review store schema version: {version}"
123                    )));
124                }
125                db.pragma_update(None, "journal_mode", "WAL")?;
126                let transaction = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
127                // Another process may have upgraded between the two reads.
128                if matches!(
129                    transaction
130                        .pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))?,
131                    1 | 2
132                ) {
133                    reuse_keys(&transaction)?;
134                }
135                transaction.execute_batch(
136                    "CREATE TABLE IF NOT EXISTS tokens(name TEXT PRIMARY KEY, hash TEXT NOT NULL UNIQUE, scopes TEXT NOT NULL, created_at TEXT NOT NULL, revoked_at TEXT);
137                    CREATE TABLE IF NOT EXISTS entries(key TEXT NOT NULL, execution_id TEXT NOT NULL, publisher TEXT NOT NULL, completed_at TEXT NOT NULL, bytes INTEGER NOT NULL, last_used TEXT NOT NULL, data TEXT NOT NULL, UNIQUE(key, execution_id));
138                    CREATE INDEX IF NOT EXISTS entries_key ON entries(key, completed_at);
139                    CREATE INDEX IF NOT EXISTS entries_lru ON entries(last_used, key);
140                    CREATE INDEX IF NOT EXISTS entries_publisher ON entries(publisher);",
141                )?;
142                transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
143                transaction.commit()?;
144                Ok(())
145            })
146            .await
147            .map_err(|e| e.to_string())?;
148        Ok(Self { connection })
149    }
150
151    /// Create a token; only its SHA-256 is stored, so the caller prints it once.
152    pub async fn add_token(
153        &self,
154        name: &crate::types::TokenName,
155        scopes: &[Scope],
156    ) -> Result<String, String> {
157        let mut scopes = scopes.to_vec();
158        scopes.sort();
159        scopes.dedup();
160        if scopes.is_empty() {
161            return Err("A token needs at least one scope.".into());
162        }
163        let mut secret = [0; TOKEN_SECRET_BYTES];
164        getrandom::fill(&mut secret).map_err(|_| "Cannot obtain secure randomness.".to_owned())?;
165        let token = format!(
166            "azt_{}",
167            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret)
168        );
169        let (name, hash, scopes) = (
170            name.clone(),
171            digest(&token),
172            serde_json::to_string(&scopes).map_err(|e| e.to_string())?,
173        );
174        self.connection
175            .call(move |db| -> Result<(), Error> {
176                let inserted = db.execute(
177                    "INSERT INTO tokens(name,hash,scopes,created_at) VALUES (?,?,?,?) ON CONFLICT(name) DO NOTHING",
178                    params![name, hash, scopes, now()],
179                )?;
180                if inserted == 0 {
181                    return Err(Error::Invalid(format!(
182                        "Token name {name} is already used; names of revoked tokens are kept."
183                    )));
184                }
185                Ok(())
186            })
187            .await
188            .map_err(|e| e.to_string())?;
189        Ok(token)
190    }
191
192    pub async fn tokens(&self) -> Result<Vec<Token>, String> {
193        self.connection
194            .call(|db| -> Result<_, Error> {
195                let mut statement = db.prepare(
196                    "SELECT name,scopes,created_at,revoked_at FROM tokens ORDER BY name",
197                )?;
198                let rows = statement
199                    .query_map([], |row| {
200                        Ok((
201                            row.get::<_, crate::types::TokenName>(0)?,
202                            row.get::<_, String>(1)?,
203                            row.get(2)?,
204                            row.get(3)?,
205                        ))
206                    })?
207                    .map(|row| {
208                        let (name, scopes, created_at, revoked_at) = row?;
209                        Ok(Token {
210                            name,
211                            scopes: serde_json::from_str(&scopes)?,
212                            created_at,
213                            revoked_at,
214                        })
215                    })
216                    .collect::<Result<_, Error>>()?;
217                Ok(rows)
218            })
219            .await
220            .map_err(|e| e.to_string())
221    }
222
223    /// Revoke immediately; `purge` also deletes every entry that token published.
224    pub async fn revoke(
225        &self,
226        name: &crate::types::TokenName,
227        purge: bool,
228    ) -> Result<Revocation, String> {
229        let name = name.clone();
230        self.connection
231            .call(move |db| -> Result<_, Error> {
232                let transaction = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
233                transaction.execute(
234                    "UPDATE tokens SET revoked_at=? WHERE name=? AND revoked_at IS NULL",
235                    params![now(), name],
236                )?;
237                let revoked_at: crate::types::Timestamp = transaction
238                    .query_row(
239                        "SELECT revoked_at FROM tokens WHERE name=?",
240                        [&name],
241                        |row| row.get(0),
242                    )
243                    .optional()?
244                    .ok_or_else(|| Error::Invalid(format!("Unknown token {name}.")))?;
245                let purged_entries = if purge {
246                    transaction.execute("DELETE FROM entries WHERE publisher=?", [&name])?
247                } else {
248                    0
249                };
250                transaction.commit()?;
251                Ok(Revocation {
252                    name,
253                    revoked_at,
254                    purged_entries,
255                })
256            })
257            .await
258            .map_err(|e| e.to_string())
259    }
260
261    /// Remove every record of a key.
262    pub async fn remove(&self, key: &crate::types::ReuseKey) -> Result<bool, String> {
263        let key = key.clone();
264        self.connection
265            .call(move |db| -> Result<_, Error> {
266                Ok(db.execute("DELETE FROM entries WHERE key=?", [key])? != 0)
267            })
268            .await
269            .map_err(|e| e.to_string())
270    }
271
272    /// The active token's name and scopes; revoked and unknown tokens yield `None`.
273    pub(super) async fn authenticate(
274        &self,
275        token: &str,
276    ) -> Result<Option<(crate::types::TokenName, Vec<Scope>)>, String> {
277        let hash = digest(token);
278        self.connection
279            .call(move |db| -> Result<_, Error> {
280                let row: Option<(crate::types::TokenName, String)> = db
281                    .query_row(
282                        "SELECT name,scopes FROM tokens WHERE hash=? AND revoked_at IS NULL",
283                        [hash],
284                        |row| Ok((row.get(0)?, row.get(1)?)),
285                    )
286                    .optional()?;
287                row.map(|(name, scopes)| Ok((name, serde_json::from_str(&scopes)?)))
288                    .transpose()
289            })
290            .await
291            .map_err(|e| e.to_string())
292    }
293
294    /// The latest record of each found key; hits update that record's last use.
295    pub(super) async fn lookup(
296        &self,
297        keys: Vec<crate::types::ReuseKey>,
298    ) -> Result<Vec<String>, String> {
299        self.connection
300            .call(move |db| -> Result<_, Error> {
301                let transaction = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
302                let used = now();
303                let mut found = Vec::new();
304                for key in keys {
305                    let data: Option<String> = transaction
306                        .query_row(
307                            "UPDATE entries SET last_used=? WHERE rowid=(SELECT rowid FROM entries WHERE key=? ORDER BY completed_at DESC, rowid DESC LIMIT 1) RETURNING data",
308                            params![used, key],
309                            |row| row.get(0),
310                        )
311                        .optional()?;
312                    found.extend(data);
313                }
314                transaction.commit()?;
315                Ok(found)
316            })
317            .await
318            .map_err(|e| e.to_string())
319    }
320
321    /// Append a record to its key's history; `false` when this execution is already stored.
322    pub(super) async fn insert(
323        &self,
324        key: &crate::types::ReuseKey,
325        execution_id: &crate::types::ExecutionId,
326        publisher: &crate::types::TokenName,
327        completed_at: crate::types::Timestamp,
328        data: String,
329    ) -> Result<bool, String> {
330        let record = (
331            key.clone(),
332            execution_id.clone(),
333            publisher.clone(),
334            completed_at,
335        );
336        self.connection
337            .call(move |db| -> Result<_, Error> {
338                let (key, execution_id, publisher, completed_at) = record;
339                let transaction = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
340                let used = now();
341                let created = transaction.execute(
342                    "INSERT INTO entries(key,execution_id,publisher,completed_at,bytes,last_used,data) VALUES (?,?,?,?,?,?,?) ON CONFLICT DO NOTHING",
343                    params![
344                        key,
345                        execution_id,
346                        publisher,
347                        completed_at,
348                        data.len() as i64,
349                        used,
350                        data
351                    ],
352                )? == 1;
353                // Evict least-recently-used records above the caps, never the new one.
354                loop {
355                    let (count, bytes): (i64, i64) = transaction.query_row(
356                        "SELECT count(*),coalesce(sum(bytes),0) FROM entries",
357                        [],
358                        |row| Ok((row.get(0)?, row.get(1)?)),
359                    )?;
360                    if count <= MAX_ENTRIES && bytes <= MAX_BYTES {
361                        break;
362                    }
363                    if transaction.execute(
364                        "DELETE FROM entries WHERE rowid=(SELECT rowid FROM entries WHERE NOT (key=?1 AND execution_id=?2) ORDER BY last_used,key,completed_at LIMIT 1)",
365                        params![key, execution_id],
366                    )? == 0
367                    {
368                        break;
369                    }
370                }
371                transaction.commit()?;
372                Ok(created)
373            })
374            .await
375            .map_err(|e| e.to_string())
376    }
377}