state.rs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. //! Application state: pools, topology-aware Redis, clock, health.
  2. //!
  3. //! [`AppState`] is cloneable and holds the `sqlx::PgPool`, the [`RedisConn`]
  4. //! enum (never a concrete connection type, so single/Sentinel/Cluster all
  5. //! fit), `Arc<Config>`, `Arc<KeyRing>`, and a [`Clock`]. [`AppState::connect`]
  6. //! fails closed at startup: an unreachable Postgres, an unreachable Redis, or
  7. //! a Redis that violates the no-eviction/AOF durability contract is an error,
  8. //! never a half-built state. Migrations are NOT run here (`serve` never
  9. //! migrates; the `migrate` subcommand in todo 14 owns that); readiness
  10. //! instead verifies the DB schema version matches [`MIGRATOR`].
  11. mod health;
  12. mod pg;
  13. mod redis;
  14. mod sentinel;
  15. #[cfg(test)]
  16. mod test_support;
  17. #[cfg(test)]
  18. mod tests_guard;
  19. #[cfg(test)]
  20. mod tests_resilience;
  21. #[cfg(test)]
  22. mod tests_topology;
  23. pub use health::health_router;
  24. pub(crate) use pg::connect_options;
  25. pub use redis::RedisConn;
  26. pub use sentinel::SentinelConn;
  27. use std::{
  28. fmt,
  29. sync::{
  30. Arc,
  31. atomic::{AtomicBool, Ordering},
  32. },
  33. time::Duration,
  34. };
  35. use time::OffsetDateTime;
  36. use crate::{
  37. config::{Config, ConfigError},
  38. keys::KeyRing,
  39. };
  40. /// Embedded migrations compiled from `./migrations` (currently empty; todos
  41. /// 8/9 add the schema). Readiness requires every embedded migration to be
  42. /// applied; `serve`/state never applies them.
  43. pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
  44. /// Upper bound for the whole readiness probe (kubelet probes time out, so a
  45. /// hung dependency must flip the probe to 503 instead of hanging it).
  46. const READINESS_TIMEOUT: Duration = Duration::from_secs(5);
  47. /// Fail-closed startup/teardown failures for [`AppState`].
  48. #[derive(Debug, thiserror::Error)]
  49. pub enum StateError {
  50. /// The configuration or its startup durability assertions failed.
  51. #[error("invalid configuration: {0}")]
  52. Config(#[from] ConfigError),
  53. /// Postgres was unreachable or rejected the pool at startup.
  54. #[error("postgres unavailable")]
  55. Database(#[source] sqlx::Error),
  56. /// Redis was unreachable or a command failed at startup.
  57. #[error("redis unavailable")]
  58. Redis(#[source] ::redis::RedisError),
  59. /// An I/O failure outside the two data stores.
  60. #[error("i/o failure")]
  61. Io(#[from] std::io::Error),
  62. }
  63. /// Injectable clock; production uses [`Clock::system`], tests pin time with
  64. /// [`Clock::fixed`].
  65. #[derive(Clone)]
  66. pub struct Clock(Arc<dyn Fn() -> OffsetDateTime + Send + Sync>);
  67. impl Clock {
  68. /// Real wall-clock time.
  69. #[must_use]
  70. pub fn system() -> Self {
  71. Self(Arc::new(OffsetDateTime::now_utc))
  72. }
  73. /// A clock frozen at `when`, for deterministic tests.
  74. #[must_use]
  75. pub fn fixed(when: OffsetDateTime) -> Self {
  76. Self(Arc::new(move || when))
  77. }
  78. /// Read the current time.
  79. #[must_use]
  80. pub fn now(&self) -> OffsetDateTime {
  81. (self.0)()
  82. }
  83. }
  84. impl Default for Clock {
  85. fn default() -> Self {
  86. Self::system()
  87. }
  88. }
  89. impl fmt::Debug for Clock {
  90. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  91. f.debug_tuple("Clock").field(&self.now()).finish()
  92. }
  93. }
  94. /// Shared application state passed to every handler.
  95. #[derive(Clone)]
  96. pub struct AppState {
  97. /// Postgres pool (15/2, 3 s acquire budget, per-connection timeouts).
  98. pub pool: sqlx::PgPool,
  99. /// Topology-aware Redis (single/Sentinel/Cluster behind one enum).
  100. pub redis: RedisConn,
  101. /// Validated runtime configuration.
  102. pub config: Arc<Config>,
  103. /// Versioned token-pepper and TOTP-encryption keys.
  104. pub keys: Arc<KeyRing>,
  105. /// Cached PII-transition state, computed once in [`AppState::connect`].
  106. /// When true, encrypt-only mode would serve over un-backfilled plaintext
  107. /// PII, so `/health/ready` is 503 until the backfill blanks the columns
  108. /// and the process restarts. Never a global or thread-local: it rides the
  109. /// state every handler already receives.
  110. pub transition_unsafe: Arc<AtomicBool>,
  111. /// Injectable clock.
  112. pub clock: Clock,
  113. }
  114. impl AppState {
  115. /// Build live state: open the Postgres pool, connect Redis for the
  116. /// configured topology, and run the Redis durability/eviction startup
  117. /// assertions. Any failure is returned; no half-built state escapes.
  118. pub async fn connect(config: Arc<Config>) -> Result<Self, StateError> {
  119. let pool = pg::connect_postgres(&config.database, config.environment).await?;
  120. let redis = redis::connect_redis(&config.redis).await?;
  121. let keys = Arc::new(config.keys.clone());
  122. // Fail-closed PII transition guard, computed once here and never per
  123. // probe: encrypt-only over un-backfilled plaintext must not serve.
  124. let transition_unsafe = probe_transition_unsafe(&pool, config.pii_dual_write).await?;
  125. Ok(Self {
  126. pool,
  127. redis,
  128. config,
  129. keys,
  130. transition_unsafe: Arc::new(AtomicBool::new(transition_unsafe)),
  131. clock: Clock::system(),
  132. })
  133. }
  134. }
  135. /// Whether encrypt-only mode would serve over un-backfilled plaintext PII.
  136. ///
  137. /// Returns `!pii_dual_write && any blanked plaintext PII column is still
  138. /// non-NULL`, covering exactly the columns `pii backfill` blanks:
  139. /// `users.email`, seven `oauth_clients` metadata columns, and
  140. /// `redirect_uris.uri`. A runtime `sqlx::query` on purpose: no `.sqlx` cache
  141. /// entry is needed. A fresh install (no rows) reports safe.
  142. ///
  143. /// Pre-migration databases have neither the table nor the `_enc` columns, so
  144. /// the probe gates on both (`to_regclass` for the table, which cannot test a
  145. /// column, plus an `information_schema` probe for `email_enc`) and skips as
  146. /// safe there; readiness already gates on migrations. A guard query that
  147. /// fails only because the columns do not exist yet (`42703`/`42P01`) is the
  148. /// same skip; any other failure is [`StateError::Database`] (fail closed,
  149. /// never a silent cached "unsafe").
  150. pub(crate) async fn probe_transition_unsafe(
  151. pool: &sqlx::PgPool,
  152. pii_dual_write: bool,
  153. ) -> Result<bool, StateError> {
  154. if pii_dual_write {
  155. return Ok(false);
  156. }
  157. let table_present: bool = sqlx::query_scalar("SELECT to_regclass('public.users') IS NOT NULL")
  158. .fetch_one(pool)
  159. .await
  160. .map_err(StateError::Database)?;
  161. if !table_present {
  162. return Ok(false);
  163. }
  164. let migrated: bool = sqlx::query_scalar(
  165. "SELECT EXISTS(SELECT 1 FROM information_schema.columns \
  166. WHERE table_schema = 'public' AND table_name = 'users' \
  167. AND column_name = 'email_enc')",
  168. )
  169. .fetch_one(pool)
  170. .await
  171. .map_err(StateError::Database)?;
  172. if !migrated {
  173. return Ok(false);
  174. }
  175. let legacy: bool = match sqlx::query_scalar::<_, bool>(
  176. "SELECT EXISTS(SELECT 1 FROM users WHERE email IS NOT NULL) \
  177. OR EXISTS(SELECT 1 FROM oauth_clients WHERE client_name IS NOT NULL) \
  178. OR EXISTS(SELECT 1 FROM oauth_clients WHERE contacts IS NOT NULL) \
  179. OR EXISTS(SELECT 1 FROM oauth_clients WHERE request_uris IS NOT NULL) \
  180. OR EXISTS(SELECT 1 FROM oauth_clients WHERE jwks_uri IS NOT NULL) \
  181. OR EXISTS(SELECT 1 FROM oauth_clients WHERE software_id IS NOT NULL) \
  182. OR EXISTS(SELECT 1 FROM oauth_clients WHERE tls_client_auth_subject_dn IS NOT NULL) \
  183. OR EXISTS(SELECT 1 FROM oauth_clients WHERE tls_client_auth_cert_thumbprint IS NOT NULL) \
  184. OR EXISTS(SELECT 1 FROM redirect_uris WHERE uri IS NOT NULL)",
  185. )
  186. .fetch_one(pool)
  187. .await
  188. {
  189. Ok(legacy) => legacy,
  190. Err(sqlx::Error::Database(db)) if matches!(db.code().as_deref(), Some("42703" | "42P01")) => {
  191. return Ok(false);
  192. }
  193. Err(other) => return Err(StateError::Database(other)),
  194. };
  195. Ok(legacy)
  196. }
  197. impl fmt::Debug for AppState {
  198. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  199. f.debug_struct("AppState")
  200. .field("pool_size", &self.pool.size())
  201. .field("redis", &self.redis.topology_name())
  202. .field("config", &self.config)
  203. .field("keys", &self.keys)
  204. .field(
  205. "transition_unsafe",
  206. &self.transition_unsafe.load(Ordering::Relaxed),
  207. )
  208. .field("clock", &self.clock)
  209. .finish()
  210. }
  211. }