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 as a concrete value, never an unresolved `SecretRef`: the platform
111    /// materializes the Kubernetes secret into the pod env. The cloud variants carry a secret
112    /// locator instead.
113    pub password: String,
114    /// Explicit TLS policy. Missing legacy configuration defaults to verified TLS.
115    #[serde(default)]
116    pub ssl_mode: ExternalPostgresSslMode,
117}
118
119/// Local embedded Postgres binding.
120// No derived `Debug` — inline `password` would print cleartext; see the redacting impl below.
121#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
123#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
124#[serde(rename_all = "camelCase")]
125pub struct LocalPostgresBinding {
126    pub host: BindingValue<String>,
127    pub port: BindingValue<u16>,
128    pub database: BindingValue<String>,
129    pub username: BindingValue<String>,
130    pub password: String,
131}
132
133// These impls redact the inline password and keep every other field, mirroring
134// `PostgresConnectionParams`. Cloud variants carry only a secret identifier, so they keep the derive.
135impl std::fmt::Debug for ExternalPostgresBinding {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct("ExternalPostgresBinding")
138            .field("host", &self.host)
139            .field("port", &self.port)
140            .field("database", &self.database)
141            .field("username", &self.username)
142            .field("password", &"<redacted>")
143            .field("ssl_mode", &self.ssl_mode)
144            .finish()
145    }
146}
147
148impl std::fmt::Debug for LocalPostgresBinding {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("LocalPostgresBinding")
151            .field("host", &self.host)
152            .field("port", &self.port)
153            .field("database", &self.database)
154            .field("username", &self.username)
155            .field("password", &"<redacted>")
156            .finish()
157    }
158}
159
160impl PostgresBinding {
161    /// Creates a Local Postgres binding.
162    pub fn local(
163        host: impl Into<BindingValue<String>>,
164        port: u16,
165        database: impl Into<BindingValue<String>>,
166        username: impl Into<BindingValue<String>>,
167        password: impl Into<String>,
168    ) -> Self {
169        Self::Local(LocalPostgresBinding {
170            host: host.into(),
171            port: BindingValue::value(port),
172            database: database.into(),
173            username: username.into(),
174            password: password.into(),
175        })
176    }
177
178    /// Creates an External (BYO / Kubernetes) Postgres binding.
179    pub fn external(
180        host: impl Into<BindingValue<String>>,
181        port: u16,
182        database: impl Into<BindingValue<String>>,
183        username: impl Into<BindingValue<String>>,
184        password: impl Into<String>,
185    ) -> Self {
186        Self::External(ExternalPostgresBinding {
187            host: host.into(),
188            port: BindingValue::value(port),
189            database: database.into(),
190            username: username.into(),
191            password: password.into(),
192            ssl_mode: ExternalPostgresSslMode::default(),
193        })
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn local_binding_uses_local_postgres_tag() {
203        let binding = PostgresBinding::local("127.0.0.1", 5432, "db", "alien", "secret");
204        let json = serde_json::to_string(&binding).unwrap();
205        assert!(json.contains(r#""service":"local-postgres""#));
206        let deserialized: PostgresBinding = serde_json::from_str(&json).unwrap();
207        assert_eq!(binding, deserialized);
208    }
209
210    #[test]
211    fn external_binding_uses_external_tag() {
212        let binding = PostgresBinding::external("db.internal", 5432, "app", "alien", "secret");
213        let json = serde_json::to_string(&binding).expect("external binding serializes");
214        assert!(json.contains(r#""service":"external""#));
215        assert!(json.contains(r#""sslMode":"verify-full""#));
216        let deserialized: PostgresBinding =
217            serde_json::from_str(&json).expect("external binding deserializes");
218        assert_eq!(binding, deserialized);
219    }
220
221    #[test]
222    fn external_binding_without_ssl_mode_defaults_to_verified_tls() {
223        let json = r#"{
224            "service": "external",
225            "host": "db.internal",
226            "port": 5432,
227            "database": "app",
228            "username": "alien",
229            "password": "secret"
230        }"#;
231
232        let binding: PostgresBinding =
233            serde_json::from_str(json).expect("legacy external binding deserializes");
234
235        let PostgresBinding::External(binding) = binding else {
236            panic!("expected external Postgres binding");
237        };
238        assert_eq!(binding.ssl_mode, ExternalPostgresSslMode::VerifyFull);
239    }
240
241    #[test]
242    fn external_binding_accepts_explicit_plaintext_opt_out() {
243        let json = r#"{
244            "service": "external",
245            "host": "db.internal",
246            "port": 5432,
247            "database": "app",
248            "username": "alien",
249            "password": "secret",
250            "sslMode": "disable"
251        }"#;
252
253        let binding: PostgresBinding =
254            serde_json::from_str(json).expect("plaintext external binding deserializes");
255
256        let PostgresBinding::External(binding) = binding else {
257            panic!("expected external Postgres binding");
258        };
259        assert_eq!(binding.ssl_mode, ExternalPostgresSslMode::Disable);
260    }
261
262    #[test]
263    fn cloud_variants_keep_hyphenated_tags() {
264        let aurora = PostgresBinding::Aurora(AuroraPostgresBinding {
265            cluster_endpoint: "cluster.rds.amazonaws.com".into(),
266            port: BindingValue::value(5432),
267            database: "db".into(),
268            username: "alien".into(),
269            password_secret_arn: "arn:aws:secretsmanager:...".into(),
270        });
271        assert!(serde_json::to_string(&aurora)
272            .unwrap()
273            .contains(r#""service":"aurora""#));
274
275        let cloud_sql = PostgresBinding::CloudSql(CloudSqlPostgresBinding {
276            host: "10.0.0.5".into(),
277            port: BindingValue::value(5432),
278            database: "db".into(),
279            username: "alien".into(),
280            server_ca_certificates: BindingValue::value(vec!["cloud-sql-root".to_string()]),
281            password_secret_name: "pg-credentials".into(),
282        });
283        assert!(serde_json::to_string(&cloud_sql)
284            .unwrap()
285            .contains(r#""service":"cloud-sql""#));
286
287        let flexible = PostgresBinding::FlexibleServer(FlexibleServerPostgresBinding {
288            host: "10.0.0.6".into(),
289            port: BindingValue::value(5432),
290            database: "db".into(),
291            username: "alien".into(),
292            password_secret_uri: "https://vault.vault.azure.net/secrets/pg".into(),
293        });
294        assert!(serde_json::to_string(&flexible)
295            .unwrap()
296            .contains(r#""service":"flexible-server""#));
297    }
298}