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
47impl From<SecretString> for DbSecret {
48    fn from(value: SecretString) -> Self {
49        Self(value)
50    }
51}
52
53impl From<DbSecret> for SecretString {
54    fn from(value: DbSecret) -> Self {
55        value.0
56    }
57}
58
59// Deserialize is provided (it only *wraps* an incoming value, never exposes one) so that
60// `DbSecret` can be used directly for inbound request fields that feed DB queries, avoiding
61// a lossy `SecretString` <-> `DbSecret` round-trip. There is deliberately no `Serialize`
62// impl: use `OutboundSecret` when a secret must be sent on the wire.
63impl<'de> Deserialize<'de> for DbSecret {
64    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
65        let s = String::deserialize(deserializer)?;
66        Ok(DbSecret::new(s))
67    }
68}
69
70impl ExposeSecret<str> for DbSecret {
71    fn expose_secret(&self) -> &str {
72        self.0.expose_secret()
73    }
74}
75
76impl fmt::Debug for DbSecret {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        f.write_str("DbSecret(…redacted…)")
79    }
80}
81
82// Intentionally no Deref / Display / AsRef<str> / Into<String> / getter.
83
84impl Type<Postgres> for DbSecret {
85    fn type_info() -> PgTypeInfo {
86        <String as Type<Postgres>>::type_info()
87    }
88    fn compatible(ty: &PgTypeInfo) -> bool {
89        <String as Type<Postgres>>::compatible(ty)
90    }
91}
92
93impl PgHasArrayType for DbSecret {
94    fn array_type_info() -> PgTypeInfo {
95        <String as PgHasArrayType>::array_type_info()
96    }
97}
98
99impl<'r> Decode<'r, Postgres> for DbSecret {
100    fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
101        let s = <String as Decode<Postgres>>::decode(value)?;
102        Ok(DbSecret::new(s))
103    }
104}
105
106impl<'q> Encode<'q, Postgres> for DbSecret {
107    fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result<IsNull, BoxDynError> {
108        // The only place the plaintext is read for DB I/O.
109        <&str as Encode<Postgres>>::encode_by_ref(&self.0.expose_secret(), buf)
110    }
111}
112
113/// A secret string that is deliberately serialized onto the wire once (e.g. a token
114/// returned to the caller from an auth endpoint).
115///
116/// Redacts in `Debug` so it never leaks into logs; its `Serialize` impl emits the raw
117/// value — that impl is the single intended exposure point. At a struct field, annotate
118/// with `#[schema(value_type = String)]` so the OpenAPI schema still reports `string`.
119#[derive(Clone)]
120pub struct OutboundSecret(SecretString);
121
122impl OutboundSecret {
123    pub fn new(value: impl Into<String>) -> Self {
124        Self(SecretString::new(value.into().into()))
125    }
126}
127
128impl From<String> for OutboundSecret {
129    fn from(value: String) -> Self {
130        Self::new(value)
131    }
132}
133
134impl ExposeSecret<str> for OutboundSecret {
135    fn expose_secret(&self) -> &str {
136        self.0.expose_secret()
137    }
138}
139
140impl fmt::Debug for OutboundSecret {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        f.write_str("OutboundSecret(…redacted…)")
143    }
144}
145
146impl Serialize for OutboundSecret {
147    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
148        // Intended exposure: this value is being sent to the caller.
149        serializer.serialize_str(self.0.expose_secret())
150    }
151}
152
153impl<'de> Deserialize<'de> for OutboundSecret {
154    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
155        let s = String::deserialize(deserializer)?;
156        Ok(OutboundSecret::new(s))
157    }
158}
159
160// sqlx integration so `OutboundSecret` can back a DB column that is also returned on the wire
161// (e.g. a giveaway code shown to the recipient). Same shape as `DbSecret`'s impls.
162impl Type<Postgres> for OutboundSecret {
163    fn type_info() -> PgTypeInfo {
164        <String as Type<Postgres>>::type_info()
165    }
166    fn compatible(ty: &PgTypeInfo) -> bool {
167        <String as Type<Postgres>>::compatible(ty)
168    }
169}
170
171impl PgHasArrayType for OutboundSecret {
172    fn array_type_info() -> PgTypeInfo {
173        <String as PgHasArrayType>::array_type_info()
174    }
175}
176
177impl<'r> Decode<'r, Postgres> for OutboundSecret {
178    fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
179        let s = <String as Decode<Postgres>>::decode(value)?;
180        Ok(OutboundSecret::new(s))
181    }
182}
183
184impl<'q> Encode<'q, Postgres> for OutboundSecret {
185    fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result<IsNull, BoxDynError> {
186        <&str as Encode<Postgres>>::encode_by_ref(&self.0.expose_secret(), buf)
187    }
188}