Skip to main content

alien_core/bindings/
postgres.rs

1//! Postgres binding definitions across platforms.
2//!
3//! The binding carries only connection details. Cloud variants keep the password
4//! out of state by referencing the cloud secret store (ARN / name / URI), resolved
5//! at load time; Local and External carry the password inline as a `BindingValue`.
6
7use super::BindingValue;
8use serde::{Deserialize, Serialize};
9
10/// Connection details for a Postgres database, one variant per backend.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
14// `rename_all = "lowercase"` would drop the hyphen (CloudSql -> cloudsql); the explicit
15// renames keep the wire tags `cloud-sql`/`flexible-server`/`local-postgres`. Every tag is
16// globally unique across all binding enums (serde dispatches on `service` alone).
17#[serde(tag = "service", rename_all = "lowercase")]
18pub enum PostgresBinding {
19    /// AWS Aurora Serverless v2 (cluster endpoint + secret ARN).
20    Aurora(AuroraPostgresBinding),
21    /// Aurora-managed JSON credentials. Separate tag makes older readers reject
22    /// this binding rather than treating the JSON document as a raw password.
23    #[serde(rename = "aurora-managed")]
24    AuroraManaged(AuroraPostgresBinding),
25    /// GCP Cloud SQL (host + secret name).
26    #[serde(rename = "cloud-sql")]
27    CloudSql(CloudSqlPostgresBinding),
28    /// Azure Database for PostgreSQL — Flexible Server (host + secret URI).
29    #[serde(rename = "flexible-server")]
30    FlexibleServer(FlexibleServerPostgresBinding),
31    /// Operator-provided / BYO database (Kubernetes, on-prem, or cloud override).
32    External(ExternalPostgresBinding),
33    /// Local embedded Postgres process.
34    #[serde(rename = "local-postgres")]
35    Local(LocalPostgresBinding),
36}
37
38/// AWS Aurora Serverless v2 binding.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
41#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
42#[serde(rename_all = "camelCase")]
43pub struct AuroraPostgresBinding {
44    pub cluster_endpoint: BindingValue<String>,
45    pub port: BindingValue<u16>,
46    pub database: BindingValue<String>,
47    pub username: BindingValue<String>,
48    /// Secrets Manager ARN of the connection password; resolved at load time.
49    pub password_secret_arn: BindingValue<String>,
50}
51
52/// GCP Cloud SQL binding.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
55#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
56#[serde(rename_all = "camelCase")]
57pub struct CloudSqlPostgresBinding {
58    pub host: BindingValue<String>,
59    pub port: BindingValue<u16>,
60    pub database: BindingValue<String>,
61    pub username: BindingValue<String>,
62    /// Per-instance Cloud SQL server CA certificates, including every root accepted
63    /// during a CA rotation.
64    pub server_ca_certificates: BindingValue<Vec<String>>,
65    /// Secret Manager secret name of the connection password; resolved at load time.
66    pub password_secret_name: BindingValue<String>,
67}
68
69/// Azure Flexible Server binding.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
72#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
73#[serde(rename_all = "camelCase")]
74pub struct FlexibleServerPostgresBinding {
75    pub host: BindingValue<String>,
76    pub port: BindingValue<u16>,
77    pub database: BindingValue<String>,
78    pub username: BindingValue<String>,
79    /// Key Vault secret URI of the connection password; resolved at load time.
80    pub password_secret_uri: BindingValue<String>,
81}
82
83/// TLS policy for an operator-provided / BYO Postgres database.
84///
85/// Unlike libpq's ambiguous `prefer` mode, both choices map exactly to the
86/// connection settings exposed by every supported SDK.
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
88#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
90#[serde(rename_all = "kebab-case")]
91pub enum ExternalPostgresSslMode {
92    /// Require TLS and verify the server certificate and hostname.
93    #[default]
94    VerifyFull,
95    /// Connect over plaintext. Intended only for explicitly configured legacy servers.
96    Disable,
97}
98
99/// Operator-provided / BYO database binding.
100// No derived `Debug` — inline `password` would print cleartext; see the redacting impl below.
101#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
103#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
104#[serde(rename_all = "camelCase")]
105pub struct ExternalPostgresBinding {
106    pub host: BindingValue<String>,
107    pub port: BindingValue<u16>,
108    pub database: BindingValue<String>,
109    pub username: BindingValue<String>,
110    /// Connection password or Kubernetes Secret reference. Kubernetes workloads resolve
111    /// the reference through secretKeyRef before the application loads its binding.
112    #[serde(deserialize_with = "deserialize_external_password")]
113    #[cfg_attr(feature = "openapi", schema(value_type = ExternalPasswordInput, inline))]
114    #[cfg_attr(feature = "jsonschema", schemars(with = "ExternalPasswordInput"))]
115    pub password: BindingValue<String>,
116    /// Explicit TLS policy. Missing legacy configuration defaults to verified TLS.
117    #[serde(default)]
118    pub ssl_mode: ExternalPostgresSslMode,
119}
120
121// Password references extend the old string contract without accepting numeric,
122// boolean, null, or array passwords through BindingValue's expression fallback.
123#[derive(Deserialize)]
124#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
125#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
126#[serde(untagged)]
127enum ExternalPasswordInput {
128    Literal(String),
129    Reference(ExternalPasswordSecretRef),
130    ChartReference(ExternalPasswordSecretKeyRef),
131}
132
133#[derive(Deserialize)]
134#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
135#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137struct ExternalPasswordSecretRef {
138    secret_ref: super::SecretReference,
139}
140
141// Chart-provisioned passwords use a distinct reference so projection can preserve
142// the published format for existing Secret references.
143#[derive(Deserialize)]
144#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
145#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
146#[serde(rename_all = "camelCase", deny_unknown_fields)]
147struct ExternalPasswordSecretKeyRef {
148    secret_key_ref: super::SecretReference,
149}
150
151fn deserialize_external_password<'de, D>(deserializer: D) -> Result<BindingValue<String>, D::Error>
152where
153    D: serde::Deserializer<'de>,
154{
155    let value = serde_json::Value::deserialize(deserializer)?;
156    if !value.is_string()
157        && !["secretRef", "secretKeyRef"]
158            .iter()
159            .any(|key| value.get(key).is_some_and(serde_json::Value::is_object))
160    {
161        return Err(serde::de::Error::custom(
162            "expected a password string or a Secret reference with name and key",
163        ));
164    }
165    match serde_json::from_value::<ExternalPasswordInput>(value)
166        .map_err(serde::de::Error::custom)?
167    {
168        ExternalPasswordInput::Literal(value) => Ok(BindingValue::Value(value)),
169        ExternalPasswordInput::Reference(reference) => Ok(BindingValue::SecretRef {
170            secret_ref: reference.secret_ref,
171        }),
172        ExternalPasswordInput::ChartReference(reference) => {
173            Ok(BindingValue::Expression(serde_json::json!({
174                "secretKeyRef": {"name": reference.secret_key_ref.name, "key": reference.secret_key_ref.key}
175            })))
176        }
177    }
178}
179
180/// Local embedded Postgres binding.
181// No derived `Debug` — inline `password` would print cleartext; see the redacting impl below.
182#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
184#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
185#[serde(rename_all = "camelCase")]
186pub struct LocalPostgresBinding {
187    pub host: BindingValue<String>,
188    pub port: BindingValue<u16>,
189    pub database: BindingValue<String>,
190    pub username: BindingValue<String>,
191    pub password: String,
192}
193
194// These impls redact the inline password and keep every other field, mirroring
195// `PostgresConnectionParams`. Cloud variants carry only a secret identifier, so they keep the derive.
196impl std::fmt::Debug for ExternalPostgresBinding {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        f.debug_struct("ExternalPostgresBinding")
199            .field("host", &self.host)
200            .field("port", &self.port)
201            .field("database", &self.database)
202            .field("username", &self.username)
203            .field("password", &"<redacted>")
204            .field("ssl_mode", &self.ssl_mode)
205            .finish()
206    }
207}
208
209impl std::fmt::Debug for LocalPostgresBinding {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        f.debug_struct("LocalPostgresBinding")
212            .field("host", &self.host)
213            .field("port", &self.port)
214            .field("database", &self.database)
215            .field("username", &self.username)
216            .field("password", &"<redacted>")
217            .finish()
218    }
219}
220
221impl PostgresBinding {
222    /// Creates a Local Postgres binding.
223    pub fn local(
224        host: impl Into<BindingValue<String>>,
225        port: u16,
226        database: impl Into<BindingValue<String>>,
227        username: impl Into<BindingValue<String>>,
228        password: impl Into<String>,
229    ) -> Self {
230        Self::Local(LocalPostgresBinding {
231            host: host.into(),
232            port: BindingValue::value(port),
233            database: database.into(),
234            username: username.into(),
235            password: password.into(),
236        })
237    }
238
239    /// Creates an External (BYO / Kubernetes) Postgres binding.
240    pub fn external(
241        host: impl Into<BindingValue<String>>,
242        port: u16,
243        database: impl Into<BindingValue<String>>,
244        username: impl Into<BindingValue<String>>,
245        password: impl Into<String>,
246    ) -> Self {
247        Self::External(ExternalPostgresBinding {
248            host: host.into(),
249            port: BindingValue::value(port),
250            database: database.into(),
251            username: username.into(),
252            password: BindingValue::value(password.into()),
253            ssl_mode: ExternalPostgresSslMode::default(),
254        })
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn external_password_rejects_non_string_literals() {
264        for password in [
265            serde_json::json!(42),
266            serde_json::json!(false),
267            serde_json::json!(null),
268            serde_json::json!([]),
269            serde_json::json!({}),
270            serde_json::json!([{"name":"credentials", "key":"password"}]),
271            serde_json::json!({"secretRef":["credentials", "password"]}),
272            serde_json::json!({"secretKeyRef":{"name":"credentials"}}),
273            serde_json::json!({"secretKeyRef":{"key":"password"}}),
274            serde_json::json!({"secretRef": {"name": "credentials"}}),
275            serde_json::json!({"secretRef": {"key": "password"}}),
276            serde_json::json!({"secretRef": {"name": 4, "key": "password"}}),
277            serde_json::json!({"envRef": "PASSWORD"}),
278        ] {
279            let binding = serde_json::json!({
280                "service": "external", "host": "db.internal", "port": 5432,
281                "database": "app", "username": "app", "password": password
282            });
283            assert!(serde_json::from_value::<PostgresBinding>(binding).is_err());
284        }
285    }
286
287    #[test]
288    fn local_binding_uses_local_postgres_tag() {
289        let binding = PostgresBinding::local("127.0.0.1", 5432, "db", "alien", "secret");
290        let json = serde_json::to_string(&binding).unwrap();
291        assert!(json.contains(r#""service":"local-postgres""#));
292        let deserialized: PostgresBinding = serde_json::from_str(&json).unwrap();
293        assert_eq!(binding, deserialized);
294    }
295
296    #[test]
297    fn external_binding_uses_external_tag() {
298        let binding = PostgresBinding::external("db.internal", 5432, "app", "alien", "secret");
299        let json = serde_json::to_string(&binding).expect("external binding serializes");
300        assert!(json.contains(r#""service":"external""#));
301        assert!(json.contains(r#""sslMode":"verify-full""#));
302        let deserialized: PostgresBinding =
303            serde_json::from_str(&json).expect("external binding deserializes");
304        assert_eq!(binding, deserialized);
305    }
306
307    #[test]
308    fn chart_password_reference_keeps_its_projection_mode() {
309        let input = serde_json::json!({"service":"external", "host":"database", "port":5432,
310            "database":"app", "username":"app", "password":{"secretKeyRef":{"name":"database", "key":"password"}}});
311        let binding: PostgresBinding = serde_json::from_value(input.clone()).unwrap();
312        assert_eq!(
313            serde_json::to_value(binding).unwrap()["password"],
314            input["password"]
315        );
316    }
317
318    #[test]
319    fn external_binding_preserves_kubernetes_password_reference() {
320        let json = serde_json::json!({
321            "service": "external",
322            "host": "database",
323            "port": 5432,
324            "database": "app",
325            "username": "app",
326            "password": {"secretRef": {"name": "database-credentials", "key": "password"}},
327            "sslMode": "disable"
328        });
329        let binding: PostgresBinding = serde_json::from_value(json.clone())
330            .expect("external binding accepts a Kubernetes password reference");
331        assert_eq!(serde_json::to_value(&binding).unwrap(), json);
332        assert!(!format!("{binding:?}").contains("database-credentials"));
333    }
334
335    #[test]
336    fn external_binding_without_ssl_mode_defaults_to_verified_tls() {
337        let json = r#"{
338            "service": "external",
339            "host": "db.internal",
340            "port": 5432,
341            "database": "app",
342            "username": "alien",
343            "password": "secret"
344        }"#;
345
346        let binding: PostgresBinding =
347            serde_json::from_str(json).expect("legacy external binding deserializes");
348
349        let PostgresBinding::External(binding) = binding else {
350            panic!("expected external Postgres binding");
351        };
352        assert_eq!(binding.ssl_mode, ExternalPostgresSslMode::VerifyFull);
353    }
354
355    #[test]
356    fn external_binding_accepts_explicit_plaintext_opt_out() {
357        let json = r#"{
358            "service": "external",
359            "host": "db.internal",
360            "port": 5432,
361            "database": "app",
362            "username": "alien",
363            "password": "secret",
364            "sslMode": "disable"
365        }"#;
366
367        let binding: PostgresBinding =
368            serde_json::from_str(json).expect("plaintext external binding deserializes");
369
370        let PostgresBinding::External(binding) = binding else {
371            panic!("expected external Postgres binding");
372        };
373        assert_eq!(binding.ssl_mode, ExternalPostgresSslMode::Disable);
374    }
375
376    #[test]
377    fn cloud_variants_keep_hyphenated_tags() {
378        let aurora = PostgresBinding::Aurora(AuroraPostgresBinding {
379            cluster_endpoint: "cluster.rds.amazonaws.com".into(),
380            port: BindingValue::value(5432),
381            database: "db".into(),
382            username: "alien".into(),
383            password_secret_arn: "arn:aws:secretsmanager:...".into(),
384        });
385        assert!(serde_json::to_string(&aurora)
386            .unwrap()
387            .contains(r#""service":"aurora""#));
388
389        let cloud_sql = PostgresBinding::CloudSql(CloudSqlPostgresBinding {
390            host: "10.0.0.5".into(),
391            port: BindingValue::value(5432),
392            database: "db".into(),
393            username: "alien".into(),
394            server_ca_certificates: BindingValue::value(vec!["cloud-sql-root".to_string()]),
395            password_secret_name: "pg-credentials".into(),
396        });
397        assert!(serde_json::to_string(&cloud_sql)
398            .unwrap()
399            .contains(r#""service":"cloud-sql""#));
400
401        let flexible = PostgresBinding::FlexibleServer(FlexibleServerPostgresBinding {
402            host: "10.0.0.6".into(),
403            port: BindingValue::value(5432),
404            database: "db".into(),
405            username: "alien".into(),
406            password_secret_uri: "https://vault.vault.azure.net/secrets/pg".into(),
407        });
408        assert!(serde_json::to_string(&flexible)
409            .unwrap()
410            .contains(r#""service":"flexible-server""#));
411    }
412}