use crate::error::{ErrorData, Result};
use crate::providers::postgres::{
cloud::resolve_secret_locator, resolve_params, resolve_verify_ca_policy,
PostgresConnectionInput,
};
use crate::traits::PostgresConnectionParams;
use alien_core::bindings::CloudSqlPostgresBinding;
use alien_error::{AlienError, Context, IntoAlienError};
use alien_gcp_clients::secret_manager::SecretManagerApi;
use base64::{engine::general_purpose::STANDARD as base64_standard, Engine as _};
use std::sync::Arc;
pub(crate) async fn resolve(
binding_name: &str,
binding: &CloudSqlPostgresBinding,
secrets: Arc<dyn SecretManagerApi>,
) -> Result<PostgresConnectionParams> {
let tls = resolve_verify_ca_policy(binding_name, &binding.server_ca_certificates)?;
let secret_name = resolve_secret_locator(
binding_name,
"passwordSecretName",
&binding.password_secret_name,
)?;
let password = read_password(binding_name, &secret_name, secrets.as_ref()).await?;
resolve_params(
binding_name,
PostgresConnectionInput {
host: &binding.host,
port: &binding.port,
database: &binding.database,
username: &binding.username,
password: &password,
tls,
},
)
}
async fn read_password(
binding_name: &str,
secret_name: &str,
secrets: &dyn SecretManagerApi,
) -> Result<String> {
let read_failed = |reason: &str| ErrorData::PostgresSecretResolutionFailed {
binding_name: binding_name.to_string(),
secret: secret_name.to_string(),
reason: reason.to_string(),
};
let invalid_value = |reason: &str| ErrorData::PostgresSecretValueInvalid {
binding_name: binding_name.to_string(),
secret: secret_name.to_string(),
reason: reason.to_string(),
};
let response = secrets
.access_secret_version(format!("{secret_name}/versions/latest"))
.await
.context(read_failed("Secret Manager accessSecretVersion failed"))?;
let encoded = response
.payload
.and_then(|payload| payload.data)
.ok_or_else(|| AlienError::new(invalid_value("secret version has no payload")))?;
let decoded = base64_standard
.decode(&encoded)
.into_alien_error()
.context(invalid_value("payload is not valid base64"))?;
if decoded.is_empty() {
return Err(AlienError::new(invalid_value(
"secret version payload is empty",
)));
}
String::from_utf8(decoded)
.into_alien_error()
.context(invalid_value("payload is not valid UTF-8"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::SslMode;
use alien_core::bindings::BindingValue;
use alien_gcp_clients::secret_manager::{
AccessSecretVersionResponse, MockSecretManagerApi, SecretPayload,
};
const SECRET_NAME: &str = "pg-credentials";
const SERVER_CA: &str =
"-----BEGIN CERTIFICATE-----\ncloud-sql-instance-root\n-----END CERTIFICATE-----";
fn binding() -> CloudSqlPostgresBinding {
CloudSqlPostgresBinding {
host: "10.0.0.5".into(),
port: BindingValue::value(5432),
database: "app".into(),
username: "alien".into(),
server_ca_certificates: BindingValue::value(vec![SERVER_CA.to_string()]),
password_secret_name: SECRET_NAME.into(),
}
}
fn response(payload: Option<&str>) -> AccessSecretVersionResponse {
AccessSecretVersionResponse {
name: Some(format!("projects/p/secrets/{SECRET_NAME}/versions/1")),
payload: payload.map(|data| SecretPayload {
data: Some(base64_standard.encode(data)),
}),
}
}
#[tokio::test]
async fn resolves_host_with_ca_verified_tls_from_latest_version() {
let mut secrets = MockSecretManagerApi::new();
secrets
.expect_access_secret_version()
.times(1)
.withf(|name| name == "pg-credentials/versions/latest")
.returning(|_| Ok(response(Some("a!b*c'd(e)f@/"))));
let params = resolve("db", &binding(), Arc::new(secrets))
.await
.expect("cloud sql binding resolves");
assert_eq!(params.host, "10.0.0.5");
assert_eq!(params.port, 5432);
assert_eq!(params.database, "app");
assert_eq!(params.username, "alien");
assert_eq!(params.password, "a!b*c'd(e)f@/");
assert_eq!(params.sslmode(), SslMode::VerifyCa);
assert_eq!(params.ca_certificates(), &[SERVER_CA]);
assert_eq!(
params.connection_string(),
"postgres://alien:a%21b%2Ac%27d%28e%29f%40%2F@10.0.0.5:5432/app?sslmode=verify-ca"
);
}
#[tokio::test]
async fn missing_server_ca_fails_before_reading_the_password() {
for certificates in [
BindingValue::value(Vec::new()),
BindingValue::Expression(serde_json::json!({
"Fn::GetAtt": ["CloudSql", "ServerCaCertificates"]
})),
] {
let mut secrets = MockSecretManagerApi::new();
secrets.expect_access_secret_version().never();
let mut malformed = binding();
malformed.server_ca_certificates = certificates;
let error = resolve("db", &malformed, Arc::new(secrets))
.await
.expect_err("Cloud SQL without concrete CA roots must fail closed");
assert_eq!(error.code, "BINDING_CONFIG_INVALID");
assert!(!error.retryable);
}
}
#[tokio::test]
async fn failed_secret_read_is_retryable() {
let mut secrets = MockSecretManagerApi::new();
secrets
.expect_access_secret_version()
.times(1)
.returning(|_| {
Err(AlienError::new(
alien_client_core::ErrorData::RemoteServiceUnavailable {
message: "secretmanager.googleapis.com is unavailable".to_string(),
},
))
});
let error = resolve("db", &binding(), Arc::new(secrets))
.await
.expect_err("a failed secret read must not resolve a connection");
assert_eq!(error.code, "POSTGRES_SECRET_RESOLUTION_FAILED");
assert!(error.retryable, "an upstream read failure is retryable");
assert!(
error.to_string().contains(SECRET_NAME),
"the error must name the secret locator so operators can find it, got: {error}"
);
}
#[tokio::test]
async fn empty_payload_fails_resolution() {
for stored in [None, Some("")] {
let mut secrets = MockSecretManagerApi::new();
secrets
.expect_access_secret_version()
.times(1)
.returning(move |_| Ok(response(stored)));
let error = resolve("db", &binding(), Arc::new(secrets))
.await
.expect_err("an empty payload must not resolve a connection");
assert_eq!(error.code, "POSTGRES_SECRET_VALUE_INVALID");
assert!(!error.retryable);
assert!(!error.internal);
}
}
#[tokio::test]
async fn malformed_payload_is_non_retryable_and_preserves_source() {
let responses = [
(
"not valid base64".to_string(),
"payload is not valid base64",
),
(base64_standard.encode([0xff]), "payload is not valid UTF-8"),
];
for (encoded, expected_reason) in responses {
let mut secrets = MockSecretManagerApi::new();
secrets
.expect_access_secret_version()
.times(1)
.returning(move |_| {
Ok(AccessSecretVersionResponse {
name: Some(format!("projects/p/secrets/{SECRET_NAME}/versions/1")),
payload: Some(SecretPayload {
data: Some(encoded.clone()),
}),
})
});
let error = resolve("db", &binding(), Arc::new(secrets))
.await
.expect_err("a malformed payload must not resolve a connection");
assert_eq!(error.code, "POSTGRES_SECRET_VALUE_INVALID");
assert!(!error.retryable);
assert!(!error.internal);
assert!(
error.to_string().contains(expected_reason),
"error must identify the malformed payload: {error}"
);
assert!(
std::error::Error::source(&error).is_some(),
"the decode error must be retained as the source"
);
}
}
#[tokio::test]
async fn unresolved_secret_name_is_non_retryable_config_error() {
let mut secrets = MockSecretManagerApi::new();
secrets.expect_access_secret_version().never();
let mut malformed = binding();
malformed.password_secret_name = BindingValue::Expression(serde_json::json!({
"Fn::GetAtt": ["PgSecret", "Name"]
}));
let error = resolve("db", &malformed, Arc::new(secrets))
.await
.expect_err("an unresolved secret name must not resolve a connection");
assert_eq!(error.code, "BINDING_CONFIG_INVALID");
assert!(!error.retryable, "bad binding config is user-fixable");
assert!(
error.to_string().contains("ALIEN_DB_BINDING"),
"the error must name the env var the user would edit, got: {error}"
);
}
}