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    /// GCP Cloud SQL (host + secret name).
22    #[serde(rename = "cloud-sql")]
23    CloudSql(CloudSqlPostgresBinding),
24    /// Azure Database for PostgreSQL — Flexible Server (host + secret URI).
25    #[serde(rename = "flexible-server")]
26    FlexibleServer(FlexibleServerPostgresBinding),
27    /// Operator-provided / BYO database (Kubernetes, on-prem, or cloud override).
28    External(ExternalPostgresBinding),
29    /// Local embedded Postgres process.
30    #[serde(rename = "local-postgres")]
31    Local(LocalPostgresBinding),
32}
33
34/// AWS Aurora Serverless v2 binding.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
37#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
38#[serde(rename_all = "camelCase")]
39pub struct AuroraPostgresBinding {
40    pub cluster_endpoint: BindingValue<String>,
41    pub port: BindingValue<u16>,
42    pub database: BindingValue<String>,
43    pub username: BindingValue<String>,
44    /// Secrets Manager ARN of the connection password; resolved at load time.
45    pub password_secret_arn: BindingValue<String>,
46}
47
48/// GCP Cloud SQL binding.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
52#[serde(rename_all = "camelCase")]
53pub struct CloudSqlPostgresBinding {
54    pub host: BindingValue<String>,
55    pub port: BindingValue<u16>,
56    pub database: BindingValue<String>,
57    pub username: BindingValue<String>,
58    /// Per-instance Cloud SQL server CA certificates, including every root accepted
59    /// during a CA rotation.
60    pub server_ca_certificates: BindingValue<Vec<String>>,
61    /// Secret Manager secret name of the connection password; resolved at load time.
62    pub password_secret_name: BindingValue<String>,
63}
64
65/// Azure Flexible Server binding.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
68#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
69#[serde(rename_all = "camelCase")]
70pub struct FlexibleServerPostgresBinding {
71    pub host: BindingValue<String>,
72    pub port: BindingValue<u16>,
73    pub database: BindingValue<String>,
74    pub username: BindingValue<String>,
75    /// Key Vault secret URI of the connection password; resolved at load time.
76    pub password_secret_uri: BindingValue<String>,
77}
78
79/// TLS policy for an operator-provided / BYO Postgres database.
80///
81/// Unlike libpq's ambiguous `prefer` mode, both choices map exactly to the
82/// connection settings exposed by every supported SDK.
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
84#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
85#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
86#[serde(rename_all = "kebab-case")]
87pub enum ExternalPostgresSslMode {
88    /// Require TLS and verify the server certificate and hostname.
89    #[default]
90    VerifyFull,
91    /// Connect over plaintext. Intended only for explicitly configured legacy servers.
92    Disable,
93}
94
95/// Operator-provided / BYO database binding.
96// No derived `Debug` — inline `password` would print cleartext; see the redacting impl below.
97#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
99#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
100#[serde(rename_all = "camelCase")]
101pub struct ExternalPostgresBinding {
102    pub host: BindingValue<String>,
103    pub port: BindingValue<u16>,
104    pub database: BindingValue<String>,
105    pub username: BindingValue<String>,
106    /// Connection password as a concrete value, never an unresolved `SecretRef`: the platform
107    /// materializes the Kubernetes secret into the pod env. The cloud variants carry a secret
108    /// locator instead.
109    pub password: String,
110    /// Explicit TLS policy. Missing legacy configuration defaults to verified TLS.
111    #[serde(default)]
112    pub ssl_mode: ExternalPostgresSslMode,
113}
114
115/// Local embedded Postgres binding.
116// No derived `Debug` — inline `password` would print cleartext; see the redacting impl below.
117#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
119#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
120#[serde(rename_all = "camelCase")]
121pub struct LocalPostgresBinding {
122    pub host: BindingValue<String>,
123    pub port: BindingValue<u16>,
124    pub database: BindingValue<String>,
125    pub username: BindingValue<String>,
126    pub password: String,
127}
128
129// These impls redact the inline password and keep every other field, mirroring
130// `PostgresConnectionParams`. Cloud variants carry only a secret identifier, so they keep the derive.
131impl std::fmt::Debug for ExternalPostgresBinding {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.debug_struct("ExternalPostgresBinding")
134            .field("host", &self.host)
135            .field("port", &self.port)
136            .field("database", &self.database)
137            .field("username", &self.username)
138            .field("password", &"<redacted>")
139            .field("ssl_mode", &self.ssl_mode)
140            .finish()
141    }
142}
143
144impl std::fmt::Debug for LocalPostgresBinding {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        f.debug_struct("LocalPostgresBinding")
147            .field("host", &self.host)
148            .field("port", &self.port)
149            .field("database", &self.database)
150            .field("username", &self.username)
151            .field("password", &"<redacted>")
152            .finish()
153    }
154}
155
156impl PostgresBinding {
157    /// Creates a Local Postgres binding.
158    pub fn local(
159        host: impl Into<BindingValue<String>>,
160        port: u16,
161        database: impl Into<BindingValue<String>>,
162        username: impl Into<BindingValue<String>>,
163        password: impl Into<String>,
164    ) -> Self {
165        Self::Local(LocalPostgresBinding {
166            host: host.into(),
167            port: BindingValue::value(port),
168            database: database.into(),
169            username: username.into(),
170            password: password.into(),
171        })
172    }
173
174    /// Creates an External (BYO / Kubernetes) Postgres binding.
175    pub fn external(
176        host: impl Into<BindingValue<String>>,
177        port: u16,
178        database: impl Into<BindingValue<String>>,
179        username: impl Into<BindingValue<String>>,
180        password: impl Into<String>,
181    ) -> Self {
182        Self::External(ExternalPostgresBinding {
183            host: host.into(),
184            port: BindingValue::value(port),
185            database: database.into(),
186            username: username.into(),
187            password: password.into(),
188            ssl_mode: ExternalPostgresSslMode::default(),
189        })
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn local_binding_uses_local_postgres_tag() {
199        let binding = PostgresBinding::local("127.0.0.1", 5432, "db", "alien", "secret");
200        let json = serde_json::to_string(&binding).unwrap();
201        assert!(json.contains(r#""service":"local-postgres""#));
202        let deserialized: PostgresBinding = serde_json::from_str(&json).unwrap();
203        assert_eq!(binding, deserialized);
204    }
205
206    #[test]
207    fn external_binding_uses_external_tag() {
208        let binding = PostgresBinding::external("db.internal", 5432, "app", "alien", "secret");
209        let json = serde_json::to_string(&binding).expect("external binding serializes");
210        assert!(json.contains(r#""service":"external""#));
211        assert!(json.contains(r#""sslMode":"verify-full""#));
212        let deserialized: PostgresBinding =
213            serde_json::from_str(&json).expect("external binding deserializes");
214        assert_eq!(binding, deserialized);
215    }
216
217    #[test]
218    fn external_binding_without_ssl_mode_defaults_to_verified_tls() {
219        let json = r#"{
220            "service": "external",
221            "host": "db.internal",
222            "port": 5432,
223            "database": "app",
224            "username": "alien",
225            "password": "secret"
226        }"#;
227
228        let binding: PostgresBinding =
229            serde_json::from_str(json).expect("legacy external binding deserializes");
230
231        let PostgresBinding::External(binding) = binding else {
232            panic!("expected external Postgres binding");
233        };
234        assert_eq!(binding.ssl_mode, ExternalPostgresSslMode::VerifyFull);
235    }
236
237    #[test]
238    fn external_binding_accepts_explicit_plaintext_opt_out() {
239        let json = r#"{
240            "service": "external",
241            "host": "db.internal",
242            "port": 5432,
243            "database": "app",
244            "username": "alien",
245            "password": "secret",
246            "sslMode": "disable"
247        }"#;
248
249        let binding: PostgresBinding =
250            serde_json::from_str(json).expect("plaintext external binding deserializes");
251
252        let PostgresBinding::External(binding) = binding else {
253            panic!("expected external Postgres binding");
254        };
255        assert_eq!(binding.ssl_mode, ExternalPostgresSslMode::Disable);
256    }
257
258    #[test]
259    fn cloud_variants_keep_hyphenated_tags() {
260        let aurora = PostgresBinding::Aurora(AuroraPostgresBinding {
261            cluster_endpoint: "cluster.rds.amazonaws.com".into(),
262            port: BindingValue::value(5432),
263            database: "db".into(),
264            username: "alien".into(),
265            password_secret_arn: "arn:aws:secretsmanager:...".into(),
266        });
267        assert!(serde_json::to_string(&aurora)
268            .unwrap()
269            .contains(r#""service":"aurora""#));
270
271        let cloud_sql = PostgresBinding::CloudSql(CloudSqlPostgresBinding {
272            host: "10.0.0.5".into(),
273            port: BindingValue::value(5432),
274            database: "db".into(),
275            username: "alien".into(),
276            server_ca_certificates: BindingValue::value(vec!["cloud-sql-root".to_string()]),
277            password_secret_name: "pg-credentials".into(),
278        });
279        assert!(serde_json::to_string(&cloud_sql)
280            .unwrap()
281            .contains(r#""service":"cloud-sql""#));
282
283        let flexible = PostgresBinding::FlexibleServer(FlexibleServerPostgresBinding {
284            host: "10.0.0.6".into(),
285            port: BindingValue::value(5432),
286            database: "db".into(),
287            username: "alien".into(),
288            password_secret_uri: "https://vault.vault.azure.net/secrets/pg".into(),
289        });
290        assert!(serde_json::to_string(&flexible)
291            .unwrap()
292            .contains(r#""service":"flexible-server""#));
293    }
294}