Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5d7c770
feat: add admin database encryption backfill API
think-in-universe Aug 23, 2026
3aa7be4
fix: make encryption backfill validation and batching reliable
think-in-universe Aug 23, 2026
69dfb34
test: cover database encryption envelopes and validation
think-in-universe Aug 23, 2026
38193f1
style: improve database encryption module spacing
think-in-universe Aug 23, 2026
39335ee
fix: resolve CI encryption nonce compilation
think-in-universe Aug 23, 2026
c790e8c
style: apply rustfmt after nonce fix
think-in-universe Aug 23, 2026
7856caa
style: organize database encryption module sections
think-in-universe Aug 24, 2026
ee6d982
fix: gate unsafe encryption execution and widen columns
think-in-universe Aug 24, 2026
dfa2042
fix: consolidate encryption migration and canonicalize scopes
think-in-universe Aug 24, 2026
f85ee02
style: code format
think-in-universe Aug 24, 2026
c5b8b3d
fix: validate encrypted envelopes during verification
think-in-universe Aug 24, 2026
799c776
fix: preserve queryable response structure during encryption
think-in-universe Aug 24, 2026
69b8a39
fix: encrypt file metadata in repository layer
think-in-universe Aug 24, 2026
a84442a
fix: encrypt conversation metadata in repository paths
think-in-universe Aug 24, 2026
20c9186
fix: encrypt response fields in repository paths
think-in-universe Aug 24, 2026
23083f9
fix: encrypt remaining confidential repository fields
think-in-universe Aug 24, 2026
3338bc3
test: read encrypted response items in disconnect e2e
think-in-universe Aug 24, 2026
9bb07b1
test: decrypt failed response item assertions
think-in-universe Aug 24, 2026
1babfae
fix: isolate invalid database encryption configuration
think-in-universe Aug 24, 2026
9254e16
fix: run resumable encryption jobs asynchronously
think-in-universe Aug 24, 2026
b32c58d
fix: bound database encryption verification scans
think-in-universe Aug 24, 2026
243b60e
test: validate confidential repository fields at rest
think-in-universe Aug 24, 2026
48ccc9a
fix: release encryption worker advisory locks
think-in-universe Aug 24, 2026
4ccd1fc
test: verify conversation clone encryption at rest
think-in-universe Aug 24, 2026
3c909e4
fix: complete encryption jobs under batching and contention
think-in-universe Aug 24, 2026
a4b5f1f
fix: validate encryption jobs and envelope detection
think-in-universe Aug 24, 2026
0db8d28
fix: resolve encryption worker review feedback
think-in-universe Aug 24, 2026
2c1bec6
refactor: run database encryption as worker
think-in-universe Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 115 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ COPY crates/ ./crates/
COPY .cargo/ ./.cargo/

# Build the application in release mode
RUN cargo build --release --locked --bin api
RUN cargo build --release --locked --bin api --bin database_encryption_worker


# Runtime stage
Expand Down Expand Up @@ -86,6 +86,7 @@ WORKDIR /app

# Copy the built binary
COPY --from=builder /app/target/release/api /app/api
COPY --from=builder /app/target/release/database_encryption_worker /app/database_encryption_worker

# Copy the migration SQL files
RUN mkdir -p /app/crates/database/src/migrations/sql
Expand Down
3 changes: 2 additions & 1 deletion crates/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ tokio-stream = "0.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tokio = { version = "1", features = ["full"] }
tokio-postgres = "0.7.18"
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
config = { path = "../config" }
database = { path = "../database" }
services = { path = "../services" }
Expand Down Expand Up @@ -73,7 +75,6 @@ dotenvy = "0.15.7"
k256 = { version = "0.13", features = ["ecdsa", "arithmetic"] }
sha3 = "0.12"
hmac = "0.13"
tokio-postgres = "0.7.18"
deadpool-postgres = { version = "0.14", features = ["rt_tokio_1"] }
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
rand = "0.10"
Expand Down
107 changes: 107 additions & 0 deletions crates/api/src/bin/database_encryption_worker.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
use anyhow::{Context, Result};
use api::database_encryption::{
operational_migrate, operational_scan, operational_verify, DatabaseEncryptionState,
};
use clap::{Parser, Subcommand};
use database::Database;
use uuid::Uuid;

#[derive(Parser)]
#[command(about = "One-off database encryption backfill worker")]
struct Cli {
#[command(subcommand)]
command: Command,
}

#[derive(Subcommand)]
enum Command {
Scan {
#[arg(long, value_delimiter = ',')]
scope: Vec<String>,
},
Migrate {
#[arg(long, required = true, value_delimiter = ',')]
scope: Vec<String>,
#[arg(long, default_value_t = 500)]
batch_size: i64,
#[arg(long)]
max_rows: Option<i64>,
#[arg(long)]
resume: Option<Uuid>,
#[arg(long)]
operator: String,
},
Verify {
#[arg(long, value_delimiter = ',')]
scope: Vec<String>,
},
}

#[tokio::main]
async fn main() {
if run().await.is_err() {
println!(
"DATABASE_ENCRYPTION_WORKER_RESULT {}",
serde_json::json!({"status":"failed","error_class":"worker_failed"})
);
std::process::exit(1);
}
Comment on lines +42 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When run().await returns an error, the actual error message, error chain, and context are completely discarded — only a generic {"status":"failed","error_class":"worker_failed"} is printed. For a one-off worker that touches encrypted production data, the operator needs to see the real error to diagnose failures. The actual anyhow::Error (including its context chain from .context() calls throughout operational_migrate, run_job, etc.) should be serialized or at minimum printed to stderr before exiting.

Suggestion:

Suggested change
if run().await.is_err() {
println!(
"DATABASE_ENCRYPTION_WORKER_RESULT {}",
serde_json::json!({"status":"failed","error_class":"worker_failed"})
);
std::process::exit(1);
}
if let Err(err) = run().await {
eprintln!("{err:#}");
println!(
"DATABASE_ENCRYPTION_WORKER_RESULT {}",
serde_json::json!({"status":"failed","error_class":"worker_failed","error": format!("{err:#}")})
);
std::process::exit(1);
}

}

async fn run() -> Result<()> {
let cli = Cli::parse();
let database_config = config::DatabaseConfig::from_env()
.map_err(anyhow::Error::msg)
.context("invalid database configuration")?;
let key = read_encryption_key()?;
let database = Database::from_config(&database_config).await?;
let state = DatabaseEncryptionState::new(database.pool().clone(), &key)?;

match cli.command {
Command::Scan { scope } => {
println!(
"{}",
serde_json::to_string_pretty(&operational_scan(&state, scope).await?)?
);
print_success(None);
}
Command::Verify { scope } => {
let report = operational_verify(&state, scope).await?;
println!("{}", serde_json::to_string_pretty(&report)?);
if report["pass"] != true {
anyhow::bail!("verification found plaintext or invalid envelopes");
}
print_success(None);
}
Command::Migrate {
scope,
batch_size,
max_rows,
resume,
operator,
} => {
let id =
operational_migrate(&state, scope, batch_size, max_rows, resume, &operator).await?;
print_success(Some(id));
}
Comment on lines +76 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When operational_migrate returns an error, the job ID is lost because operational_migrate returns Result<Uuid> and the ? operator discards the ID on failure. Tracing into database_encryption.rs, operational_migrate creates a job record (setting status to 'queued') and then calls run_job directly. run_job sets the job status to 'running' at the start of run_locked_job, but if it errors mid-batch, only the spawned (spawn_job) path marks the job as 'failed' — the worker path via operational_migrate does not. This leaves the job stuck in 'running' status indefinitely. The operator has no job ID in the worker output to use with --resume, requiring a manual database query to find and recover the stuck job. Consider having operational_migrate either mark the job as failed on error or include the job ID in the error context.

Suggestion:

Suggested change
Command::Migrate {
scope,
batch_size,
max_rows,
resume,
operator,
} => {
let id =
operational_migrate(&state, scope, batch_size, max_rows, resume, &operator).await?;
print_success(Some(id));
}
Command::Migrate {
scope,
batch_size,
max_rows,
resume,
operator,
} => {
let id =
operational_migrate(&state, scope, batch_size, max_rows, resume, &operator)
.await
.map_err(|err| {
eprintln!("migration failed; check database_encryption_jobs for stuck 'running' jobs: {err:#}");
err
})?;
print_success(Some(id));
}

}
Ok(())
}

fn print_success(job_id: Option<Uuid>) {
println!(
"DATABASE_ENCRYPTION_WORKER_RESULT {}",
serde_json::json!({"status":"completed","job_id":job_id})
);
}

fn read_encryption_key() -> Result<String> {
if let Ok(key) = std::env::var("S3_ENCRYPTION_KEY") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Medium · Match the API's encryption-key source precedence

The API configuration prefers S3_ENCRYPTION_KEY_FILE, while this worker returns S3_ENCRYPTION_KEY first. When both are supplied with different values, the worker verifies with the wrong key and can write envelopes the API cannot decrypt. Prefer the file source consistently or reject conflicting configuration.

return Ok(key);
}
let path = std::env::var("S3_ENCRYPTION_KEY_FILE")
.context("S3_ENCRYPTION_KEY or S3_ENCRYPTION_KEY_FILE is required")?;
std::fs::read_to_string(path)
.context("failed to read S3_ENCRYPTION_KEY_FILE")
.map(|key| key.trim().to_string())
}
Comment on lines +98 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key-source precedence here is inverted compared to the canonical implementation in config::S3Config::from_env() (crates/config/src/types.rs lines 969-985): the config layer checks S3_ENCRYPTION_KEY_FILE first and falls back to S3_ENCRYPTION_KEY, whereas this worker checks the env var first and only tries the file path on failure. If both variables are set in the same deployment environment (e.g., an env var from legacy config plus a mounted secret file), the main API process and this migration worker will resolve different keys. The worker would then encrypt production data with a key the API cannot decrypt, silently corrupting records. This worker should reuse the existing config::S3Config::from_env() key-loading logic (or at minimum match its FILE-before-ENV precedence and empty-key validation) to guarantee both processes agree on the same encryption key.

Suggestion:

Suggested change
fn read_encryption_key() -> Result<String> {
if let Ok(key) = std::env::var("S3_ENCRYPTION_KEY") {
return Ok(key);
}
let path = std::env::var("S3_ENCRYPTION_KEY_FILE")
.context("S3_ENCRYPTION_KEY or S3_ENCRYPTION_KEY_FILE is required")?;
std::fs::read_to_string(path)
.context("failed to read S3_ENCRYPTION_KEY_FILE")
.map(|key| key.trim().to_string())
}
fn read_encryption_key() -> Result<String> {
// Match the precedence used by config::S3Config::from_env():
// file-based secret first, env var as fallback.
if let Ok(path) = std::env::var("S3_ENCRYPTION_KEY_FILE") {
let key = std::fs::read_to_string(path)
.context("failed to read S3_ENCRYPTION_KEY_FILE")?
.trim()
.to_string();
if key.is_empty() {
anyhow::bail!("S3 encryption key cannot be empty");
}
return Ok(key);
}
let key = std::env::var("S3_ENCRYPTION_KEY")
.context("S3_ENCRYPTION_KEY or S3_ENCRYPTION_KEY_FILE is required")?;
if key.is_empty() {
anyhow::bail!("S3 encryption key cannot be empty");
}
Ok(key)
}

Loading
Loading