Skip to main content

alien_bindings/providers/postgres/
mod.rs

1//! Postgres binding providers.
2//!
3//! Postgres is connection-only: the provider resolves connection details and the
4//! application connects with its own driver. There is no gRPC service (by design).
5//!
6//! `Local` and `External` carry the password inline. The three cloud variants carry only
7//! a *pointer* to the password in that cloud's secret store (Secrets Manager ARN /
8//! Secret Manager name / Key Vault secret URI) — the password never flows through the
9//! control plane and never sits in a plaintext environment variable. Each cloud provider
10//! reads that pointer with the workload's own identity, which is exactly what the
11//! `postgres/data-access` permission set grants.
12//!
13//! Resolution happens up front, when the binding is loaded, so
14//! [`crate::traits::Postgres`] stays synchronous and one handle can be read repeatedly
15//! without another secret read. A cloud handle therefore holds the password that was
16//! current when it was created; `BindingsProvider::load_postgres` deliberately does not
17//! cache it, so loading the binding again re-reads the secret and picks up a rotation.
18//! (The secret-store *client* is cached, so re-reading does not rebuild a connection
19//! pool; [`runtime::PostgresRuntime`] owns both policies.)
20
21#[cfg(feature = "aws")]
22pub(crate) mod aurora;
23#[cfg(any(feature = "aws", feature = "gcp", feature = "azure"))]
24pub(crate) mod cloud;
25#[cfg(feature = "gcp")]
26pub(crate) mod cloud_sql;
27#[cfg(feature = "azure")]
28pub(crate) mod flexible_server;
29pub mod local;
30pub(crate) mod runtime;
31
32use crate::error::{ErrorData, Result};
33#[cfg(any(feature = "gcp", test))]
34use crate::traits::SslMode;
35use crate::traits::{Binding, Postgres, PostgresConnectionParams, PostgresTlsPolicy};
36use alien_core::bindings::BindingValue;
37#[cfg(feature = "gcp")]
38use alien_error::AlienError;
39use alien_error::Context;
40#[cfg(any(feature = "aws", feature = "azure"))]
41use std::sync::OnceLock;
42
43/// Official Amazon RDS roots for every commercial region.
44///
45/// Source: <https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem>.
46/// AWS uses region-specific roots for each supported CA algorithm, so the global
47/// set is intentionally larger than a conventional public-root bundle.
48#[cfg(feature = "aws")]
49pub(crate) const AWS_RDS_CA_CERTIFICATES: &[&str] = &[include_str!("ca/aws-rds-global-roots.pem")];
50
51/// Roots currently recommended by Azure Database for PostgreSQL.
52///
53/// Root rotation is handled by updating this embedded set and releasing the SDK.
54/// Intermediate and server certificates must never be added.
55#[cfg(feature = "azure")]
56pub(crate) const AZURE_POSTGRES_CA_CERTIFICATES: &[&str] =
57    &[include_str!("ca/azure-postgres-roots.pem")];
58
59/// A Postgres handle whose cloud-specific work has already been completed.
60///
61/// Local, external, and cloud bindings differ only while resolving their connection
62/// details. They all expose the same immutable handle afterwards.
63#[derive(Debug)]
64pub struct ResolvedPostgres {
65    params: PostgresConnectionParams,
66}
67
68impl ResolvedPostgres {
69    pub fn new(params: PostgresConnectionParams) -> Self {
70        Self { params }
71    }
72}
73
74impl Binding for ResolvedPostgres {}
75
76impl Postgres for ResolvedPostgres {
77    fn connection_params(&self) -> &PostgresConnectionParams {
78        &self.params
79    }
80}
81
82/// Concrete inputs shared by every Postgres backend after password and TLS resolution.
83pub(crate) struct PostgresConnectionInput<'a> {
84    pub(crate) host: &'a BindingValue<String>,
85    pub(crate) port: &'a BindingValue<u16>,
86    pub(crate) database: &'a BindingValue<String>,
87    pub(crate) username: &'a BindingValue<String>,
88    pub(crate) password: &'a str,
89    pub(crate) tls: PostgresTlsPolicy,
90}
91
92/// Combines a binding's concrete connection fields with an already-resolved `password`.
93///
94/// Every field arrives as a [`BindingValue`], so an unresolved template expression or
95/// `SecretRef` is a user-fixable configuration problem (`BINDING_CONFIG_INVALID`, not
96/// retryable) rather than a runtime failure.
97///
98/// `host` is whichever field the backend dials: the cluster endpoint for Aurora, the
99/// host for every other backend.
100pub(crate) fn resolve_params(
101    binding_name: &str,
102    input: PostgresConnectionInput<'_>,
103) -> Result<PostgresConnectionParams> {
104    let invalid = |field: &str| ErrorData::BindingConfigInvalid {
105        env_var: crate::error::binding_env_var(binding_name),
106        binding_name: binding_name.to_string(),
107        reason: format!("Failed to extract '{}' from Postgres binding", field),
108    };
109
110    Ok(PostgresConnectionParams::new(
111        input
112            .host
113            .clone()
114            .into_value(binding_name, "host")
115            .context(invalid("host"))?,
116        input
117            .port
118            .clone()
119            .into_value(binding_name, "port")
120            .context(invalid("port"))?,
121        input
122            .database
123            .clone()
124            .into_value(binding_name, "database")
125            .context(invalid("database"))?,
126        input
127            .username
128            .clone()
129            .into_value(binding_name, "username")
130            .context(invalid("username"))?,
131        input.password.to_string(),
132        input.tls,
133    ))
134}
135
136/// Resolves and validates the per-instance CA roots carried by a Cloud SQL binding.
137#[cfg(feature = "gcp")]
138pub(crate) fn resolve_verify_ca_policy(
139    binding_name: &str,
140    certificates: &BindingValue<Vec<String>>,
141) -> Result<PostgresTlsPolicy> {
142    let certificates = certificates
143        .clone()
144        .into_value(binding_name, "serverCaCertificates")
145        .context(ErrorData::config_invalid(
146            binding_name,
147            "Failed to extract 'serverCaCertificates' from Postgres binding",
148        ))?;
149    verified_tls_policy(
150        binding_name,
151        SslMode::VerifyCa,
152        PostgresTlsPolicy::verify_ca(certificates),
153    )
154}
155
156#[cfg(feature = "gcp")]
157fn verified_tls_policy(
158    binding_name: &str,
159    sslmode: SslMode,
160    policy: std::result::Result<PostgresTlsPolicy, crate::traits::InvalidPostgresCaCertificates>,
161) -> Result<PostgresTlsPolicy> {
162    policy.map_err(|_| {
163        AlienError::new(ErrorData::config_invalid(
164            binding_name,
165            format!(
166                "Postgres sslmode '{}' requires one or more non-empty PEM server CA certificates",
167                sslmode.as_str()
168            ),
169        ))
170    })
171}
172
173/// Cached Aurora TLS policy. Its large embedded root bundle is parsed and allocated once,
174/// then shared by all resolved handles.
175#[cfg(feature = "aws")]
176pub(crate) fn aws_rds_tls_policy() -> PostgresTlsPolicy {
177    static POLICY: OnceLock<PostgresTlsPolicy> = OnceLock::new();
178    POLICY
179        .get_or_init(|| {
180            PostgresTlsPolicy::verify_full(
181                AWS_RDS_CA_CERTIFICATES
182                    .iter()
183                    .map(|certificate| (*certificate).to_string())
184                    .collect(),
185            )
186            .expect("the embedded AWS RDS root bundle must contain valid PEM certificates")
187        })
188        .clone()
189}
190
191/// Cached Flexible Server TLS policy. Embedded roots are parsed and allocated once,
192/// then shared by all resolved handles.
193#[cfg(feature = "azure")]
194pub(crate) fn azure_postgres_tls_policy() -> PostgresTlsPolicy {
195    static POLICY: OnceLock<PostgresTlsPolicy> = OnceLock::new();
196    POLICY
197        .get_or_init(|| {
198            PostgresTlsPolicy::verify_full(
199                AZURE_POSTGRES_CA_CERTIFICATES
200                    .iter()
201                    .map(|certificate| (*certificate).to_string())
202                    .collect(),
203            )
204            .expect("the embedded Azure Postgres roots must contain valid PEM certificates")
205        })
206        .clone()
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    /// An unresolved `SecretRef` in a connection field must fail as user-fixable config,
214    /// not silently produce a half-resolved connection.
215    #[test]
216    fn unresolved_secret_ref_field_is_binding_config_invalid() {
217        let error = resolve_params(
218            "db",
219            PostgresConnectionInput {
220                host: &BindingValue::SecretRef {
221                    secret_ref: alien_core::bindings::SecretReference {
222                        name: "pg-credentials".to_string(),
223                        key: "host".to_string(),
224                    },
225                },
226                port: &BindingValue::value(5432),
227                database: &"db".into(),
228                username: &"alien".into(),
229                password: "pw",
230                tls: PostgresTlsPolicy::verify_full(vec![pem("root")]).unwrap(),
231            },
232        )
233        .expect_err("an unresolved SecretRef host must not resolve");
234
235        assert_eq!(error.code, "BINDING_CONFIG_INVALID");
236        assert!(!error.retryable, "bad binding config is user-fixable");
237        assert!(
238            error.to_string().contains("host"),
239            "the error must name the offending field, got: {error}"
240        );
241    }
242
243    /// The redacting `Debug` on `PostgresConnectionParams` is the only thing keeping a
244    /// resolved cloud password out of logs and panic output; every handle derives its own
245    /// `Debug` from it, so pin it here so a derive can never quietly replace it.
246    #[test]
247    fn debug_output_never_contains_the_password() {
248        let params = resolve_params(
249            "db",
250            PostgresConnectionInput {
251                host: &"h".into(),
252                port: &BindingValue::value(5432),
253                database: &"db".into(),
254                username: &"alien".into(),
255                password: "super-secret-password",
256                tls: PostgresTlsPolicy::verify_full(vec![pem("root")]).unwrap(),
257            },
258        )
259        .expect("concrete fields resolve");
260
261        let rendered = format!("{params:?}");
262        assert!(
263            !rendered.contains("super-secret-password"),
264            "password leaked into Debug output: {rendered}"
265        );
266        assert!(
267            !rendered.contains("BEGIN CERTIFICATE"),
268            "certificate bundle expanded into Debug output: {rendered}"
269        );
270        assert!(rendered.contains("<redacted>"), "got: {rendered}");
271    }
272
273    #[test]
274    fn verify_ca_requires_valid_ca_certificates() {
275        for ca_certificates in [
276            Vec::new(),
277            vec!["".to_string()],
278            vec!["not a certificate".to_string()],
279        ] {
280            let error = verified_tls_policy(
281                "db",
282                SslMode::VerifyCa,
283                PostgresTlsPolicy::verify_ca(ca_certificates),
284            )
285            .expect_err("verified TLS without a PEM root must fail closed");
286
287            assert_eq!(error.code, "BINDING_CONFIG_INVALID");
288            assert!(!error.retryable);
289        }
290    }
291
292    #[test]
293    fn verify_full_can_use_the_system_trust_store() {
294        let params = resolve_params(
295            "db",
296            PostgresConnectionInput {
297                host: &"db.example.com".into(),
298                port: &BindingValue::value(5432),
299                database: &"db".into(),
300                username: &"alien".into(),
301                password: "pw",
302                tls: PostgresTlsPolicy::verify_full_with_system_roots(),
303            },
304        )
305        .expect("BYO verify-full can rely on the runtime trust store");
306
307        assert!(params.ca_certificates().is_empty());
308        assert_eq!(params.sslmode(), SslMode::VerifyFull);
309    }
310
311    #[test]
312    fn tls_policy_cannot_pair_plaintext_with_roots_or_verify_ca_without_roots() {
313        assert!(PostgresTlsPolicy::verify_ca(Vec::new()).is_err());
314        assert!(PostgresTlsPolicy::disabled().ca_certificates().is_empty());
315        assert_eq!(PostgresTlsPolicy::disabled().sslmode(), SslMode::Disable);
316    }
317
318    fn pem(body: &str) -> String {
319        format!("-----BEGIN CERTIFICATE-----\n{body}\n-----END CERTIFICATE-----")
320    }
321}