rullst-orm 13.0.0-alpha.2

Fast, un-opinionated, database-agnostic ORM and query builder for Rust
Documentation
//! Serde contract for [`SecretString`]: never plaintext.
//!
//! `Serialize` writes an authenticated `RULLST:v2` envelope under the
//! configured key, so secondary stores such as the Redis query cache hold
//! ciphertext that `Deserialize` turns back into the real value. Generated
//! audit, event and search projections run inside [`with_redacted_secrets`],
//! where the value becomes the fixed `"***"` marker and no key is needed.
//! Client input goes through [`deserialize_plaintext_secret`] instead, which
//! never decrypts.

use super::{
    ENVELOPE_PREFIX, PrivacyError, SecretString, configured_key, current_key, current_key_id,
    decrypt_envelope, encrypt_with_context, parse_envelope,
};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::cell::Cell;

/// Authenticated-data context for serialized secrets. It differs from the SQL
/// column context, so a serialized value and a stored column value are not
/// interchangeable ciphertexts.
const SERDE_CONTEXT: &[u8] = b"rullst-orm:secret-string:serde";

thread_local! {
    static REDACTION_DEPTH: Cell<usize> = const { Cell::new(0) };
}

struct RedactionScope;

impl RedactionScope {
    fn enter() -> Self {
        REDACTION_DEPTH.with(|depth| depth.set(depth.get().saturating_add(1)));
        Self
    }
}

impl Drop for RedactionScope {
    fn drop(&mut self) {
        REDACTION_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
    }
}

/// Runs `serialize` with every [`SecretString`] serialized as `"***"`.
///
/// Generated `to_json()` and search projections use this scope. It applies to
/// the current thread for the duration of the synchronous closure only.
#[doc(hidden)]
pub fn with_redacted_secrets<R>(serialize: impl FnOnce() -> R) -> R {
    let _scope = RedactionScope::enter();
    serialize()
}

fn redacting() -> bool {
    REDACTION_DEPTH.with(|depth| depth.get() > 0)
}

fn encrypt_serialized(plaintext: &str) -> Result<String, PrivacyError> {
    let key_id = current_key_id()?;
    let key = current_key()?;
    encrypt_with_context(plaintext, &key, &key_id, SERDE_CONTEXT)
}

fn decrypt_serialized(encrypted: &str) -> Result<String, PrivacyError> {
    let envelope = parse_envelope(encrypted)?;
    let key = configured_key(envelope.key_id)?;
    decrypt_envelope(&envelope, &key, SERDE_CONTEXT)
}

fn is_envelope(value: &str) -> bool {
    value
        .strip_prefix(ENVELOPE_PREFIX)
        .is_some_and(|rest| rest.starts_with(':'))
}

impl Serialize for SecretString {
    /// Emits an encrypted envelope, or `"***"` inside a redacted projection.
    /// Fails when `RULLST_ENCRYPTION_KEY` is not configured.
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        if redacting() {
            return serializer.serialize_str(crate::audit::REDACTED_VALUE);
        }
        let envelope = encrypt_serialized(&self.0).map_err(serde::ser::Error::custom)?;
        serializer.serialize_str(&envelope)
    }
}

impl<'de> Deserialize<'de> for SecretString {
    /// Decrypts an envelope written by `Serialize` (current key or keyring).
    /// Any other string is accepted as plaintext input; a malformed or
    /// unauthenticated envelope is rejected.
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let value = String::deserialize(deserializer)?;
        if !is_envelope(&value) {
            return Ok(SecretString(value));
        }
        decrypt_serialized(&value)
            .map(SecretString)
            .map_err(serde::de::Error::custom)
    }
}

const ENVELOPE_INPUT_ERROR: &str = "an encrypted RULLST envelope is not accepted as secret input";

/// Deserializes a [`SecretString`] from untrusted input, such as a request
/// body, accepting plaintext only.
///
/// `SecretString`'s own `Deserialize` decrypts a serde envelope so trusted
/// stores (the generated query cache and Redis model hashes) round-trip it.
/// That envelope is bound to the key, not to a model, field or owner, so an
/// envelope exposed for one record (for example by a plain
/// `#[derive(Serialize)]` response) would decrypt into another record if a
/// client could submit it. Fields filled from clients therefore use
/// `#[serde(deserialize_with = "rullst_orm::privacy::deserialize_plaintext_secret")]`,
/// which rejects any envelope-shaped value (`RULLST:...`) without decrypting
/// it. The generated cache path decodes fields with their own `Deserialize`
/// and is unaffected. Added in v13.
pub fn deserialize_plaintext_secret<'de, D: Deserializer<'de>>(
    deserializer: D,
) -> Result<SecretString, D::Error> {
    plaintext(String::deserialize(deserializer)?)
}

/// [`deserialize_plaintext_secret`] for an `Option<SecretString>` field:
/// `null` is `None`. Add `#[serde(default)]` to accept a missing field too.
/// Added in v13.
pub fn deserialize_optional_plaintext_secret<'de, D: Deserializer<'de>>(
    deserializer: D,
) -> Result<Option<SecretString>, D::Error> {
    Option::<String>::deserialize(deserializer)?
        .map(plaintext)
        .transpose()
}

fn plaintext<E: serde::de::Error>(value: String) -> Result<SecretString, E> {
    if is_envelope(&value) {
        return Err(E::custom(ENVELOPE_INPUT_ERROR));
    }
    Ok(SecretString(value))
}