Transparent field encryption
Your application needs a secret's plaintext, but the database should store only encrypted bytes. A field codec handles the conversion on writes and reads, so callers keep using the ordinary generated API.
First, mark the bytes as sensitive in RSL. This records their purpose. The codec below performs the encryption:
model EncryptedRecord {
id BigInt @id
secret Bytes @rent.sensitive
}Attach a codec once when building the generated client:
let client = Client::new(pool.clone()).with_encrypted_record_field_codec(
encrypted_record::EncryptedRecord::secret,
FieldCodec::bytes(
move |value| encoder.encode(value),
move |value| codec.decode(value),
),
);The generated model exposes plaintext bytes to your application. The SQL column holds ciphertext. The encoder and decoder here use AES-256-GCM. Keep their key material in your application's key management, and avoid logging plaintext.
Complete example
Download this project, or copy the files below into an empty directory.
Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.
rent generate
cargo runThe project includes its database setup. Each lesson uses a small, self-contained schema. You can run it without applying the earlier lessons.
Cargo.toml
[package]
name = "rent-lesson-12-encryption"
version = "0.1.0"
edition = "2024"
[workspace]
[[bin]]
name = "rent-lesson-12-encryption"
path = "main.rs"
[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"
[database]
dialect = "sqlite"
backends = ["sqlite"]schema.rsl
datasource db {
provider = "sqlite"
}
model EncryptedRecord {
id BigInt @id
secret Bytes @rent.sensitive
@@map("encrypted_records")
}main.rs
use anyhow::{Result, ensure};
use rent::driver::DatabasePool;
use rent::driver::value::Aes256GcmValueCodec;
use rent::sql::Dialect;
use rent::sql::ast::{Select, TableRef};
#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;
async fn exercise() -> Result<()> {
let pool = DatabasePool::connect(Dialect::Sqlite, "sqlite::memory:").await?;
pool.execute("CREATE TABLE encrypted_records (id INTEGER PRIMARY KEY, secret BLOB NOT NULL)")
.await?;
let codec = Aes256GcmValueCodec::new(b"tutorial encryption key".to_vec());
let encoder = codec.clone();
let client = generated::client::Client::new(pool.clone()).with_encrypted_record_field_codec(
generated::encrypted_record::EncryptedRecord::secret,
rent::runtime::FieldCodec::bytes(move |value| encoder.encode(value), move |value| codec.decode(value)),
);
let plaintext = b"private draft".to_vec();
let record = client.create_encrypted_record().id(1).secret(plaintext.clone()).save().await?;
ensure!(record.secret == plaintext);
let stored = pool
.fetch_bytes(&Select::new(Dialect::Sqlite, TableRef::new("encrypted_records"), ["secret"]).render())
.await?;
let ciphertext = stored.first().ok_or_else(|| anyhow::anyhow!("encrypted row is missing"))?;
ensure!(ciphertext != &plaintext);
ensure!(client.encrypted_record().only().await?.secret == plaintext);
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
exercise().await
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn example_runs() -> anyhow::Result<()> {
super::exercise().await
}
}