Skip to main content

headless_lms_models/
secret.rs

1//! Wrapper types for sensitive values backed by the `secrecy` crate.
2//!
3//! The whole point of these wrappers is auditability: the only way to read the
4//! plaintext is `expose_secret()`, so `grep expose_secret` enumerates every site
5//! where a secret could leak. To keep that guarantee, these types intentionally do
6//! **not** implement `Deref`, `Display`, `AsRef<str>`, `Into<String>`, or any plain
7//! getter. Do not add them.
8//!
9//! - [`DbSecret`] — a secret string that flows through the `sqlx` `query!`/`query_as!`
10//!   macros. Map columns to it via `sqlx.toml` `table-overrides`. Backed by
11//!   `SecretString` (zeroized on drop, redacted from `Debug`). The only plaintext-read
12//!   points are its `Encode` impl (writing to the DB) and explicit `expose_secret()` calls.
13//! - [`OutboundSecret`] — a secret string that is *intended* to be serialized onto the
14//!   wire exactly once (e.g. an OAuth/verification token returned to the caller). It
15//!   redacts in `Debug` (so it never leaks into logs) but its `Serialize` impl emits the
16//!   raw value. That single `Serialize` impl is the one audited exposure point.
17
18use core::fmt;
19
20use secrecy::{ExposeSecret, SecretString};
21use serde::{Deserialize, Deserializer, Serialize, Serializer};
22
23use sqlx::encode::IsNull;
24use sqlx::postgres::{PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueRef};
25use sqlx::{Decode, Encode, Postgres, Type, error::BoxDynError};
26
27/// A secret string stored in / read from the database.
28///
29/// Backed by [`secrecy::SecretString`]: zeroized on drop and redacted from `Debug`.
30/// Use it for DB columns holding tokens, codes, keys, etc., wiring the column to this
31/// type through `sqlx.toml` `table-overrides`.
32#[derive(Clone)]
33pub struct DbSecret(SecretString);
34
35impl DbSecret {
36    pub fn new(value: impl Into<String>) -> Self {
37        Self(SecretString::new(value.into().into()))
38    }
39}
40
41impl From<String> for DbSecret {
42    fn from(value: String) -> Self {
43        Self::new(value)
44    }
45}
46
47// Deserialize is provided (it only *wraps* an incoming value, never exposes one) so that
48// `DbSecret` can be used directly for inbound request fields that feed DB queries, avoiding
49// a lossy `SecretString` <-> `DbSecret` round-trip. There is deliberately no `Serialize`
50// impl: use `OutboundSecret` when a secret must be sent on the wire.
51impl<'de> Deserialize<'de> for DbSecret {
52    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
53        let s = String::deserialize(deserializer)?;
54        Ok(DbSecret::new(s))
55    }
56}
57
58impl ExposeSecret<str> for DbSecret {
59    fn expose_secret(&self) -> &str {
60        self.0.expose_secret()
61    }
62}
63
64impl fmt::Debug for DbSecret {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str("DbSecret(…redacted…)")
67    }
68}
69
70// Intentionally no Deref / Display / AsRef<str> / Into<String> / getter.
71
72impl Type<Postgres> for DbSecret {
73    fn type_info() -> PgTypeInfo {
74        <String as Type<Postgres>>::type_info()
75    }
76    fn compatible(ty: &PgTypeInfo) -> bool {
77        <String as Type<Postgres>>::compatible(ty)
78    }
79}
80
81impl PgHasArrayType for DbSecret {
82    fn array_type_info() -> PgTypeInfo {
83        <String as PgHasArrayType>::array_type_info()
84    }
85}
86
87impl<'r> Decode<'r, Postgres> for DbSecret {
88    fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
89        let s = <String as Decode<Postgres>>::decode(value)?;
90        Ok(DbSecret::new(s))
91    }
92}
93
94impl<'q> Encode<'q, Postgres> for DbSecret {
95    fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result<IsNull, BoxDynError> {
96        // The only place the plaintext is read for DB I/O.
97        <&str as Encode<Postgres>>::encode_by_ref(&self.0.expose_secret(), buf)
98    }
99}
100
101/// A secret string that is deliberately serialized onto the wire once (e.g. a token
102/// returned to the caller from an auth endpoint).
103///
104/// Redacts in `Debug` so it never leaks into logs; its `Serialize` impl emits the raw
105/// value — that impl is the single intended exposure point. At a struct field, annotate
106/// with `#[schema(value_type = String)]` so the OpenAPI schema still reports `string`.
107#[derive(Clone)]
108pub struct OutboundSecret(SecretString);
109
110impl OutboundSecret {
111    pub fn new(value: impl Into<String>) -> Self {
112        Self(SecretString::new(value.into().into()))
113    }
114}
115
116impl From<String> for OutboundSecret {
117    fn from(value: String) -> Self {
118        Self::new(value)
119    }
120}
121
122impl ExposeSecret<str> for OutboundSecret {
123    fn expose_secret(&self) -> &str {
124        self.0.expose_secret()
125    }
126}
127
128impl fmt::Debug for OutboundSecret {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        f.write_str("OutboundSecret(…redacted…)")
131    }
132}
133
134impl Serialize for OutboundSecret {
135    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
136        // Intended exposure: this value is being sent to the caller.
137        serializer.serialize_str(self.0.expose_secret())
138    }
139}
140
141impl<'de> Deserialize<'de> for OutboundSecret {
142    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
143        let s = String::deserialize(deserializer)?;
144        Ok(OutboundSecret::new(s))
145    }
146}
147
148// sqlx integration so `OutboundSecret` can back a DB column that is also returned on the wire
149// (e.g. a giveaway code shown to the recipient). Same shape as `DbSecret`'s impls.
150impl Type<Postgres> for OutboundSecret {
151    fn type_info() -> PgTypeInfo {
152        <String as Type<Postgres>>::type_info()
153    }
154    fn compatible(ty: &PgTypeInfo) -> bool {
155        <String as Type<Postgres>>::compatible(ty)
156    }
157}
158
159impl PgHasArrayType for OutboundSecret {
160    fn array_type_info() -> PgTypeInfo {
161        <String as PgHasArrayType>::array_type_info()
162    }
163}
164
165impl<'r> Decode<'r, Postgres> for OutboundSecret {
166    fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
167        let s = <String as Decode<Postgres>>::decode(value)?;
168        Ok(OutboundSecret::new(s))
169    }
170}
171
172impl<'q> Encode<'q, Postgres> for OutboundSecret {
173    fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result<IsNull, BoxDynError> {
174        <&str as Encode<Postgres>>::encode_by_ref(&self.0.expose_secret(), buf)
175    }
176}