WP-02: encrypted settings, SMTP mail, job runner and cron scheduler
AES-256-GCM secret storage keyed by MASTER_KEY, SMTP settings with test mail (lettre), persisted job runs with log and status, JobRunner with per-kind concurrency guard, 6-field cron schedules with defaults, scheduler loop. Settings and Jobs pages in the UI. FAKE_HOST mode for dev machines. Tests: 30 application, 8 infrastructure, 23 API, 16 Vitest, 6 Playwright. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
80
backend/crates/infrastructure/src/cipher.rs
Normal file
80
backend/crates/infrastructure/src/cipher.rs
Normal file
@ -0,0 +1,80 @@
|
||||
//! AES-256-GCM encryption for secrets at rest. Format: base64url(nonce || ciphertext).
|
||||
use aes_gcm::aead::{Aead, KeyInit, OsRng};
|
||||
use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce};
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use domain::ports::Cipher;
|
||||
use domain::DomainError;
|
||||
|
||||
pub struct AesGcmCipher(Aes256Gcm);
|
||||
|
||||
impl AesGcmCipher {
|
||||
/// `hex_key` is a 64-character hex string (32 bytes).
|
||||
pub fn from_hex(hex_key: &str) -> anyhow::Result<Self> {
|
||||
let bytes = hex_decode(hex_key)?;
|
||||
anyhow::ensure!(
|
||||
bytes.len() == 32,
|
||||
"MASTER_KEY must be 32 bytes (64 hex characters)"
|
||||
);
|
||||
Ok(Self(Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&bytes))))
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_decode(s: &str) -> anyhow::Result<Vec<u8>> {
|
||||
anyhow::ensure!(s.len() % 2 == 0, "odd hex length");
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| Ok(u8::from_str_radix(&s[i..i + 2], 16)?))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Cipher for AesGcmCipher {
|
||||
fn encrypt(&self, plain: &str) -> Result<String, DomainError> {
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let ct = self
|
||||
.0
|
||||
.encrypt(&nonce, plain.as_bytes())
|
||||
.map_err(|e| DomainError::Storage(e.to_string()))?;
|
||||
let mut out = nonce.to_vec();
|
||||
out.extend(ct);
|
||||
Ok(URL_SAFE_NO_PAD.encode(out))
|
||||
}
|
||||
|
||||
fn decrypt(&self, cipher_text: &str) -> Result<String, DomainError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cipher_text)
|
||||
.map_err(|e| DomainError::Storage(e.to_string()))?;
|
||||
if bytes.len() < 12 {
|
||||
return Err(DomainError::Storage("cipher text too short".into()));
|
||||
}
|
||||
let (nonce, ct) = bytes.split_at(12);
|
||||
let plain = self
|
||||
.0
|
||||
.decrypt(Nonce::from_slice(nonce), ct)
|
||||
.map_err(|_| DomainError::Storage("decryption failed".into()))?;
|
||||
String::from_utf8(plain).map_err(|e| DomainError::Storage(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
||||
|
||||
#[test]
|
||||
fn roundtrip_and_tamper_detection() {
|
||||
let c = AesGcmCipher::from_hex(KEY).unwrap();
|
||||
let ct = c.encrypt("hello secret").unwrap();
|
||||
assert_ne!(
|
||||
c.encrypt("hello secret").unwrap(),
|
||||
ct,
|
||||
"nonce must be random"
|
||||
);
|
||||
assert_eq!(c.decrypt(&ct).unwrap(), "hello secret");
|
||||
let other = AesGcmCipher::from_hex(&KEY.replace('0', "f")).unwrap();
|
||||
assert!(other.decrypt(&ct).is_err());
|
||||
assert!(c.decrypt("AAAA").is_err());
|
||||
assert!(AesGcmCipher::from_hex("abcd").is_err());
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,14 @@
|
||||
//! Infrastructure layer: SQLite repositories, Argon2 hashing, JWT issuing.
|
||||
pub mod cipher;
|
||||
pub mod db;
|
||||
pub mod mail;
|
||||
pub mod password;
|
||||
pub mod sqlite;
|
||||
pub mod token;
|
||||
|
||||
pub use cipher::AesGcmCipher;
|
||||
pub use db::{connect, DbPool};
|
||||
pub use mail::LettreMailer;
|
||||
pub use password::Argon2Hasher;
|
||||
pub use sqlite::{SqliteAuditLog, SqliteRefreshTokens, SqliteUsers};
|
||||
pub use sqlite::{SqliteAuditLog, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers};
|
||||
pub use token::JwtIssuer;
|
||||
|
||||
62
backend/crates/infrastructure/src/mail.rs
Normal file
62
backend/crates/infrastructure/src/mail.rs
Normal file
@ -0,0 +1,62 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::Mailer;
|
||||
use domain::settings::{SmtpSecurity, SmtpSettings};
|
||||
use domain::DomainError;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LettreMailer;
|
||||
|
||||
#[async_trait]
|
||||
impl Mailer for LettreMailer {
|
||||
async fn send(
|
||||
&self,
|
||||
smtp: &SmtpSettings,
|
||||
to: &[String],
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let unavailable = |e: String| DomainError::Unavailable(format!("smtp: {e}"));
|
||||
let mut msg = Message::builder()
|
||||
.from(
|
||||
smtp.from
|
||||
.parse()
|
||||
.map_err(|e: lettre::address::AddressError| unavailable(e.to_string()))?,
|
||||
)
|
||||
.subject(subject);
|
||||
for r in to {
|
||||
msg = msg.to(r
|
||||
.parse()
|
||||
.map_err(|e: lettre::address::AddressError| unavailable(e.to_string()))?);
|
||||
}
|
||||
let msg = msg
|
||||
.body(body.to_string())
|
||||
.map_err(|e| unavailable(e.to_string()))?;
|
||||
|
||||
let mut builder = match smtp.security {
|
||||
SmtpSecurity::None => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&smtp.host)
|
||||
}
|
||||
SmtpSecurity::StartTls => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host)
|
||||
.map_err(|e| unavailable(e.to_string()))?
|
||||
}
|
||||
SmtpSecurity::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)
|
||||
.map_err(|e| unavailable(e.to_string()))?,
|
||||
}
|
||||
.port(smtp.port);
|
||||
if !smtp.username.is_empty() {
|
||||
builder = builder.credentials(Credentials::new(
|
||||
smtp.username.clone(),
|
||||
smtp.password.clone(),
|
||||
));
|
||||
}
|
||||
builder
|
||||
.build()
|
||||
.send(msg)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| unavailable(e.to_string()))
|
||||
}
|
||||
}
|
||||
@ -280,3 +280,184 @@ mod tests {
|
||||
assert!(repo.find_by_hash("nope").await.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SqliteSettings(pub DbPool);
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::SettingsRepository for SqliteSettings {
|
||||
async fn get(&self, key: &str) -> Result<Option<String>, DomainError> {
|
||||
sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(key)
|
||||
.fetch_optional(&self.0)
|
||||
.await
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn set(&self, key: &str, value: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.execute(&self.0)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(storage)
|
||||
}
|
||||
}
|
||||
|
||||
use domain::jobs::{JobKind, JobRun, JobStatus};
|
||||
|
||||
fn job_from_row(r: &SqliteRow) -> JobRun {
|
||||
JobRun {
|
||||
id: r.get("id"),
|
||||
kind: JobKind::parse(r.get::<String, _>("kind").as_str())
|
||||
.unwrap_or(JobKind::PackageRefresh),
|
||||
params: r.get("params"),
|
||||
status: JobStatus::parse(r.get::<String, _>("status").as_str())
|
||||
.unwrap_or(JobStatus::Failed),
|
||||
started_at: parse_ts(r.get::<String, _>("started_at").as_str()),
|
||||
finished_at: r
|
||||
.get::<Option<String>, _>("finished_at")
|
||||
.as_deref()
|
||||
.map(parse_ts),
|
||||
log: r.get("log"),
|
||||
triggered_by: r.get("triggered_by"),
|
||||
}
|
||||
}
|
||||
|
||||
const JOB_COLS: &str = "id, kind, params, status, started_at, finished_at, log, triggered_by";
|
||||
|
||||
pub struct SqliteJobRuns(pub DbPool);
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::JobRunRepository for SqliteJobRuns {
|
||||
async fn insert(&self, run: &JobRun) -> Result<(), DomainError> {
|
||||
sqlx::query(&format!(
|
||||
"INSERT INTO job_runs ({JOB_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
))
|
||||
.bind(run.id)
|
||||
.bind(run.kind.as_str())
|
||||
.bind(&run.params)
|
||||
.bind(run.status.as_str())
|
||||
.bind(run.started_at.to_rfc3339())
|
||||
.bind(run.finished_at.map(|t| t.to_rfc3339()))
|
||||
.bind(&run.log)
|
||||
.bind(&run.triggered_by)
|
||||
.execute(&self.0)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn append_log(&self, id: Uuid, line: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE job_runs SET log = log || ? || char(10) WHERE id = ?")
|
||||
.bind(line)
|
||||
.bind(id)
|
||||
.execute(&self.0)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn finish(&self, id: Uuid, status: JobStatus) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE job_runs SET status = ?, finished_at = ? WHERE id = ?")
|
||||
.bind(status.as_str())
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.bind(id)
|
||||
.execute(&self.0)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn get(&self, id: Uuid) -> Result<Option<JobRun>, DomainError> {
|
||||
sqlx::query(&format!("SELECT {JOB_COLS} FROM job_runs WHERE id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&self.0)
|
||||
.await
|
||||
.map(|r| r.as_ref().map(job_from_row))
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn list(&self, limit: u32) -> Result<Vec<JobRun>, DomainError> {
|
||||
sqlx::query(&format!(
|
||||
"SELECT {JOB_COLS} FROM job_runs ORDER BY started_at DESC LIMIT ?"
|
||||
))
|
||||
.bind(limit)
|
||||
.fetch_all(&self.0)
|
||||
.await
|
||||
.map(|rows| rows.iter().map(job_from_row).collect())
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn find_running(&self, kind: JobKind) -> Result<Option<JobRun>, DomainError> {
|
||||
sqlx::query(&format!(
|
||||
"SELECT {JOB_COLS} FROM job_runs WHERE kind = ? AND status = 'running' LIMIT 1"
|
||||
))
|
||||
.bind(kind.as_str())
|
||||
.fetch_optional(&self.0)
|
||||
.await
|
||||
.map(|r| r.as_ref().map(job_from_row))
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn last_finished(&self, kind: JobKind) -> Result<Option<JobRun>, DomainError> {
|
||||
sqlx::query(&format!("SELECT {JOB_COLS} FROM job_runs WHERE kind = ? AND status != 'running' ORDER BY started_at DESC LIMIT 1"))
|
||||
.bind(kind.as_str())
|
||||
.fetch_optional(&self.0)
|
||||
.await
|
||||
.map(|r| r.as_ref().map(job_from_row))
|
||||
.map_err(storage)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod job_tests {
|
||||
use super::*;
|
||||
use domain::ports::{JobRunRepository, SettingsRepository};
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_upsert() {
|
||||
let pool = crate::connect("sqlite::memory:").await.unwrap();
|
||||
let s = SqliteSettings(pool);
|
||||
assert_eq!(s.get("k").await.unwrap(), None);
|
||||
s.set("k", "1").await.unwrap();
|
||||
s.set("k", "2").await.unwrap();
|
||||
assert_eq!(s.get("k").await.unwrap().as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_runs_log_finish_and_queries() {
|
||||
let pool = crate::connect("sqlite::memory:").await.unwrap();
|
||||
let repo = SqliteJobRuns(pool);
|
||||
let run = JobRun {
|
||||
id: Uuid::new_v4(),
|
||||
kind: JobKind::PackageRefresh,
|
||||
params: None,
|
||||
status: JobStatus::Running,
|
||||
started_at: Utc::now(),
|
||||
finished_at: None,
|
||||
log: String::new(),
|
||||
triggered_by: "test".into(),
|
||||
};
|
||||
repo.insert(&run).await.unwrap();
|
||||
assert!(repo
|
||||
.find_running(JobKind::PackageRefresh)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
repo.append_log(run.id, "a").await.unwrap();
|
||||
repo.append_log(run.id, "b").await.unwrap();
|
||||
repo.finish(run.id, JobStatus::Success).await.unwrap();
|
||||
let got = repo.get(run.id).await.unwrap().unwrap();
|
||||
assert_eq!(got.log, "a\nb\n");
|
||||
assert_eq!(got.status, JobStatus::Success);
|
||||
assert!(got.finished_at.is_some());
|
||||
assert!(repo
|
||||
.find_running(JobKind::PackageRefresh)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
repo.last_finished(JobKind::PackageRefresh)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id,
|
||||
run.id
|
||||
);
|
||||
assert_eq!(repo.list(10).await.unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user